@blamejs/exceptd-skills 0.19.33 → 0.19.34
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/CHANGELOG.md +10 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
|
@@ -1,31 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* SECURITY.md / security.txt presence, sbom + signature pair,
|
|
9
|
-
* publish-workflow shape (id-token write, action-ref pinning, frozen
|
|
10
|
-
* install), package.json provenance opt-in, vendor-tree provenance,
|
|
11
|
-
* lockfile integrity.
|
|
12
|
-
*
|
|
13
|
-
* Skipped indicators (require external API or runtime data, left
|
|
14
|
-
* unflipped so the runner returns inconclusive rather than a forced
|
|
15
|
-
* miss):
|
|
16
|
-
*
|
|
17
|
-
* tag-protection-absent GitHub branch-protection API
|
|
18
|
-
* private-vuln-reporting-disabled GitHub repo-settings API
|
|
19
|
-
* no-rekor-entry-for-latest-release sigstore lookup
|
|
20
|
-
* release-tag-not-signed git tag --verify on each release
|
|
21
|
-
* release-signed-with-personal-gpg-key GPG identity policy
|
|
22
|
-
* sbom-regenerated-at-request-time timing-based; needs build
|
|
23
|
-
* pipeline trace
|
|
24
|
-
* skill-signing-but-verification-not-gated project-internal CI
|
|
25
|
-
* gate inspection
|
|
26
|
-
* ssdf-claimed-cra-not-ready operator interview shape
|
|
27
|
-
* gha-workflow-script-injection-sink complex pattern; future pass
|
|
28
|
-
*
|
|
4
|
+
* Companion collector for the `library-author` playbook: audits a publisher-side
|
|
5
|
+
* repository for supply-chain posture markers. Indicators needing an external API
|
|
6
|
+
* or a build-pipeline trace are left unflipped, so the runner returns
|
|
7
|
+
* inconclusive rather than a forced miss.
|
|
29
8
|
* Interface: see lib/collectors/README.md
|
|
30
9
|
*/
|
|
31
10
|
|
|
@@ -36,10 +15,8 @@ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require(
|
|
|
36
15
|
const COLLECTOR_ID = "library-author";
|
|
37
16
|
|
|
38
17
|
const DEFAULT_MAX_DEPTH = 6;
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// walk so a linked worktree or dependency cache nested under `vendor/`
|
|
42
|
-
// isn't mistaken for vendored provenance state.
|
|
18
|
+
// Applied to the vendor-tree walk, so a cache nested under `vendor/` is not
|
|
19
|
+
// mistaken for vendored provenance state.
|
|
43
20
|
const DEFAULT_EXCLUDES = codeExcludeSet();
|
|
44
21
|
|
|
45
22
|
function readSafe(full, max = 512 * 1024) {
|
|
@@ -48,10 +25,8 @@ function readSafe(full, max = 512 * 1024) {
|
|
|
48
25
|
fd = fs.openSync(full, "r");
|
|
49
26
|
const s = fs.fstatSync(fd);
|
|
50
27
|
if (s.size > max) return null;
|
|
51
|
-
// readFileSync(fd)
|
|
52
|
-
//
|
|
53
|
-
// leave the buffer tail NUL-filled and silently drop trailing content.
|
|
54
|
-
// Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
|
|
28
|
+
// readFileSync(fd), never a single readSync: a short read on a network fd
|
|
29
|
+
// NUL-fills the tail. Reading the open fd is also TOCTOU-free.
|
|
55
30
|
return fs.readFileSync(fd, "utf8");
|
|
56
31
|
} catch { return null; }
|
|
57
32
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -74,53 +49,34 @@ function walkWorkflows(root) {
|
|
|
74
49
|
}
|
|
75
50
|
|
|
76
51
|
function looksLikePublishWorkflow(name, content) {
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// `id-token: write`, bare `sigstore/cosign-installer`, or `cosign
|
|
80
|
-
// sign-blob` (commonly used in test workflows) DO NOT count; they
|
|
81
|
-
// produced false positives on verification + e2e workflows that
|
|
82
|
-
// happen to share signing infrastructure.
|
|
52
|
+
// Broad signals — `id-token: write`, a cosign installer — must not count:
|
|
53
|
+
// verification and e2e workflows share the same signing infrastructure.
|
|
83
54
|
const lower = name.toLowerCase();
|
|
84
55
|
|
|
85
|
-
// Verification / test / e2e workflows are never publish, regardless
|
|
86
|
-
// of body content. Hard demotion.
|
|
87
56
|
if (/^(test|verify|validate|e2e|kind|check|conformance|coverage)/.test(lower)) return false;
|
|
88
57
|
|
|
89
|
-
// Filename prefix signals: only the canonical publish words.
|
|
90
58
|
if (/^(release|publish|deploy|promote|tag-and-release)/.test(lower)) return true;
|
|
91
59
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// publish workflow's depth.") can't masquerade as a publish command. The
|
|
95
|
-
// filename-prefix checks above are unaffected. A `#` inside a quoted string
|
|
96
|
-
// is rare in workflow YAML and not load-bearing for these command probes
|
|
97
|
-
// (stripping can only REMOVE comment text, never create a false match).
|
|
60
|
+
// Comment-stripped, so a `#` line mentioning a publish verb cannot masquerade
|
|
61
|
+
// as one. Stripping can only remove text, never create a match.
|
|
98
62
|
const code = stripYamlComments(content);
|
|
99
63
|
|
|
100
|
-
// Explicit publish-shape commands — these are commitments to push
|
|
101
|
-
// artifacts, not setup / scaffolding.
|
|
102
64
|
if (/\bnpm\s+publish\b/.test(code)) return true;
|
|
103
65
|
if (/pypa\/gh-action-pypi-publish/.test(code)) return true;
|
|
104
66
|
if (/\bcargo\s+publish\b/.test(code)) return true;
|
|
105
67
|
if (/goreleaser/.test(code)) return true;
|
|
106
68
|
if (/softprops\/action-gh-release/.test(code)) return true;
|
|
107
|
-
// `ko
|
|
108
|
-
// --push=false). All four sub-commands push to a registry.
|
|
69
|
+
// All four `ko` sub-commands push to a registry by default, `build` included.
|
|
109
70
|
if (/\bko\s+(?:publish|build|apply|resolve)\b/.test(code)) return true;
|
|
110
|
-
// `cosign sign
|
|
111
|
-
// `cosign sign-blob` which signs arbitrary artifacts and shows
|
|
112
|
-
// up in verification / e2e tests as often as in publishes.
|
|
71
|
+
// The `(?!-)` keeps out `cosign sign-blob`, as common in verification workflows.
|
|
113
72
|
if (/\bcosign\s+sign\b(?!-)/.test(code)) return true;
|
|
114
73
|
if (/\bcrane\s+(?:push|copy|append)\b/.test(code)) return true;
|
|
115
74
|
if (/\boras\s+(?:push|copy)\b/.test(code)) return true;
|
|
116
75
|
if (/docker\/build-push-action/.test(code)) return true;
|
|
117
76
|
if (/\bdocker\s+push\b/.test(code)) return true;
|
|
118
77
|
|
|
119
|
-
// Registry login
|
|
120
|
-
//
|
|
121
|
-
// publish paths (cosign's build.yaml uses make sign-ci-containers
|
|
122
|
-
// after docker/login-action). Does NOT match verification tests
|
|
123
|
-
// because those don't authenticate to a registry.
|
|
78
|
+
// Registry login stands in for opaque publish paths (a Makefile target run
|
|
79
|
+
// after docker/login-action); verification workflows never authenticate.
|
|
124
80
|
if (/docker\/login-action/.test(code)) return true;
|
|
125
81
|
if (/google-github-actions\/auth/.test(code) && /gcloud auth configure-docker/.test(code)) return true;
|
|
126
82
|
if (/aws-actions\/configure-aws-credentials/.test(code) && /amazon-ecr/.test(code)) return true;
|
|
@@ -128,33 +84,20 @@ function looksLikePublishWorkflow(name, content) {
|
|
|
128
84
|
return false;
|
|
129
85
|
}
|
|
130
86
|
|
|
131
|
-
// `id-token: write`
|
|
132
|
-
//
|
|
133
|
-
// a specific job's `permissions:` block, so any occurrence of the
|
|
134
|
-
// `id-token: write` token within the publish workflow file counts as the
|
|
135
|
-
// capability being present — this is what lets a job-scoped declaration
|
|
136
|
-
// (not just a workflow-level one) satisfy the check. The match is
|
|
137
|
-
// deliberately scoped to the file being scanned, not repo-wide: a sibling
|
|
138
|
-
// workflow's OIDC does not grant it to the publish job.
|
|
87
|
+
// `id-token: write` is valid at workflow OR job scope, so any occurrence counts.
|
|
88
|
+
// Scoped to the one file passed in: a sibling workflow's OIDC does not grant it.
|
|
139
89
|
function hasIdTokenWriteAnyScope(content) {
|
|
140
90
|
return /\bid-token:\s*write\b/.test(content);
|
|
141
91
|
}
|
|
142
92
|
|
|
143
|
-
//
|
|
144
|
-
// token
|
|
145
|
-
// (looksLikePublishWorkflow) already does this; the indicator probes — and the
|
|
146
|
-
// provenance / SBOM-capability probes in collect() — must use the same view,
|
|
147
|
-
// or a comment produces a false (often deterministic) hit, and in the
|
|
148
|
-
// provenance direction a commented `--provenance` would suppress a real gap
|
|
149
|
-
// (a false negative on a security-relevant posture check).
|
|
93
|
+
// Every probe that reads workflow bodies must use this view: a commented publish
|
|
94
|
+
// token otherwise produces a false hit, and a commented `--provenance` hides a gap.
|
|
150
95
|
function stripYamlComments(content) {
|
|
151
96
|
return content.replace(/#.*$/gm, "");
|
|
152
97
|
}
|
|
153
98
|
|
|
154
99
|
function scanPublishWorkflow(content, rel) {
|
|
155
|
-
// Whole-content probes below
|
|
156
|
-
// `uses:` line scan stays on the raw lines — its anchored regex already
|
|
157
|
-
// rejects `#`-prefixed lines, so a commented `uses:` cannot match.
|
|
100
|
+
// Whole-content probes read `code`; the `uses:` scan below stays on raw lines.
|
|
158
101
|
const code = stripYamlComments(content);
|
|
159
102
|
const hits = {
|
|
160
103
|
"publish-workflow-uses-static-token": [],
|
|
@@ -164,18 +107,10 @@ function scanPublishWorkflow(content, rel) {
|
|
|
164
107
|
"publish-workflow-runs-on-self-hosted": [],
|
|
165
108
|
};
|
|
166
109
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// The predicate (per data/playbooks/library-author.json) lists
|
|
170
|
-
// NPM_TOKEN / PYPI_TOKEN / CARGO_TOKEN / RUBYGEMS_API_KEY /
|
|
171
|
-
// GEM_HOST_API_KEY; expand to cover the common variants for each
|
|
172
|
-
// ecosystem.
|
|
110
|
+
// Widens the predicate in data/playbooks/library-author.json to the common
|
|
111
|
+
// token-name variant per ecosystem.
|
|
173
112
|
const usesStaticToken = /\bsecrets\.(NPM_TOKEN|PYPI_TOKEN|PYPI_API_TOKEN|CARGO_TOKEN|CARGO_REGISTRY_TOKEN|RUBYGEMS_API_KEY|GEM_HOST_API_KEY|MAVEN_TOKEN|MAVEN_CENTRAL_TOKEN|GH_TOKEN)\b/.test(code);
|
|
174
|
-
//
|
|
175
|
-
// any scope (workflow or job). Scoped to the file by design — a sibling
|
|
176
|
-
// workflow's OIDC does not authenticate this publish job. Read from the
|
|
177
|
-
// comment-stripped view so a commented `id-token: write` cannot falsely
|
|
178
|
-
// satisfy the capability (which would suppress the static-token finding).
|
|
113
|
+
// Comment-stripped: a commented `id-token: write` would suppress the finding.
|
|
179
114
|
const hasIdTokenWrite = hasIdTokenWriteAnyScope(code);
|
|
180
115
|
if (usesStaticToken && !hasIdTokenWrite) {
|
|
181
116
|
hits["publish-workflow-uses-static-token"].push({ file: rel, line: 0, snippet: "publish workflow uses a static long-lived token (NPM_TOKEN / PYPI / Cargo / Maven) without id-token: write for OIDC" });
|
|
@@ -184,20 +119,15 @@ function scanPublishWorkflow(content, rel) {
|
|
|
184
119
|
hits["publish-workflow-no-id-token-write"].push({ file: rel, line: 0, snippet: "no id-token: write at any scope (workflow or job) — npm provenance / sigstore signing unavailable" });
|
|
185
120
|
}
|
|
186
121
|
|
|
187
|
-
//
|
|
188
|
-
// a 40-char hex sha. Excludes `uses: ./local-action`.
|
|
122
|
+
// Mutable = any `uses: <action>@<ref>` whose ref is not a 40-char hex sha.
|
|
189
123
|
const lines = content.split(/\r?\n/);
|
|
190
124
|
for (let i = 0; i < lines.length; i++) {
|
|
191
125
|
const line = lines[i];
|
|
192
|
-
// A real `uses:` line is never
|
|
193
|
-
// crafted whitespace run
|
|
126
|
+
// A real `uses:` line is never KBs long; skipping overlong ones keeps a
|
|
127
|
+
// crafted whitespace run from driving regex backtracking.
|
|
194
128
|
if (line.length > 4096) continue;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// `- ` list marker — no overlapping `\s*` runs that backtrack. Exclude `#`
|
|
198
|
-
// from the capture and drop the end anchor (mirroring the cicd collector) so
|
|
199
|
-
// a `uses: x@v1 # comment` line is still matched — the `$`-anchored pattern
|
|
200
|
-
// silently missed every floating ref that carried a trailing YAML comment.
|
|
129
|
+
// No end anchor and `#` excluded from the capture, so `uses: x@v1 # comment`
|
|
130
|
+
// still matches. The indent and `- ` marker are each anchored once.
|
|
201
131
|
const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
|
|
202
132
|
if (!m) continue;
|
|
203
133
|
const ref = m[1];
|
|
@@ -210,9 +140,6 @@ function scanPublishWorkflow(content, rel) {
|
|
|
210
140
|
}
|
|
211
141
|
}
|
|
212
142
|
|
|
213
|
-
// non-frozen-install: workflow uses `npm install` instead of `npm ci`,
|
|
214
|
-
// or `pip install <pkg>` without `--require-hashes`, or `cargo
|
|
215
|
-
// install` without `--locked`.
|
|
216
143
|
if (/\bnpm\s+install\b/.test(code) && !/\bnpm\s+ci\b/.test(code)) {
|
|
217
144
|
hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "publish workflow uses `npm install` rather than `npm ci` — lockfile is not enforced" });
|
|
218
145
|
}
|
|
@@ -220,7 +147,6 @@ function scanPublishWorkflow(content, rel) {
|
|
|
220
147
|
hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "cargo build/install without --locked / --frozen" });
|
|
221
148
|
}
|
|
222
149
|
|
|
223
|
-
// runs-on-self-hosted: any `runs-on: self-hosted` line.
|
|
224
150
|
if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(code)) {
|
|
225
151
|
hits["publish-workflow-runs-on-self-hosted"].push({ file: rel, line: 0, snippet: "publish workflow runs on a self-hosted runner — non-ephemeral execution context" });
|
|
226
152
|
}
|
|
@@ -233,7 +159,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
233
159
|
const startTime = Date.now();
|
|
234
160
|
const root = path.resolve(cwd);
|
|
235
161
|
|
|
236
|
-
// package-manifest detection.
|
|
237
162
|
const manifests = [];
|
|
238
163
|
const manifestFiles = [
|
|
239
164
|
"package.json", "pyproject.toml", "Cargo.toml", "go.mod",
|
|
@@ -246,7 +171,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
246
171
|
manifests.push({ file: f, path: p, content: readSafe(p) });
|
|
247
172
|
}
|
|
248
173
|
}
|
|
249
|
-
// *.gemspec — glob at root only.
|
|
250
174
|
try {
|
|
251
175
|
for (const e of fs.readdirSync(root)) {
|
|
252
176
|
if (e.endsWith(".gemspec")) {
|
|
@@ -256,11 +180,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
256
180
|
}
|
|
257
181
|
} catch {}
|
|
258
182
|
|
|
259
|
-
// Release / publish workflows.
|
|
260
183
|
const workflows = walkWorkflows(root).map(w => ({ ...w, content: readSafe(w.full) || "" }));
|
|
261
184
|
const publishWorkflows = workflows.filter(w => looksLikePublishWorkflow(w.name, w.content));
|
|
262
185
|
|
|
263
|
-
// Aggregate publish-workflow indicator hits.
|
|
264
186
|
const workflowHits = {
|
|
265
187
|
"publish-workflow-uses-static-token": [],
|
|
266
188
|
"publish-workflow-no-id-token-write": [],
|
|
@@ -268,24 +190,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
268
190
|
"release-workflow-non-frozen-install": [],
|
|
269
191
|
"publish-workflow-runs-on-self-hosted": [],
|
|
270
192
|
};
|
|
271
|
-
// OIDC capability is evaluated per publish workflow,
|
|
272
|
-
//
|
|
273
|
-
// OIDC to a release job that still publishes with a long-lived
|
|
274
|
-
// `secrets.NPM_TOKEN`. Treating it repo-wide would mask exactly the
|
|
275
|
-
// static-token publisher-takeover case this indicator exists to catch.
|
|
276
|
-
// Job-scoped `id-token: write` inside the publish file still counts —
|
|
277
|
-
// hasIdTokenWriteAnyScope matches any scope within the scanned file.
|
|
193
|
+
// OIDC capability is evaluated per publish workflow, never repo-wide: a sibling
|
|
194
|
+
// workflow's `id-token: write` gives nothing to a job using a static token.
|
|
278
195
|
for (const w of publishWorkflows) {
|
|
279
196
|
const h = scanPublishWorkflow(w.content, w.rel);
|
|
280
197
|
for (const [id, list] of Object.entries(h)) workflowHits[id].push(...list);
|
|
281
198
|
}
|
|
282
199
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// true`) is missing AND the publish workflow does not invoke
|
|
286
|
-
// `npm publish --provenance`. Repos that flip provenance on at
|
|
287
|
-
// the workflow level (the modern path) should not be flagged.
|
|
288
|
-
// We need the workflow inventory above this point.
|
|
200
|
+
// Fires only when neither `publishConfig.provenance: true` nor a workflow
|
|
201
|
+
// `npm publish --provenance` opts in. Needs the workflow inventory above.
|
|
289
202
|
let provenanceMissing = "miss";
|
|
290
203
|
const pkgManifest = manifests.find(m => m.file === "package.json");
|
|
291
204
|
if (pkgManifest && pkgManifest.content) {
|
|
@@ -299,16 +212,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
299
212
|
}
|
|
300
213
|
}
|
|
301
214
|
|
|
302
|
-
// lockfile
|
|
303
|
-
//
|
|
304
|
-
// sha256, sri-integrity, go.sum h1: hash)". The predicate
|
|
305
|
-
// covers every ecosystem's lockfile shape, not just npm. Walk
|
|
306
|
-
// each supported lockfile and flip the indicator if any one of
|
|
307
|
-
// them has a missing-integrity entry. When NO lockfile is
|
|
308
|
-
// present, leave the indicator unflipped (undefined) so the
|
|
309
|
-
// runner returns inconclusive — a no-lockfile repo is not
|
|
310
|
-
// evidence of "no missing integrity"; it's evidence we can't
|
|
311
|
-
// tell, and the playbook covers that case as inconclusive.
|
|
215
|
+
// Any one lockfile with a missing-integrity entry flips the indicator. With no
|
|
216
|
+
// lockfile it stays undefined: a repo without one is not evidence of integrity.
|
|
312
217
|
let lockfileMissingIntegrity = undefined;
|
|
313
218
|
const lockfilesChecked = [];
|
|
314
219
|
const lockfileScans = [
|
|
@@ -317,15 +222,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
317
222
|
scan: (text) => {
|
|
318
223
|
const j = JSON.parse(text);
|
|
319
224
|
let missing = 0;
|
|
320
|
-
// Only a
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// file:/link:/workspace:/git: refs legitimately have NO integrity (a
|
|
324
|
-
// local symlink is not a registry artifact). Counting them flips
|
|
325
|
-
// lockfile-missing-integrity to a false hit on every npm-workspaces
|
|
326
|
-
// monorepo even when every registry dep is sha512-pinned. Mirrors
|
|
327
|
-
// sbom.js's local-ref demotion; the bare-relative-path + `link:true`
|
|
328
|
-
// shape is the canonical workspace link.
|
|
225
|
+
// Only a remote-tarball entry (`resolved` is an http(s)/git URL) owes an
|
|
226
|
+
// integrity hash; a workspace link and file:/link:/workspace: refs have
|
|
227
|
+
// none by nature, and counting them false-hits every workspaces monorepo.
|
|
329
228
|
const walkObj = (obj) => {
|
|
330
229
|
if (!obj || typeof obj !== "object") return;
|
|
331
230
|
if (
|
|
@@ -343,8 +242,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
343
242
|
{
|
|
344
243
|
file: "yarn.lock",
|
|
345
244
|
scan: (text) => {
|
|
346
|
-
//
|
|
347
|
-
// followed within ~5 lines by an `integrity sha512-...` line.
|
|
245
|
+
// A `resolved "https://..."` line owes an `integrity sha512-...` nearby.
|
|
348
246
|
const lines = text.split(/\r?\n/);
|
|
349
247
|
let missing = 0;
|
|
350
248
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -358,9 +256,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
358
256
|
{
|
|
359
257
|
file: "pnpm-lock.yaml",
|
|
360
258
|
scan: (text) => {
|
|
361
|
-
// pnpm
|
|
362
|
-
// with `resolution: { tarball: ... }` lines whose nearby
|
|
363
|
-
// block lacks `integrity:`.
|
|
259
|
+
// A pnpm block carrying `resolution:` owes an `integrity:` field.
|
|
364
260
|
const blocks = text.split(/\n(?=\s+\/)/);
|
|
365
261
|
let missing = 0;
|
|
366
262
|
for (const b of blocks) {
|
|
@@ -373,14 +269,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
373
269
|
{
|
|
374
270
|
file: "Cargo.lock",
|
|
375
271
|
scan: (text) => {
|
|
376
|
-
//
|
|
377
|
-
// (registry deps only — path / git deps legitimately have
|
|
378
|
-
// none, so count only registry packages).
|
|
272
|
+
// `checksum` is owed per [[package]] by registry deps only.
|
|
379
273
|
const packages = text.split(/^\[\[package\]\]/m).slice(1);
|
|
380
274
|
let missing = 0;
|
|
381
275
|
for (const p of packages) {
|
|
382
|
-
// Skip path / git deps — they have a `source = "<...>"`
|
|
383
|
-
// line; registry deps have `source = "registry+..."`.
|
|
384
276
|
const sourceMatch = p.match(/^source\s*=\s*"([^"]+)"/m);
|
|
385
277
|
if (!sourceMatch) continue; // path dep, no integrity expected
|
|
386
278
|
if (!sourceMatch[1].startsWith("registry+")) continue;
|
|
@@ -392,8 +284,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
392
284
|
{
|
|
393
285
|
file: "go.sum",
|
|
394
286
|
scan: (text) => {
|
|
395
|
-
// go.sum
|
|
396
|
-
// Missing-integrity = a line without `h1:` (rare; malformed).
|
|
287
|
+
// A go.sum line is `<module> <version> h1:<hash>=`; one without `h1:` is malformed.
|
|
397
288
|
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
398
289
|
let missing = 0;
|
|
399
290
|
for (const l of lines) {
|
|
@@ -414,8 +305,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
414
305
|
lockfileMissingIntegrity = "hit";
|
|
415
306
|
break;
|
|
416
307
|
}
|
|
417
|
-
// If at least one lockfile scanned cleanly, the indicator
|
|
418
|
-
// moves to miss — we have evidence of integrity coverage.
|
|
419
308
|
if (lockfileMissingIntegrity === undefined) lockfileMissingIntegrity = "miss";
|
|
420
309
|
} catch (e) {
|
|
421
310
|
errors.push({
|
|
@@ -425,55 +314,37 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
425
314
|
});
|
|
426
315
|
}
|
|
427
316
|
}
|
|
428
|
-
// If no supported lockfile was present, leave the indicator
|
|
429
|
-
// unflipped (undefined). The runner returns inconclusive.
|
|
430
|
-
|
|
431
|
-
// SECURITY.md presence (root + .github/SECURITY.md).
|
|
432
317
|
const securityMdPresent =
|
|
433
318
|
fs.existsSync(path.join(root, "SECURITY.md")) ||
|
|
434
319
|
fs.existsSync(path.join(root, ".github", "SECURITY.md"));
|
|
435
320
|
|
|
436
|
-
// security.txt presence (.well-known/security.txt + security.txt).
|
|
437
321
|
const securityTxtPresent =
|
|
438
322
|
fs.existsSync(path.join(root, ".well-known", "security.txt")) ||
|
|
439
323
|
fs.existsSync(path.join(root, "security.txt"));
|
|
440
324
|
|
|
441
|
-
//
|
|
442
|
-
// present but no matching .sig sidecar.
|
|
325
|
+
// Fires when there is no root SBOM, or one with no matching .sig sidecar.
|
|
443
326
|
let sbomFile = null;
|
|
444
327
|
for (const f of ["sbom.cdx.json", "sbom.json", "bom.json", "sbom.spdx.json", "sbom.cdx.xml"]) {
|
|
445
328
|
if (fs.existsSync(path.join(root, f))) { sbomFile = f; break; }
|
|
446
329
|
}
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
// release time inside the publish workflow. Those assets never land in
|
|
450
|
-
// the committed tree, so a repo-state-only check can't see them. Detect
|
|
451
|
-
// the *capability* in the publish workflows: an SBOM-generation step
|
|
452
|
-
// (cyclonedx / syft / anchore-sbom-action / trivy / `npm sbom`), npm
|
|
453
|
-
// provenance (`--provenance` flag or `publishConfig.provenance: true`),
|
|
454
|
-
// or a sigstore/cosign signing step. If any is present, the SBOM /
|
|
455
|
-
// signed-attestation capability exists at release and the indicator
|
|
456
|
-
// should not fire on the absence of a committed artifact.
|
|
330
|
+
// An SBOM generated and signed at release time never lands in the committed
|
|
331
|
+
// tree, so the capability is detected in the publish workflows instead.
|
|
457
332
|
const releaseSbomCapable = publishWorkflows.some(w => {
|
|
458
333
|
const c = stripYamlComments(w.content);
|
|
459
334
|
return (
|
|
460
|
-
// SBOM-generation tooling invoked in the workflow.
|
|
461
335
|
/cyclonedx/i.test(c) ||
|
|
462
336
|
/\bsyft\b/i.test(c) ||
|
|
463
337
|
/anchore\/sbom-action/i.test(c) ||
|
|
464
338
|
/\btrivy\b[^\n]*\bsbom\b/i.test(c) ||
|
|
465
339
|
/\bnpm\s+sbom\b/.test(c) ||
|
|
466
340
|
/spdx-sbom-generator/i.test(c) ||
|
|
467
|
-
// npm provenance (signed build provenance attestation at publish).
|
|
468
341
|
/npm\s+publish[^\n]*--provenance\b/.test(c) ||
|
|
469
|
-
// sigstore / cosign signing of release artifacts.
|
|
470
342
|
/\bcosign\s+(?:sign|attest)\b/.test(c) ||
|
|
471
343
|
/sigstore\//i.test(c) ||
|
|
472
344
|
/gh-action-sigstore-python/i.test(c)
|
|
473
345
|
);
|
|
474
346
|
});
|
|
475
|
-
//
|
|
476
|
-
// provenance attestation is produced at publish time.
|
|
347
|
+
// The manifest opt-in likewise produces a signed provenance attestation.
|
|
477
348
|
const manifestProvenanceOptIn = (() => {
|
|
478
349
|
if (!pkgManifest || !pkgManifest.content) return false;
|
|
479
350
|
try { return JSON.parse(pkgManifest.content)?.publishConfig?.provenance === true; }
|
|
@@ -484,14 +355,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
484
355
|
const sigPath = path.join(root, `${sbomFile}.sig`);
|
|
485
356
|
sbomAbsentOrUnsigned = fs.existsSync(sigPath) ? "miss" : "hit";
|
|
486
357
|
} else if (releaseSbomCapable || manifestProvenanceOptIn) {
|
|
487
|
-
// No committed SBOM, but the release pipeline generates / signs one
|
|
488
|
-
// (or emits a signed provenance attestation). Treat the capability
|
|
489
|
-
// as present-at-release rather than reporting it absent.
|
|
490
358
|
sbomAbsentOrUnsigned = "miss";
|
|
491
359
|
}
|
|
492
360
|
|
|
493
|
-
//
|
|
494
|
-
// _PROVENANCE.json at any level inside it.
|
|
361
|
+
// Fires when vendor/ exists with no _PROVENANCE.json at any level inside it.
|
|
495
362
|
let vendoredNoProvenance = "miss";
|
|
496
363
|
const vendorDir = path.join(root, "vendor");
|
|
497
364
|
if (fs.existsSync(vendorDir)) {
|
|
@@ -505,10 +372,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
505
372
|
if (e.isDirectory()) {
|
|
506
373
|
if (DEFAULT_EXCLUDES.has(e.name)) continue;
|
|
507
374
|
const sub = path.join(dir, e.name);
|
|
508
|
-
//
|
|
509
|
-
// vendor/ — its `.git` is a gitdir pointer file, and a repo
|
|
510
|
-
// copy stamped by agent tooling carries unrelated provenance
|
|
511
|
-
// state that shouldn't count toward this tree's vendoring.
|
|
375
|
+
// A linked worktree under vendor/ is a separate repo copy.
|
|
512
376
|
if (isLinkedWorktreeDir(sub)) continue;
|
|
513
377
|
walkVendor(sub, depth + 1);
|
|
514
378
|
}
|
|
@@ -530,19 +394,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
530
394
|
"no-security-txt": securityTxtPresent ? "miss" : "hit",
|
|
531
395
|
"vendored-no-provenance": vendoredNoProvenance,
|
|
532
396
|
};
|
|
533
|
-
//
|
|
534
|
-
// out (so the runner returns inconclusive) when no supported
|
|
535
|
-
// lockfile was present to scan.
|
|
397
|
+
// Omitted when no lockfile was there to scan, so the runner reads inconclusive.
|
|
536
398
|
if (lockfileMissingIntegrity !== undefined) {
|
|
537
399
|
signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
|
|
538
400
|
}
|
|
539
401
|
|
|
540
|
-
//
|
|
541
|
-
// false_positive_checks_required
|
|
542
|
-
//
|
|
543
|
-
// demotes the finding — attest survival when no such config exists.
|
|
544
|
-
// [1] every mutable ref pointing to a github-owned action is lower risk —
|
|
545
|
-
// attest survival when at least one mutable ref is third-party.
|
|
402
|
+
// The keys "0" and "1" are positional indexes into the indicator's
|
|
403
|
+
// false_positive_checks_required list: [0] Dependabot on github-actions demotes,
|
|
404
|
+
// [1] all-github-owned refs are lower risk. Reordering that list mis-attests.
|
|
546
405
|
if (signal_overrides["publish-workflow-action-refs-mutable"] === "hit") {
|
|
547
406
|
let dependabotActions = false;
|
|
548
407
|
try {
|
|
@@ -567,12 +426,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
567
426
|
if (Object.keys(att).length) signal_overrides["publish-workflow-action-refs-mutable__fp_checks"] = att;
|
|
568
427
|
}
|
|
569
428
|
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
// for mutable action refs, the offending `uses:` line). The other
|
|
573
|
-
// indicators (provenance / sbom / security.md / security.txt / vendor /
|
|
574
|
-
// lockfile) reflect a whole-repo presence-or-absence state with no single
|
|
575
|
-
// offending file, so they carry no file-level location here.
|
|
429
|
+
// Only the workflow indicators get locations, so a SARIF result can point at the
|
|
430
|
+
// offending `uses:` line; the rest are whole-repo presence or absence.
|
|
576
431
|
const evidence_locations = {};
|
|
577
432
|
for (const id of Object.keys(workflowHits)) {
|
|
578
433
|
if (signal_overrides[id] === "hit") {
|
|
@@ -610,13 +465,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
610
465
|
return {
|
|
611
466
|
precondition_checks: {
|
|
612
467
|
"publisher-context": manifests.length > 0,
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
// manifest surfaces a spurious precondition_unverified warning. (The
|
|
617
|
-
// repo-walk-access HALT gate is intentionally NOT attested here — it is
|
|
618
|
-
// resolved host-side and attesting it from the collector would turn a
|
|
619
|
-
// currently-passing run on a non-git dir into a fail-closed halt.)
|
|
468
|
+
// Attested here because the runner cannot probe the scanned --cwd. The
|
|
469
|
+
// repo-walk-access HALT gate must not be attested from the collector: it is
|
|
470
|
+
// resolved host-side, and attesting it turns a passing run into a halt.
|
|
620
471
|
"publishable-artifact-evidence": manifests.length > 0 || publishWorkflows.length > 0,
|
|
621
472
|
},
|
|
622
473
|
artifacts,
|