supply-chain-guard 5.28.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -35
- package/action.yml +20 -2
- package/dist/cli.js +42 -10
- package/dist/cli.js.map +1 -1
- package/dist/continuous-monitor.d.ts +77 -0
- package/dist/continuous-monitor.d.ts.map +1 -1
- package/dist/continuous-monitor.js +133 -11
- package/dist/continuous-monitor.js.map +1 -1
- package/dist/correlation-engine.d.ts +24 -2
- package/dist/correlation-engine.d.ts.map +1 -1
- package/dist/correlation-engine.js +32 -9
- package/dist/correlation-engine.js.map +1 -1
- package/dist/dependency-confusion.js +3 -3
- package/dist/dependency-confusion.js.map +1 -1
- package/dist/dockerfile-scanner.d.ts.map +1 -1
- package/dist/dockerfile-scanner.js +293 -4
- package/dist/dockerfile-scanner.js.map +1 -1
- package/dist/feed.d.ts +79 -3
- package/dist/feed.d.ts.map +1 -1
- package/dist/feed.js +171 -27
- package/dist/feed.js.map +1 -1
- package/dist/github-actions-scanner.js +11 -11
- package/dist/index.d.ts +14 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -2
- package/dist/index.js.map +1 -1
- package/dist/install-guard.d.ts +4 -2
- package/dist/install-guard.d.ts.map +1 -1
- package/dist/install-guard.js +20 -0
- package/dist/install-guard.js.map +1 -1
- package/dist/internal-disclosure.d.ts +58 -0
- package/dist/internal-disclosure.d.ts.map +1 -1
- package/dist/internal-disclosure.js +230 -29
- package/dist/internal-disclosure.js.map +1 -1
- package/dist/ioc-blocklist.js +2 -2
- package/dist/metrics.d.ts +37 -2
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +99 -20
- package/dist/metrics.js.map +1 -1
- package/dist/npm-scanner.d.ts +16 -0
- package/dist/npm-scanner.d.ts.map +1 -1
- package/dist/npm-scanner.js +51 -7
- package/dist/npm-scanner.js.map +1 -1
- package/dist/pattern-scanner.d.ts +18 -0
- package/dist/pattern-scanner.d.ts.map +1 -1
- package/dist/pattern-scanner.js +25 -0
- package/dist/pattern-scanner.js.map +1 -1
- package/dist/patterns.d.ts +24 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +84 -3
- package/dist/patterns.js.map +1 -1
- package/dist/policy-engine.d.ts +37 -1
- package/dist/policy-engine.d.ts.map +1 -1
- package/dist/policy-engine.js +151 -3
- package/dist/policy-engine.js.map +1 -1
- package/dist/pypi-scanner.js +1 -1
- package/dist/pypi-scanner.js.map +1 -1
- package/dist/regex-complexity.d.ts +37 -0
- package/dist/regex-complexity.d.ts.map +1 -1
- package/dist/regex-complexity.js +143 -0
- package/dist/regex-complexity.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +585 -70
- package/dist/reporter.js.map +1 -1
- package/dist/sbom-generator.d.ts +134 -6
- package/dist/sbom-generator.d.ts.map +1 -1
- package/dist/sbom-generator.js +753 -76
- package/dist/sbom-generator.js.map +1 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +221 -15
- package/dist/scanner.js.map +1 -1
- package/dist/sla-engine.d.ts +60 -0
- package/dist/sla-engine.d.ts.map +1 -1
- package/dist/sla-engine.js +113 -17
- package/dist/sla-engine.js.map +1 -1
- package/dist/slsa-verifier.d.ts +138 -25
- package/dist/slsa-verifier.d.ts.map +1 -1
- package/dist/slsa-verifier.js +454 -117
- package/dist/slsa-verifier.js.map +1 -1
- package/dist/state-dir.d.ts +78 -0
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +55 -0
- package/dist/state-dir.js.map +1 -1
- package/dist/threat-intel.d.ts +88 -2
- package/dist/threat-intel.d.ts.map +1 -1
- package/dist/threat-intel.js +268 -7
- package/dist/threat-intel.js.map +1 -1
- package/dist/triage-engine.d.ts +59 -0
- package/dist/triage-engine.d.ts.map +1 -1
- package/dist/triage-engine.js +126 -15
- package/dist/triage-engine.js.map +1 -1
- package/dist/triage-scope.d.ts +74 -0
- package/dist/triage-scope.d.ts.map +1 -0
- package/dist/triage-scope.js +100 -0
- package/dist/triage-scope.js.map +1 -0
- package/dist/trust-breakdown.d.ts +9 -1
- package/dist/trust-breakdown.d.ts.map +1 -1
- package/dist/trust-breakdown.js +70 -11
- package/dist/trust-breakdown.js.map +1 -1
- package/dist/types.d.ts +300 -25
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vscode-scanner.js +1 -1
- package/dist/vscode-scanner.js.map +1 -1
- package/dist/workflow-ast.d.ts +20 -0
- package/dist/workflow-ast.d.ts.map +1 -1
- package/dist/workflow-ast.js +22 -0
- package/dist/workflow-ast.js.map +1 -1
- package/package.json +7 -3
- package/policy-schema.json +38 -11
- package/socket.yml +7 -7
package/README.md
CHANGED
|
@@ -73,7 +73,9 @@ For a deep dive into how GlassWorm infiltrates the software supply chain and the
|
|
|
73
73
|
### Infrastructure & CI/CD
|
|
74
74
|
- GitHub Actions: unpinned actions, secrets exfiltration, encoded payloads, curl piping
|
|
75
75
|
- Agentic workflows (GitLost class): AI-agent steps and gh-aw `.github/workflows/*.md` that ingest untrusted issue/PR text, hold a cross-repo token, and can post publicly - the prompt-injection data-leak posture
|
|
76
|
-
- Dockerfile: curl pipe,
|
|
76
|
+
- Dockerfile / Containerfile: curl pipe, base images on a moving channel tag or without a digest,
|
|
77
|
+
hardcoded secrets, SUID bits. Compose `image:` values are out of scope for every Docker rule
|
|
78
|
+
(see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#base-image-pinning-decision-record))
|
|
77
79
|
- Terraform/IaC: inline scripts, external modules, hardcoded secrets
|
|
78
80
|
- Package manager configs (.npmrc, .yarnrc, pip.conf): HTTP registries, exposed tokens
|
|
79
81
|
- Git hooks and submodule security
|
|
@@ -112,14 +114,14 @@ Links individual findings into incident-level attack chains:
|
|
|
112
114
|
- 15+ correlation rules with confidence scoring
|
|
113
115
|
|
|
114
116
|
### Trust Breakdown (v4.2)
|
|
115
|
-
|
|
116
|
-
- Publisher Trust (40%) / Code Quality (30%) / Dependency Trust (20%) / Release Process (10%)
|
|
117
|
+
Multi-dimension trust scoring for package and repository inspections:
|
|
118
|
+
- Publisher Trust (40%) / Code Quality (30%) / Dependency Trust (20%) / Release Process (10%) (all 4 dimensions populated for `npm`, `pypi`, `repo`, and remote `scan <github-url>` modes; local directory scans evaluate Code Quality and Dependency Trust with renormalised weights).
|
|
117
119
|
|
|
118
120
|
## Installation
|
|
119
121
|
|
|
120
|
-
**Requires Node.js 22 or newer.**
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
**Requires Node.js 22 or newer.** Every release runs its complete test suite, and
|
|
123
|
+
installs and executes its own packed tarball, on Node 22 and on Node 24, the current
|
|
124
|
+
Active LTS. Full policy, including what the
|
|
123
125
|
package is published from and what the Action and container image run on:
|
|
124
126
|
[`docs/node-support.md`](docs/node-support.md).
|
|
125
127
|
|
|
@@ -140,7 +142,7 @@ Run the scanner as a [pre-commit](https://pre-commit.com) hook (Python-ecosystem
|
|
|
140
142
|
```yaml
|
|
141
143
|
repos:
|
|
142
144
|
- repo: https://github.com/homeofe/supply-chain-guard
|
|
143
|
-
rev:
|
|
145
|
+
rev: v6.0.0
|
|
144
146
|
hooks:
|
|
145
147
|
- id: supply-chain-guard
|
|
146
148
|
```
|
|
@@ -149,6 +151,22 @@ The scanner writes its risk history to `.scg-history/` in the scanned repo;
|
|
|
149
151
|
it is not written when `--no-history` is set, which the hook now uses. For
|
|
150
152
|
plain scans without that flag, add the folder to your `.gitignore`.
|
|
151
153
|
|
|
154
|
+
**If a file in `.scg-history/` cannot be read, the scan says so and fails.** The
|
|
155
|
+
two stores there, `risk-history.json` and `triage-decisions.json`, are the
|
|
156
|
+
baseline that trend, forecast and triage-governance rules compare against. A
|
|
157
|
+
store that is absent is a first scan and stays silent, which is the normal case
|
|
158
|
+
on a fresh checkout or a hosted runner. A store that exists but does not parse,
|
|
159
|
+
because a scan was interrupted mid-write or the file was edited by hand, is lost
|
|
160
|
+
evidence, and the two are deliberately not reported the same way: the scan emits
|
|
161
|
+
`RISK_HISTORY_UNREADABLE` or `TRIAGE_STORE_UNREADABLE` at `high`, sets
|
|
162
|
+
`partialScan: true`, and exits nonzero regardless of `--fail-on`, because an
|
|
163
|
+
unusable baseline is an indeterminate result rather than a clean one. The
|
|
164
|
+
unreadable file is left on disk rather than overwritten, so complete entries can
|
|
165
|
+
still be recovered from it, usually by closing the truncated JSON array by hand.
|
|
166
|
+
Delete the file to start a new baseline once you have decided the old trend is
|
|
167
|
+
expendable. `--no-history` does not silence this: that flag stops the write, not
|
|
168
|
+
the read, so a corrupt store still degrades the verdict and is still reported.
|
|
169
|
+
|
|
152
170
|
The hook scans the repository root on every commit and fails on high or critical findings.
|
|
153
171
|
|
|
154
172
|
### Docker
|
|
@@ -156,7 +174,7 @@ The hook scans the repository root on every commit and fails on high or critical
|
|
|
156
174
|
Run the scanner without a Node toolchain via the official multi-arch image (linux/amd64, linux/arm64), published to GHCR on every release tag:
|
|
157
175
|
|
|
158
176
|
```bash
|
|
159
|
-
docker run --rm -v ${PWD}:/scan ghcr.io/homeofe/supply-chain-guard:
|
|
177
|
+
docker run --rm -v ${PWD}:/scan ghcr.io/homeofe/supply-chain-guard:6.0.0 scan /scan
|
|
160
178
|
```
|
|
161
179
|
|
|
162
180
|
`${PWD}` works in bash, zsh, and PowerShell; in cmd.exe use `%cd%` instead.
|
|
@@ -194,6 +212,9 @@ supply-chain-guard org my-github-org
|
|
|
194
212
|
# Scan only files changed since a commit (diff mode)
|
|
195
213
|
supply-chain-guard scan ./project --since HEAD~5
|
|
196
214
|
|
|
215
|
+
# Scan with registry version-drift check (requires network)
|
|
216
|
+
supply-chain-guard scan ./project --check-registry
|
|
217
|
+
|
|
197
218
|
# Monitor a Solana C2 wallet
|
|
198
219
|
supply-chain-guard monitor <wallet-address> --once
|
|
199
220
|
```
|
|
@@ -207,7 +228,7 @@ supply-chain-guard scan ./project --format html # Standalone HTML report
|
|
|
207
228
|
supply-chain-guard scan ./project --format markdown # Markdown (for PR comments)
|
|
208
229
|
supply-chain-guard scan ./project --format sarif # SARIF 2.1.0 (GitHub Code Scanning)
|
|
209
230
|
supply-chain-guard scan ./project --format sbom # CycloneDX 1.6 SBOM with real dependency inventory
|
|
210
|
-
supply-chain-guard scan ./project --sbom-output sbom.json #
|
|
231
|
+
supply-chain-guard scan ./project --sbom-output sbom.json # The same SBOM, written to a file instead of stdout
|
|
211
232
|
supply-chain-guard scan ./project --format badge # Shields.io endpoint JSON
|
|
212
233
|
supply-chain-guard scan ./project --format gitlab # GitLab Dependency Scanning report (security-report-schemas 15.2.4, see examples/gitlab-ci.yml)
|
|
213
234
|
supply-chain-guard scan ./project --format markdown --json-output canonical.json # Same scan, human report plus canonical JSON
|
|
@@ -290,10 +311,14 @@ Practically: the default gate exits non-zero on `critical` and `high` only, so *
|
|
|
290
311
|
|
|
291
312
|
```yaml
|
|
292
313
|
rules:
|
|
293
|
-
disable:
|
|
294
|
-
|
|
314
|
+
disable:
|
|
315
|
+
INTERNAL_PRIVATE_IP: RFC1918 addresses are expected in this repository's fixtures
|
|
316
|
+
INTERNAL_HOSTNAME: internal names are already covered by a separate review
|
|
295
317
|
```
|
|
296
318
|
|
|
319
|
+
The parser reads block style only; a flow sequence on one line
|
|
320
|
+
(`disable: [A, B]`) is reported as `POLICY_UNKNOWN_KEY` and disables nothing.
|
|
321
|
+
|
|
297
322
|
### False-positive controls
|
|
298
323
|
|
|
299
324
|
A rule that screams on every README gets switched off, and a switched-off rule protects nothing. Three independent layers keep this quiet.
|
|
@@ -416,6 +441,29 @@ SCG_INTERNAL_DISCLOSURE_FILE=~/.config/scg/internal-terms supply-chain-guard sca
|
|
|
416
441
|
|
|
417
442
|
The external file is one entry per line, `#` for comments, `sha256:<digest>` for a hashed entry, `/pattern/flags` for a regex, anything else is a case-insensitive literal. If the file is configured but absent (a shared CI runner that never received it), you get an `INTERNAL_DENYLIST_UNAVAILABLE` finding at `info` severity rather than silence: a deny-list that quietly stopped running looks exactly like a repository that is clean. An entry that cannot be compiled is reported the same way (`INTERNAL_DENYLIST_INVALID_ENTRY`, medium). Neither finding ever prints the entry, and the environment variable is named but its value is not, because a path can itself contain an account name.
|
|
418
443
|
|
|
444
|
+
**The two sources are not equally trusted, and the difference is deliberate.** `SCG_INTERNAL_DISCLOSURE_FILE` is set by whoever runs the scan, so it may name any path on the machine and carry any pattern. `internalDisclosure.externalFile` and `internalDisclosure.patterns` live in the committed policy file, which travels inside the repository being scanned, and scanning a repository you do not own is the ordinary case for this tool. Entries from there are therefore bounded:
|
|
445
|
+
|
|
446
|
+
- `externalFile` must stay inside the scanned directory. An absolute path is refused, a relative path that climbs out with `..` is refused, and so is one that leaves through a symbolic link. The file is not opened, so nothing about a path outside the tree reaches the report. The bound is the scanned directory and nothing narrower: a path that stays inside it is still read, `.git/config` included, so a committed `externalFile` can still point at whatever your runner wrote into the workspace. Matches from it stay redacted.
|
|
447
|
+
- A regular expression from `patterns`, **or from an `externalFile` that is inside the tree**, is capped at 200 characters and refused when it quantifies a group that already contains a variable quantifier (`(a+)+`, `(a?)*`, and the like). That shape can take exponential time to report no match, so one committed line would otherwise occupy a runner until the workflow times out.
|
|
448
|
+
- Whatever survives those checks runs under a wall-clock budget for the whole scan. On overrun the file reports `INTERNAL_DISCLOSURE_TRUNCATED` rather than running on.
|
|
449
|
+
|
|
450
|
+
A refusal is an `INTERNAL_DENYLIST_REFUSED` finding at `medium` severity, and like every other coverage finding it marks the scan partial rather than passing quietly. In the published Action a partial scan exits 1 on its own, independently of `fail-on`. None of this applies to the environment-variable source.
|
|
451
|
+
|
|
452
|
+
**Two limits of the shape check, both worth knowing before you upgrade.**
|
|
453
|
+
|
|
454
|
+
It refuses more than it has to, and the shape it most often refuses is the ordinary one. A chained label group is how an internal hostname is normally written, and it is rejected even though it is linear in practice:
|
|
455
|
+
|
|
456
|
+
```yaml
|
|
457
|
+
internalDisclosure:
|
|
458
|
+
patterns:
|
|
459
|
+
- /(?:[a-z0-9-]+\.)+corp\.example/ # REFUSED: quantified group holding "+"
|
|
460
|
+
- /[a-z0-9.-]+\.corp\.example/ # accepted, and matches the same hosts
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
If you have the first form today, in `patterns` or in your own gitignored `externalFile`, rewrite it before you upgrade. Left as it is, the term stops being looked for, the scan becomes partial, and the Action exits 1.
|
|
464
|
+
|
|
465
|
+
It also refuses less than it has to, so an accepted pattern is not a promise about time. The check reads the source text, which cannot see ambiguity that comes from overlapping alternation, so `/(a|a)+$/` and `/(a|ab)+$/` are accepted and are still catastrophic, and the wall-clock budget cannot interrupt a match that is already running. Availability from a committed pattern is narrowed here, not closed; the remaining case is tracked on [issue 169](https://github.com/homeofe/supply-chain-guard/issues/169).
|
|
466
|
+
|
|
419
467
|
**One more note on the paradox.** `allowlist.domains` also answers `INTERNAL_HOSTNAME`, `INTERNAL_SERVICE_ENDPOINT` and `INTERNAL_GIT_REMOTE` for a given host, which is convenient and publishes the host name. If that is not acceptable, suppress by path instead, which names nothing:
|
|
420
468
|
|
|
421
469
|
```yaml
|
|
@@ -431,9 +479,12 @@ Create `.supply-chain-guard.yml` in your project root to customize behavior:
|
|
|
431
479
|
|
|
432
480
|
```yaml
|
|
433
481
|
rules:
|
|
482
|
+
# Every disabled rule needs a written reason, the same bar `suppress` has met
|
|
483
|
+
# since v5.3. The bare list form (`- HEX_ARRAY`) still disables the rule and is
|
|
484
|
+
# reported as POLICY_DISABLE_NO_REASON.
|
|
434
485
|
disable:
|
|
435
|
-
-
|
|
436
|
-
|
|
486
|
+
HEX_ARRAY: minified vendor bundles in this repository, reviewed 2026-08
|
|
487
|
+
CHARCODE_OBFUSCATION: same bundles, same review
|
|
437
488
|
severityOverrides:
|
|
438
489
|
GHA_UNPINNED_ACTION: medium
|
|
439
490
|
|
|
@@ -451,10 +502,13 @@ allowlist:
|
|
|
451
502
|
# says who publishes the code, not that every version of it is safe.
|
|
452
503
|
- my-org
|
|
453
504
|
|
|
454
|
-
# Skip files matched by these path globs (** / * / ?) during the scan.
|
|
505
|
+
# Skip files matched by these path globs (** / * / ?) during the scan. These
|
|
506
|
+
# files are never opened, so nothing about them reaches the report except the
|
|
507
|
+
# policy block below. Each glob needs a written reason; the bare list form
|
|
508
|
+
# (`- vendor/**`) still skips the path and is reported as POLICY_IGNORE_NO_REASON.
|
|
455
509
|
ignore:
|
|
456
|
-
-
|
|
457
|
-
|
|
510
|
+
"vendor/**": third-party code, tracked by the upstream project's own scanning
|
|
511
|
+
"**/*.min.js": build output, scanned at source instead
|
|
458
512
|
|
|
459
513
|
suppress:
|
|
460
514
|
- rule: RELEASE_EXE_ARTIFACT
|
|
@@ -472,6 +526,37 @@ Findings can also be suppressed inline with a comment on the line directly
|
|
|
472
526
|
above them: `// scg-ignore-next-line RULE reason` (JS/TS) or
|
|
473
527
|
`# scg-ignore-next-line RULE` (Python/YAML/shell).
|
|
474
528
|
|
|
529
|
+
### Where the policy is read from, and what that means on a pull request
|
|
530
|
+
|
|
531
|
+
The policy file is read **from the directory being scanned**, and from nowhere
|
|
532
|
+
else. There is no flag, environment variable or Action input that points the
|
|
533
|
+
scanner at a policy outside the scan target.
|
|
534
|
+
|
|
535
|
+
On a `pull_request` event the checkout materialises the **head of the proposing
|
|
536
|
+
branch**, so the policy that governs the scan is the one on the branch under
|
|
537
|
+
review, not the one on your default branch. A change that adds
|
|
538
|
+
`.supply-chain-guard.yml` alongside the code it excuses is applying its own
|
|
539
|
+
policy to itself. Anyone who can push a branch can therefore narrow the scan of
|
|
540
|
+
that branch.
|
|
541
|
+
|
|
542
|
+
That is a property of reading policy from the tree, and it is stated here rather
|
|
543
|
+
than left to be discovered. What it is **not** is silent:
|
|
544
|
+
|
|
545
|
+
- Every narrowing is named in the report, in **all nine output formats**,
|
|
546
|
+
including the markdown pull request comment the Action posts by default.
|
|
547
|
+
A scan narrowed by policy can no longer be mistaken for a clean scan in any
|
|
548
|
+
format, including `ignore:`, which removes files before any rule opens them
|
|
549
|
+
and used to leave no trace anywhere.
|
|
550
|
+
- A narrowing declared without a written reason is reported as a finding
|
|
551
|
+
(`POLICY_DISABLE_NO_REASON`, `POLICY_IGNORE_NO_REASON`,
|
|
552
|
+
`POLICY_SUPPRESSION_NO_REASON`), so an undocumented exclusion costs a line in
|
|
553
|
+
the report rather than nothing.
|
|
554
|
+
|
|
555
|
+
If your threat model includes an untrusted proposer, the controls that actually
|
|
556
|
+
hold are outside this tool: require review on `.supply-chain-guard.yml` through
|
|
557
|
+
`CODEOWNERS`, or scan a base-ref checkout in a separate job. Treat a policy file
|
|
558
|
+
in a pull request diff as a change to your security gate, because it is one.
|
|
559
|
+
|
|
475
560
|
## Baseline Diffing (v4.4)
|
|
476
561
|
|
|
477
562
|
Only report NEW findings (ignore known baseline):
|
|
@@ -566,7 +651,7 @@ supply-chain-guard scan ./project --baseline .scg-baseline.json
|
|
|
566
651
|
| RubyGems | `scan` | Gemfile, Gemfile.lock (malicious-gem IOCs, http/git sources) |
|
|
567
652
|
| Composer/PHP | `scan` | composer.json, composer.lock (malicious-package IOCs, http repos) |
|
|
568
653
|
| NuGet/.NET | `scan` | packages.lock.json, *.csproj, nuget.config (malicious-package IOCs, http feeds) |
|
|
569
|
-
| Docker | `scan` | Dockerfile, docker-compose.yml,
|
|
654
|
+
| Docker | `scan` | Dockerfile, Dockerfile.*, Containerfile. `docker-compose.yml` is read, but every Docker rule is anchored on a Dockerfile instruction keyword, so Compose `image:` values are not covered |
|
|
570
655
|
| Terraform | `scan` | .tf, .hcl files (provisioners, modules, secrets) |
|
|
571
656
|
| VS Code | `vscode` | .vsix files, activation events, dangerous APIs |
|
|
572
657
|
| GitHub Actions | `scan` | .github/workflows/*.yml |
|
|
@@ -612,7 +697,18 @@ supply-chain-guard contributes to each of those activities:
|
|
|
612
697
|
|
|
613
698
|
- **Component inventory:** generates a [CycloneDX 1.6](https://cyclonedx.org/)
|
|
614
699
|
SBOM from the real resolved dependency tree, as a machine-readable component
|
|
615
|
-
list you can attach to technical documentation.
|
|
700
|
+
list you can attach to technical documentation. The inventory is built from
|
|
701
|
+
**npm only**, and specifically from `package-lock.json` (lockfile version 2 or
|
|
702
|
+
later) for the full transitive tree, falling back to the direct dependencies
|
|
703
|
+
declared in `package.json`. `pnpm-lock.yaml`, `yarn.lock` and `bun.lockb` are
|
|
704
|
+
**not** read, and neither is any non-npm manifest: a Python, Cargo, Go,
|
|
705
|
+
RubyGems, Composer or NuGet project produces an SBOM with no components from
|
|
706
|
+
that ecosystem. Every such file that is present is named in the document, in
|
|
707
|
+
`metadata.properties`, alongside an `inventory-coverage` value of
|
|
708
|
+
`full-transitive`, `direct-only` or `none`, so an inventory that was never
|
|
709
|
+
taken is never mistaken for a product that ships nothing. The scanner's threat
|
|
710
|
+
detection covers all the ecosystems listed at the top of this README; only the
|
|
711
|
+
SBOM inventory is npm-scoped.
|
|
616
712
|
- **Dependency risk:** detects known-malicious packages and versions,
|
|
617
713
|
typosquatting, dependency confusion, and compromised publisher activity, at
|
|
618
714
|
scan time and at install time.
|
|
@@ -625,6 +721,68 @@ supply-chain-guard contributes to each of those activities:
|
|
|
625
721
|
supply-chain-guard scan ./project --sbom-output sbom.json
|
|
626
722
|
```
|
|
627
723
|
|
|
724
|
+
`--sbom-output <file>` and `--format sbom` produce the SAME document for the
|
|
725
|
+
same scan: the same components, the same dependency graph, the same
|
|
726
|
+
`vulnerabilities` entries and the same incident annotations. Only the
|
|
727
|
+
`serialNumber` and the timestamps differ, because each invocation is its own
|
|
728
|
+
run. The two exist so an SBOM can be written to a file while the scan report
|
|
729
|
+
itself goes to stdout in another format.
|
|
730
|
+
|
|
731
|
+
#### What the SBOM carries, and what it says it could not assess
|
|
732
|
+
|
|
733
|
+
From `package-lock.json` (v2 or later) every component carries a stable
|
|
734
|
+
`bom-ref`, a `purl`, the integrity hashes, the CycloneDX `scope`, and the
|
|
735
|
+
licence the lockfile declares, expressed as an SPDX `expression` when the string
|
|
736
|
+
is an expression and as `license.id` when it is a plain SPDX identifier. An
|
|
737
|
+
identifier the generator cannot vouch for is kept as `license.name` rather than
|
|
738
|
+
asserted as SPDX, because the CycloneDX schema constrains `license.id` to the
|
|
739
|
+
SPDX enum. Relationships are emitted as a top level `dependencies` array rooted
|
|
740
|
+
at the subject component and resolved the way npm resolves them, so a nested
|
|
741
|
+
duplicate is linked to the dependent that actually installed it rather than to
|
|
742
|
+
the hoisted copy.
|
|
743
|
+
|
|
744
|
+
What could not be assessed is stated instead of left blank. A component whose
|
|
745
|
+
manifest declares no licence carries a `supply-chain-guard:license` property
|
|
746
|
+
saying so, so an empty licence column is never read as "no licence terms". At
|
|
747
|
+
the document level, `metadata.properties` records which manifest the inventory
|
|
748
|
+
came from, how many components carry a declared licence, whether the dependency
|
|
749
|
+
graph was resolved, partial or not assessed, and how many declared edges resolve
|
|
750
|
+
to no component in the document (uninstalled optional peer dependencies,
|
|
751
|
+
normally). A declared edge whose target is not in the document is counted there
|
|
752
|
+
rather than emitted as a `dependsOn` pointing at a `bom-ref` that does not
|
|
753
|
+
exist.
|
|
754
|
+
|
|
755
|
+
```bash
|
|
756
|
+
# What this SBOM says it could and could not assess
|
|
757
|
+
supply-chain-guard scan ./project --format sbom > sbom.json
|
|
758
|
+
node -e "const d=require('./sbom.json');for (const p of d.metadata.properties ?? []) console.log(p.name, '=', p.value)"
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Findings removed by a `suppress:` entry in `.supply-chain-guard.yml` are emitted
|
|
762
|
+
as CycloneDX VEX statements, with the reason the policy declared carried
|
|
763
|
+
verbatim in `analysis.detail`. No `analysis.justification` is emitted: that
|
|
764
|
+
field is a fixed enum that a free-text reason cannot be mapped to. A suppression
|
|
765
|
+
with no recorded reason produces a statement that says exactly that.
|
|
766
|
+
|
|
767
|
+
Component hashes are hexadecimal digests, decoded from the base64 Subresource
|
|
768
|
+
Integrity value npm writes into the lockfile, because that is the encoding the
|
|
769
|
+
CycloneDX `hash-content` pattern requires. An integrity part whose algorithm is
|
|
770
|
+
not one this generator maps, or whose payload does not decode to the digest
|
|
771
|
+
length its algorithm requires, is dropped and reported on the component rather
|
|
772
|
+
than emitted, and counted at the document level. purls are canonical: the npm
|
|
773
|
+
scope is the purl namespace and the separator after it is a literal `/`.
|
|
774
|
+
|
|
775
|
+
Where the inventory came from `package.json` because no lockfile was present, a
|
|
776
|
+
component carries `version` and `purl` only when the manifest declares one exact
|
|
777
|
+
version. A range, a dist-tag such as `latest`, a git or URL specifier and a
|
|
778
|
+
`workspace:` protocol are constraints, not versions: those components carry
|
|
779
|
+
neither field, a `supply-chain-guard:version` property records why, and
|
|
780
|
+
`supply-chain-guard:declared-specifier` keeps the declared string verbatim.
|
|
781
|
+
|
|
782
|
+
Supplier and author are not emitted. `package-lock.json` does not carry either
|
|
783
|
+
field, and the SBOM generator reads only `package-lock.json` and `package.json`,
|
|
784
|
+
so there is nothing to populate them from without a registry lookup.
|
|
785
|
+
|
|
628
786
|
Article and paragraph citations are deliberately omitted here. Map these outputs
|
|
629
787
|
to specific provisions against the final published regulation text, with your own
|
|
630
788
|
legal review, rather than against this README.
|
|
@@ -637,20 +795,45 @@ chain security. The relevant capabilities are:
|
|
|
637
795
|
- **Supply chain risk:** typosquatting, dependency confusion, compromised
|
|
638
796
|
packages, and malicious GitHub Actions in CI/CD workflows.
|
|
639
797
|
- **Incident evidence:** the correlation engine links individual findings into
|
|
640
|
-
named attack chains with confidence scores
|
|
641
|
-
|
|
798
|
+
named attack chains with confidence scores calibrated to indicator match
|
|
799
|
+
completeness. The incident record itself, with its name, confidence, indicator
|
|
800
|
+
counts (matched and total), list and narrative, is carried by three
|
|
801
|
+
formats: **JSON** (`incidents` on the report), **SARIF** (the incident list on
|
|
802
|
+
`runs[0].properties`, and the incidents each result belongs to in that
|
|
803
|
+
result's property bag) and **CycloneDX** (one `annotations` entry per
|
|
804
|
+
incident, whose `subjects` are the `vulnerabilities` entries it groups). The
|
|
805
|
+
text renderer prints it as a panel. The markdown, HTML, badge, GitLab and
|
|
806
|
+
JUnit formats carry the individual findings only, not the incident record.
|
|
807
|
+
For a ready-made evidence document rather than a machine format, `scan
|
|
808
|
+
--export-incident-md` writes a markdown incident report to stdout: risk score,
|
|
809
|
+
every detected incident with its confidence, narrative and indicator list, the
|
|
810
|
+
critical findings, and the response playbooks.
|
|
642
811
|
- **Configuration exposure:** IaC, Dockerfile, and `.npmrc` / `.yarnrc` scanning
|
|
643
812
|
surfaces misconfiguration before deployment.
|
|
644
813
|
|
|
645
814
|
### Operating model
|
|
646
815
|
|
|
647
816
|
Apache-2.0, no account required, and no telemetry: the scanner reports only to
|
|
648
|
-
its own output.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
`
|
|
652
|
-
|
|
653
|
-
|
|
817
|
+
its own output.
|
|
818
|
+
|
|
819
|
+
**Offline by default:**
|
|
820
|
+
`scan` on a local path runs fully offline against the bundled threat feed (unless
|
|
821
|
+
the opt-in `--check-registry` flag is passed), as do `guard`, `feed stats`, and
|
|
822
|
+
all report formatters. These commands make zero network requests and are suitable
|
|
823
|
+
for air-gapped and data-egress-restricted environments.
|
|
824
|
+
|
|
825
|
+
**Networked commands and external disclosures:**
|
|
826
|
+
The commands that reach the network do so deliberately for their specific functions:
|
|
827
|
+
- `supply-chain-guard npm <pkg>` / `pypi <pkg>` / `vscode <ext>`: fetch and inspect remote packages and extensions from public registries (npm, PyPI, VS Code Marketplace, Open VSX).
|
|
828
|
+
- `supply-chain-guard confusion <dir>`: inspects project dependency manifests and **transmits every declared dependency and devDependency package name to the public npm and PyPI registries** to determine whether private or internal packages are registered publicly.
|
|
829
|
+
- `supply-chain-guard repo <url>` and `supply-chain-guard org <name>`: inspect remote GitHub repositories and organizations by invoking the `gh` CLI as a child process, using the caller's ambient GitHub credentials.
|
|
830
|
+
- `supply-chain-guard monitor <wallet>`: polls public Solana RPC nodes for C2 wallet transaction activity.
|
|
831
|
+
- `supply-chain-guard feed refresh`: downloads updated threat intelligence from the upstream repository into the local cache.
|
|
832
|
+
- `supply-chain-guard scan <github-url>`: clones a remote repository via Git for analysis.
|
|
833
|
+
- `supply-chain-guard scan . --check-registry`: opt-in flag that queries the public npm registry for the package's latest published version to detect version drift.
|
|
834
|
+
|
|
835
|
+
Offline runs use the feed bundled with the installed version, so pin the version
|
|
836
|
+
you intend to audit against.
|
|
654
837
|
|
|
655
838
|
## GitHub Action
|
|
656
839
|
|
|
@@ -672,7 +855,7 @@ jobs:
|
|
|
672
855
|
runs-on: ubuntu-latest
|
|
673
856
|
steps:
|
|
674
857
|
- uses: actions/checkout@v4
|
|
675
|
-
- uses: homeofe/supply-chain-guard@
|
|
858
|
+
- uses: homeofe/supply-chain-guard@v6.0.0
|
|
676
859
|
with:
|
|
677
860
|
fail-on: critical
|
|
678
861
|
comment-on-pr: true
|
|
@@ -687,21 +870,83 @@ updates:
|
|
|
687
870
|
- package-ecosystem: github-actions
|
|
688
871
|
directory: "/"
|
|
689
872
|
schedule:
|
|
690
|
-
interval:
|
|
873
|
+
interval: daily
|
|
691
874
|
```
|
|
692
875
|
|
|
693
|
-
|
|
876
|
+
**`daily`, not `weekly`, and this project's release rate is why.** Measured over
|
|
877
|
+
the 155 days to 2026-08-21: 134 releases, about 1.4 a day over the last two
|
|
878
|
+
months, with a median of 20 hours between releases and two thirds of the gaps
|
|
879
|
+
under a day. A weekly schedule cannot track that. Each weekly run opens a correct
|
|
880
|
+
bump pull request, and the next weekly run closes it as superseded and opens
|
|
881
|
+
another, so an unattended pin never moves at all. Measured in one consumer of
|
|
882
|
+
this Action: eight consecutive weekly bump pull requests, each alive for exactly
|
|
883
|
+
seven days, each proposing a newer target than the last, while the pin itself sat
|
|
884
|
+
unchanged for 49 days and fell 82 releases behind. Every scan check was green
|
|
885
|
+
throughout, because a stale pin is not a failing scan.
|
|
694
886
|
|
|
695
|
-
|
|
887
|
+
The interval is the cheap half. The half that actually decides the outcome is
|
|
888
|
+
whether somebody merges the pull request, and no setting in this file supplies
|
|
889
|
+
that.
|
|
890
|
+
|
|
891
|
+
### `@v6`, and what it does and does not guarantee
|
|
892
|
+
|
|
893
|
+
`@v6` also works and stays supported. It is a floating **branch**, fast-forwarded
|
|
696
894
|
to each release by CI, and the composite action on it pins an exact npm version
|
|
697
|
-
that is bumped and build-gated on every release. So `@
|
|
895
|
+
that is bumped and build-gated on every release. So `@v6` is not `latest`: every
|
|
698
896
|
resolution still installs one exact, release-gated version.
|
|
699
897
|
|
|
700
|
-
The caveat is what happens after a major. If the
|
|
701
|
-
|
|
898
|
+
The caveat is what happens after a major. If the v6 line stops being released once
|
|
899
|
+
v7 ships, `@v6` keeps resolving a frozen action that pins an old npm version, and
|
|
702
900
|
the IOC feed it installs stops updating. For a scanner that is a silent
|
|
703
901
|
false-negative generator, and nothing in your workflow would report it. An exact
|
|
704
|
-
pin turns that into a
|
|
902
|
+
pin is what turns that into a reviewable out-of-date dependency instead.
|
|
903
|
+
|
|
904
|
+
**Be precise about what an exact pin does and does not buy you, because a frozen
|
|
905
|
+
exact pin is the same silent false negative.** It ages exactly as quietly. This
|
|
906
|
+
scanner runs offline against the IOC feed bundled with the pinned version, so a
|
|
907
|
+
pin that stops moving freezes the detection rules at that date. What an exact pin
|
|
908
|
+
buys is a *place* where the staleness becomes reviewable: the bump pull request.
|
|
909
|
+
That is a different claim from the staleness being visible on its own, and the
|
|
910
|
+
measurement above is what the difference costs. If those pull requests are opened
|
|
911
|
+
and superseded without ever being merged, the pin is frozen and the rule set is
|
|
912
|
+
ageing.
|
|
913
|
+
|
|
914
|
+
**The scan itself now says so.** Every scan measures how old the rule set it just
|
|
915
|
+
matched against actually is, and reports `THREAT_FEED_STALE` (medium) once the
|
|
916
|
+
newest indicator in that rule set is more than 30 days old. The finding carries
|
|
917
|
+
the measured age and the newest indicator's date, it raises the risk score off
|
|
918
|
+
zero and the risk level off `clean`, and it is named in eight of the nine report
|
|
919
|
+
formats and in the Action's pull request comment, which renders whichever
|
|
920
|
+
`format` you set (`markdown` by default). It is derived offline from the feed
|
|
921
|
+
itself, so it travels with the pin: an installation that never updates reports
|
|
922
|
+
its own age without needing a network call, a registry lookup, or anything
|
|
923
|
+
configured by the consumer.
|
|
924
|
+
|
|
925
|
+
Where it appears, exactly, because "every format" would be one format too many:
|
|
926
|
+
`text`, `json`, `markdown`, `sarif`, `sbom`, `html`, `gitlab` and `junit` all
|
|
927
|
+
carry the rule id `THREAT_FEED_STALE`, and all but `junit` carry the full
|
|
928
|
+
description as well. The ninth format, `badge`, does not: the Shields.io endpoint
|
|
929
|
+
payload is `{schemaVersion, label, message, color}` derived from the findings
|
|
930
|
+
summary counts, so it never names a rule. What you see there instead is the
|
|
931
|
+
badge for an otherwise clean repository turning from `clean`/`brightgreen` into
|
|
932
|
+
`1 medium`/`yellow` - the condition is visible, but not identifiable, and a
|
|
933
|
+
badge is the one surface where that matters least. In `junit` the rule id is a
|
|
934
|
+
passing `<testcase>` rather than a `<failure>`, because only `critical` and
|
|
935
|
+
`high` become failures there.
|
|
936
|
+
|
|
937
|
+
The measurement is taken over the rule set the scan *used*, not over the version
|
|
938
|
+
number. A consumer running `supply-chain-guard feed refresh` before each scan
|
|
939
|
+
merges the published feed for 24 hours and is correctly reported as current even
|
|
940
|
+
on an old pin. `supply-chain-guard feed stats` prints both ages side by side, the
|
|
941
|
+
one bundled with the installed version and the effective one at scan time, so the
|
|
942
|
+
two are never confused. If a deliberately frozen rule set is the intent, exclude
|
|
943
|
+
the rule by name:
|
|
944
|
+
|
|
945
|
+
```yaml
|
|
946
|
+
- uses: homeofe/supply-chain-guard@v6.0.0
|
|
947
|
+
with:
|
|
948
|
+
exclude-rules: THREAT_FEED_STALE
|
|
949
|
+
```
|
|
705
950
|
|
|
706
951
|
### Action Inputs
|
|
707
952
|
|
|
@@ -774,13 +1019,20 @@ The bundled IOC feed ships with every release, and the same data is published as
|
|
|
774
1019
|
is ingested, not at the next release:
|
|
775
1020
|
|
|
776
1021
|
```bash
|
|
777
|
-
supply-chain-guard feed stats # entry counts by type and severity
|
|
1022
|
+
supply-chain-guard feed stats # entry counts by type and severity, plus rule-set age
|
|
778
1023
|
supply-chain-guard feed refresh # pull the latest published feed into the local cache
|
|
779
1024
|
supply-chain-guard feed osv # export malicious-package IOCs as OSV records
|
|
780
1025
|
```
|
|
781
1026
|
|
|
782
1027
|
A refreshed feed is merged into every scan for the next 24 hours automatically.
|
|
783
1028
|
|
|
1029
|
+
**Rule-set age:** `feed stats` reports two ages, the one bundled with the
|
|
1030
|
+
installed version and the effective one at scan time, and marks either `[STALE]`
|
|
1031
|
+
past 30 days. `--format json` returns the same values as `bundledFreshness` and
|
|
1032
|
+
`freshness` (`newestIndicator`, `ageDays`, `datedEntries`, `stale`) for a
|
|
1033
|
+
workflow that wants to assert on them directly. Both are computed offline, from
|
|
1034
|
+
the feed itself.
|
|
1035
|
+
|
|
784
1036
|
**OSV export:** `feed osv` emits the feed's malicious-package indicators (npm,
|
|
785
1037
|
PyPI-adjacent, Go, RubyGems, Packagist, crates.io, NuGet) as [OSV-schema](https://ossf.github.io/osv-schema/)
|
|
786
1038
|
records, so the feed is consumable by `osv-scanner` and other OSV-native tooling:
|
|
@@ -796,6 +1048,16 @@ validate each entry against its type's shape and quarantine anything invalid -
|
|
|
796
1048
|
a malformed or hostile feed entry can neither crash a scan nor flood it with
|
|
797
1049
|
garbage matches, and a rejected refresh never overwrites the previous cache.
|
|
798
1050
|
|
|
1051
|
+
**Acquisition bounds:** both download paths are bounded before anything is
|
|
1052
|
+
parsed or written. An absolute 30 second deadline covers DNS, connect, headers
|
|
1053
|
+
and the body read; the response is capped at 32 MiB, refused on a declared
|
|
1054
|
+
`Content-Length` over the cap before a byte is read and counted again while
|
|
1055
|
+
streaming when no length is declared; at most 5 redirects are followed and every
|
|
1056
|
+
hop is revalidated. An inactivity timeout would not be enough, because a peer
|
|
1057
|
+
that keeps trickling bytes never triggers one. Every bound fails closed and
|
|
1058
|
+
loudly: the download is abandoned, one line naming the bound goes to stderr, the
|
|
1059
|
+
command exits non-zero, and the previous cache stays in effect.
|
|
1060
|
+
|
|
799
1061
|
### Where the feed comes from
|
|
800
1062
|
|
|
801
1063
|
Curated entries are hand-added from vendor write-ups. Malicious-package entries
|
package/action.yml
CHANGED
|
@@ -6,6 +6,20 @@ branding:
|
|
|
6
6
|
icon: "shield"
|
|
7
7
|
color: "red"
|
|
8
8
|
|
|
9
|
+
# POLICY SOURCE, stated because a consumer cannot see it from the inputs below.
|
|
10
|
+
#
|
|
11
|
+
# The scanner reads .supply-chain-guard.yml (or .yaml / .scg.yml / .scg.yaml)
|
|
12
|
+
# from the DIRECTORY IT SCANS. There is no input here that points it anywhere
|
|
13
|
+
# else. On a pull_request event the checkout is the HEAD of the proposing
|
|
14
|
+
# branch, so the policy applied to the scan is the proposing branch's policy: a
|
|
15
|
+
# change can ship a config that narrows the scan of that same change.
|
|
16
|
+
#
|
|
17
|
+
# It cannot do so silently. Every rule disabled, path ignored and rule
|
|
18
|
+
# suppressed by the loaded config is named in the scan report in every output
|
|
19
|
+
# format, including the markdown body of the pull request comment this Action
|
|
20
|
+
# posts by default, and a narrowing with no written reason is reported as a
|
|
21
|
+
# finding. See the "Where the policy is read from" section of the README for
|
|
22
|
+
# the controls that apply when the proposer is untrusted.
|
|
9
23
|
inputs:
|
|
10
24
|
path:
|
|
11
25
|
description: "Path to scan (defaults to repository root)"
|
|
@@ -66,7 +80,7 @@ runs:
|
|
|
66
80
|
|
|
67
81
|
- name: Install supply-chain-guard
|
|
68
82
|
shell: bash
|
|
69
|
-
run: npm install -g supply-chain-guard@
|
|
83
|
+
run: npm install -g supply-chain-guard@6.0.0 --ignore-scripts --no-audit --no-fund
|
|
70
84
|
|
|
71
85
|
- name: Run scan
|
|
72
86
|
id: scan
|
|
@@ -174,7 +188,11 @@ runs:
|
|
|
174
188
|
or . == "NPM_NO_ARTIFACT"
|
|
175
189
|
or . == "INTERNAL_DENYLIST_UNAVAILABLE"
|
|
176
190
|
or . == "INTERNAL_DENYLIST_INVALID_ENTRY"
|
|
177
|
-
or . == "
|
|
191
|
+
or . == "INTERNAL_DENYLIST_REFUSED"
|
|
192
|
+
or . == "POLICY_INVALID_INTERNAL_TERM"
|
|
193
|
+
or . == "RISK_HISTORY_UNREADABLE"
|
|
194
|
+
or . == "TRIAGE_STORE_UNREADABLE"
|
|
195
|
+
or . == "SCAN_ZERO_COVERAGE";
|
|
178
196
|
type == "object"
|
|
179
197
|
and (.summary | type == "object")
|
|
180
198
|
and (.findings | type == "array")
|
package/dist/cli.js
CHANGED
|
@@ -187,7 +187,7 @@ function assertDistinctOutputPaths(options) {
|
|
|
187
187
|
program
|
|
188
188
|
.name("supply-chain-guard")
|
|
189
189
|
.description("Open-source supply-chain security scanner. Detects GlassWorm and similar malware campaigns in npm packages, PyPI packages, code repos, VS Code extensions, and project dependencies.")
|
|
190
|
-
.version("
|
|
190
|
+
.version("6.0.0");
|
|
191
191
|
// ── scan command ────────────────────────────────────────────────────
|
|
192
192
|
program
|
|
193
193
|
.command("scan")
|
|
@@ -273,14 +273,24 @@ program
|
|
|
273
273
|
console.log(JSON.stringify(report.attackGraph, null, 2));
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
-
// Write SBOM to separate file if requested
|
|
276
|
+
// Write SBOM to separate file if requested.
|
|
277
|
+
//
|
|
278
|
+
// v5.30 (issue 198): this goes through the SAME renderer as
|
|
279
|
+
// `--format sbom`. It used to serialise report.sbomDocument directly
|
|
280
|
+
// unless the scan was partial, so the file carried no `vulnerabilities`
|
|
281
|
+
// key at all while stdout carried one entry per finding - two
|
|
282
|
+
// different documents from one scan, both presented by the README as
|
|
283
|
+
// the same artefact. The FILE moved, because the stdout document is the
|
|
284
|
+
// complete one and making stdout match the file would have deleted the
|
|
285
|
+
// findings from the SBOM instead of adding them to it.
|
|
277
286
|
if (opts.sbomOutput && report.sbomDocument) {
|
|
278
287
|
const { writeFileSync } = await import("node:fs");
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
const { describeInventoryCoverage } = await import("./sbom-generator.js");
|
|
289
|
+
writeFileSync(opts.sbomOutput, (0, reporter_js_1.formatReport)(report, "sbom"), "utf-8");
|
|
290
|
+
// A bare component count cannot distinguish "this project has no
|
|
291
|
+
// components" from "this ecosystem was not read" (issue 195), so the
|
|
292
|
+
// coverage sentence the document itself carries is printed with it.
|
|
293
|
+
console.error(`SBOM written to ${opts.sbomOutput} (CycloneDX 1.6, ${report.sbomDocument.components.length} components; ${describeInventoryCoverage(report.sbomDocument)})`);
|
|
284
294
|
}
|
|
285
295
|
// Show fix suggestions if requested
|
|
286
296
|
if (opts.exportFixes && report.fixSuggestions && report.fixSuggestions.length > 0) {
|
|
@@ -684,22 +694,44 @@ const feedCmd = program
|
|
|
684
694
|
.description("Inspect and refresh the threat-intel IOC feed");
|
|
685
695
|
feedCmd
|
|
686
696
|
.command("stats")
|
|
687
|
-
.description("Show IOC entry counts by type and severity (offline)")
|
|
697
|
+
.description("Show IOC entry counts, and how old the rule set is, by type and severity (offline)")
|
|
688
698
|
.option("-f, --format <format>", "Output format: text, json", "text")
|
|
689
699
|
.action(async (opts) => {
|
|
690
700
|
try {
|
|
691
701
|
const { getBundledFeed, loadThreatIntel } = await import("./threat-intel.js");
|
|
692
|
-
const { feedStats } = await import("./feed.js");
|
|
702
|
+
const { feedStats, feedFreshness, FEED_STALE_AFTER_DAYS } = await import("./feed.js");
|
|
693
703
|
const bundled = getBundledFeed();
|
|
694
704
|
const effective = loadThreatIntel();
|
|
695
705
|
const stats = feedStats(effective);
|
|
706
|
+
// Age of what a scan would actually match against, and of the pin on its
|
|
707
|
+
// own, so the two are distinguishable: a refreshed cache can make an old
|
|
708
|
+
// pin current, and only the pair shows that.
|
|
709
|
+
const freshness = feedFreshness(effective);
|
|
710
|
+
const bundledFreshness = feedFreshness(bundled);
|
|
696
711
|
if (opts.format === "json") {
|
|
697
|
-
console.log(JSON.stringify({
|
|
712
|
+
console.log(JSON.stringify({
|
|
713
|
+
bundledEntries: bundled.length,
|
|
714
|
+
...stats,
|
|
715
|
+
freshness,
|
|
716
|
+
bundledFreshness,
|
|
717
|
+
staleAfterDays: FEED_STALE_AFTER_DAYS,
|
|
718
|
+
}, null, 2));
|
|
698
719
|
return;
|
|
699
720
|
}
|
|
721
|
+
const describeAge = (f) => f.ageDays === null || f.newestIndicator === null
|
|
722
|
+
? "unknown (no indicator carries a usable date)"
|
|
723
|
+
: `${f.ageDays} day(s), newest indicator ${f.newestIndicator}${f.stale ? " [STALE]" : ""}`;
|
|
700
724
|
console.log(`\n Threat-intel feed statistics:\n`);
|
|
701
725
|
console.log(` Bundled entries: ${bundled.length}`);
|
|
702
726
|
console.log(` Effective entries: ${stats.total} (bundled + fresh cache)`);
|
|
727
|
+
console.log(`\n Rule-set age (stale after ${FEED_STALE_AFTER_DAYS} days):`);
|
|
728
|
+
console.log(` bundled with this version ${describeAge(bundledFreshness)}`);
|
|
729
|
+
console.log(` effective at scan time ${describeAge(freshness)}`);
|
|
730
|
+
if (freshness.stale) {
|
|
731
|
+
console.log(`\n This rule set is stale. Scanning is offline, so indicators published\n` +
|
|
732
|
+
` after that date cannot be detected. Update the package, or run\n` +
|
|
733
|
+
` \`supply-chain-guard feed refresh\` before scanning.`);
|
|
734
|
+
}
|
|
703
735
|
console.log(`\n By type:`);
|
|
704
736
|
for (const [type, count] of Object.entries(stats.byType)) {
|
|
705
737
|
console.log(` ${type.padEnd(10)} ${count}`);
|