@blamejs/exceptd-skills 0.16.28 → 0.16.29

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.29 — 2026-06-12
4
+
5
+ A correctness pass across the refresh pipeline, scoring, attestation, the collectors, and offline mode.
6
+
7
+ `refresh --apply` over the network no longer downgrades a curated CVSS 3.1 score and vector to NVD's legacy v2 metric on older CVEs — the offline cache path already guarded this in 0.16.27, and the live path now applies the same cross-version guard. New-RFC discovery now honors `--air-gap` (it previously queried IETF Datatracker live regardless), and an intrinsically air-gapped playbook — secrets, cred-stores, containers — refuses the `--upstream-check` npm-registry probe without the explicit flag. The `--from-cache` help no longer implies new-RFC discovery is offline; it stays live unless `--air-gap` is also passed.
8
+
9
+ A CVE that a VEX statement marks fixed no longer inflates a finding's adjusted RWEP through its exploitation, KEV, and proof-of-concept multipliers, and a patched CVE's exploitation status no longer drives the notification draft. Jurisdiction coverage no longer attributes a skill to a jurisdiction from a bare two-letter ISO code that appears only in prose or inside a control identifier; coverage is driven by the regulation-name mapping. The skill-currency staleness check can now reach the warn and critical tiers it gates on — they were unreachable, so the scheduled currency workflow could never flag a skill past its review window; a genuinely abandoned skill now scores into them while a maintained one does not.
10
+
11
+ `attest diff --against` now verifies the comparison attestation's Ed25519 signature, not only the local side, and refuses a tampered `--against` attestation (exit 6; `--force-replay` overrides). Both sides' verification is recorded in the output.
12
+
13
+ The collectors no longer raise false findings from `#`-commented YAML — a commented `npm install`, `runs-on: self-hosted`, or `secrets.NPM_TOKEN` is no longer read as the real thing — and a commented `npm publish --provenance` no longer suppresses the missing-build-provenance finding. A documentation or redaction-pattern snippet of a service-account private key no longer registers as an embedded secret. A skill.md with CRLF line endings no longer produces a misleading frontmatter-parse error, and the `run --format` reference now lists `json`, which the runtime already accepts.
14
+
15
+ The scheduled external-data refresh keeps the package description's entry counts in sync with the data it applies, so an auto-refresh that changes a count no longer fails the SBOM currency check on its pull request.
16
+
3
17
  ## 0.16.28 — 2026-06-10
4
18
 
5
19
  Refreshes the pinned MITRE threat-framework versions. MITRE ATLAS is now pinned to v2026.05: its content moved to a YYYY.MM calendar-versioning scheme, and the release adds platform tags (Predictive AI, Generative AI, Agentic AI, Enterprise) to every technique. MITRE ATT&CK is pinned to v19.1, a point release of typo and data corrections over v19.0. Both bumps were audited against every ATLAS and ATT&CK technique ID the catalog cites: none was removed, renamed, or revoked, so all existing references remain valid.
package/README.md CHANGED
@@ -228,7 +228,7 @@ exceptd run [playbook] Phases 4-7. Auto-detects cwd context when
228
228
  --evidence-dir <dir> Per-playbook submission files (cron-friendly).
229
229
  --scope <type> | --all Multi-playbook run.
230
230
  --vex <file> CycloneDX / OpenVEX filter (drop not_affected).
231
- --format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary.
231
+ --format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary | json.
232
232
  Repeatable. CSAF is primary; extras go to
233
233
  close.evidence_package.bundles_by_format.
234
234
  --diff-from-latest Drift vs prior attestation for same playbook.
