@blamejs/exceptd-skills 0.19.33 → 0.19.35
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 +28 -0
- package/bin/exceptd.js +895 -2828
- 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 +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- 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 +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- 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 -155
- 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 +39 -113
- 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 +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- 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 +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- 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 -413
- 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 +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- 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 +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- 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 +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- 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 +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- 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 -141
|
@@ -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
|
|
|
@@ -35,11 +14,8 @@ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require(
|
|
|
35
14
|
|
|
36
15
|
const COLLECTOR_ID = "library-author";
|
|
37
16
|
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
// agent/editor scratch including `.claude/`). Applied to the vendor-tree
|
|
41
|
-
// walk so a linked worktree or dependency cache nested under `vendor/`
|
|
42
|
-
// isn't mistaken for vendored provenance state.
|
|
17
|
+
// Applied to the vendor-tree walk, so a cache nested under `vendor/` is not
|
|
18
|
+
// mistaken for vendored provenance state.
|
|
43
19
|
const DEFAULT_EXCLUDES = codeExcludeSet();
|
|
44
20
|
|
|
45
21
|
function readSafe(full, max = 512 * 1024) {
|
|
@@ -48,10 +24,8 @@ function readSafe(full, max = 512 * 1024) {
|
|
|
48
24
|
fd = fs.openSync(full, "r");
|
|
49
25
|
const s = fs.fstatSync(fd);
|
|
50
26
|
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.
|
|
27
|
+
// readFileSync(fd), never a single readSync: a short read on a network fd
|
|
28
|
+
// NUL-fills the tail. Reading the open fd is also TOCTOU-free.
|
|
55
29
|
return fs.readFileSync(fd, "utf8");
|
|
56
30
|
} catch { return null; }
|
|
57
31
|
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
|
|
@@ -74,53 +48,34 @@ function walkWorkflows(root) {
|
|
|
74
48
|
}
|
|
75
49
|
|
|
76
50
|
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.
|
|
51
|
+
// Broad signals — `id-token: write`, a cosign installer — must not count:
|
|
52
|
+
// verification and e2e workflows share the same signing infrastructure.
|
|
83
53
|
const lower = name.toLowerCase();
|
|
84
54
|
|
|
85
|
-
// Verification / test / e2e workflows are never publish, regardless
|
|
86
|
-
// of body content. Hard demotion.
|
|
87
55
|
if (/^(test|verify|validate|e2e|kind|check|conformance|coverage)/.test(lower)) return false;
|
|
88
56
|
|
|
89
|
-
// Filename prefix signals: only the canonical publish words.
|
|
90
57
|
if (/^(release|publish|deploy|promote|tag-and-release)/.test(lower)) return true;
|
|
91
58
|
|
|
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).
|
|
59
|
+
// Comment-stripped, so a `#` line mentioning a publish verb cannot masquerade
|
|
60
|
+
// as one. Stripping can only remove text, never create a match.
|
|
98
61
|
const code = stripYamlComments(content);
|
|
99
62
|
|
|
100
|
-
// Explicit publish-shape commands — these are commitments to push
|
|
101
|
-
// artifacts, not setup / scaffolding.
|
|
102
63
|
if (/\bnpm\s+publish\b/.test(code)) return true;
|
|
103
64
|
if (/pypa\/gh-action-pypi-publish/.test(code)) return true;
|
|
104
65
|
if (/\bcargo\s+publish\b/.test(code)) return true;
|
|
105
66
|
if (/goreleaser/.test(code)) return true;
|
|
106
67
|
if (/softprops\/action-gh-release/.test(code)) return true;
|
|
107
|
-
// `ko
|
|
108
|
-
// --push=false). All four sub-commands push to a registry.
|
|
68
|
+
// All four `ko` sub-commands push to a registry by default, `build` included.
|
|
109
69
|
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.
|
|
70
|
+
// The `(?!-)` keeps out `cosign sign-blob`, as common in verification workflows.
|
|
113
71
|
if (/\bcosign\s+sign\b(?!-)/.test(code)) return true;
|
|
114
72
|
if (/\bcrane\s+(?:push|copy|append)\b/.test(code)) return true;
|
|
115
73
|
if (/\boras\s+(?:push|copy)\b/.test(code)) return true;
|
|
116
74
|
if (/docker\/build-push-action/.test(code)) return true;
|
|
117
75
|
if (/\bdocker\s+push\b/.test(code)) return true;
|
|
118
76
|
|
|
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.
|
|
77
|
+
// Registry login stands in for opaque publish paths (a Makefile target run
|
|
78
|
+
// after docker/login-action); verification workflows never authenticate.
|
|
124
79
|
if (/docker\/login-action/.test(code)) return true;
|
|
125
80
|
if (/google-github-actions\/auth/.test(code) && /gcloud auth configure-docker/.test(code)) return true;
|
|
126
81
|
if (/aws-actions\/configure-aws-credentials/.test(code) && /amazon-ecr/.test(code)) return true;
|
|
@@ -128,33 +83,117 @@ function looksLikePublishWorkflow(name, content) {
|
|
|
128
83
|
return false;
|
|
129
84
|
}
|
|
130
85
|
|
|
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.
|
|
86
|
+
// `id-token: write` is valid at workflow OR job scope, so any occurrence counts.
|
|
87
|
+
// Scoped to the one file passed in: a sibling workflow's OIDC does not grant it.
|
|
139
88
|
function hasIdTokenWriteAnyScope(content) {
|
|
140
89
|
return /\bid-token:\s*write\b/.test(content);
|
|
141
90
|
}
|
|
142
91
|
|
|
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).
|
|
92
|
+
// Every probe that reads workflow bodies must use this view: a commented publish
|
|
93
|
+
// token otherwise produces a false hit, and a commented `--provenance` hides a gap.
|
|
150
94
|
function stripYamlComments(content) {
|
|
151
95
|
return content.replace(/#.*$/gm, "");
|
|
152
96
|
}
|
|
153
97
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
98
|
+
// Splits comment-stripped workflow text into individual shell commands, so a
|
|
99
|
+
// flag on one install cannot vouch for another. A workflow that hash-pins its
|
|
100
|
+
// build requirements and then resolves its runtime ones must still report the
|
|
101
|
+
// unpinned command, which a whole-file flag test would suppress.
|
|
102
|
+
function shellCommands(code) {
|
|
103
|
+
const out = [];
|
|
104
|
+
const lines = code.split(/\r?\n/);
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
// A real `run:` line is never KBs long; skipping overlong ones keeps a
|
|
107
|
+
// crafted whitespace run from driving regex backtracking.
|
|
108
|
+
if (lines[i].length > 4096) continue;
|
|
109
|
+
// A backslash-continued command is ONE command: a `pip install \` whose
|
|
110
|
+
// `-r` sits on the next line is otherwise two fragments that each match
|
|
111
|
+
// nothing. The continuation lines are consumed, never re-emitted.
|
|
112
|
+
const startLine = i + 1;
|
|
113
|
+
let raw = lines[i];
|
|
114
|
+
while (/\\[ \t]*$/.test(raw) && i + 1 < lines.length &&
|
|
115
|
+
lines[i + 1].length <= 4096 && raw.length <= 8192) {
|
|
116
|
+
raw = `${raw.replace(/\\[ \t]*$/, " ")}${lines[i + 1].trim()}`;
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
for (const seg of raw.split(/&&|\|\||[;|]/)) {
|
|
120
|
+
const cmd = seg.trim();
|
|
121
|
+
// Comment stripping preserves line breaks, so the index still addresses
|
|
122
|
+
// the line the command STARTS on in the file an operator opens.
|
|
123
|
+
if (cmd) out.push({ cmd, line: startLine });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Evidence snippets are read in a report, so a pathological one-line script
|
|
130
|
+
// cannot be pasted in whole.
|
|
131
|
+
function snippetOf(cmd, max = 200) {
|
|
132
|
+
return cmd.length > max ? `${cmd.slice(0, max)}…` : cmd;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// pip enters hash-checking mode as soon as ONE requirement carries a hash, so
|
|
136
|
+
// `pip install -r <file>` against a `pip-compile --generate-hashes` output is
|
|
137
|
+
// already reproducible. Resolves the named file one level only: a nested `-r`
|
|
138
|
+
// include is not followed, and an unreadable path counts as unhashed.
|
|
139
|
+
// pip accepts the requirement file attached (`-rreq.txt`) as well as separated
|
|
140
|
+
// (`-r req.txt`, `--requirement=req.txt`). One definition for both the detection
|
|
141
|
+
// predicate and the hash lookup: two regexes for one flag drift, and the pair
|
|
142
|
+
// that drifts silently is a command examined by neither.
|
|
143
|
+
// `(?:^|\s)-r` rather than `\b-r` so the `-r` inside `--requirement` is not read
|
|
144
|
+
// as the short form with `equirement...` as its filename.
|
|
145
|
+
const PIP_REQUIREMENT_RE = /(?:^|\s)(?:-r\s*=?\s*|--requirement[=\s]*)['"]?([^'"\s]+)/;
|
|
146
|
+
|
|
147
|
+
function pipRequirementTarget(cmd) {
|
|
148
|
+
const m = cmd.match(PIP_REQUIREMENT_RE);
|
|
149
|
+
return m ? m[1] : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `working-directory:`, at job defaults or on a single step, is what a `run:`
|
|
153
|
+
// command resolves its relative paths against. Collected as a set of candidate
|
|
154
|
+
// bases rather than bound to the step that declared it: the scanner is
|
|
155
|
+
// line-based, so attributing one to a specific command would need the step
|
|
156
|
+
// nesting it does not track.
|
|
157
|
+
// Matches the block form and the flow form (`{ run: { working-directory: x } }`)
|
|
158
|
+
// alike. A key-shaped string inside a `run:` command matches too; that only adds
|
|
159
|
+
// a base that does not resolve, and an unresolvable base is skipped.
|
|
160
|
+
function workingDirectories(code) {
|
|
161
|
+
const dirs = [];
|
|
162
|
+
for (const m of code.matchAll(/working-directory:\s*['"]?([^'"\n,}]+?)['"]?\s*(?=$|[,}])/gm)) {
|
|
163
|
+
const d = m[1].trim();
|
|
164
|
+
if (d && d !== "." && !path.isAbsolute(d)) dirs.push(d);
|
|
165
|
+
}
|
|
166
|
+
return dirs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// True only when every candidate that EXISTS is hash-pinned, and at least one
|
|
170
|
+
// does. Requiring all of them keeps a hashed copy in one working directory from
|
|
171
|
+
// vouching for an unhashed copy in another — over-suppression here reports an
|
|
172
|
+
// unpinned release as reproducible, which is the direction that costs something.
|
|
173
|
+
// A target that resolves nowhere stays unproven, so the caller still reports it.
|
|
174
|
+
function requirementsFileIsHashed(cmd, root, extraBases = []) {
|
|
175
|
+
if (!root) return false;
|
|
176
|
+
const target = pipRequirementTarget(cmd);
|
|
177
|
+
if (!target) return false;
|
|
178
|
+
if (path.isAbsolute(target)) return false;
|
|
179
|
+
|
|
180
|
+
let found = 0;
|
|
181
|
+
for (const base of [root, ...extraBases.map((d) => path.resolve(root, d))]) {
|
|
182
|
+
// Never read outside the scanned tree, whatever `../` the workflow names —
|
|
183
|
+
// for the base itself as well as the target resolved against it.
|
|
184
|
+
if (base !== root && !base.startsWith(root + path.sep)) continue;
|
|
185
|
+
const full = path.resolve(base, target);
|
|
186
|
+
if (full !== root && !full.startsWith(root + path.sep)) continue;
|
|
187
|
+
const body = readSafe(full);
|
|
188
|
+
if (body === null) continue;
|
|
189
|
+
found++;
|
|
190
|
+
if (!/--hash=/.test(body)) return false;
|
|
191
|
+
}
|
|
192
|
+
return found > 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function scanPublishWorkflow(content, rel, root) {
|
|
196
|
+
// Whole-content probes read `code`; the `uses:` scan below stays on raw lines.
|
|
158
197
|
const code = stripYamlComments(content);
|
|
159
198
|
const hits = {
|
|
160
199
|
"publish-workflow-uses-static-token": [],
|
|
@@ -164,18 +203,10 @@ function scanPublishWorkflow(content, rel) {
|
|
|
164
203
|
"publish-workflow-runs-on-self-hosted": [],
|
|
165
204
|
};
|
|
166
205
|
|
|
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.
|
|
206
|
+
// Widens the predicate in data/playbooks/library-author.json to the common
|
|
207
|
+
// token-name variant per ecosystem.
|
|
173
208
|
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).
|
|
209
|
+
// Comment-stripped: a commented `id-token: write` would suppress the finding.
|
|
179
210
|
const hasIdTokenWrite = hasIdTokenWriteAnyScope(code);
|
|
180
211
|
if (usesStaticToken && !hasIdTokenWrite) {
|
|
181
212
|
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 +215,15 @@ function scanPublishWorkflow(content, rel) {
|
|
|
184
215
|
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
216
|
}
|
|
186
217
|
|
|
187
|
-
//
|
|
188
|
-
// a 40-char hex sha. Excludes `uses: ./local-action`.
|
|
218
|
+
// Mutable = any `uses: <action>@<ref>` whose ref is not a 40-char hex sha.
|
|
189
219
|
const lines = content.split(/\r?\n/);
|
|
190
220
|
for (let i = 0; i < lines.length; i++) {
|
|
191
221
|
const line = lines[i];
|
|
192
|
-
// A real `uses:` line is never
|
|
193
|
-
// crafted whitespace run
|
|
222
|
+
// A real `uses:` line is never KBs long; skipping overlong ones keeps a
|
|
223
|
+
// crafted whitespace run from driving regex backtracking.
|
|
194
224
|
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.
|
|
225
|
+
// No end anchor and `#` excluded from the capture, so `uses: x@v1 # comment`
|
|
226
|
+
// still matches. The indent and `- ` marker are each anchored once.
|
|
201
227
|
const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
|
|
202
228
|
if (!m) continue;
|
|
203
229
|
const ref = m[1];
|
|
@@ -210,17 +236,53 @@ function scanPublishWorkflow(content, rel) {
|
|
|
210
236
|
}
|
|
211
237
|
}
|
|
212
238
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// install`
|
|
239
|
+
// npm alone stays a whole-file test, because its suppressor is a DIFFERENT
|
|
240
|
+
// command rather than a flag: `npm ci` elsewhere in the workflow is the
|
|
241
|
+
// frozen install, and the `npm install` line beside it is usually tooling.
|
|
216
242
|
if (/\bnpm\s+install\b/.test(code) && !/\bnpm\s+ci\b/.test(code)) {
|
|
217
243
|
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
244
|
}
|
|
219
|
-
|
|
220
|
-
|
|
245
|
+
|
|
246
|
+
// Bundler's frozen mode can be set once for the job rather than per command
|
|
247
|
+
// (`bundle config set frozen/deployment`, or the BUNDLE_ env equivalents), so
|
|
248
|
+
// that suppressor is genuinely file-scoped; the flags below are not.
|
|
249
|
+
// The setting has to be turned ON. `bundle config set frozen false` names the
|
|
250
|
+
// key and disables it, so matching the key alone reads an explicit opt-out as
|
|
251
|
+
// the protection it opts out of — the env form already required a value.
|
|
252
|
+
const bundlerFrozenConfig =
|
|
253
|
+
/\bbundle\s+config\b[^\n]*\b(?:frozen|deployment)\s+['"]?(?:true|1)\b/.test(code) ||
|
|
254
|
+
/\bBUNDLE_(?:FROZEN|DEPLOYMENT)\s*[:=]\s*['"]?(?:true|1)\b/i.test(code);
|
|
255
|
+
|
|
256
|
+
const runBases = workingDirectories(code);
|
|
257
|
+
|
|
258
|
+
// Every remaining ecosystem is probed one command at a time. The playbook
|
|
259
|
+
// indicator names npm / pnpm / pip -r / bundle; cargo is probed on the same
|
|
260
|
+
// --locked / --frozen predicate.
|
|
261
|
+
for (const { cmd, line } of shellCommands(code)) {
|
|
262
|
+
if (/\bcargo\s+(?:build|install)\b/.test(cmd) && !/--locked\b/.test(cmd) && !/--frozen\b/.test(cmd)) {
|
|
263
|
+
hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `cargo build/install without --locked / --frozen: ${snippetOf(cmd)}` });
|
|
264
|
+
}
|
|
265
|
+
// Without --require-hashes the requirement set resolves at release time, so
|
|
266
|
+
// the artifact is not reproducible — unless the named file is hash-pinned,
|
|
267
|
+
// which switches pip into hash-checking mode on its own.
|
|
268
|
+
if (/\b(?:pip3?|python3?\s+-m\s+pip)\s+install\b/.test(cmd) &&
|
|
269
|
+
pipRequirementTarget(cmd) !== null &&
|
|
270
|
+
!/--require-hashes\b/.test(cmd) &&
|
|
271
|
+
!requirementsFileIsHashed(cmd, root, runBases)) {
|
|
272
|
+
hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `pip install -r without --require-hashes — requirements are resolved at release time: ${snippetOf(cmd)}` });
|
|
273
|
+
}
|
|
274
|
+
// pnpm defaults to --frozen-lockfile when CI is set, which GitHub Actions
|
|
275
|
+
// always sets — so a bare `pnpm install` here is already frozen and only an
|
|
276
|
+
// explicit opt-out resolves at release time.
|
|
277
|
+
if (/\bpnpm\s+(?:install|i)\b/.test(cmd) &&
|
|
278
|
+
/--no-frozen-lockfile\b|--frozen-lockfile[=\s]+false\b/.test(cmd)) {
|
|
279
|
+
hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `pnpm install opts out of the frozen lockfile: ${snippetOf(cmd)}` });
|
|
280
|
+
}
|
|
281
|
+
if (/\bbundle\s+install\b/.test(cmd) && !/--deployment\b/.test(cmd) && !/--frozen\b/.test(cmd) && !bundlerFrozenConfig) {
|
|
282
|
+
hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `bundle install without --deployment / --frozen: ${snippetOf(cmd)}` });
|
|
283
|
+
}
|
|
221
284
|
}
|
|
222
285
|
|
|
223
|
-
// runs-on-self-hosted: any `runs-on: self-hosted` line.
|
|
224
286
|
if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(code)) {
|
|
225
287
|
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
288
|
}
|
|
@@ -233,7 +295,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
233
295
|
const startTime = Date.now();
|
|
234
296
|
const root = path.resolve(cwd);
|
|
235
297
|
|
|
236
|
-
// package-manifest detection.
|
|
237
298
|
const manifests = [];
|
|
238
299
|
const manifestFiles = [
|
|
239
300
|
"package.json", "pyproject.toml", "Cargo.toml", "go.mod",
|
|
@@ -246,7 +307,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
246
307
|
manifests.push({ file: f, path: p, content: readSafe(p) });
|
|
247
308
|
}
|
|
248
309
|
}
|
|
249
|
-
// *.gemspec — glob at root only.
|
|
250
310
|
try {
|
|
251
311
|
for (const e of fs.readdirSync(root)) {
|
|
252
312
|
if (e.endsWith(".gemspec")) {
|
|
@@ -256,11 +316,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
256
316
|
}
|
|
257
317
|
} catch {}
|
|
258
318
|
|
|
259
|
-
// Release / publish workflows.
|
|
260
319
|
const workflows = walkWorkflows(root).map(w => ({ ...w, content: readSafe(w.full) || "" }));
|
|
261
320
|
const publishWorkflows = workflows.filter(w => looksLikePublishWorkflow(w.name, w.content));
|
|
262
321
|
|
|
263
|
-
// Aggregate publish-workflow indicator hits.
|
|
264
322
|
const workflowHits = {
|
|
265
323
|
"publish-workflow-uses-static-token": [],
|
|
266
324
|
"publish-workflow-no-id-token-write": [],
|
|
@@ -268,24 +326,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
268
326
|
"release-workflow-non-frozen-install": [],
|
|
269
327
|
"publish-workflow-runs-on-self-hosted": [],
|
|
270
328
|
};
|
|
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.
|
|
329
|
+
// OIDC capability is evaluated per publish workflow, never repo-wide: a sibling
|
|
330
|
+
// workflow's `id-token: write` gives nothing to a job using a static token.
|
|
278
331
|
for (const w of publishWorkflows) {
|
|
279
|
-
const h = scanPublishWorkflow(w.content, w.rel);
|
|
332
|
+
const h = scanPublishWorkflow(w.content, w.rel, root);
|
|
280
333
|
for (const [id, list] of Object.entries(h)) workflowHits[id].push(...list);
|
|
281
334
|
}
|
|
282
335
|
|
|
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.
|
|
336
|
+
// Fires only when neither `publishConfig.provenance: true` nor a workflow
|
|
337
|
+
// `npm publish --provenance` opts in. Needs the workflow inventory above.
|
|
289
338
|
let provenanceMissing = "miss";
|
|
290
339
|
const pkgManifest = manifests.find(m => m.file === "package.json");
|
|
291
340
|
if (pkgManifest && pkgManifest.content) {
|
|
@@ -299,16 +348,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
299
348
|
}
|
|
300
349
|
}
|
|
301
350
|
|
|
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.
|
|
351
|
+
// Any one lockfile with a missing-integrity entry flips the indicator. With no
|
|
352
|
+
// lockfile it stays undefined: a repo without one is not evidence of integrity.
|
|
312
353
|
let lockfileMissingIntegrity = undefined;
|
|
313
354
|
const lockfilesChecked = [];
|
|
314
355
|
const lockfileScans = [
|
|
@@ -317,15 +358,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
317
358
|
scan: (text) => {
|
|
318
359
|
const j = JSON.parse(text);
|
|
319
360
|
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.
|
|
361
|
+
// Only a remote-tarball entry (`resolved` is an http(s)/git URL) owes an
|
|
362
|
+
// integrity hash; a workspace link and file:/link:/workspace: refs have
|
|
363
|
+
// none by nature, and counting them false-hits every workspaces monorepo.
|
|
329
364
|
const walkObj = (obj) => {
|
|
330
365
|
if (!obj || typeof obj !== "object") return;
|
|
331
366
|
if (
|
|
@@ -343,8 +378,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
343
378
|
{
|
|
344
379
|
file: "yarn.lock",
|
|
345
380
|
scan: (text) => {
|
|
346
|
-
//
|
|
347
|
-
// followed within ~5 lines by an `integrity sha512-...` line.
|
|
381
|
+
// A `resolved "https://..."` line owes an `integrity sha512-...` nearby.
|
|
348
382
|
const lines = text.split(/\r?\n/);
|
|
349
383
|
let missing = 0;
|
|
350
384
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -358,9 +392,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
358
392
|
{
|
|
359
393
|
file: "pnpm-lock.yaml",
|
|
360
394
|
scan: (text) => {
|
|
361
|
-
// pnpm
|
|
362
|
-
// with `resolution: { tarball: ... }` lines whose nearby
|
|
363
|
-
// block lacks `integrity:`.
|
|
395
|
+
// A pnpm block carrying `resolution:` owes an `integrity:` field.
|
|
364
396
|
const blocks = text.split(/\n(?=\s+\/)/);
|
|
365
397
|
let missing = 0;
|
|
366
398
|
for (const b of blocks) {
|
|
@@ -373,14 +405,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
373
405
|
{
|
|
374
406
|
file: "Cargo.lock",
|
|
375
407
|
scan: (text) => {
|
|
376
|
-
//
|
|
377
|
-
// (registry deps only — path / git deps legitimately have
|
|
378
|
-
// none, so count only registry packages).
|
|
408
|
+
// `checksum` is owed per [[package]] by registry deps only.
|
|
379
409
|
const packages = text.split(/^\[\[package\]\]/m).slice(1);
|
|
380
410
|
let missing = 0;
|
|
381
411
|
for (const p of packages) {
|
|
382
|
-
// Skip path / git deps — they have a `source = "<...>"`
|
|
383
|
-
// line; registry deps have `source = "registry+..."`.
|
|
384
412
|
const sourceMatch = p.match(/^source\s*=\s*"([^"]+)"/m);
|
|
385
413
|
if (!sourceMatch) continue; // path dep, no integrity expected
|
|
386
414
|
if (!sourceMatch[1].startsWith("registry+")) continue;
|
|
@@ -392,8 +420,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
392
420
|
{
|
|
393
421
|
file: "go.sum",
|
|
394
422
|
scan: (text) => {
|
|
395
|
-
// go.sum
|
|
396
|
-
// Missing-integrity = a line without `h1:` (rare; malformed).
|
|
423
|
+
// A go.sum line is `<module> <version> h1:<hash>=`; one without `h1:` is malformed.
|
|
397
424
|
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
398
425
|
let missing = 0;
|
|
399
426
|
for (const l of lines) {
|
|
@@ -414,8 +441,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
414
441
|
lockfileMissingIntegrity = "hit";
|
|
415
442
|
break;
|
|
416
443
|
}
|
|
417
|
-
// If at least one lockfile scanned cleanly, the indicator
|
|
418
|
-
// moves to miss — we have evidence of integrity coverage.
|
|
419
444
|
if (lockfileMissingIntegrity === undefined) lockfileMissingIntegrity = "miss";
|
|
420
445
|
} catch (e) {
|
|
421
446
|
errors.push({
|
|
@@ -425,55 +450,37 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
425
450
|
});
|
|
426
451
|
}
|
|
427
452
|
}
|
|
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
453
|
const securityMdPresent =
|
|
433
454
|
fs.existsSync(path.join(root, "SECURITY.md")) ||
|
|
434
455
|
fs.existsSync(path.join(root, ".github", "SECURITY.md"));
|
|
435
456
|
|
|
436
|
-
// security.txt presence (.well-known/security.txt + security.txt).
|
|
437
457
|
const securityTxtPresent =
|
|
438
458
|
fs.existsSync(path.join(root, ".well-known", "security.txt")) ||
|
|
439
459
|
fs.existsSync(path.join(root, "security.txt"));
|
|
440
460
|
|
|
441
|
-
//
|
|
442
|
-
// present but no matching .sig sidecar.
|
|
461
|
+
// Fires when there is no root SBOM, or one with no matching .sig sidecar.
|
|
443
462
|
let sbomFile = null;
|
|
444
463
|
for (const f of ["sbom.cdx.json", "sbom.json", "bom.json", "sbom.spdx.json", "sbom.cdx.xml"]) {
|
|
445
464
|
if (fs.existsSync(path.join(root, f))) { sbomFile = f; break; }
|
|
446
465
|
}
|
|
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.
|
|
466
|
+
// An SBOM generated and signed at release time never lands in the committed
|
|
467
|
+
// tree, so the capability is detected in the publish workflows instead.
|
|
457
468
|
const releaseSbomCapable = publishWorkflows.some(w => {
|
|
458
469
|
const c = stripYamlComments(w.content);
|
|
459
470
|
return (
|
|
460
|
-
// SBOM-generation tooling invoked in the workflow.
|
|
461
471
|
/cyclonedx/i.test(c) ||
|
|
462
472
|
/\bsyft\b/i.test(c) ||
|
|
463
473
|
/anchore\/sbom-action/i.test(c) ||
|
|
464
474
|
/\btrivy\b[^\n]*\bsbom\b/i.test(c) ||
|
|
465
475
|
/\bnpm\s+sbom\b/.test(c) ||
|
|
466
476
|
/spdx-sbom-generator/i.test(c) ||
|
|
467
|
-
// npm provenance (signed build provenance attestation at publish).
|
|
468
477
|
/npm\s+publish[^\n]*--provenance\b/.test(c) ||
|
|
469
|
-
// sigstore / cosign signing of release artifacts.
|
|
470
478
|
/\bcosign\s+(?:sign|attest)\b/.test(c) ||
|
|
471
479
|
/sigstore\//i.test(c) ||
|
|
472
480
|
/gh-action-sigstore-python/i.test(c)
|
|
473
481
|
);
|
|
474
482
|
});
|
|
475
|
-
//
|
|
476
|
-
// provenance attestation is produced at publish time.
|
|
483
|
+
// The manifest opt-in likewise produces a signed provenance attestation.
|
|
477
484
|
const manifestProvenanceOptIn = (() => {
|
|
478
485
|
if (!pkgManifest || !pkgManifest.content) return false;
|
|
479
486
|
try { return JSON.parse(pkgManifest.content)?.publishConfig?.provenance === true; }
|
|
@@ -484,14 +491,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
484
491
|
const sigPath = path.join(root, `${sbomFile}.sig`);
|
|
485
492
|
sbomAbsentOrUnsigned = fs.existsSync(sigPath) ? "miss" : "hit";
|
|
486
493
|
} 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
494
|
sbomAbsentOrUnsigned = "miss";
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
//
|
|
494
|
-
// _PROVENANCE.json at any level inside it.
|
|
497
|
+
// Fires when vendor/ exists with no _PROVENANCE.json at any level inside it.
|
|
495
498
|
let vendoredNoProvenance = "miss";
|
|
496
499
|
const vendorDir = path.join(root, "vendor");
|
|
497
500
|
if (fs.existsSync(vendorDir)) {
|
|
@@ -505,10 +508,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
505
508
|
if (e.isDirectory()) {
|
|
506
509
|
if (DEFAULT_EXCLUDES.has(e.name)) continue;
|
|
507
510
|
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.
|
|
511
|
+
// A linked worktree under vendor/ is a separate repo copy.
|
|
512
512
|
if (isLinkedWorktreeDir(sub)) continue;
|
|
513
513
|
walkVendor(sub, depth + 1);
|
|
514
514
|
}
|
|
@@ -530,19 +530,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
530
530
|
"no-security-txt": securityTxtPresent ? "miss" : "hit",
|
|
531
531
|
"vendored-no-provenance": vendoredNoProvenance,
|
|
532
532
|
};
|
|
533
|
-
//
|
|
534
|
-
// out (so the runner returns inconclusive) when no supported
|
|
535
|
-
// lockfile was present to scan.
|
|
533
|
+
// Omitted when no lockfile was there to scan, so the runner reads inconclusive.
|
|
536
534
|
if (lockfileMissingIntegrity !== undefined) {
|
|
537
535
|
signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
|
|
538
536
|
}
|
|
539
537
|
|
|
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.
|
|
538
|
+
// The keys "0" and "1" are positional indexes into the indicator's
|
|
539
|
+
// false_positive_checks_required list: [0] Dependabot on github-actions demotes,
|
|
540
|
+
// [1] all-github-owned refs are lower risk. Reordering that list mis-attests.
|
|
546
541
|
if (signal_overrides["publish-workflow-action-refs-mutable"] === "hit") {
|
|
547
542
|
let dependabotActions = false;
|
|
548
543
|
try {
|
|
@@ -567,12 +562,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
567
562
|
if (Object.keys(att).length) signal_overrides["publish-workflow-action-refs-mutable__fp_checks"] = att;
|
|
568
563
|
}
|
|
569
564
|
|
|
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.
|
|
565
|
+
// Only the workflow indicators get locations, so a SARIF result can point at the
|
|
566
|
+
// offending `uses:` line; the rest are whole-repo presence or absence.
|
|
576
567
|
const evidence_locations = {};
|
|
577
568
|
for (const id of Object.keys(workflowHits)) {
|
|
578
569
|
if (signal_overrides[id] === "hit") {
|
|
@@ -610,13 +601,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
610
601
|
return {
|
|
611
602
|
precondition_checks: {
|
|
612
603
|
"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.)
|
|
604
|
+
// Attested here because the runner cannot probe the scanned --cwd. The
|
|
605
|
+
// repo-walk-access HALT gate must not be attested from the collector: it is
|
|
606
|
+
// resolved host-side, and attesting it turns a passing run into a halt.
|
|
620
607
|
"publishable-artifact-evidence": manifests.length > 0 || publishWorkflows.length > 0,
|
|
621
608
|
},
|
|
622
609
|
artifacts,
|