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.
Files changed (111) hide show
  1. package/README.md +297 -35
  2. package/action.yml +20 -2
  3. package/dist/cli.js +42 -10
  4. package/dist/cli.js.map +1 -1
  5. package/dist/continuous-monitor.d.ts +77 -0
  6. package/dist/continuous-monitor.d.ts.map +1 -1
  7. package/dist/continuous-monitor.js +133 -11
  8. package/dist/continuous-monitor.js.map +1 -1
  9. package/dist/correlation-engine.d.ts +24 -2
  10. package/dist/correlation-engine.d.ts.map +1 -1
  11. package/dist/correlation-engine.js +32 -9
  12. package/dist/correlation-engine.js.map +1 -1
  13. package/dist/dependency-confusion.js +3 -3
  14. package/dist/dependency-confusion.js.map +1 -1
  15. package/dist/dockerfile-scanner.d.ts.map +1 -1
  16. package/dist/dockerfile-scanner.js +293 -4
  17. package/dist/dockerfile-scanner.js.map +1 -1
  18. package/dist/feed.d.ts +79 -3
  19. package/dist/feed.d.ts.map +1 -1
  20. package/dist/feed.js +171 -27
  21. package/dist/feed.js.map +1 -1
  22. package/dist/github-actions-scanner.js +11 -11
  23. package/dist/index.d.ts +14 -8
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +38 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/install-guard.d.ts +4 -2
  28. package/dist/install-guard.d.ts.map +1 -1
  29. package/dist/install-guard.js +20 -0
  30. package/dist/install-guard.js.map +1 -1
  31. package/dist/internal-disclosure.d.ts +58 -0
  32. package/dist/internal-disclosure.d.ts.map +1 -1
  33. package/dist/internal-disclosure.js +230 -29
  34. package/dist/internal-disclosure.js.map +1 -1
  35. package/dist/ioc-blocklist.js +2 -2
  36. package/dist/metrics.d.ts +37 -2
  37. package/dist/metrics.d.ts.map +1 -1
  38. package/dist/metrics.js +99 -20
  39. package/dist/metrics.js.map +1 -1
  40. package/dist/npm-scanner.d.ts +16 -0
  41. package/dist/npm-scanner.d.ts.map +1 -1
  42. package/dist/npm-scanner.js +51 -7
  43. package/dist/npm-scanner.js.map +1 -1
  44. package/dist/pattern-scanner.d.ts +18 -0
  45. package/dist/pattern-scanner.d.ts.map +1 -1
  46. package/dist/pattern-scanner.js +25 -0
  47. package/dist/pattern-scanner.js.map +1 -1
  48. package/dist/patterns.d.ts +24 -0
  49. package/dist/patterns.d.ts.map +1 -1
  50. package/dist/patterns.js +84 -3
  51. package/dist/patterns.js.map +1 -1
  52. package/dist/policy-engine.d.ts +37 -1
  53. package/dist/policy-engine.d.ts.map +1 -1
  54. package/dist/policy-engine.js +151 -3
  55. package/dist/policy-engine.js.map +1 -1
  56. package/dist/pypi-scanner.js +1 -1
  57. package/dist/pypi-scanner.js.map +1 -1
  58. package/dist/regex-complexity.d.ts +37 -0
  59. package/dist/regex-complexity.d.ts.map +1 -1
  60. package/dist/regex-complexity.js +143 -0
  61. package/dist/regex-complexity.js.map +1 -1
  62. package/dist/reporter.d.ts.map +1 -1
  63. package/dist/reporter.js +585 -70
  64. package/dist/reporter.js.map +1 -1
  65. package/dist/sbom-generator.d.ts +134 -6
  66. package/dist/sbom-generator.d.ts.map +1 -1
  67. package/dist/sbom-generator.js +753 -76
  68. package/dist/sbom-generator.js.map +1 -1
  69. package/dist/scanner.d.ts.map +1 -1
  70. package/dist/scanner.js +221 -15
  71. package/dist/scanner.js.map +1 -1
  72. package/dist/sla-engine.d.ts +60 -0
  73. package/dist/sla-engine.d.ts.map +1 -1
  74. package/dist/sla-engine.js +113 -17
  75. package/dist/sla-engine.js.map +1 -1
  76. package/dist/slsa-verifier.d.ts +138 -25
  77. package/dist/slsa-verifier.d.ts.map +1 -1
  78. package/dist/slsa-verifier.js +454 -117
  79. package/dist/slsa-verifier.js.map +1 -1
  80. package/dist/state-dir.d.ts +78 -0
  81. package/dist/state-dir.d.ts.map +1 -1
  82. package/dist/state-dir.js +55 -0
  83. package/dist/state-dir.js.map +1 -1
  84. package/dist/threat-intel.d.ts +88 -2
  85. package/dist/threat-intel.d.ts.map +1 -1
  86. package/dist/threat-intel.js +268 -7
  87. package/dist/threat-intel.js.map +1 -1
  88. package/dist/triage-engine.d.ts +59 -0
  89. package/dist/triage-engine.d.ts.map +1 -1
  90. package/dist/triage-engine.js +126 -15
  91. package/dist/triage-engine.js.map +1 -1
  92. package/dist/triage-scope.d.ts +74 -0
  93. package/dist/triage-scope.d.ts.map +1 -0
  94. package/dist/triage-scope.js +100 -0
  95. package/dist/triage-scope.js.map +1 -0
  96. package/dist/trust-breakdown.d.ts +9 -1
  97. package/dist/trust-breakdown.d.ts.map +1 -1
  98. package/dist/trust-breakdown.js +70 -11
  99. package/dist/trust-breakdown.js.map +1 -1
  100. package/dist/types.d.ts +300 -25
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/dist/vscode-scanner.js +1 -1
  104. package/dist/vscode-scanner.js.map +1 -1
  105. package/dist/workflow-ast.d.ts +20 -0
  106. package/dist/workflow-ast.d.ts.map +1 -1
  107. package/dist/workflow-ast.js +22 -0
  108. package/dist/workflow-ast.js.map +1 -1
  109. package/package.json +7 -3
  110. package/policy-schema.json +38 -11
  111. 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, unpinned base images, hardcoded secrets, SUID bits
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
- 4-dimension trust scoring for every scan:
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.** Node 20 reached end of life on
121
- 2026-04-30; it still installs and is still covered by CI as a transition lane, but it
122
- is out of support and that lane is removed in 5.29.0. Full policy, including what the
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: v5.28.1
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:5.28.1 scan /scan
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 # Write SBOM to file separately
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: [INTERNAL_PRIVATE_IP, INTERNAL_HOSTNAME, INTERNAL_SERVICE_ENDPOINT,
294
- INTERNAL_GIT_REMOTE, INTERNAL_DEV_PATH, INTERNAL_SINGLE_LABEL_URL]
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
- - HEX_ARRAY
436
- - CHARCODE_OBFUSCATION
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
- - vendor/**
457
- - "**/*.min.js"
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, Containerfile |
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, and reports are exportable as
641
- SARIF, JSON, and CycloneDX for retention alongside an incident record.
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. `scan` runs fully offline against the bundled threat feed, so it
649
- is suitable for air-gapped use. The commands that deliberately reach the network
650
- are the ones that exist to do so: `supply-chain-guard npm <pkg>` and
651
- `supply-chain-guard pypi <pkg>` fetch the package under inspection, and the feed
652
- refresh fetches updated indicators. Offline runs use the feed bundled with the
653
- installed version, so pin the version you intend to audit against.
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@v5.28.1
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: weekly
873
+ interval: daily
691
874
  ```
692
875
 
693
- ### `@v5`, and what it does and does not guarantee
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
- `@v5` also works and stays supported. It is a floating **branch**, fast-forwarded
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 `@v5` is not `latest`: every
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 v5 line stops being released once
701
- v6 ships, `@v5` keeps resolving a frozen action that pins an old npm version, and
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 visible out-of-date dependency instead.
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@5.28.1 --ignore-scripts --no-audit --no-fund
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 . == "POLICY_INVALID_INTERNAL_TERM";
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("5.28.1");
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 sbomOutput = report.partialScan
280
- ? (0, reporter_js_1.formatReport)(report, "sbom")
281
- : JSON.stringify(report.sbomDocument, null, 2);
282
- writeFileSync(opts.sbomOutput, sbomOutput, "utf-8");
283
- console.error(`SBOM written to ${opts.sbomOutput} (CycloneDX 1.6, ${report.sbomDocument.components.length} components)`);
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({ bundledEntries: bundled.length, ...stats }, null, 2));
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}`);