@blamejs/exceptd-skills 0.19.32 → 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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- 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 +2 -2
- package/sbom.cdx.json +253 -253
- 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,21 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* catalog counts. Drift means an SBOM regen was forgotten — operators
|
|
7
|
-
* downloading the tarball would see counts that disagree with the actual
|
|
8
|
-
* manifest.json and data/*.json contents.
|
|
9
|
-
*
|
|
10
|
-
* Anchors to ROOT in this order of preference:
|
|
11
|
-
* 1. `--root <dir>` on argv (testability — staged tempdir layouts).
|
|
12
|
-
* 2. `EXCEPTD_ROOT` environment variable.
|
|
13
|
-
* 3. `path.join(__dirname, '..')` — the running script's parent dir.
|
|
14
|
-
*
|
|
15
|
-
* Exit 0 on current SBOM, 1 on any drift (catalog count, skill count, or
|
|
16
|
-
* CycloneDX-format mismatch).
|
|
17
|
-
*
|
|
18
|
-
* No external dependencies. Node 24 stdlib only.
|
|
3
|
+
* Predeploy gate: sbom.cdx.json must be current against the live skill and
|
|
4
|
+
* catalog counts, the recorded file hashes and the shipped-file set — drift
|
|
5
|
+
* means the tarball ships an SBOM that disagrees with its own contents.
|
|
19
6
|
*/
|
|
20
7
|
|
|
21
8
|
const fs = require("fs");
|
|
@@ -31,16 +18,10 @@ function resolveRoot(argv) {
|
|
|
31
18
|
return path.join(__dirname, "..");
|
|
32
19
|
}
|
|
33
20
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// RFC-…) with a single _meta block, so the live entry total is the key count
|
|
37
|
-
// excluding _meta.
|
|
21
|
+
// Live entry total for a data/*.json catalog, or null when the file is absent so
|
|
22
|
+
// a `--root` pointed at a partial tree skips that token instead of crashing.
|
|
38
23
|
function catalogEntryCount(dataDir, file) {
|
|
39
24
|
const p = path.join(dataDir, file);
|
|
40
|
-
// A --root pointed at a partial tree (no such catalog file) skips that
|
|
41
|
-
// token's check rather than crashing — catalog PRESENCE is asserted by
|
|
42
|
-
// the cardinality check above and the per-component hash check below,
|
|
43
|
-
// not by the description parser.
|
|
44
25
|
if (!fs.existsSync(p)) return null;
|
|
45
26
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
46
27
|
if (Array.isArray(j)) return j.length;
|
|
@@ -50,18 +31,13 @@ function catalogEntryCount(dataDir, file) {
|
|
|
50
31
|
return 0;
|
|
51
32
|
}
|
|
52
33
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// pre-computed index cache under data/_indexes/ is regenerated/test-mutated, so
|
|
57
|
-
// it carries no per-file component. The completeness check below must apply the
|
|
58
|
-
// SAME exclusions or it would demand a component for a file the generator never
|
|
59
|
-
// emits one for.
|
|
34
|
+
// Mirrors scripts/refresh-sbom.js SELF_EXCLUDED + DERIVABLE_PREFIXES: the
|
|
35
|
+
// completeness check must apply the SAME exclusions or it demands a component
|
|
36
|
+
// the generator never emits.
|
|
60
37
|
const SBOM_SELF_EXCLUDED = new Set(["sbom.cdx.json"]);
|
|
61
38
|
const SBOM_DERIVABLE_PREFIXES = ["data/_indexes/"];
|
|
62
39
|
// Mirrors refresh-sbom's ALWAYS_SHIPPED: npm adds package.json to every tarball
|
|
63
|
-
// without it appearing in `files`, so
|
|
64
|
-
// component for it the same way the generator now emits one.
|
|
40
|
+
// without it appearing in `files`, so a component is expected for it.
|
|
65
41
|
const SBOM_ALWAYS_SHIPPED = ["package.json", "sources/README.md"];
|
|
66
42
|
|
|
67
43
|
function sbomIsDerivable(rel) {
|
|
@@ -70,9 +46,7 @@ function sbomIsDerivable(rel) {
|
|
|
70
46
|
);
|
|
71
47
|
}
|
|
72
48
|
|
|
73
|
-
//
|
|
74
|
-
// Mirrors refresh-sbom's walkFiles (which is root-agnostic — it walks whatever
|
|
75
|
-
// absolute dir it is given).
|
|
49
|
+
// Mirrors refresh-sbom's walkFiles, walking whatever absolute dir it is handed.
|
|
76
50
|
function walkFilesAbs(absDir) {
|
|
77
51
|
const out = [];
|
|
78
52
|
let entries;
|
|
@@ -86,15 +60,9 @@ function walkFilesAbs(absDir) {
|
|
|
86
60
|
return out;
|
|
87
61
|
}
|
|
88
62
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// own REPO_ROOT (it joins/relativizes against __dirname/..), so under a `--root`
|
|
93
|
-
// target the completeness check would otherwise validate the SOURCE repo's file
|
|
94
|
-
// list against the TARGET SBOM — the wrong tree. Replicating the expansion here
|
|
95
|
-
// (same SELF_EXCLUDED + DERIVABLE_PREFIXES exclusions, same dedupe+sort) keeps
|
|
96
|
-
// the gate honest under `--root` without reaching across into the generator's
|
|
97
|
-
// module-level root.
|
|
63
|
+
// The shipped-file set that must each have a file: component, computed against
|
|
64
|
+
// the TARGET tree. refresh-sbom's expandAllowlist is bound to its own REPO_ROOT,
|
|
65
|
+
// so it would validate the source repo's file list against the target SBOM.
|
|
98
66
|
function expandAllowlistAt(allowlist, root) {
|
|
99
67
|
const abs = [];
|
|
100
68
|
for (const entry of [...allowlist, ...SBOM_ALWAYS_SHIPPED]) {
|
|
@@ -114,11 +82,8 @@ function expandAllowlistAt(allowlist, root) {
|
|
|
114
82
|
return rel;
|
|
115
83
|
}
|
|
116
84
|
|
|
117
|
-
// The description
|
|
118
|
-
//
|
|
119
|
-
// 468 D3FEND / 8888 RFCs)". Each token maps to one data/*.json catalog whose
|
|
120
|
-
// live entry count must match. `label` is the regex-escaped text that follows
|
|
121
|
-
// the number in the description.
|
|
85
|
+
// The description embeds per-catalog ENTRY counts as free text ("… 177 CWEs /
|
|
86
|
+
// 805 ATT&CK + ICS …"). `label` is the regex-escaped text after the number.
|
|
122
87
|
const DESCRIPTION_ENTRY_TOKENS = [
|
|
123
88
|
{ file: "cve-catalog.json", label: "CVEs" },
|
|
124
89
|
{ file: "cwe-catalog.json", label: "CWEs" },
|
|
@@ -161,14 +126,8 @@ function checkSbomCurrency(root) {
|
|
|
161
126
|
errors.push("SBOM is not CycloneDX 1.6");
|
|
162
127
|
}
|
|
163
128
|
|
|
164
|
-
// The
|
|
165
|
-
//
|
|
166
|
-
// package.json). The numeric properties above only cover catalog/skill
|
|
167
|
-
// CARDINALITY (file count + skill count), so a catalog's entry total can
|
|
168
|
-
// drift past the count baked into the description while the dedicated SBOM
|
|
169
|
-
// gate still passes. Parse each token out of the description and assert it
|
|
170
|
-
// against the live entry count so a stale published-SBOM description fails
|
|
171
|
-
// the gate.
|
|
129
|
+
// The numeric properties above cover only CARDINALITY, so an entry total can
|
|
130
|
+
// drift in the description while those still agree.
|
|
172
131
|
const description =
|
|
173
132
|
(sbom.metadata && sbom.metadata.component && sbom.metadata.component.description) || "";
|
|
174
133
|
for (const { file, label } of DESCRIPTION_ENTRY_TOKENS) {
|
|
@@ -188,7 +147,6 @@ function checkSbomCurrency(root) {
|
|
|
188
147
|
);
|
|
189
148
|
}
|
|
190
149
|
}
|
|
191
|
-
// The skill count is embedded in the same description string ("N skills").
|
|
192
150
|
const skillMatch = description.match(/(\d+)\s+skills\b/);
|
|
193
151
|
if (!skillMatch) {
|
|
194
152
|
errors.push(
|
|
@@ -200,15 +158,9 @@ function checkSbomCurrency(root) {
|
|
|
200
158
|
);
|
|
201
159
|
}
|
|
202
160
|
|
|
203
|
-
// The "N catalogs" and "N jurisdictions" free-text counts in the same
|
|
204
|
-
// description string were never validated — only the per-catalog entry tokens
|
|
205
|
-
// and the skill count were. Pin them to the live values so a stale
|
|
206
|
-
// description (e.g. after an auto-refresh changed a count) fails the gate.
|
|
207
161
|
const catalogMatch = description.match(/(\d+)\s+catalogs?\b/i);
|
|
208
162
|
if (!catalogMatch) {
|
|
209
|
-
//
|
|
210
|
-
// description (or an auto-refresh that dropped the token) must not silently
|
|
211
|
-
// skip the count check — that is the asymmetric-absent fail-open class.
|
|
163
|
+
// Absence fails CLOSED: a reworded description must not skip the check.
|
|
212
164
|
errors.push(
|
|
213
165
|
"SBOM description is missing the catalog-count token (N catalogs) — regenerate via `npm run refresh-sbom`"
|
|
214
166
|
);
|
|
@@ -220,21 +172,16 @@ function checkSbomCurrency(root) {
|
|
|
220
172
|
const liveJurisdictions = (() => {
|
|
221
173
|
try {
|
|
222
174
|
const gf = JSON.parse(fs.readFileSync(path.join(dataDir, "global-frameworks.json"), "utf8"));
|
|
223
|
-
// Non-underscore top-level keys — the
|
|
224
|
-
// README badge and catalog-summaries use.
|
|
175
|
+
// Non-underscore top-level keys — the count the README badge reports.
|
|
225
176
|
return Object.keys(gf).filter((k) => !k.startsWith("_")).length;
|
|
226
177
|
} catch {
|
|
227
178
|
return null;
|
|
228
179
|
}
|
|
229
180
|
})();
|
|
230
181
|
const jurisdictionMatch = description.match(/(\d+)\s+jurisdictions?\b/i);
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// When the source IS present, absence of the token fails CLOSED (the
|
|
235
|
-
// description token is the SBOM's only jurisdiction-count assertion — there is
|
|
236
|
-
// no structured jurisdiction property — so a dropped token would otherwise
|
|
237
|
-
// leave the count entirely unvalidated).
|
|
182
|
+
// A partial `--root` tree without global-frameworks.json skips the check. Where
|
|
183
|
+
// the source IS present the token is the SBOM's only jurisdiction assertion —
|
|
184
|
+
// no structured property carries it — so a dropped token fails CLOSED.
|
|
238
185
|
if (liveJurisdictions !== null) {
|
|
239
186
|
if (!jurisdictionMatch) {
|
|
240
187
|
errors.push(
|
|
@@ -247,15 +194,9 @@ function checkSbomCurrency(root) {
|
|
|
247
194
|
}
|
|
248
195
|
}
|
|
249
196
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// the file: component pass below (a bumped or renamed skill changes those
|
|
254
|
-
// bytes). This branch is therefore not exercised by a clean SBOM, but it is
|
|
255
|
-
// retained as a tamper guard: a forged or buggy SBOM that injected a skill
|
|
256
|
-
// component with a stale version (or a skill name no longer in the manifest)
|
|
257
|
-
// is still caught here. Vendor components are validated against
|
|
258
|
-
// vendor/blamejs/_PROVENANCE.json.
|
|
197
|
+
// A clean SBOM carries no "skill:" components — skill drift shows up in the
|
|
198
|
+
// file: hashes. This branch is the tamper guard for a forged SBOM that injects
|
|
199
|
+
// one with a stale version.
|
|
259
200
|
const components = Array.isArray(sbom.components) ? sbom.components : [];
|
|
260
201
|
const skillByName = new Map(
|
|
261
202
|
(manifest.skills || []).map((s) => [s.name, s])
|
|
@@ -297,27 +238,17 @@ function checkSbomCurrency(root) {
|
|
|
297
238
|
}
|
|
298
239
|
}
|
|
299
240
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// release-ordering bug where sbom.cdx.json was regenerated BEFORE the
|
|
304
|
-
// final sign-all pass — the recorded manifest.json hash drifted from
|
|
305
|
-
// the signed-and-committed bytes, but the count-based check above
|
|
306
|
-
// could not see it. Codex P2 flag on PR #48 surfaced one instance;
|
|
307
|
-
// this gate makes it unreachable.
|
|
241
|
+
// Every "file:" component's recorded hash must match the live bytes. This is
|
|
242
|
+
// what catches an SBOM regenerated BEFORE the final sign-all, where the
|
|
243
|
+
// recorded manifest.json hash drifts from the signed-and-committed bytes.
|
|
308
244
|
let fileComponentsChecked = 0;
|
|
309
245
|
const rootResolved = path.resolve(root);
|
|
310
246
|
for (const comp of components) {
|
|
311
247
|
const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
|
|
312
248
|
if (!bomRef.startsWith("file:")) continue;
|
|
313
249
|
const relPath = bomRef.slice("file:".length);
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// tampered or carelessly-edited sbom.cdx.json with `file:../outside`
|
|
317
|
-
// would read + hash a path OUTSIDE the checkout — the gate would
|
|
318
|
-
// either report "exists, hash matches" (silently weakening the
|
|
319
|
-
// integrity guarantee) or "does not exist" without ever flagging the
|
|
320
|
-
// attempted escape. Refuse early.
|
|
250
|
+
// A `file:../outside` bom-ref would otherwise hash a path outside the
|
|
251
|
+
// checkout and report "exists, hash matches" — never the escape.
|
|
321
252
|
if (relPath.includes("..") || path.isAbsolute(relPath)) {
|
|
322
253
|
errors.push(
|
|
323
254
|
`SBOM file component "${relPath}" rejected: path must be repo-relative without ".." segments (path-traversal guard)`
|
|
@@ -325,9 +256,8 @@ function checkSbomCurrency(root) {
|
|
|
325
256
|
continue;
|
|
326
257
|
}
|
|
327
258
|
const absPath = path.resolve(rootResolved, relPath);
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// changes to the textual filter would surface here.
|
|
259
|
+
// The resolved path must also land under root — a symlink, or a loosening of
|
|
260
|
+
// the textual filter above, surfaces here.
|
|
331
261
|
const rel = path.relative(rootResolved, absPath);
|
|
332
262
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
333
263
|
errors.push(
|
|
@@ -341,17 +271,8 @@ function checkSbomCurrency(root) {
|
|
|
341
271
|
);
|
|
342
272
|
continue;
|
|
343
273
|
}
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
// Trivy / Dependency-Track / GitHub Dependency Graph). SHA3-512
|
|
347
|
-
// is the SHA-3 family hedge, matching the existing key-fingerprint
|
|
348
|
-
// pattern (lib/verify.js). Both must agree with the live bytes;
|
|
349
|
-
// a mismatch on either fires the same drift error. A missing
|
|
350
|
-
// SHA-256 is a hard error (the universal contract is the floor);
|
|
351
|
-
// a missing SHA3-512 surfaces as a downgrade-attack warning so an
|
|
352
|
-
// operator who intentionally strips the second hash from an
|
|
353
|
-
// SBOM (post-quantum posture relaxation) sees it in the gate
|
|
354
|
-
// output, not in the JSON downstream.
|
|
274
|
+
// SHA-256 is the universal-tool contract (CycloneDX 1.6 default, read by
|
|
275
|
+
// Anchore / Trivy / Dependency-Track); SHA3-512 is the SHA-3 family hedge.
|
|
355
276
|
const sha256Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA-256");
|
|
356
277
|
const sha3Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA3-512");
|
|
357
278
|
if (!sha256Entry || typeof sha256Entry.content !== "string") {
|
|
@@ -367,12 +288,8 @@ function checkSbomCurrency(root) {
|
|
|
367
288
|
`SBOM file component "${relPath}" SHA-256 drift: recorded ${sha256Entry.content.slice(0, 12)}…, live ${liveSha256.slice(0, 12)}… — re-sign skills (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) and then \`npm run refresh-sbom\`, in that order (sbom must regenerate AFTER the final sign).`,
|
|
368
289
|
);
|
|
369
290
|
}
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// sbom-generator regression) that strips the SHA3-512 column would
|
|
373
|
-
// silently pass the gate under an `if (sha3Entry)` guard, defeating
|
|
374
|
-
// the downgrade defense the dual-hash design is supposed to provide.
|
|
375
|
-
// Refuse absence as a hard error.
|
|
291
|
+
// Absence is a hard error, never an `if (sha3Entry)` guard: stripping the
|
|
292
|
+
// SHA3-512 column would otherwise pass and defeat the downgrade defense.
|
|
376
293
|
if (!sha3Entry || typeof sha3Entry.content !== "string") {
|
|
377
294
|
errors.push(
|
|
378
295
|
`SBOM file component "${relPath}" lacks a SHA3-512 hash entry — the dual-hash contract (SHA-256 + SHA3-512) requires both algorithms on every file: component. Regenerate via \`npm run refresh-sbom\` (v0.13.12+).`
|
|
@@ -388,20 +305,12 @@ function checkSbomCurrency(root) {
|
|
|
388
305
|
fileComponentsChecked++;
|
|
389
306
|
}
|
|
390
307
|
|
|
391
|
-
// Completeness
|
|
392
|
-
// every RECORDED
|
|
393
|
-
//
|
|
394
|
-
// newly-shipped file would ship unhashed and silent. And the aggregate
|
|
395
|
-
// bundle digest in metadata.component.hashes[] was never recomputed. Reuse
|
|
396
|
-
// refresh-sbom's exact allowlist expansion + digest so the gate can't drift
|
|
397
|
-
// from the generator.
|
|
308
|
+
// Completeness plus the aggregate bundle digest. The per-file pass verifies
|
|
309
|
+
// every RECORDED component, so without this a newly-shipped file carrying no
|
|
310
|
+
// component would go unhashed and silent.
|
|
398
311
|
try {
|
|
399
|
-
// bundleDigest
|
|
400
|
-
//
|
|
401
|
-
// allowlist expansion, by contrast, MUST run against the target `root`
|
|
402
|
-
// (expandAllowlistAt below) — refresh-sbom's exported expandAllowlist is
|
|
403
|
-
// pinned to its own source-repo REPO_ROOT and would validate the wrong tree
|
|
404
|
-
// under `--root`.
|
|
312
|
+
// bundleDigest reads only the file: component objects, so it is
|
|
313
|
+
// root-agnostic; the allowlist expansion is not, hence expandAllowlistAt.
|
|
405
314
|
const { bundleDigest } = require("./refresh-sbom");
|
|
406
315
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
407
316
|
const expected = expandAllowlistAt(pkg.files || [], root);
|
|
@@ -416,16 +325,11 @@ function checkSbomCurrency(root) {
|
|
|
416
325
|
);
|
|
417
326
|
}
|
|
418
327
|
}
|
|
419
|
-
//
|
|
420
|
-
// SHA-256 hashes and compare to metadata.component.hashes[] (the per-file
|
|
421
|
-
// pass already tied each recorded hash to live bytes).
|
|
328
|
+
// Recomputed from the recorded per-file SHA-256s, already tied to live bytes.
|
|
422
329
|
const compHashes = (sbom.metadata && sbom.metadata.component && sbom.metadata.component.hashes) || [];
|
|
423
330
|
const recorded = (compHashes.find((h) => h && h.alg === "SHA-256") || {}).content;
|
|
424
331
|
if (fileComps.length) {
|
|
425
|
-
//
|
|
426
|
-
// verification anchor. An absent one must fail the gate (symmetric with
|
|
427
|
-
// the per-file SHA3-512 hard-error that defeats downgrade/strip attacks) —
|
|
428
|
-
// previously an absent digest silently skipped the comparison entirely.
|
|
332
|
+
// The bundle-as-a-whole anchor: absence fails rather than skipping.
|
|
429
333
|
if (!recorded) {
|
|
430
334
|
errors.push(
|
|
431
335
|
"SBOM metadata.component.hashes lacks a SHA-256 bundle digest — the aggregate verification anchor is missing; run `npm run refresh-sbom`"
|
|
@@ -459,9 +363,7 @@ function main() {
|
|
|
459
363
|
if (!result.ok) {
|
|
460
364
|
for (const e of result.errors) process.stderr.write(e + "\n");
|
|
461
365
|
process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
|
|
462
|
-
//
|
|
463
|
-
// writes drain before the event loop exits. process.exit() can
|
|
464
|
-
// truncate piped output (CI log capture, JSON consumers).
|
|
366
|
+
// process.exitCode, not process.exit() — the exit can truncate a piped write.
|
|
465
367
|
process.exitCode = 1;
|
|
466
368
|
return;
|
|
467
369
|
}
|
|
@@ -2,41 +2,15 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Canonical-test-count predeploy gate: catches test-set shrinkage the lint and
|
|
6
|
+
* diff-coverage gates cannot see.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* noticing. The lint + diff-coverage gates catch source changes; this gate
|
|
11
|
-
* catches test-set shrinkage.
|
|
8
|
+
* Counts DECLARATIONS statically across `tests/*.test.js` — `test(` and `it(`
|
|
9
|
+
* with their `.only` / `.skip` variants. `describe(` is NOT counted: a container
|
|
10
|
+
* is not a test. Blind spot: a test neutered in place still counts as one.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* test neutered in place: `test('name', { skip: true }, fn)`, `test.skip(`,
|
|
16
|
-
* and `it.skip(` all still count as one declaration, so flipping a running
|
|
17
|
-
* test to permanently skipped leaves the count unchanged. Guarding against
|
|
18
|
-
* skip-in-place would need runnable-vs-skipped tracking; that is out of scope
|
|
19
|
-
* for this gate.
|
|
20
|
-
*
|
|
21
|
-
* Mechanism: count `test(`/`it(` declarations (including the `.only`/`.skip`
|
|
22
|
-
* variants) across `tests/*.test.js` via static analysis (faster than
|
|
23
|
-
* running). node:test supports both `test()` and the BDD-style `it()`/
|
|
24
|
-
* `describe()` aliases as first-class declarations, and the suite mixes both,
|
|
25
|
-
* so counting only `test(` was blind to every `it(` test. `describe(` is NOT
|
|
26
|
-
* counted — those are containers, not tests, so counting them would inflate
|
|
27
|
-
* the total and conflate removing a grouping block with removing a test.
|
|
28
|
-
* Compare to a baseline pinned in `tests/.test-count-baseline.json`. Fail if
|
|
29
|
-
* the observed count drops MORE than the configured tolerance (default 1)
|
|
30
|
-
* below the baseline. Growth above baseline is fine; if the count grows by
|
|
31
|
-
* more than `update_baseline_when_growth_exceeds`, surface a notice that the
|
|
32
|
-
* baseline file should be refreshed (operator commits the refresh as part
|
|
33
|
-
* of the release that added the tests).
|
|
34
|
-
*
|
|
35
|
-
* Output:
|
|
36
|
-
* stdout: structured JSON when --json, else a one-line summary
|
|
37
|
-
* exit 0: observed count is at or above baseline minus tolerance
|
|
38
|
-
* exit 1: observed count dropped beyond tolerance — fail predeploy
|
|
39
|
-
* exit 2: baseline file missing or malformed
|
|
12
|
+
* exit 0 at or above baseline minus tolerance, 1 when it drops further, 2 when
|
|
13
|
+
* the baseline file is missing or malformed.
|
|
40
14
|
*/
|
|
41
15
|
|
|
42
16
|
const fs = require('fs');
|
|
@@ -63,16 +37,13 @@ function listTestFiles(dir) {
|
|
|
63
37
|
|
|
64
38
|
function countTests(filePath) {
|
|
65
39
|
let text = fs.readFileSync(filePath, 'utf8');
|
|
66
|
-
// Strip block comments first
|
|
67
|
-
//
|
|
68
|
-
// defeated this gate's entire purpose (it only stripped whole-line `//`).
|
|
40
|
+
// Strip block comments first: commenting a test out is the usual way to
|
|
41
|
+
// disable one, and counting it anyway defeats the gate.
|
|
69
42
|
text = text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
70
43
|
let count = 0;
|
|
71
44
|
for (const rawLine of text.split('\n')) {
|
|
72
|
-
// Blank
|
|
73
|
-
//
|
|
74
|
-
// file's docstring examples) is not miscounted as a declaration — that
|
|
75
|
-
// phantom inflated the baseline and could mask a real test deletion.
|
|
45
|
+
// Blank string and template bodies first, so a `test(` inside a string
|
|
46
|
+
// literal is not read as a declaration — a phantom inflates the baseline.
|
|
76
47
|
const noStrings = rawLine.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "''");
|
|
77
48
|
// Drop a trailing line comment too (`test('x'); // disabled`).
|
|
78
49
|
const stripped = noStrings.replace(/\/\/.*$/, '').trim();
|
|
@@ -86,11 +57,8 @@ function main() {
|
|
|
86
57
|
const wantJson = process.argv.includes('--json');
|
|
87
58
|
const wantUpdate = process.argv.includes('--update-baseline');
|
|
88
59
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// check-then-use window (CodeQL js/file-system-race) where the file the
|
|
92
|
-
// gate decides about is not the file it later reads. ENOENT from the single
|
|
93
|
-
// read IS the "missing" signal — no second path access needed.
|
|
60
|
+
// Branch on the read RESULT, never on a prior existsSync probe: ENOENT from
|
|
61
|
+
// this single read IS the "missing" signal.
|
|
94
62
|
let baselineRaw = null;
|
|
95
63
|
try {
|
|
96
64
|
baselineRaw = fs.readFileSync(BASELINE_PATH, 'utf8');
|
|
@@ -99,13 +67,10 @@ function main() {
|
|
|
99
67
|
console.error(`[check-test-count] cannot read baseline: ${e.message}`);
|
|
100
68
|
process.exit(2);
|
|
101
69
|
}
|
|
102
|
-
// ENOENT — baseline absent.
|
|
103
70
|
if (wantUpdate) {
|
|
104
71
|
const files = listTestFiles(TESTS_DIR);
|
|
105
72
|
const observed = files.reduce((n, f) => n + countTests(f), 0);
|
|
106
|
-
// Exclusive create
|
|
107
|
-
// between the read above and this write, so we never clobber a baseline
|
|
108
|
-
// a concurrent run just produced — atomic, no check-then-write window.
|
|
73
|
+
// Exclusive create: EEXIST rather than clobbering a concurrent run's baseline.
|
|
109
74
|
fs.writeFileSync(BASELINE_PATH, JSON.stringify({
|
|
110
75
|
baseline: observed,
|
|
111
76
|
tolerance: 1,
|
|
@@ -173,9 +138,7 @@ function main() {
|
|
|
173
138
|
console.error(`[check-test-count] FAIL - test count dropped from ${baseline} to ${observed} (delta ${delta}, tolerance -${tolerance}).`);
|
|
174
139
|
console.error('[check-test-count] Either a test file was accidentally removed, a test()/it() invocation was deleted, OR the baseline is stale.');
|
|
175
140
|
console.error('[check-test-count] If the drop is intentional, run: node scripts/check-test-count.js --update-baseline');
|
|
176
|
-
// exitCode
|
|
177
|
-
// (the --json result / one-line summary) drains before the event loop ends
|
|
178
|
-
// — process.exit() can truncate piped output.
|
|
141
|
+
// `process.exitCode`, not `process.exit()`: the buffered stdout write must drain.
|
|
179
142
|
process.exitCode = 1;
|
|
180
143
|
return;
|
|
181
144
|
}
|