package/bin/exceptd.js CHANGED
@@ -3406,8 +3406,10 @@ function cmdRun(runner, args, runOpts, pretty) {
3406
3406
  // Audit 3 A.6: --air-gap must refuse the registry probe. The
3407
3407
  // upstream-check helper has no air-gap awareness of its own; the
3408
3408
  // central refusal lives here so any future caller of --upstream-check
3409
- // inherits it.
3410
- if (runOpts.airGap || process.env.EXCEPTD_AIR_GAP === "1") {
3409
+ // inherits it. Mirror the line-3444 hoist: an intrinsically air-gapped
3410
+ // playbook (_meta.air_gap_mode secrets / cred-stores / containers) must
3411
+ // refuse the egress too, even without the explicit --air-gap flag.
3412
+ if (runOpts.airGap || process.env.EXCEPTD_AIR_GAP === "1" || pb._meta?.air_gap_mode) {
3411
3413
  upstreamCheck = {
3412
3414
  ok: false,
3413
3415
  source: "air-gap",
@@ -5641,24 +5643,26 @@ function cmdAttest(runner, args, runOpts, pretty) {
5641
5643
  // attestation.json cannot shadow the real attestation in the
5642
5644
  // diff.
5643
5645
  let other = null;
5646
+ let otherPath = null;
5644
5647
  const otherAttestationPath = path.join(otherDir, "attestation.json");
5645
5648
  if (fs.existsSync(otherAttestationPath)) {
5646
5649
  try {
5647
5650
  const parsed = JSON.parse(fs.readFileSync(otherAttestationPath, "utf8"));
5648
- if (parsed && parsed.kind !== "replay") other = parsed;
5651
+ if (parsed && parsed.kind !== "replay") { other = parsed; otherPath = otherAttestationPath; }
5649
5652
  } catch { /* fall through to scan */ }
5650
5653
  }
5651
5654
  if (!other) {
5652
5655
  const candidates = [];
5653
5656
  for (const f of otherFiles) {
5654
5657
  try {
5655
- const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
5658
+ const fp = path.join(otherDir, f);
5659
+ const parsed = JSON.parse(fs.readFileSync(fp, "utf8"));
5656
5660
  if (!parsed || parsed.kind === "replay") continue;
5657
- candidates.push(parsed);
5661
+ candidates.push({ parsed, file: fp });
5658
5662
  } catch { /* skip malformed */ }
5659
5663
  }
5660
- candidates.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
5661
- other = candidates[0] || null;
5664
+ candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
5665
+ if (candidates[0]) { other = candidates[0].parsed; otherPath = candidates[0].file; }
5662
5666
  }
5663
5667
  if (!other) {
5664
5668
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
@@ -5673,6 +5677,33 @@ function cmdAttest(runner, args, runOpts, pretty) {
5673
5677
  pretty
5674
5678
  );
5675
5679
  }
5680
+ // Verify BOTH attestations' sidecars — the --against (B-side) drives the
5681
+ // drift verdict as much as the A-side, so a forged comparison attestation
5682
+ // must be refused too, not silently diffed under an A-only green sidecar
5683
+ // line. Mirrors reattest's tamper-refusal contract (exit TAMPERED unless
5684
+ // --force-replay); surfaces a_/b_sidecar_verify either way.
5685
+ const aSidecarVerify = verifyAttestationSidecar(path.join(dir, "attestation.json"));
5686
+ const bSidecarVerify = otherPath
5687
+ ? verifyAttestationSidecar(otherPath)
5688
+ : { file: null, signed: false, verified: false, reason: "no B-side attestation file resolved" };
5689
+ const aTampered = isTamperedSidecarVerify(aSidecarVerify);
5690
+ const bTampered = isTamperedSidecarVerify(bSidecarVerify);
5691
+ if ((aTampered || bTampered) && !args["force-replay"]) {
5692
+ const sides = [aTampered && "A-side", bTampered && "--against (B-side)"].filter(Boolean).join(" + ");
5693
+ process.stderr.write(`[exceptd attest diff] TAMPERED: ${sides} attestation failed Ed25519 verification. Refusing to diff against forged input. Pass --force-replay to override (the output records a_sidecar_verify + b_sidecar_verify).\n`);
5694
+ emit({
5695
+ ok: false,
5696
+ error: `attest diff: ${sides} attestation failed signature verification — refusing to diff`,
5697
+ verb: "attest diff",
5698
+ a_session: sessionId,
5699
+ b_session: args.against,
5700
+ a_sidecar_verify: aSidecarVerify,
5701
+ b_sidecar_verify: bSidecarVerify,
5702
+ hint: "If a sidecar was intentionally removed/rotated and you have inspected the attestation, pass --force-replay.",
5703
+ }, pretty);
5704
+ process.exitCode = EXIT_CODES.TAMPERED;
5705
+ return;
5706
+ }
5676
5707
  emit({
5677
5708
  verb: "attest diff",
5678
5709
  a_session: sessionId,
@@ -5683,7 +5714,9 @@ function cmdAttest(runner, args, runOpts, pretty) {
5683
5714
  a_evidence_hash: self.evidence_hash,
5684
5715
  b_evidence_hash: other.evidence_hash,
5685
5716
  status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
5686
- sidecar_verify: verifyAttestationSidecar(path.join(dir, "attestation.json")),
5717
+ sidecar_verify: aSidecarVerify,
5718
+ a_sidecar_verify: aSidecarVerify,
5719
+ b_sidecar_verify: bSidecarVerify,
5687
5720
  // v0.11.8 (#102): normalize submissions before diffing so flat-shape
5688
5721
  // (observations + verdict) submissions emit meaningful artifact_diff
5689
5722
  // counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-10T16:38:38.784Z",
3
+ "generated_at": "2026-06-12T07:53:17.256Z",
4
4
  "generator": "scripts/build-indexes.js",
5
- "source_count": 63,
5
+ "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "21f2f84671301efb38afbaad51f8d06fa46137747d078d94c439defa669c114b",
7
+ "manifest.json": "7cdbf86213bc03cc55f8cd1ec5516f7c492177f0968c27216beabf68fdd68ef1",
8
+ "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
8
9
  "data/atlas-ttps.json": "29f3447ac5c45f42f50b3ed8a46010c2b8ecbcc8094bb19b5db57ba4707b396c",
9
10
  "data/attack-techniques.json": "6506db66fdd69bb3564e12aef8f727edddc55d0e6e99f60833a200a57e8ee65e",
10
11
  "data/cve-catalog.json": "51d8425a49e5cc0375d0a154a83a16816e99c3141a5bbafe6383607ca11be240",
@@ -35,7 +35,6 @@
35
35
  "policy-exception-gen",
36
36
  "pqc-first",
37
37
  "privacy-consent-ops",
38
- "rag-pipeline-security",
39
38
  "ransomware-response",
40
39
  "researcher",
41
40
  "sector-energy",
@@ -54,7 +53,7 @@
54
53
  "zeroday-gap-learn"
55
54
  ],
56
55
  "example_excerpts": {},
57
- "skill_count": 51
56
+ "skill_count": 50
58
57
  },
59
58
  "UK": {
60
59
  "skills": [
@@ -63,15 +62,11 @@
63
62
  "ai-c2-detection",
64
63
  "ai-risk-management",
65
64
  "api-security",
66
- "attack-surface-pentest",
67
- "cloud-iam-incident",
68
65
  "cloud-security",
69
66
  "compliance-theater",
70
67
  "container-runtime-security",
71
68
  "coordinated-vuln-disclosure",
72
- "decompression-dos",
73
69
  "defensive-countermeasure-mapping",
74
- "dlp-gap-analysis",
75
70
  "email-security-anti-phishing",
76
71
  "exploit-scoring",
77
72
  "framework-gap-analysis",
@@ -80,36 +75,22 @@
80
75
  "identity-assurance",
81
76
  "idp-incident-response",
82
77
  "incident-response-playbook",
83
- "kernel-lpe-triage",
84
- "log-injection-telemetry",
85
78
  "mcp-agent-trust",
86
- "mlops-security",
87
- "multitenancy-isolation",
88
- "network-trust",
89
- "ot-ics-security",
90
79
  "policy-exception-gen",
91
80
  "pqc-first",
92
- "privacy-consent-ops",
93
- "rag-pipeline-security",
94
81
  "ransomware-response",
95
82
  "researcher",
96
83
  "sector-energy",
97
84
  "sector-federal-government",
98
- "sector-financial",
99
- "sector-healthcare",
100
85
  "sector-telecom",
101
- "security-maturity-tiers",
102
- "self-update-integrity",
103
86
  "skill-update-loop",
104
87
  "supply-chain-integrity",
105
88
  "threat-model-currency",
106
89
  "threat-modeling-methodology",
107
- "vc-wallet-trust",
108
- "webapp-security",
109
- "zeroday-gap-learn"
90
+ "webapp-security"
110
91
  ],
111
92
  "example_excerpts": {},
112
- "skill_count": 49
93
+ "skill_count": 31
113
94
  },
114
95
  "AU": {
115
96
  "skills": [
@@ -138,13 +119,11 @@
138
119
  "kernel-lpe-triage",
139
120
  "log-injection-telemetry",
140
121
  "mcp-agent-trust",
141
- "mlops-security",
142
122
  "multitenancy-isolation",
143
123
  "ot-ics-security",
144
124
  "policy-exception-gen",
145
125
  "pqc-first",
146
126
  "privacy-consent-ops",
147
- "rag-pipeline-security",
148
127
  "ransomware-response",
149
128
  "researcher",
150
129
  "sector-energy",
@@ -162,72 +141,51 @@
162
141
  "zeroday-gap-learn"
163
142
  ],
164
143
  "example_excerpts": {},
165
- "skill_count": 47
144
+ "skill_count": 45
166
145
  },
167
146
  "SG": {
168
147
  "skills": [
169
- "age-gates-child-safety",
170
- "ai-attack-surface",
171
- "api-security",
172
- "cloud-iam-incident",
173
- "cloud-security",
174
148
  "container-runtime-security",
175
- "coordinated-vuln-disclosure",
176
- "email-security-anti-phishing",
177
149
  "framework-gap-analysis",
178
150
  "global-grc",
179
151
  "identity-assurance",
180
- "incident-response-playbook",
181
- "mcp-agent-trust",
182
- "mlops-security",
183
152
  "researcher",
184
- "sector-federal-government",
185
153
  "sector-financial",
186
- "sector-healthcare",
187
- "sector-telecom",
188
154
  "threat-modeling-methodology",
189
155
  "webapp-security"
190
156
  ],
191
157
  "example_excerpts": {},
192
- "skill_count": 21
158
+ "skill_count": 8
193
159
  },
194
160
  "JP": {
195
161
  "skills": [
196
162
  "age-gates-child-safety",
197
- "ai-risk-management",
198
163
  "api-security",
199
164
  "cloud-iam-incident",
200
- "cloud-security",
201
165
  "container-runtime-security",
202
- "coordinated-vuln-disclosure",
203
166
  "dlp-gap-analysis",
204
167
  "email-security-anti-phishing",
205
168
  "framework-gap-analysis",
206
169
  "global-grc",
207
170
  "identity-assurance",
208
171
  "incident-response-playbook",
209
- "mlops-security",
210
172
  "ot-ics-security",
211
173
  "pqc-first",
212
- "ransomware-response",
213
174
  "sector-energy",
214
175
  "sector-federal-government",
215
176
  "sector-financial",
216
177
  "sector-healthcare",
217
- "sector-telecom",
218
178
  "supply-chain-integrity",
219
179
  "threat-modeling-methodology",
220
180
  "webapp-security"
221
181
  ],
222
182
  "example_excerpts": {},
223
- "skill_count": 25
183
+ "skill_count": 19
224
184
  },
225
185
  "IN": {
226
186
  "skills": [
227
187
  "age-gates-child-safety",
228
188
  "ai-risk-management",
229
- "api-security",
230
- "cloud-security",
231
189
  "dlp-gap-analysis",
232
190
  "email-security-anti-phishing",
233
191
  "framework-gap-analysis",
@@ -242,51 +200,36 @@
242
200
  "threat-modeling-methodology"
243
201
  ],
244
202
  "example_excerpts": {},
245
- "skill_count": 16
203
+ "skill_count": 14
246
204
  },
247
205
  "CA": {
248
206
  "skills": [
249
207
  "age-gates-child-safety",
250
- "ai-c2-detection",
251
- "cloud-iam-incident",
252
- "cloud-security",
253
- "defensive-countermeasure-mapping",
254
208
  "dlp-gap-analysis",
255
209
  "framework-gap-analysis",
256
210
  "global-grc",
257
- "identity-assurance",
258
211
  "idp-incident-response",
259
- "network-trust",
260
- "sector-energy",
261
- "sector-federal-government",
262
- "sector-financial",
263
- "sector-healthcare",
264
- "sector-telecom",
265
- "self-update-integrity",
266
- "skill-update-loop",
267
- "zeroday-gap-learn"
212
+ "sector-financial"
268
213
  ],
269
214
  "example_excerpts": {},
270
- "skill_count": 19
215
+ "skill_count": 6
271
216
  },
272
217
  "BR": {
273
218
  "skills": [
274
219
  "age-gates-child-safety",
275
220
  "ai-risk-management",
276
- "api-security",
277
221
  "cloud-security",
278
222
  "dlp-gap-analysis",
279
223
  "framework-gap-analysis",
280
224
  "global-grc",
281
225
  "incident-response-playbook",
282
226
  "pqc-first",
283
- "sector-financial",
284
227
  "sector-healthcare",
285
228
  "supply-chain-integrity",
286
229
  "threat-modeling-methodology"
287
230
  ],
288
231
  "example_excerpts": {},
289
- "skill_count": 13
232
+ "skill_count": 11
290
233
  },
291
234
  "CN": {
292
235
  "skills": [
@@ -315,53 +258,33 @@
315
258
  "skill_count": 1
316
259
  },
317
260
  "AE": {
318
- "skills": [
319
- "incident-response-playbook",
320
- "sector-financial"
321
- ],
261
+ "skills": [],
322
262
  "example_excerpts": {},
323
- "skill_count": 2
263
+ "skill_count": 0
324
264
  },
325
265
  "SA": {
326
266
  "skills": [
327
267
  "age-gates-child-safety",
328
- "compliance-theater",
329
- "defensive-countermeasure-mapping",
330
268
  "dlp-gap-analysis",
331
- "framework-gap-analysis",
332
- "fuzz-testing-strategy",
333
- "global-grc",
334
- "mcp-agent-trust",
335
- "mlops-security",
336
- "pqc-first",
337
- "sector-energy",
338
- "sector-federal-government",
339
- "sector-financial",
340
- "sector-healthcare",
341
- "supply-chain-integrity",
342
- "zeroday-gap-learn"
269
+ "sector-financial"
343
270
  ],
344
271
  "example_excerpts": {},
345
- "skill_count": 16
272
+ "skill_count": 3
346
273
  },
347
274
  "NZ": {
348
- "skills": [
349
- "sector-financial",
350
- "sector-telecom"
351
- ],
275
+ "skills": [],
352
276
  "example_excerpts": {},
353
- "skill_count": 2
277
+ "skill_count": 0
354
278
  },
355
279
  "KR": {
356
280
  "skills": [
357
281
  "age-gates-child-safety",
358
282
  "dlp-gap-analysis",
359
283
  "framework-gap-analysis",
360
- "global-grc",
361
- "supply-chain-integrity"
284
+ "global-grc"
362
285
  ],
363
286
  "example_excerpts": {},
364
- "skill_count": 5
287
+ "skill_count": 4
365
288
  },
366
289
  "CL": {
367
290
  "skills": [],
@@ -371,8 +294,6 @@
371
294
  "IL": {
372
295
  "skills": [
373
296
  "ai-risk-management",
374
- "api-security",
375
- "cloud-iam-incident",
376
297
  "cloud-security",
377
298
  "container-runtime-security",
378
299
  "coordinated-vuln-disclosure",
@@ -394,7 +315,7 @@
394
315
  "webapp-security"
395
316
  ],
396
317
  "example_excerpts": {},
397
- "skill_count": 22
318
+ "skill_count": 20
398
319
  },
399
320
  "CH": {
400
321
  "skills": [
@@ -423,74 +344,33 @@
423
344
  },
424
345
  "TW": {
425
346
  "skills": [
426
- "cloud-security",
427
347
  "container-runtime-security",
428
- "dlp-gap-analysis",
429
- "framework-gap-analysis",
430
348
  "global-grc",
431
349
  "ot-ics-security",
432
350
  "pqc-first",
433
351
  "supply-chain-integrity"
434
352
  ],
435
353
  "example_excerpts": {},
436
- "skill_count": 8
354
+ "skill_count": 5
437
355
  },
438
356
  "ID": {
439
357
  "skills": [
440
- "age-gates-child-safety",
441
- "ai-attack-surface",
442
- "ai-c2-detection",
443
358
  "ai-risk-management",
444
- "api-security",
445
- "attack-surface-pentest",
446
- "cloud-iam-incident",
447
- "cloud-security",
448
- "compliance-theater",
449
- "container-runtime-security",
450
- "coordinated-vuln-disclosure",
451
- "defensive-countermeasure-mapping",
452
359
  "dlp-gap-analysis",
453
- "email-security-anti-phishing",
454
- "exploit-scoring",
455
360
  "framework-gap-analysis",
456
- "fuzz-testing-strategy",
457
361
  "global-grc",
458
362
  "identity-assurance",
459
- "idp-incident-response",
460
- "incident-response-playbook",
461
- "kernel-lpe-triage",
462
- "mcp-agent-trust",
463
- "mlops-security",
464
363
  "ot-ics-security",
465
- "policy-exception-gen",
466
364
  "pqc-first",
467
- "rag-pipeline-security",
468
- "ransomware-response",
469
- "researcher",
470
- "sector-energy",
471
- "sector-federal-government",
472
- "sector-financial",
473
- "sector-healthcare",
474
- "sector-telecom",
475
- "skill-update-loop",
476
- "supply-chain-integrity",
477
- "threat-model-currency",
478
- "threat-modeling-methodology",
479
- "webapp-security",
480
- "zeroday-gap-learn"
365
+ "supply-chain-integrity"
481
366
  ],
482
367
  "example_excerpts": {},
483
- "skill_count": 41
368
+ "skill_count": 8
484
369
  },
485
370
  "VN": {
486
- "skills": [
487
- "dlp-gap-analysis",
488
- "framework-gap-analysis",
489
- "global-grc",
490
- "supply-chain-integrity"
491
- ],
371
+ "skills": [],
492
372
  "example_excerpts": {},
493
- "skill_count": 4
373
+ "skill_count": 0
494
374
  },
495
375
  "US_NYDFS": {
496
376
  "skills": [
@@ -520,33 +400,26 @@
520
400
  },
521
401
  "NO": {
522
402
  "skills": [
523
- "mail-server-hardening",
524
403
  "sector-energy",
525
404
  "skill-update-loop"
526
405
  ],
527
406
  "example_excerpts": {},
528
- "skill_count": 3
407
+ "skill_count": 2
529
408
  },
530
409
  "MX": {
531
- "skills": [
532
- "sector-financial"
533
- ],
410
+ "skills": [],
534
411
  "example_excerpts": {},
535
- "skill_count": 1
412
+ "skill_count": 0
536
413
  },
537
414
  "AR": {
538
- "skills": [
539
- "age-gates-child-safety"
540
- ],
415
+ "skills": [],
541
416
  "example_excerpts": {},
542
- "skill_count": 1
417
+ "skill_count": 0
543
418
  },
544
419
  "TR": {
545
- "skills": [
546
- "sector-telecom"
547
- ],
420
+ "skills": [],
548
421
  "example_excerpts": {},
549
- "skill_count": 1
422
+ "skill_count": 0
550
423
  },
551
424
  "TH": {
552
425
  "skills": [],
@@ -557,6 +557,14 @@ function extractAcronymFromGroupUri(uri) {
557
557
  * @param {object} opts { cap?: number, sinceDays?: number }
558
558
  */
559
559
  async function discoverNewRfcs(ctx, opts = {}) {
560
+ // Air-gap: new-RFC discovery queries IETF Datatracker live (~one call per
561
+ // project working group). Refuse the egress under --air-gap so a fully
562
+ // offline run makes no network calls — the other discovery paths guard the
563
+ // same way. --from-cache alone (network-available host, e.g. the scheduled
564
+ // refresh) still discovers live; add --air-gap for a truly offline run.
565
+ if ((ctx && ctx.airGap === true) || process.env.EXCEPTD_AIR_GAP === "1") {
566
+ return { diffs: [], errors: 0, spilled: 0, summary: "RFC discovery: skipped under air-gap (no live Datatracker query)" };
567
+ }
560
568
  const cap = opts.cap ?? DEFAULT_CAP;
561
569
  const sinceDays = opts.sinceDays ?? 180;
562
570
  const cutoff = new Date(Date.now() - sinceDays * 86_400_000).toISOString().slice(0, 10);
@@ -88,7 +88,7 @@ function looksLikePublishWorkflow(name, content) {
88
88
  // filename-prefix checks above are unaffected. A `#` inside a quoted string
89
89
  // is rare in workflow YAML and not load-bearing for these command probes
90
90
  // (stripping can only REMOVE comment text, never create a false match).
91
- const code = content.replace(/#.*$/gm, '');
91
+ const code = stripYamlComments(content);
92
92
 
93
93
  // Explicit publish-shape commands — these are commitments to push
94
94
  // artifacts, not setup / scaffolding.
@@ -133,7 +133,22 @@ function hasIdTokenWriteAnyScope(content) {
133
133
  return /\bid-token:\s*write\b/.test(content);
134
134
  }
135
135
 
136
+ // Strip YAML line-comments so a `#`-commented MENTION of a publish-shape
137
+ // token / command / runner is not read as the real thing. The classifier
138
+ // (looksLikePublishWorkflow) already does this; the indicator probes — and the
139
+ // provenance / SBOM-capability probes in collect() — must use the same view,
140
+ // or a comment produces a false (often deterministic) hit, and in the
141
+ // provenance direction a commented `--provenance` would suppress a real gap
142
+ // (a false negative on a security-relevant posture check).
143
+ function stripYamlComments(content) {
144
+ return content.replace(/#.*$/gm, "");
145
+ }
146
+
136
147
  function scanPublishWorkflow(content, rel) {
148
+ // Whole-content probes below run against a comment-stripped view. The
149
+ // `uses:` line scan stays on the raw lines — its anchored regex already
150
+ // rejects `#`-prefixed lines, so a commented `uses:` cannot match.
151
+ const code = stripYamlComments(content);
137
152
  const hits = {
138
153
  "publish-workflow-uses-static-token": [],
139
154
  "publish-workflow-no-id-token-write": [],
@@ -148,11 +163,13 @@ function scanPublishWorkflow(content, rel) {
148
163
  // NPM_TOKEN / PYPI_TOKEN / CARGO_TOKEN / RUBYGEMS_API_KEY /
149
164
  // GEM_HOST_API_KEY; expand to cover the common variants for each
150
165
  // ecosystem.
151
- 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(content);
166
+ 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);
152
167
  // OIDC is available when THIS publish file declares `id-token: write` at
153
168
  // any scope (workflow or job). Scoped to the file by design — a sibling
154
- // workflow's OIDC does not authenticate this publish job.
155
- const hasIdTokenWrite = hasIdTokenWriteAnyScope(content);
169
+ // workflow's OIDC does not authenticate this publish job. Read from the
170
+ // comment-stripped view so a commented `id-token: write` cannot falsely
171
+ // satisfy the capability (which would suppress the static-token finding).
172
+ const hasIdTokenWrite = hasIdTokenWriteAnyScope(code);
156
173
  if (usesStaticToken && !hasIdTokenWrite) {
157
174
  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" });
158
175
  }
@@ -186,15 +203,15 @@ function scanPublishWorkflow(content, rel) {
186
203
  // non-frozen-install: workflow uses `npm install` instead of `npm ci`,
187
204
  // or `pip install <pkg>` without `--require-hashes`, or `cargo
188
205
  // install` without `--locked`.
189
- if (/\bnpm\s+install\b/.test(content) && !/\bnpm\s+ci\b/.test(content)) {
206
+ if (/\bnpm\s+install\b/.test(code) && !/\bnpm\s+ci\b/.test(code)) {
190
207
  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" });
191
208
  }
192
- if (/\bcargo\s+(?:build|install)\b/.test(content) && !/--locked\b/.test(content) && !/--frozen\b/.test(content)) {
209
+ if (/\bcargo\s+(?:build|install)\b/.test(code) && !/--locked\b/.test(code) && !/--frozen\b/.test(code)) {
193
210
  hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "cargo build/install without --locked / --frozen" });
194
211
  }
195
212
 
196
213
  // runs-on-self-hosted: any `runs-on: self-hosted` line.
197
- if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(content)) {
214
+ if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(code)) {
198
215
  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" });
199
216
  }
200
217
 
@@ -265,7 +282,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
265
282
  try {
266
283
  const j = JSON.parse(pkgManifest.content);
267
284
  const manifestOptIn = j?.publishConfig?.provenance === true;
268
- const workflowOptIn = publishWorkflows.some(w => /npm\s+publish[^\n]*--provenance\b/.test(w.content));
285
+ const workflowOptIn = publishWorkflows.some(w => /npm\s+publish[^\n]*--provenance\b/.test(stripYamlComments(w.content)));
269
286
  provenanceMissing = (manifestOptIn || workflowOptIn) ? "miss" : "hit";
270
287
  } catch (e) {
271
288
  errors.push({ artifact_id: "package-manifest", kind: "parse_failed", reason: `package.json: ${e.message}` });
@@ -414,7 +431,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
414
431
  // signed-attestation capability exists at release and the indicator
415
432
  // should not fire on the absence of a committed artifact.
416
433
  const releaseSbomCapable = publishWorkflows.some(w => {
417
- const c = w.content;
434
+ const c = stripYamlComments(w.content);
418
435
  return (
419
436
  // SBOM-generation tooling invoked in the workflow.
420
437
  /cyclonedx/i.test(c) ||