id: openssf-scorecard-dependency-watch
namespace: company.team
description: |
Track the OpenSSF Scorecard security results of the open-source projects you depend on, detect
when a project's score drops or a critical check such as Maintained, Code-Review or
Dangerous-Workflow regresses, and post a Slack alert where Gemini explains the risk and what to do.
inputs:
- id: projects
type: JSON
displayName: Projects to watch
description: List of objects with a GitHub repository (owner/repo) and how you use it.
defaults: |
[
{"repo": "expressjs/express", "usage": "HTTP framework of our public API."},
{"repo": "psf/requests", "usage": "HTTP client in every Python service."},
{"repo": "axios/axios", "usage": "HTTP client in the web frontend."},
{"repo": "lodash/lodash", "usage": "Utility library in the web frontend."}
]
- id: critical_checks
type: JSON
displayName: Critical checks
description: Scorecard checks whose regression is always reported, whatever the
overall score.
defaults: '["Maintained", "Code-Review", "Dangerous-Workflow",
"Token-Permissions", "Vulnerabilities", "Branch-Protection"]'
- id: min_score
type: FLOAT
displayName: Minimum overall score
description: Report a project once when its overall score falls below this value.
defaults: 5.0
- id: model_name
type: STRING
displayName: Gemini model
defaults: gemini-2.5-flash
tasks:
- id: for_each_project
type: io.kestra.plugin.core.flow.Loop
description: Check every project independently, four at a time.
values: "{{ inputs.projects }}"
concurrencyLimit: 4
tasks:
- id: fetch_scorecard
type: io.kestra.plugin.core.http.Download
description: Latest Scorecard result from the public OpenSSF API (no key),
refreshed weekly by OpenSSF.
uri: "https://api.securityscorecards.dev/projects/github.com/{{
fromJson(item.value).repo }}"
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: The result seen at the last run. Missing on the first run.
key: "scorecard_{{ fromJson(item.value).repo | slugify }}"
errorOnMissing: false
- id: compare
type: io.kestra.plugin.scripts.python.Script
description: Compare the overall score and every check with the previous result.
containerImage: python:3.12-slim
inputFiles:
current.json: "{{ outputs.fetch_scorecard.uri }}"
previous.json: "{{ (outputs.load_previous.value ?? {}) | toJson }}"
critical.json: "{{ inputs.critical_checks | toJson }}"
script: |
import json
def load(name):
with open(name, encoding="utf-8") as f:
data = json.load(f)
return json.loads(data) if isinstance(data, str) else data
current = load("current.json")
previous = load("previous.json") or {}
critical = set(load("critical.json"))
min_score = {{ inputs.min_score }}
checks = {c["name"]: {"score": c["score"], "reason": c.get("reason", "")} for c in current["checks"]}
snapshot = {"date": current["date"], "commit": current["repo"]["commit"], "score": current["score"],
"checks": {name: c["score"] for name, c in checks.items()}}
regressions = []
if previous:
for name, c in checks.items():
before = previous.get("checks", {}).get(name)
# -1 means "not applicable or not evaluated", which is not a regression.
if before is None or before < 0 or c["score"] < 0 or c["score"] >= before:
continue
if name in critical or before - c["score"] >= 3:
regressions.append({"check": name, "before": before, "after": c["score"], "reason": c["reason"]})
below_min = current["score"] < min_score and previous.get("score", 10) >= min_score
print(f"{current['repo']['name']}: score {current['score']} (previously {previous.get('score', 'n/a')}), "
f"{len(regressions)} check regressions, below minimum now: {below_min}")
print("::" + json.dumps({"outputs": {
"score": current["score"],
"previous_score": previous.get("score"),
"date": current["date"][:10],
"regressions": regressions,
"below_min": below_min,
"should_alert": bool(regressions) or below_min,
"weakest": sorted(({"check": n, "score": c["score"], "reason": c["reason"]}
for n, c in checks.items() if 0 <= c["score"] < 5),
key=lambda c: c["score"])[:5],
"snapshot": snapshot,
}}) + "::")
- id: if_regressed
type: io.kestra.plugin.core.flow.If
description: Only call the LLM and Slack when a critical check regressed or the
score fell below the minimum.
condition: "{{ outputs.compare.vars.should_alert }}"
then:
- id: assess_risk
type: io.kestra.plugin.ai.completion.ChatCompletion
description: Explain what the regression means for a project that depends on
this repository.
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
apiKey: "{{ secret('GEMINI_API_KEY') }}"
modelName: "{{ inputs.model_name }}"
configuration:
temperature: 0.1
responseFormat:
type: JSON
jsonSchema:
type: object
properties:
risk:
type: string
enum:
- LOW
- MEDIUM
- HIGH
summary:
type: string
action:
type: string
required:
- risk
- summary
- action
messages:
- type: SYSTEM
content: |
You are an application security engineer reviewing a dependency. You receive OpenSSF Scorecard
check regressions for an open-source project and how the team uses it. Rate the supply-chain risk
for the team, summarize what changed and why it matters in at most 2 sentences, and give one concrete
action (for example pin to a known-good version, watch the next release, open an issue upstream, or
evaluate an alternative). Use only the check names, scores and reasons you are given.
- type: USER
content: |
Project: {{ fromJson(item.value).repo }}
How we use it: {{ fromJson(item.value).usage }}
Overall score: {{ outputs.compare.vars.previous_score }} -> {{ outputs.compare.vars.score }}
Regressed checks: {{ outputs.compare.vars.regressions | toJson }}
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the Scorecard facts (never from the model) with the AI
assessment.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ outputs.assess_risk.jsonOutput.risk == 'HIGH' ? ':rotating_light:' : ':warning:' }} *OpenSSF Scorecard regression: <https://scorecard.dev/viewer/?uri=github.com/{{ fromJson(item.value).repo }}|{{ fromJson(item.value).repo }}>* (risk for us: {{ outputs.assess_risk.jsonOutput.risk }})
Overall score: {{ outputs.compare.vars.previous_score ?? 'n/a' }} -> *{{ outputs.compare.vars.score }}* (scan of {{ outputs.compare.vars.date }}){% if outputs.compare.vars.below_min %}, now below our minimum of {{ inputs.min_score }}{% endif %}
{% for r in outputs.compare.vars.regressions %}- {{ r.check }}: {{ r.before }} -> {{ r.after }} ({{ r.reason }})
{% endfor %}*What it means:* {{ outputs.assess_risk.jsonOutput.summary }}
*Suggested action:* {{ outputs.assess_risk.jsonOutput.action }}
- id: remember_result
type: io.kestra.plugin.core.kv.Set
description: Store this result as the baseline for the next run. Runs after the
alert.
key: "scorecard_{{ fromJson(item.value).repo | slugify }}"
kvType: JSON
overwrite: true
value: "{{ outputs.compare.vars.snapshot | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A project without a Scorecard result, or a failed model call, must
not look like "no regressions".
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: OpenSSF Scorecard watch failed in execution {{
execution.id }} ({{ flow.namespace }}.{{ flow.id }}). Open the failed loop
iteration: the repository may not be scanned by OpenSSF (HTTP 404), or
GEMINI_API_KEY may be invalid."
triggers:
- id: every_monday
type: io.kestra.plugin.core.trigger.Schedule
description: OpenSSF rescans projects weekly, so a weekly check is enough.
Disabled until the secrets are set.
cron: "0 9 * * 1"
disabled: true