id: npm-dependency-release-risk-watch
namespace: company.team
description: |
Watch new releases of the npm packages your company depends on and alert in Slack when a release
shows the signs of a hijacked publish: a different publisher, lost provenance or trusted publishing,
a new install script, or a maintainer change.
inputs:
- id: packages
type: JSON
displayName: Packages to watch
description: List of objects with an npm package name (scoped names such as
`@types/node` work) and an owner to mention.
defaults: |
[
{"name": "axios", "owner": "@web-team"},
{"name": "chalk", "owner": "@platform"},
{"name": "esbuild", "owner": "@platform"},
{"name": "@types/node", "owner": "@platform"}
]
- id: notify_every_release
type: BOOL
displayName: Post every release
description: Also post releases that show no risk signal.
defaults: false
tasks:
- id: for_each_package
type: io.kestra.plugin.core.flow.Loop
description: Check every package independently, four at a time.
values: "{{ inputs.packages }}"
concurrencyLimit: 4
tasks:
- id: fetch_latest
type: io.kestra.plugin.core.http.Download
description: Manifest of the version behind the `latest` dist-tag, from the
public npm registry (no key).
uri: "https://registry.npmjs.org/{{ fromJson(item.value).name }}/latest"
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: Release facts recorded at the last run. Missing on the first run.
key: "npm_watch_{{ fromJson(item.value).name | replace({'@': 'scoped-', '/':
'-'}) | slugify }}"
errorOnMissing: false
- id: evaluate
type: io.kestra.plugin.scripts.python.Script
description: Extract publisher, provenance, install scripts, dependencies and
maintainers, and compare them with the last release seen.
containerImage: python:3.12-slim
inputFiles:
latest.json: "{{ outputs.fetch_latest.uri }}"
previous.json: "{{ (outputs.load_previous.value ?? {}) | 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
m = load("latest.json")
previous = load("previous.json") or {}
user = m.get("_npmUser") or {}
state = {
"version": m["version"],
"publisher": user.get("name", "unknown"),
"trusted_publisher": bool(user.get("trustedPublisher")),
"provenance": bool((m.get("dist") or {}).get("attestations")),
"install_scripts": sorted(k for k in (m.get("scripts") or {}) if k in ("preinstall", "install", "postinstall")),
"dependencies": sorted((m.get("dependencies") or {}).keys()),
"maintainers": sorted(x.get("name", "") for x in m.get("maintainers", [])),
}
risks = []
new_release = bool(previous) and state["version"] != previous.get("version")
if new_release:
if state["publisher"] != previous.get("publisher"):
risks.append(f"Published by `{state['publisher']}`; the previous release was published by `{previous.get('publisher')}`.")
if previous.get("provenance") and not state["provenance"]:
risks.append("No provenance attestation, although the previous release had one. A release built outside CI is how stolen-token publishes look.")
if previous.get("trusted_publisher") and not state["trusted_publisher"]:
risks.append("Published with a token instead of trusted publishing (OIDC), unlike the previous release.")
added_scripts = [s for s in state["install_scripts"] if s not in previous.get("install_scripts", [])]
if added_scripts:
risks.append(f"New install script: `{', '.join(added_scripts)}` runs on every `npm install`. Read it before upgrading.")
added_deps = [d for d in state["dependencies"] if d not in previous.get("dependencies", [])]
if added_deps:
risks.append(f"New dependencies: {', '.join(f'`{d}`' for d in added_deps)}.")
if previous:
added = [x for x in state["maintainers"] if x not in previous.get("maintainers", [])]
removed = [x for x in previous.get("maintainers", []) if x not in state["maintainers"]]
if added:
risks.append(f"Maintainer added: {', '.join(added)}.")
if removed:
risks.append(f"Maintainer removed: {', '.join(removed)}.")
if risks and new_release:
headline = f":rotating_light: *Check before upgrading: {m['name']} {state['version']}* (was {previous.get('version')})"
elif risks:
headline = f":busts_in_silhouette: *Maintainers of {m['name']} changed* (latest release {state['version']})"
else:
headline = f":package: *{m['name']} {state['version']} released* (was {previous.get('version')})"
print(f"{m['name']}@{state['version']}: publisher {state['publisher']}, provenance {state['provenance']}, "
f"trusted publishing {state['trusted_publisher']}, install scripts {state['install_scripts'] or 'none'}, "
f"new release {new_release}, {len(risks)} risk signals")
print("::" + json.dumps({"outputs": {
"risks": risks,
"new_release": new_release,
"headline": headline,
"notify": bool(risks) or (new_release and {{ inputs.notify_every_release ? 'True' : 'False' }}),
"state": state,
}}) + "::")
- id: if_notify
type: io.kestra.plugin.core.flow.If
description: Only post risky releases, or every release when
`notify_every_release` is on.
condition: "{{ outputs.evaluate.vars.notify }}"
then:
- id: alert_owner
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the release facts from the registry and every risk signal
found.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ outputs.evaluate.vars.headline }} (owner: {{ fromJson(item.value).owner }})
{% for r in outputs.evaluate.vars.risks %}- {{ r }}
{% endfor %}Publisher: `{{ outputs.evaluate.vars.state.publisher }}` | provenance: {{ outputs.evaluate.vars.state.provenance ? 'yes' : '*no*' }} | trusted publishing: {{ outputs.evaluate.vars.state.trusted_publisher ? 'yes' : 'no' }} | install scripts: {{ outputs.evaluate.vars.state.install_scripts | length > 0 ? outputs.evaluate.vars.state.install_scripts | join(', ') : 'none' }}
<https://www.npmjs.com/package/{{ fromJson(item.value).name }}/v/{{ outputs.evaluate.vars.state.version }}|Open on npm>
- id: remember_release
type: io.kestra.plugin.core.kv.Set
description: Store the release facts for the next comparison. Runs after the alert.
key: "npm_watch_{{ fromJson(item.value).name | replace({'@': 'scoped-', '/':
'-'}) | slugify }}"
kvType: JSON
overwrite: true
value: "{{ outputs.evaluate.vars.state | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A package that cannot be checked must not look safe.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: npm release risk watch failed in execution {{
execution.id }} ({{ flow.namespace }}.{{ flow.id }}). Open the failed loop
iteration: the package name may be misspelled (HTTP 404), or it may have
been unpublished."
triggers:
- id: hourly
type: io.kestra.plugin.core.trigger.Schedule
description: Hijacked releases are usually pulled within hours, so check often.
Disabled until the Slack secret is set.
cron: "0 * * * *"
disabled: true