Skip to content

Commit a494cda

Browse files
authored
feat(vet): review dormant npm publisher handovers (#37)
1 parent b199512 commit a494cda

9 files changed

Lines changed: 942 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5151

5252
### Added
5353

54+
- `ca9 vet --scan-publisher-changes` optionally reviews direct npm lockfile
55+
dependencies for recent publisher handovers after long release gaps using
56+
public npm metadata. It emits review warnings tied to the locked version.
5457
- `ca9 review --base FILE --head FILE` compares npm v2/v3 dependency updates
5558
using lockfile occurrence identity, verified release artifacts, lifecycle and
5659
entry-point declarations, and stable static observations. Markdown and JSON

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ scope. See the [dependency review guide](docs/guide/dependency-review.md).
280280
```bash
281281
ca9 vet --repo .
282282
ca9 vet --repo . --malware-query
283+
ca9 vet --repo . --scan-publisher-changes
283284
ca9 vet --repo . --scan-artifacts
284285
ca9 vet --repo . --scan-workflows
285286
ca9 vet --repo . --internal-package 'acme-*' --private-index https://packages.acme.internal/simple
@@ -300,6 +301,14 @@ untrusted indexes, known malicious packages, too-new package versions, and
300301
high-risk workflow patterns are blocking findings; weaker local signals are
301302
warnings or investigation items.
302303

304+
With `--scan-publisher-changes`, ca9 queries public npm registry metadata for
305+
direct, locked npm dependencies. It warns when a new publisher released a stable
306+
version after at least 180 days without a release, within the last 365 days, and
307+
the locked version belongs to that publisher's later releases. Verified npm
308+
trusted publishing and publishers who maintain another package with at least
309+
100,000 weekly downloads are exempt. This is a review signal, not a malware
310+
verdict. The check requires network access and cannot be combined with `--offline`.
311+
303312
With `--scan-artifacts`, ca9 downloads only lockfile artifacts with hashes by
304313
default, verifies the hash, safely unpacks Python wheels/sdists and npm tarballs
305314
without executing code, and runs GuardDog-style static heuristics for suspicious
@@ -687,13 +696,14 @@ Vet-only options:
687696
--private-index URL Private index allowed for internal packages
688697
--internal-package PATTERN Internal package glob, e.g. acme-*; repeatable
689698
--malware-query Query OSV for known malicious packages
699+
--scan-publisher-changes Query npm for recent publisher handovers after dormancy
690700
--scan-artifacts Hash-verify, unpack, and statically inspect artifacts
691701
--scan-workflows Scan GitHub Actions workflow risk patterns
692702
--allow-unhashed-downloads Allow artifact downloads without lockfile hashes
693703
--max-artifact-mb N Max artifact download size [default: 100]
694704
--deny-license ID Denied license identifier; repeatable
695705
--require-known-license Warn when artifact metadata has no known license
696-
--offline Use cached OSV data only for malware query
706+
--offline Use cached OSV data for malware query; incompatible with publisher checks
697707
698708
Policy-only options:
699709
--policy PATH ca9 package policy TOML

‎docs/guide/cli.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,14 @@ Options:
214214
| `--private-index URL` | Private package index allowed for internal package names. Can be repeated. |
215215
| `--internal-package PATTERN` | Internal package name or glob pattern, e.g. `acme-*`. Can be repeated. |
216216
| `--malware-query` | Query OSV for known malicious-package advisories. |
217+
| `--scan-publisher-changes` | Query public npm metadata for recent publisher changes after release dormancy in direct, locked dependencies. |
217218
| `--scan-artifacts` | Hash-verify, unpack, and statically inspect package artifacts. |
218219
| `--scan-workflows` | Scan GitHub Actions workflows for risky token, OIDC, and trust-boundary patterns. |
219220
| `--allow-unhashed-downloads` | Allow artifact scanning when the lockfile has no artifact hash. |
220221
| `--max-artifact-mb N` | Maximum artifact download size for `--scan-artifacts`. Defaults to `100`. |
221222
| `--deny-license ID` | Denied license identifier. Can be repeated. |
222223
| `--require-known-license` | Warn when scanned artifact metadata has no known license. |
223-
| `--offline` | Use cached OSV data only for `--malware-query`. |
224+
| `--offline` | Use cached OSV data only for `--malware-query`; incompatible with `--scan-publisher-changes`. |
224225
| `--refresh-cache` | Clear OSV cache before `--malware-query`. |
225226
| `--max-osv-workers N` | Maximum concurrent OSV detail fetches. Defaults to `8`. |
226227

@@ -229,6 +230,7 @@ Examples:
229230
```bash
230231
ca9 vet --repo .
231232
ca9 vet --repo . --scan-artifacts
233+
ca9 vet --repo . --scan-publisher-changes
232234
ca9 vet --repo . --scan-workflows
233235
ca9 vet --repo . --malware-query --offline
234236
ca9 vet --repo . --internal-package 'acme-*' --private-index https://packages.acme.internal/simple

‎docs/guide/supply-chain.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,33 @@ is separate from the installed local feed: the feed path is deterministic and lo
178178
while `--malware-query` asks OSV or uses the OSV cache. Use `--offline` to restrict the
179179
query path to cached OSV data.
180180

181+
## Npm Publisher Change Review
182+
183+
Query the public npm registry for recent changes in publishers after a dormant
184+
release period:
185+
186+
```bash
187+
ca9 vet --repo . --scan-publisher-changes
188+
```
189+
190+
This optional check covers direct npm dependencies pinned in `package-lock.json`
191+
and resolved from `https://registry.npmjs.org`. It reads full package metadata
192+
and warns when a stable release in the past 365 days followed a gap of at least
193+
180 days and came from a publisher not seen in earlier releases. The locked
194+
version must be from that publisher and no older than the handover. Releases
195+
with npm's verified trusted-publisher identity and publishers with another
196+
package receiving at least 100,000 weekly downloads are excluded. If the
197+
publisher lookup is unavailable, ca9 keeps the finding and adds a report
198+
warning. Metadata fetch failures also appear in report warnings. This signal calls
199+
for human review; it does not establish malicious behavior. The check can
200+
download large registry documents, so it is opt-in and cannot run with
201+
`--offline`.
202+
203+
Some old npm releases have timestamps but no publisher identity. ca9 reports
204+
that incomplete history and can still warn when the publisher immediately
205+
before the handover is known. In that case, the new publisher is the first
206+
*observed* publisher in the available metadata, not necessarily the first ever.
207+
181208
## GitHub Actions Workflow Scanning
182209

183210
Scan workflow files for risky token and trust-boundary patterns:

‎src/ca9/cli.py‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,12 @@ def protect_cmd(
947947
default=False,
948948
help="Query OSV for known malicious package advisories.",
949949
)
950+
@click.option(
951+
"--scan-publisher-changes",
952+
is_flag=True,
953+
default=False,
954+
help="Query public npm metadata for recent publisher changes after release dormancy.",
955+
)
950956
@click.option(
951957
"--scan-artifacts",
952958
is_flag=True,
@@ -988,7 +994,7 @@ def protect_cmd(
988994
"--offline",
989995
is_flag=True,
990996
default=False,
991-
help="Use only cached OSV data for --malware-query.",
997+
help="Use cached OSV data for --malware-query; cannot scan publisher changes.",
992998
)
993999
@click.option(
9941000
"--refresh-cache",
@@ -1014,6 +1020,7 @@ def vet_cmd(
10141020
private_indexes: tuple[str, ...],
10151021
internal_package_patterns: tuple[str, ...],
10161022
malware_query: bool,
1023+
scan_publisher_changes: bool,
10171024
scan_artifacts: bool,
10181025
scan_workflows: bool,
10191026
allow_unhashed_downloads: bool,
@@ -1050,6 +1057,18 @@ def vet_cmd(
10501057
raise click.ClickException(str(exc)) from None
10511058

10521059
inventory = build_inventory(repo_path)
1060+
if offline and scan_publisher_changes:
1061+
raise click.ClickException(
1062+
"--scan-publisher-changes requires npm registry access; remove --offline"
1063+
)
1064+
1065+
publisher_findings = []
1066+
publisher_warnings = []
1067+
if scan_publisher_changes:
1068+
from ca9.npm_publisher import scan_npm_publisher_changes
1069+
1070+
publisher_findings, publisher_warnings = scan_npm_publisher_changes(inventory.packages)
1071+
10531072
policy_findings = []
10541073
feed_warnings = []
10551074
if package_policy.package_age.enabled:
@@ -1107,7 +1126,7 @@ def vet_cmd(
11071126
raise click.ClickException(str(e)) from None
11081127

11091128
artifact_findings = []
1110-
artifact_warnings = [*policy_warnings, *feed_warnings]
1129+
artifact_warnings = [*policy_warnings, *feed_warnings, *publisher_warnings]
11111130
artifact_scans = 0
11121131
skipped_artifacts = 0
11131132
workflow_findings = []
@@ -1132,7 +1151,12 @@ def vet_cmd(
11321151
require_known_license=require_known_license,
11331152
)
11341153
artifact_findings.extend(analyze_license_policy(artifact_result.snapshots, license_policy))
1135-
artifact_warnings = [*policy_warnings, *feed_warnings, *artifact_result.warnings]
1154+
artifact_warnings = [
1155+
*policy_warnings,
1156+
*feed_warnings,
1157+
*publisher_warnings,
1158+
*artifact_result.warnings,
1159+
]
11361160
artifact_scans = artifact_result.scanned_artifacts
11371161
skipped_artifacts = artifact_result.skipped_artifacts
11381162

@@ -1157,7 +1181,12 @@ def vet_cmd(
11571181
inventory,
11581182
policy=policy,
11591183
malware_advisories=malware_advisories,
1160-
extra_findings=[*policy_findings, *artifact_findings, *workflow_findings],
1184+
extra_findings=[
1185+
*policy_findings,
1186+
*publisher_findings,
1187+
*artifact_findings,
1188+
*workflow_findings,
1189+
],
11611190
extra_warnings=artifact_warnings,
11621191
artifact_scans=artifact_scans,
11631192
skipped_artifacts=skipped_artifacts,

0 commit comments

Comments
 (0)