Skip to content

Commit 32c6052

Browse files
ChatchawanIllyeschristophetdclaude
authored
Flag npm packages when provenance attestations disappear (#819)
* Flag npm packages when provenance attestations disappear Checks whether the version being scanned has npm provenance attestations (dist.attestations in registry metadata). If it doesn't, walks back through the package's publish history to find the most recent version that did have them. A version losing provenance after earlier versions had it is treated as a signal worth flagging, since it can indicate a publish made outside the normal CI-attested flow, similar to the pattern seen in the nx compromise. A package that never adopted provenance isn't flagged. Only a regression (had it, lost it) counts. Fixes #603 * code formatting * autogenerate docs * Only treat semver-earlier versions as provenance evidence The regression walk ordered versions by publish time alone, so an attested version of a newer release line counted as evidence against an older line's later-published patch. rxjs was flagged for this: its 8.0.0-alpha versions publish from attested CI, while stable 7.8.2 was cut afterwards from the unattested 7.x flow. A candidate now has to precede the scanned version in semver order too, and prereleases only count as evidence for another prerelease. Regressions across a major bump still flag. * Extract shared npm publish-history helper provenance_regression and risky_new_dependency each carried their own copy of the registry time-map walk, so edge cases like unpublished versions had to be handled twice. Both now call published_versions_before in guarddog.utils.npm. * Categorize provenance regression as a metadata threat risk_engine caps a package's score at 6.9 when every risk is in the metadata category, so metadata-only signals never earn the high_risk label. Declaring threat.npm.provenance-regression made this detector read as source-code evidence and lifted that cap: paired with typosquatting it scored 7.4/high_risk where the same metadata-only pair under threat.metadata.* stays 6.9/suspicious. The detector reads only registry metadata (dist.attestations and the time map), so metadata is the accurate category. It remains standalone-eligible, since risk_engine already forms risks from metadata threats without a paired capability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Christophe Tafani-Dereeper <christophe.tafanidereeper@datadoghq.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ff249a5 commit 32c6052

7 files changed

Lines changed: 465 additions & 25 deletions

File tree

RULES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Rules are categorized by their `identifies` field which determines how they part
3030

3131
| **Rule** | **Identifies** | **Description** | **Severity** | **PyPI** | **npm** | **go** | **GitHub Action** | **Extension** | **RubyGems** |
3232
|:---------|:---------------|:----------------|:------------:|:---:|:---:|:---:|:---:|:---:|:---:|
33-
| threat-filesystem-read | `threat.filesystem.read` | Detects access to sensitive files (credentials, configs, keys) | high | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
33+
| threat-filesystem-read | `threat.filesystem.read` | Detects access to sensitive files (credentials, configs, keys) | high | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
3434
| threat-runtime-obfuscation-chr | `threat.runtime.obfuscation` | Detects chr-based code obfuscation: exec/eval of chr() sequences | high | :white_check_mark: | | | | | |
3535
| threat-runtime-obfuscation-dynamic-eval | `threat.runtime.obfuscation.dynamic-eval` | Detects JavaScript payloads executed through eval/Function over a self-decoding wrapper or character-code/base64 decoded data | high | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
3636
| threat-npm-preinstall-script | `threat.process.hooks` | Detects npm preinstall scripts, which are almost exclusively used for malware delivery | high | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
@@ -53,7 +53,7 @@ Rules are categorized by their `identifies` field which determines how they part
5353
| threat-process-hooks | `threat.process.hooks` | Detects LOLBAS usage in install hooks (execution and network tools) | medium | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
5454
| threat-runtime-obfuscation | `threat.runtime.obfuscation` | Detects heavy obfuscation techniques commonly used by malware | low | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | |
5555
| threat-npm-http-dependency | `threat.npm.http.dependency` | Detects HTTP/HTTPS URL dependencies in package.json (dependency confusion, untrusted sources) | high | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
56-
| threat-network-outbound-shady-links | `threat.network.outbound.shady_links` | Detects URLs to URL shorteners, file sharing, and suspicious services | medium | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
56+
| threat-network-outbound-shady-links | `threat.network.outbound.shady_links` | Detects URLs to URL shorteners, file sharing, and suspicious services | medium | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
5757
| threat-runtime-obfuscation-unicode | `threat.runtime.obfuscation.unicode` | Detects unicode homoglyphs and uncommon characters used for obfuscation | medium | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | |
5858
| threat-process-sysinfo | `threat.process.spawn.sysinfo` | Detects LOLBAS usage in process spawning | medium | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
5959
| threat-runtime-obfuscation-log-suppress | `threat.runtime.obfuscation` | Detects log/console suppression combined with obfuscated code, a common malware evasion pattern | medium | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | |
@@ -87,5 +87,6 @@ Rules are categorized by their `identifies` field which determines how they part
8787
| metadata_mismatch | `threat.metadata.manifest-mismatch` | Identify packages with mismatches between registry metadata and the actual package manifest | medium | execution | :white_check_mark: | :white_check_mark: | | | | |
8888
| direct_url_dependency | `threat.metadata.direct-url-dep` | Identify packages with direct URL dependencies. Dependencies fetched this way are not immutable and can be used to inject untrusted code or reduce the likelihood of a reproducible install. | medium | initial-access | | :white_check_mark: | | | | |
8989
| risky_new_dependency | `threat.npm.risky-new-dependency` | Identify newly added dependencies that are themselves risky. A dependency introduced in this version but absent from the previous one is scanned as a package; it is flagged when its risk score is high. | high | initial-access | | :white_check_mark: | | | | |
90+
| provenance_regression | `threat.metadata.provenance-regression` | Identify a version that lost npm provenance attestations that earlier versions had. A version dropping provenance after prior versions carried it can indicate a publish made outside the normal CI-attested flow. | medium | initial-access | | :white_check_mark: | | | | |
9091

9192
<!-- END_RULE_LIST -->

guarddog/analyzer/metadata/npm/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
from guarddog.analyzer.metadata.npm.risky_new_dependency import (
1515
NPMRiskyNewDependencyDetector,
1616
)
17+
from guarddog.analyzer.metadata.npm.provenance_regression import (
18+
NPMProvenanceRegressionDetector,
19+
)
1720

1821
NPM_METADATA_RULES = {}
1922

@@ -26,6 +29,7 @@
2629
NPMBundledBinary,
2730
NPMDeceptiveAuthor,
2831
NPMRiskyNewDependencyDetector,
32+
NPMProvenanceRegressionDetector,
2933
]
3034

