@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +895 -2828
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -155
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +39 -113
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -413
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +63 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +62 -81
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +83 -198
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +7 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -141
@@ -1,16 +1,9 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/sbom.js
5
- *
6
- * Companion collector for the `sbom` playbook. Identifies the lockfile
7
- * fingerprint of the cwd (npm / yarn / pnpm / pip / cargo / go / ruby /
8
- * composer) so the runner can correlate against the SBOM-currency +
9
- * supply-chain integrity indicators. Counts components per lockfile
10
- * for a coarse SBOM-presence signal.
11
- *
12
- * Scope: any cwd with a recognizable lockfile. Multi-ecosystem repos
13
- * report every detected lockfile.
4
+ * Companion collector for the `sbom` playbook. Fingerprints the lockfiles in the
5
+ * cwd (npm, yarn, pnpm, pip, cargo, go, ruby, composer) and counts components
6
+ * per lockfile as a coarse SBOM-presence signal.
14
7
  *
15
8
  * Interface: see lib/collectors/README.md
16
9
  */
@@ -21,8 +14,8 @@ const { buildEvidenceLocations } = require("./scan-excludes");
21
14
 
22
15
  const COLLECTOR_ID = "sbom";
23
16
 
24
- // Lockfile fingerprints. Each entry: { file: <basename>, ecosystem,
25
- // parser: <function(content) -> { component_count, top_level_count }> }.
17
+ // Each parser takes the file content and returns
18
+ // { component_count, top_level_count, lockfile_version } or { error }.
26
19
  const LOCKFILES = [
27
20
  {
28
21
  file: "package-lock.json",
@@ -123,30 +116,21 @@ const LOCKFILES = [
123
116
  } catch (e) { return { error: e.message }; }
124
117
  },
125
118
  },
126
- // Python dependency MANIFEST (not strictly a lockfile but the
127
- // canonical project file for modern Python projects). Counted as
128
- // a Python ecosystem dependency source so projects with only a
129
- // pyproject.toml + no requirements.txt are recognized.
119
+ // A manifest, not a lockfile: a pyproject-only project is still recognized.
130
120
  {
131
121
  file: "pyproject.toml",
132
122
  ecosystem: "python",
133
123
  parser: (content) => {
134
- // Count entries in [project.dependencies] / [project.optional-
135
- // dependencies.*] / [tool.poetry.dependencies] / [tool.poetry
136
- // .dev-dependencies]. Coarse line-based count — the TOML parser
137
- // would pull a dep into the stdlib-only contract.
124
+ // Line-based, not parsed: the collector contract is stdlib-only.
138
125
  const depBlocks = content.match(/^\[(?:project\.(?:dependencies|optional-dependencies)|tool\.poetry\.(?:dependencies|dev-dependencies|group\.[a-z0-9_-]+\.dependencies))[^\]]*\][\s\S]*?(?=^\[|$)/gm) || [];
139
126
  let count = 0;
140
127
  for (const block of depBlocks) {
141
- // count "name = ..." lines (excluding the block header)
142
128
  const lines = block.split(/\r?\n/).slice(1);
143
129
  for (const line of lines) {
144
130
  if (/^\s*[A-Za-z][A-Za-z0-9._\-]*\s*=/.test(line)) count++;
145
131
  }
146
132
  }
147
- // Also handle the PEP 621 array-style:
148
- // [project]
149
- // dependencies = [ "a", "b", ... ]
133
+ // PEP 621 array style: `dependencies = [ "a", "b" ]` under [project].
150
134
  const arrMatch = content.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
151
135
  if (arrMatch) {
152
136
  const entries = arrMatch[1].match(/"([^"]+)"|'([^']+)'/g) || [];
@@ -157,10 +141,7 @@ const LOCKFILES = [
157
141
  },
158
142
  ];
159
143
 
160
- // Python requirements*.txt glob. Variants include requirements-dev
161
- // .txt, requirements-prod.txt, dev-requirements.txt. The
162
- // requirements.txt entry above covers the canonical name; this
163
- // glob extends coverage to the common variants.
144
+ // requirements*.txt variants; the canonical name is a LOCKFILES entry above.
164
145
  const REQUIREMENTS_GLOB_RE = /^(?:[a-z0-9_-]+-)?requirements(?:-[a-z0-9_-]+)?\.txt$/i;
165
146
  const REQUIREMENTS_LF = {
166
147
  ecosystem: "pip",
@@ -170,11 +151,7 @@ const REQUIREMENTS_LF = {
170
151
  },
171
152
  };
172
153
 
173
- // Subdirectory probe paths — one level deep, hand-listed to keep
174
- // the walk bounded. Covers the common monorepo / docs-subdir / iac
175
- // layouts: docs/ (requirements.txt for sphinx-style docs builds),
176
- // packages/* (monorepo workspaces), backend/ + frontend/ +
177
- // infra/ + iac/ (split-stack repos).
154
+ // Probed one level deep and hand-listed so the walk stays bounded.
178
155
  const SUBDIR_PROBE_PATHS = ["docs", "packages", "backend", "frontend", "infra", "iac", "src", "app"];
179
156
 
180
157
  const SBOM_FORMATS = [
@@ -195,24 +172,31 @@ function captureLockfile(p, ecosystem, parser, label) {
195
172
  path: p,
196
173
  size_bytes: Buffer.byteLength(content, "utf8"),
197
174
  ...stats,
175
+ // A parser that could not decode the structure returns `error`; the read
176
+ // itself succeeded, so the two failures carry different kinds downstream.
177
+ ...(stats && stats.error ? { error_kind: "lockfile_parse_failed" } : {}),
198
178
  };
199
179
  } catch (e) {
200
- return { file: label, ecosystem, path: p, error: e.message };
180
+ return { file: label, ecosystem, path: p, error: e.message, error_kind: "lockfile_read_failed" };
201
181
  }
202
182
  }
203
183
 
204
- function findLockfiles(cwd) {
184
+ // `errors` is the collector_errors channel: a directory the walk could not read
185
+ // is a scan gap the operator has to see, not a silent zero. Required, not
186
+ // defaulted: a default array would restore the discard-into-the-void behaviour
187
+ // for any caller that forgets it, and neither this function nor
188
+ // findSbomDocuments is exported, so no test could reach that caller.
189
+ function findLockfiles(cwd, errors) {
190
+ if (!Array.isArray(errors)) {
191
+ throw new TypeError("findLockfiles: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
192
+ }
205
193
  const found = [];
206
- // Canonical names at cwd root.
207
194
  for (const lf of LOCKFILES) {
208
195
  const p = path.join(cwd, lf.file);
209
196
  if (fs.existsSync(p)) {
210
197
  found.push(captureLockfile(p, lf.ecosystem, lf.parser, lf.file));
211
198
  }
212
199
  }
213
- // requirements*.txt glob at cwd root — covers requirements-dev.txt,
214
- // dev-requirements.txt, etc. The exact-name `requirements.txt`
215
- // already lands via LOCKFILES; skip it here.
216
200
  try {
217
201
  for (const entry of fs.readdirSync(cwd)) {
218
202
  if (entry === "requirements.txt") continue; // captured above
@@ -223,25 +207,35 @@ function findLockfiles(cwd) {
223
207
  }
224
208
  }
225
209
  }
226
- } catch { /* swallow */ }
210
+ } catch (e) {
211
+ errors.push({
212
+ artifact_id: "lockfile-inventory",
213
+ kind: "readdir_failed",
214
+ reason: `cwd root: ${e.message} — requirements*.txt variants were not enumerated`,
215
+ });
216
+ }
227
217
 
228
- // One-level subdirectory probe for canonical-name lockfiles. The
229
- // common pattern: docs/requirements.txt (sphinx builds),
230
- // packages/*/package.json (monorepo workspaces), backend/Gemfile
231
- // .lock (split-stack repos). Capped depth (1 level only) and
232
- // pre-listed subdirs to keep the walk bounded.
233
218
  for (const sub of SUBDIR_PROBE_PATHS) {
234
219
  const subDir = path.join(cwd, sub);
235
220
  let entries;
236
221
  try {
237
222
  if (!fs.statSync(subDir).isDirectory()) continue;
238
223
  entries = fs.readdirSync(subDir, { withFileTypes: true });
239
- } catch { continue; }
224
+ } catch (e) {
225
+ // An absent probe dir is the normal case, not a gap. A permission or I/O
226
+ // failure on one that IS there means the subtree went unscanned.
227
+ if (e.code !== "ENOENT") {
228
+ errors.push({
229
+ artifact_id: "lockfile-inventory",
230
+ kind: "readdir_failed",
231
+ reason: `${sub}/: ${e.message} — subtree not scanned for lockfiles`,
232
+ });
233
+ }
234
+ continue;
235
+ }
240
236
  for (const e of entries) {
241
237
  if (e.isDirectory()) {
242
- // For packages/* etc. — probe ONE level deeper for canonical
243
- // names. (Doesn't recurse further; monorepo workspaces are
244
- // the only common case.)
238
+ // packages/* gets one more level for monorepo workspaces, no deeper.
245
239
  if (sub === "packages") {
246
240
  for (const lf of LOCKFILES) {
247
241
  const p = path.join(subDir, e.name, lf.file);
@@ -254,10 +248,7 @@ function findLockfiles(cwd) {
254
248
  continue;
255
249
  }
256
250
  if (!e.isFile()) continue;
257
- // Try canonical LOCKFILES name match first; if it matches, the
258
- // file is captured there. The requirements glob below catches
259
- // ONLY non-canonical names (e.g. requirements-dev.txt) so
260
- // exact `requirements.txt` doesn't double-fire.
251
+ // A canonical name wins, so `requirements.txt` cannot double-fire below.
261
252
  let captured = false;
262
253
  for (const lf of LOCKFILES) {
263
254
  if (e.name === lf.file) {
@@ -279,37 +270,53 @@ function findLockfiles(cwd) {
279
270
  return found;
280
271
  }
281
272
 
282
- function findSbomDocuments(cwd) {
273
+ // `errors` is required for the same reason it is on findLockfiles above.
274
+ function findSbomDocuments(cwd, errors) {
275
+ if (!Array.isArray(errors)) {
276
+ throw new TypeError("findSbomDocuments: `errors` must be the collector_errors array — scan gaps have nowhere else to go");
277
+ }
283
278
  const found = [];
284
279
  for (const s of SBOM_FORMATS) {
285
280
  const p = path.join(cwd, s.file);
286
281
  let fd;
287
- // Open once and fstat the descriptor instead of existsSync→statSync→read:
288
- // an absent file (ENOENT) is skipped just as the existsSync(false) path did,
289
- // but there is no TOCTOU window between the check, the size stat, and the read.
282
+ // Open once and fstat the descriptor rather than existsSync→statSync→read:
283
+ // ENOENT still skips, and there is no TOCTOU window.
290
284
  try { fd = fs.openSync(p, "r"); }
291
- catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message }); continue; }
285
+ catch (e) { if (e.code === "ENOENT") continue; found.push({ file: s.file, format: s.format, error: e.message, error_kind: "open_failed" }); continue; }
292
286
  try {
293
287
  const stat = fs.fstatSync(fd);
294
288
  let content;
295
- // Read from the already-open descriptor (no re-open by path → no
296
- // TOCTOU window, preserving the fstat-on-fd hardening). readFileSync(fd)
297
- // loops internally to EOF; a single readSync(fd, buf, …, stat.size) does
298
- // NOT — it can return a short read (network/FUSE mount, signal), leaving
299
- // the buffer NUL-padded and truncating valid JSON, so JSON.parse would
300
- // throw and component_count silently fall back to null on a present,
301
- // parseable SBOM. The fd stays open for the finally{} closeSync.
302
- try { content = fs.readFileSync(fd, "utf8"); } catch { content = null; }
289
+ // Read from the open descriptor, never by path again. readFileSync(fd) loops
290
+ // to EOF; a single readSync can return short on a network or FUSE mount,
291
+ // NUL-padding the buffer and truncating otherwise-valid JSON.
292
+ try { content = fs.readFileSync(fd, "utf8"); } catch (e) {
293
+ content = null;
294
+ errors.push({
295
+ artifact_id: "sbom-document",
296
+ kind: "read_failed",
297
+ reason: `${s.file}: ${e.message} — component count not derived`,
298
+ });
299
+ }
303
300
  let component_count = null;
304
301
  if (content && s.format === "cyclonedx-1.x") {
305
302
  try {
306
303
  const j = JSON.parse(content);
307
304
  component_count = (j.components || []).length;
308
- } catch {}
305
+ } catch (e) {
306
+ // A present-but-unparseable SBOM reported `null components` with no
307
+ // explanation, which reads as "no components" rather than "not read".
308
+ errors.push({
309
+ artifact_id: "sbom-document",
310
+ kind: "parse_failed",
311
+ reason: `${s.file}: ${e.message} — component count unavailable`,
312
+ });
313
+ }
309
314
  }
310
315
  found.push({ file: s.file, format: s.format, size_bytes: stat.size, component_count });
311
316
  } catch (e) {
312
- found.push({ file: s.file, format: s.format, error: e.message });
317
+ // The descriptor opened; fstat is what failed here. Reporting it as an
318
+ // open failure sends the operator after the wrong thing.
319
+ found.push({ file: s.file, format: s.format, error: e.message, error_kind: "stat_failed" });
313
320
  } finally {
314
321
  if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } }
315
322
  }
@@ -322,13 +329,32 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
322
329
  const startTime = Date.now();
323
330
  const root = path.resolve(cwd);
324
331
 
325
- // Precondition: sbom-tool-available — the runner / playbook treats
326
- // this as "an operator has SOME way to produce an SBOM". For the
327
- // collector, "we found a lockfile or an SBOM" is a sufficient proxy.
328
- const lockfiles = findLockfiles(root);
329
- const sbomDocuments = findSbomDocuments(root);
332
+ // sbom-tool-available: a lockfile or an SBOM document is a sufficient proxy.
333
+ const lockfiles = findLockfiles(root, errors);
334
+ const sbomDocuments = findSbomDocuments(root, errors);
330
335
  const hasAnything = lockfiles.length > 0 || sbomDocuments.length > 0;
331
336
 
337
+ // A lockfile that was found but not read contributes no component count, so
338
+ // the inventory under-reports; the operator sees which one and why.
339
+ for (const l of lockfiles) {
340
+ if (!l.error) continue;
341
+ errors.push({
342
+ artifact_id: "lockfile-inventory",
343
+ kind: l.error_kind || "lockfile_read_failed",
344
+ reason: `${l.file}: ${l.error}`,
345
+ });
346
+ }
347
+ for (const s of sbomDocuments) {
348
+ if (!s.error) continue;
349
+ errors.push({
350
+ artifact_id: "sbom-document",
351
+ // Carried from the push site, mirroring the lockfile loop above: an
352
+ // fstat failure and an open failure are different operator actions.
353
+ kind: s.error_kind || "open_failed",
354
+ reason: `${s.file}: ${s.error}`,
355
+ });
356
+ }
357
+
332
358
  const artifacts = {
333
359
  "lockfile-inventory": {
334
360
  value: lockfiles.length
@@ -344,22 +370,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
344
370
  },
345
371
  };
346
372
 
347
- // The sbom playbook's detect indicators (package-matches-catalogued-cve,
348
- // lockfile-no-integrity, transitive-deps-incomplete-sbom,
349
- // matched-cve-without-vex, ai-code-no-provenance, ...) require
350
- // catalog cross-referencing the collector does not have — that's
351
- // the runner's job. The collector's role here is to surface the
352
- // artifacts (lockfile-inventory + sbom-document) and let the
353
- // runner evaluate the indicators against them. Emitting
354
- // signal_overrides for keys that don't exist in the playbook
355
- // would be silently ignored; surfacing the artifacts honestly
356
- // is the contract.
357
- //
358
- // One indicator the collector CAN decide deterministically:
359
- // lockfile-no-integrity — true when an npm package-lock.json
360
- // exists but has zero `integrity` entries (lockfileVersion 1
361
- // legacy) OR when the dependency list contains entries lacking
362
- // `integrity` strings.
373
+ // lockfile-no-integrity is the one indicator decidable here: an npm
374
+ // package-lock.json with entries carrying no `integrity`. The rest need catalog
375
+ // cross-referencing, which the runner does against the artifacts above.
363
376
  const npmLockfile = lockfiles.find(l => l.file === "package-lock.json");
364
377
  const signal_overrides = {};
365
378
  if (npmLockfile && !npmLockfile.error) {
@@ -367,19 +380,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
367
380
  const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
368
381
  let withIntegrity = 0;
369
382
  let withoutIntegrity = 0;
370
- // Track whether any integrity-less entry is a local-path / workspace /
371
- // git ref. lockfile-no-integrity FP[0] demotes those — they legitimately
372
- // have no registry integrity hash. A remote-registry tarball without
373
- // integrity is the genuine finding.
383
+ // A local-path, workspace or git ref legitimately has no integrity hash, so
384
+ // FP check [0] demotes those; a remote-registry tarball is the finding.
374
385
  let withoutIntegrityLocalOnly = true;
375
386
  const LOCAL_REF_RE = /^(?:file:|link:|workspace:|git\+ssh:|git\+https:|git:|github:|portal:)/i;
376
387
  const walk = (obj) => {
377
388
  if (!obj || typeof obj !== "object") return;
378
- // Only remote-tarball entries (those with a `resolved` URL) are
379
- // expected to carry an `integrity` hash. The npm 7+ root entry
380
- // `"": { name, version }` legitimately has no `resolved` and no
381
- // `integrity`, so keying off `version` would false-positive on
382
- // every clean lockfile. Mirror library-author.js's guard.
389
+ // Only entries with a `resolved` URL are expected to carry `integrity`.
390
+ // The npm 7+ root entry `"": { name, version }` has neither, so keying off
391
+ // `version` false-positives. library-author.js guards the same way.
383
392
  if (obj.resolved != null) {
384
393
  if (obj.integrity != null) {
385
394
  withIntegrity++;
@@ -391,15 +400,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
391
400
  for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
392
401
  };
393
402
  walk(j.packages || j.dependencies || {});
394
- // Fire only if integrity is missing on ANY package entry that
395
- // resolves to a remote tarball — the indicator captures the
396
- // class, not full coverage.
403
+ // Any single integrity-less resolved entry fires it: the class, not coverage.
397
404
  if (withoutIntegrity > 0) {
398
405
  signal_overrides["lockfile-no-integrity"] = "hit";
399
- // __fp_checks attestation. [0]: at least one integrity-less entry is a
400
- // remote-registry tarball (not exclusively local-path/workspace/git
401
- // refs). [1]: the lockfile is the canonical root package-lock.json the
402
- // build consumes, not a stale copy under archive/ pre-migration/.
406
+ // __fp_checks attestation — [0]: an integrity-less entry is a remote
407
+ // tarball, not only local, workspace or git refs. [1]: this is the root
408
+ // lockfile the build consumes, not a stale archived copy.
403
409
  const att = {};
404
410
  if (!withoutIntegrityLocalOnly) att["0"] = true;
405
411
  const rel = (npmLockfile.path || "").replace(/\\/g, "/");
@@ -408,19 +414,19 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
408
414
  } else if (withIntegrity > 0) {
409
415
  signal_overrides["lockfile-no-integrity"] = "miss";
410
416
  }
411
- // Stash diagnostic counts on collector_meta further below.
412
417
  npmLockfile.integrity_present_count = withIntegrity;
413
418
  npmLockfile.integrity_missing_count = withoutIntegrity;
414
- } catch {
415
- // Malformed lockfile — leave the indicator unflipped so the
416
- // runner returns inconclusive rather than a forced miss.
419
+ } catch (e) {
420
+ // A malformed lockfile leaves the indicator unflipped: inconclusive, not miss.
421
+ errors.push({
422
+ artifact_id: "lockfile-inventory",
423
+ kind: "lockfile_parse_failed",
424
+ reason: `${npmLockfile.file}: ${e.message} — lockfile-no-integrity left undecided (inconclusive, not miss)`,
425
+ });
417
426
  }
418
427
  }
419
428
 
420
- // Per-indicator file location for the one deterministically-decided
421
- // indicator: a lockfile-no-integrity hit points at the npm lockfile that
422
- // carries integrity-less entries. File-level (the gap is spread across
423
- // many entries, not one line).
429
+ // File-level: the gap is spread across entries rather than sitting on one line.
424
430
  const evidence_locations = {};
425
431
  if (signal_overrides["lockfile-no-integrity"] === "hit" && npmLockfile) {
426
432
  const locs = buildEvidenceLocations([{ file: npmLockfile.file }]);
@@ -430,11 +436,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
430
436
  return {
431
437
  precondition_checks: {
432
438
  "sbom-tool-available": hasAnything,
433
- // Auto-attest the playbook's any-package-manager-present gate from what
434
- // we actually collected: the runner's autoDetectPreconditions can't probe
435
- // the scanned --cwd (it sees the run process cwd, not the collected repo),
436
- // so a lockfile we found here would otherwise surface a spurious
437
- // precondition_unverified warning on a repo that clearly has one.
439
+ // Attested from what was collected: autoDetectPreconditions sees the run
440
+ // process cwd, not the scanned --cwd, so this would warn as unverified.
438
441
  "any-package-manager-present": lockfiles.length > 0,
439
442
  },
440
443
  artifacts,
@@ -450,6 +453,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
450
453
  lockfiles_found: lockfiles.length,
451
454
  sbom_documents_found: sbomDocuments.length,
452
455
  ecosystems_detected: [...new Set(lockfiles.map(l => l.ecosystem))],
456
+ // Magnitude behind the lockfile-no-integrity verdict: "hit" alone does not
457
+ // say whether one entry or nine hundred resolve without a hash. Present
458
+ // only when an npm lockfile was found AND parsed.
459
+ ...(npmLockfile && npmLockfile.integrity_present_count != null
460
+ ? {
461
+ npm_integrity_present_count: npmLockfile.integrity_present_count,
462
+ npm_integrity_missing_count: npmLockfile.integrity_missing_count,
463
+ }
464
+ : {}),
453
465
  },
454
466
  collector_errors: errors,
455
467
  };