3135
for detectorClass in classes:
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Provenance Regression Detector
2+
3+
npm records a `dist.attestations` field on a version when it was published with
4+
provenance (`npm publish --provenance` from a trusted CI flow). When a version
5+
that previously carried attestations is followed by one that lacks them, the
6+
publish likely happened outside the normal attested flow. That regression is the
7+
signal this detector flags, mirroring the pattern seen in the nx compromise where
8+
malicious versions were pushed with stolen credentials.
9+
10+
A package that never adopted provenance is not flagged. Absence alone is common
11+
and legitimate; only a regression (had it, then lost it) counts.
12+
13+
Evidence of prior provenance must come from a version that precedes the scanned one
14+
in semver order, not merely in publish time. Projects routinely publish a modernized
15+
release line from an attested CI workflow while still cutting maintenance releases of
16+
an older line the old way, so a later-published patch of an older line is not a
17+
regression against it.
18+
"""
19+
20+
import logging
21+
from typing import Optional
22+
23+
from semantic_version import Version # type: ignore
24+
25+
from guarddog.analyzer.metadata.detector import Detector
26+
from guarddog.utils.npm import published_versions_before
27+
28+
log = logging.getLogger("guarddog")
29+
30+
31+
def _parse_semver(version: str) -> Optional[Version]:
32+
try:
33+
return Version(version)
34+
except ValueError:
35+
return None
36+
37+
38+
class NPMProvenanceRegressionDetector(Detector):
39+
"""Detects a version that dropped npm provenance attestations earlier versions had.
40+
41+
The scanned version's `dist.attestations` is checked first: if present, there is
42+
no regression. If absent, the package's publish history (ordered by the registry
43+
`time` map) is walked backward. Finding an earlier version that did carry
44+
attestations flags the package; running out of earlier versions without finding
45+
one means the package never used provenance, which is not a regression.
46+
47+
Only versions that also precede the scanned one in semver order count as evidence,
48+
and prereleases only count when the scanned version is itself a prerelease."""
49+
50+
def __init__(self):
51+
super().__init__(
52+
name="provenance_regression",
53+
description="Identify a version that lost npm provenance attestations that "
54+
"earlier versions had. A version dropping provenance after prior versions "
55+
"carried it can indicate a publish made outside the normal CI-attested flow.",
56+
identifies="threat.metadata.provenance-regression",
57+
severity="medium",
58+
mitre_tactics="initial-access",
59+
specificity="medium",
60+
sophistication="low",
61+
)
62+
63+
def detect(
64+
self,
65+
package_info,
66+
path: Optional[str] = None,
67+
name: Optional[str] = None,
68+
version: Optional[str] = None,
69+
) -> tuple[bool, Optional[str]]:
70+
package_name = name or package_info.get("name", "")
71+
versions = package_info.get("versions", {})
72+
current_version = version or package_info.get("dist-tags", {}).get("latest")
73+
if not current_version or current_version not in versions:
74+
log.debug(
75+
f"[{self.name}] No usable version for '{package_name}' "
76+
f"(resolved '{current_version}'); skipping"
77+
)
78+
return False, None
79+
80+
if self._has_attestations(versions.get(current_version, {})):
81+
log.debug(
82+
f"[{self.name}] '{package_name}@{current_version}' has provenance "
83+
f"attestations; no regression"
84+
)
85+
return False, None
86+
87+
last_attested = self._most_recent_attested_before(package_info, current_version)
88+
if last_attested is None:
89+
log.debug(
90+
f"[{self.name}] '{package_name}@{current_version}' lacks attestations "
91+
f"and no earlier version had them; not a regression"
92+
)
93+
return False, None
94+
95+
log.debug(
96+
f"[{self.name}] '{package_name}@{current_version}' lost provenance "
97+
f"attestations last seen on '{last_attested}'; flagging"
98+
)
99+
return True, (
100+
f"Version {current_version} was published without npm provenance "
101+
f"attestations, but the earlier version {last_attested} had them. Losing "
102+
f"provenance after previous versions carried it can indicate a publish made "
103+
f"outside the normal CI-attested flow, as seen in the nx compromise."
104+
)
105+
106+
@staticmethod
107+
def _has_attestations(version_info: dict) -> bool:
108+
"""Whether a version was published with npm provenance attestations."""
109+
return "attestations" in (version_info.get("dist") or {})
110+
111+
def _most_recent_attested_before(
112+
self, package_info, current_version: str
113+
) -> Optional[str]:
114+
"""Walk earlier versions newest-first and return the first with attestations.
115+
116+
The walk does not stop at the immediately preceding version: a compromise may
117+
push several unsigned releases in a row, so the search continues back until a
118+
version with attestations is found or the history is exhausted.
119+
"""
120+
versions = package_info.get("versions", {})
121+
for earlier_version in published_versions_before(package_info, current_version):
122+
if not self._precedes_in_release_order(earlier_version, current_version):
123+
continue
124+
if self._has_attestations(versions.get(earlier_version, {})):
125+
return earlier_version
126+
return None
127+
128+
@staticmethod
129+
def _precedes_in_release_order(candidate: str, current_version: str) -> bool:
130+
"""Whether `candidate` can be evidence of provenance the current version lost.
131+
132+
A version published earlier in time still belongs to a later release line when
133+
it is semver-greater, e.g. an attested `8.0.0-alpha` published before an
134+
unattested `7.8.2` maintenance patch. Such a version is not something the
135+
current one regressed from.
136+
137+
Prereleases only count as evidence for another prerelease: a project commonly
138+
pipes its `next` line through attested CI before its stable line, and a stable
139+
release that lacks what only an alpha had has not lost anything.
140+
"""
141+
current_semver = _parse_semver(current_version)
142+
candidate_semver = _parse_semver(candidate)
143+
if current_semver is None or candidate_semver is None:
144+
# npm requires valid semver, so this is unreachable in practice; fall back
145+
# to publish order rather than silently dropping the version from the walk.
146+
return True
147+
if candidate_semver.prerelease and not current_semver.prerelease:
148+
return False
149+
return candidate_semver < current_semver

guarddog/analyzer/metadata/npm/risky_new_dependency.py

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
from guarddog.analyzer.metadata.detector import Detector
1818
from guarddog.analyzer.risk_engine import RiskLabel
1919
from guarddog.utils.config import NEW_DEPENDENCY_RISK_THRESHOLD
20-
from guarddog.utils.npm import highest_matching_version, resolve_npm_alias
20+
from guarddog.utils.npm import (
21+
highest_matching_version,
22+
published_versions_before,
23+
resolve_npm_alias,
24+
)
2125

2226
log = logging.getLogger("guarddog")
2327

@@ -35,9 +39,6 @@ class DependencyRisk:
3539
# Upper bound on a single sub-dependency scan so one slow scan can't hang the parent.
3640
SUBSCAN_TIMEOUT_SECONDS = 300
3741

38-
# Keys in the registry `time` object that are not version publish timestamps.
39-
_NON_VERSION_TIME_KEYS = {"created", "modified"}
40-
4142
# Risk labels phrased to match GuardDog's overall assessment wording.
4243
_LABEL_PHRASE = {
4344
RiskLabel.HIGH_RISK.value: "high risk",
@@ -147,25 +148,9 @@ def detect(
147148
def _previous_published_version(
148149
self, package_info, current_version: str
149150
) -> Optional[str]:
150-
"""Return the version published immediately before `current_version`.
151-
152-
Publish times come from the registry `time` map; ISO 8601 timestamps sort
153-
lexicographically, so the previous version is the one with the greatest
154-
timestamp strictly before the current version's.
155-
"""
156-
published = {
157-
v: t
158-
for v, t in package_info.get("time", {}).items()
159-
if v not in _NON_VERSION_TIME_KEYS and v in package_info.get("versions", {})
160-
}
161-
current_time = published.get(current_version)
162-
if current_time is None:
163-
return None
164-
165-
earlier = [(t, v) for v, t in published.items() if t < current_time]
166-
if not earlier:
167-
return None
168-
return max(earlier)[1]
151+
"""Return the version published immediately before `current_version`."""
152+
earlier = published_versions_before(package_info, current_version)
153+
return earlier[0] if earlier else None
169154

170155
@staticmethod
171156
def _installed_dependencies(version_info: dict) -> dict:

guarddog/utils/npm.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,33 @@
1313
r"^npm:(?P<package>@[^/@\s]+/[^@\s]+|[^@\s]+)(?:@(?P<selector>.+))?$"
1414
)
1515

16+
# Keys in the registry `time` object that are not version publish timestamps.
17+
NON_VERSION_TIME_KEYS = {"created", "modified"}
18+
19+
20+
def published_versions_before(package_info: dict, version: str) -> list[str]:
21+
"""Versions of a package published before `version`, most recent publish first.
22+
23+
Publish times come from the registry `time` map, whose ISO 8601 timestamps sort
24+
lexicographically. Entries with no matching record under `versions` are dropped so
25+
unpublished versions don't appear as history. The result is empty when `version`
26+
itself has no publish timestamp, since then nothing can be ordered against it.
27+
"""
28+
versions = package_info.get("versions", {})
29+
published = {
30+
v: t
31+
for v, t in package_info.get("time", {}).items()
32+
if v not in NON_VERSION_TIME_KEYS and v in versions
33+
}
34+
current_time = published.get(version)
35+
if current_time is None:
36+
return []
37+
38+
# The version-string tie-break only matters when two versions share an identical
39+
# timestamp, which does not happen in practice.
40+
earlier = [(t, v) for v, t in published.items() if t < current_time]
41+
return [v for _, v in sorted(earlier, reverse=True)]
42+
1643

1744
def resolve_npm_alias(package_name: str, selector: str) -> tuple[str, str]:
1845
"""Normalize an npm alias so scanning targets the real package.

0 commit comments

Comments
 (0)