@blamejs/exceptd-skills 0.12.23 → 0.12.24

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.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-15T14:25:49.905Z",
3
+ "generated_at": "2026-05-15T18:45:49.423Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 50,
6
6
  "source_hashes": {
7
- "manifest.json": "6fda2187c6d4dfbc333a711cea0991d59c459ded1ed5005126a8c555b9e77cf2",
7
+ "manifest.json": "557bf7b459de4fb6af3d0bbad86626bf76581f44e31c30e240682ca87dbb9f69",
8
8
  "data/atlas-ttps.json": "20339e0ae3cd89c06f1385be31c50f408f827edc2e8ab8aef026ade3bcf0a917",
9
9
  "data/attack-techniques.json": "6db08a8e8a4d03d9309b1d185112de7f3c9595d2cd3d24566b7ce0b3b8aa5d1a",
10
10
  "data/cve-catalog.json": "7936ba3c8f27156235bf327830e8f1a684658865e97f089aed98b2a7cdbb88ef",
@@ -257,7 +257,7 @@
257
257
  "monitor": 65,
258
258
  "close": 35
259
259
  },
260
- "framework_lag_declaration": "NIST 800-190 + CIS K8s Benchmark + NIST 800-53 CM-7 + ISO A.8.9 + PCI Req.2.2 collectively cover container hardening but accept cluster-wide attestation as evidence. None require per-manifest analysis as a control. Manifest drift in GitOps repos can introduce privileged: true / hostPID / unscoped capabilities faster than any attestation cadence. EU CRA + NIST SSDF + SLSA cover supply-chain posture but most orgs are at SLSA L1-L2 without digest pinning. Gap = ~21 days between manifest drift and review cadence; per-CVE gap (e.g. Leaky Vessels) is hours from public PoC to weaponization vs. weeks of node-staging windows. UK CAF Principles B4 (System Security) and B5 (Resilient Networks & Systems) treat container-runtime hardening as an outcome assessable via attestation, without binding evidence to per-manifest privileged-flag or host-namespace exposure. AU Essential 8 ML2 Application Control (E8 M.1) + Configure Microsoft Office Macro Settings (E8 M.2 by analogy, application-execution policy) plus ACSC ISM-1546 and ISM-1683 (workload isolation) cover application execution and workload separation, but Essential 8 has no maturity-level requirement that bans privileged + hostPID + hostNetwork in cluster manifests.",
260
+ "framework_lag_declaration": "NIST 800-190 + CIS K8s Benchmark + NIST 800-53 CM-7 + ISO A.8.9 + PCI Req.2.2 collectively cover container hardening but accept cluster-wide attestation as evidence. None require per-manifest analysis as a control. Manifest drift in GitOps repos can introduce privileged: true / hostPID / unscoped capabilities faster than any attestation cadence. EU CRA + NIST SSDF + SLSA cover supply-chain posture but most orgs are at SLSA L1-L2 without digest pinning. Gap = ~21 days between manifest drift and review cadence; per-CVE gap (e.g. Leaky Vessels) is hours from public PoC to weaponization vs. weeks of node-staging windows. UK CAF Principles B4 (System Security) and B5 (Resilient Networks & Systems) treat container-runtime hardening as an outcome assessable via attestation, without binding evidence to per-manifest privileged-flag or host-namespace exposure. AU Essential 8 Strategy 1 (Application Control, E8 M.1 ML2) covers execution allowlisting on workstations/servers but does not bind to Kubernetes admission-controller policy: privileged-pod denial via OPA/Kyverno or PodSecurity `restricted` admission is the operational analogue, and Essential 8 has no maturity-level requirement enforcing it. ACSC ISM-1546 and ISM-1683 (workload isolation) name workload separation but stop short of banning privileged + hostPID + hostNetwork in cluster manifests as an attested admission-time gate.",
261
261
  "skill_chain": [
262
262
  {
263
263
  "skill": "container-runtime-security",
@@ -322,7 +322,7 @@
322
322
  "monitor": 45,
323
323
  "close": 25
324
324
  },
325
- "framework_lag_declaration": "Frameworks structurally bind the OPERATING organization, not the library author. NIST 800-53 SC-13, ISO 27001:2022 A.8.24/A.8.28, PCI DSS 4.0 §3.6/§8.3.2 all push obligations downstream to consumers who inherit the shipped defaults. EU CRA Annex I §1 is the first framework with direct upstream obligations on manufacturers of products with digital elements, but its binding date for vulnerability-handling and SBOM provisions is 2026-09-11 (four months from now) with full compliance 2027-12-11. NIS2 Art.21(2)(g) exerts indirect pressure via essential-entity consumers. UK CAF C.5 + UK PSTI covers connected products only. ISO 27001:2022 A.8.28 'secure coding' is silent on PQC, KDF iteration minimums, RNG-source requirements, constant-time-implementation requirements — the longest structural laggard. NIST IR 8547 + OMB M-23-02 + CNSA 2.0 push federal-system PQC migration through 2030 but rely on procurement pressure to flow upstream. Gap = ~365 days from operational PQC readiness (2024-08-13 FIPS finalization) to binding library-author obligations (2026-09-11 EU CRA partial bind). Compensating controls (downstream-consumer adoption pressure, supply-chain auditing tools, voluntary SECURITY.md disclosure of cryptographic provenance) must close this gap pending EU CRA enforcement. AU Essential 8 ML2 Patch Applications (E8 M.2) plus ACSC ISM-1138 (cryptographic-algorithm transition), ISM-0467 (approved cryptographic algorithms), and ISM-0471 (cryptographic protocol selection) reference downstream consumer migration but place no obligation on upstream library authors to ship PQC-by-default, KDF iteration minima, or constant-time implementations — Essential 8 evidence flows from operating-org posture, not from library-publisher posture.",
325
+ "framework_lag_declaration": "Frameworks structurally bind the OPERATING organization, not the library author. NIST 800-53 SC-13, ISO 27001:2022 A.8.24/A.8.28, PCI DSS 4.0 §3.6/§8.3.2 all push obligations downstream to consumers who inherit the shipped defaults. EU CRA Annex I §1 is the first framework with direct upstream obligations on manufacturers of products with digital elements, but its binding date for vulnerability-handling and SBOM provisions is 2026-09-11 (four months from now) with full compliance 2027-12-11. NIS2 Art.21(2)(g) exerts indirect pressure via essential-entity consumers. UK CAF C.5 + UK PSTI covers connected products only. ISO 27001:2022 A.8.28 'secure coding' is silent on PQC, KDF iteration minimums, RNG-source requirements, constant-time-implementation requirements — the longest structural laggard. NIST IR 8547 + OMB M-23-02 + CNSA 2.0 push federal-system PQC migration through 2030 but rely on procurement pressure to flow upstream. Gap = ~365 days from operational PQC readiness (2024-08-13 FIPS finalization) to binding library-author obligations (2026-09-11 EU CRA partial bind). Compensating controls (downstream-consumer adoption pressure, supply-chain auditing tools, voluntary SECURITY.md disclosure of cryptographic provenance) must close this gap pending EU CRA enforcement. AU Essential 8 ML2 Patch Applications (E8 M.2) plus ACSC ISM-1138 (cryptographic-algorithm transition), ISM-0467 (approved cryptographic algorithms), and ISM-0471 (cryptographic protocol selection) reference downstream consumer migration but place no obligation on upstream library authors to ship PQC-by-default, KDF iteration minima, or constant-time implementations — Essential 8 evidence flows from operating-org posture, not from library-publisher posture. UK CAF Principle C.5 (System Security — outcome-tested cryptographic deployments) and UK PSTI together lag because CAF C.5 mandates outcome-tested cryptographic deployments at the operator but does not require library authors to ship PQC-by-default, constant-time implementations, or KDF iteration minima; PSTI scope is connected products only and excludes upstream library distribution entirely. The library-author surface therefore sits in a coverage seam between CAF (operator-side) and PSTI (finished-product-side) — neither binds the upstream publisher.",
326
326
  "skill_chain": [
327
327
  {
328
328
  "skill": "pqc-first",
@@ -320,7 +320,7 @@
320
320
  "monitor": 40,
321
321
  "close": 20
322
322
  },
323
- "framework_lag_declaration": "All 20 frameworks listed in domain.frameworks_in_scope are structurally insufficient for at least one upstream-playbook threat class. ISO 27001:2022, SOC 2 TSC, and PCI DSS 4.0 are the longest-laggard for AI/MCP/PQC threats (no scheduled amendments). NIST 800-53, NIS2, DORA, EU AI Act, and EU CRA have publishing cadences but lag the threat tempo by 90-365 days. UK CAF (outcome-based) and AU Essential 8 are partially forward-compatible but inconsistent across regulators/sectors. SG MAS TRM, JP FISC, IN CERT-In, CA OSFI B-10 are sector- and jurisdiction-specific with tempo varying by sector. Compound effect: an org running all current threat-class exposures under a single audit opinion is the modal state in mid-2026, not an outlier.",
323
+ "framework_lag_declaration": "All 20 frameworks listed in domain.frameworks_in_scope are structurally insufficient for at least one upstream-playbook threat class. ISO 27001:2022, SOC 2 TSC, and PCI DSS 4.0 are the longest-laggard for AI/MCP/PQC threats (no scheduled amendments). NIST 800-53, NIS2, DORA, EU AI Act, and EU CRA have publishing cadences but lag the threat tempo by 90-365 days. UK CAF (outcome-based) and AU Essential 8 are partially forward-compatible but inconsistent across regulators/sectors. SG MAS TRM, JP FISC, IN CERT-In, CA OSFI B-10 are sector- and jurisdiction-specific with tempo varying by sector. Compound effect: an org running all current threat-class exposures under a single audit opinion is the modal state in mid-2026, not an outlier. Named per-framework lags: NIST 800-53 CA-7 (Continuous Monitoring) lags because it is designed for control-effectiveness assessment and does not require correlation across frameworks to surface paper-vs-actual evidence. EU NIS2 Art.21(2) lags because it enumerates 10 risk-management categories but does not require operators to correlate their asset inventory against EU AI Act risk classifications when AI systems are present. UK CAF Principle A (Governance) lags because it is designed to assess governance-of-cyber-resilience outcomes and does not require correlation of `outcome-test` failures across CAF outcomes to surface compliance-theater. AU Essential 8 Strategy 1 (Application Control, ML2) lags because it is designed for execution allowlisting on workstations/servers and does not reach AI-tool-and-MCP-server allowlisting as a sub-class of executable trust. ISO/IEC 27001:2022 A.5.1 (Policies for information security) lags because it requires top-level policy presence and does not require cross-framework gap analysis as part of policy maintenance.",
324
324
  "skill_chain": [
325
325
  {
326
326
  "skill": "framework-gap-analysis",
@@ -226,7 +226,7 @@
226
226
  "monitor": 65,
227
227
  "close": 30
228
228
  },
229
- "framework_lag_declaration": "NIST CM-6, ISO A.8.9, Essential 8 OS Hardening, CMMC CM.L2-3.4.1/2 all permit baseline + change-management evidence without requiring continuous attestation of the specific kernel hardening flags (kptr_restrict, unprivileged_userns_clone, unprivileged_bpf_disabled, yama.ptrace_scope, dmesg_restrict, kernel.lockdown, MAC enforcement mode) that determine whether a vulnerable kernel is actually exploitable. Gap = ~21 days at typical orgs between drift event and review. For environments using GitOps-deployed kernel parameters, drift can occur faster than monitoring catches. UK CAF Principles B4 (System Security) and B5 (Resilient Networks & Systems) treat OS hardening as a system-security outcome assessable via baseline-and-change-management evidence, without binding to continuous attestation of specific kernel hardening flags (kptr_restrict, unprivileged_userns_clone, unprivileged_bpf_disabled, yama.ptrace_scope, dmesg_restrict, kernel.lockdown, MAC enforcement) — CAF evidence passes against a host whose flags drifted hours after the last review. AU Essential 8 ML2 Patch Operating Systems (E8 M.2) + Restrict Admin Privileges (E8 M.5) and ACSC ISM-1144 / ISM-1493 (vulnerability management) reference OS-hardening posture but do not enumerate the per-sysctl flag set whose state determines whether a vulnerable kernel is actually exploitable.",
229
+ "framework_lag_declaration": "NIST CM-6, ISO A.8.9, Essential 8 OS Hardening, CMMC CM.L2-3.4.1/2 all permit baseline + change-management evidence without requiring continuous attestation of the specific kernel hardening flags (kptr_restrict, unprivileged_userns_clone, unprivileged_bpf_disabled, yama.ptrace_scope, dmesg_restrict, kernel.lockdown, MAC enforcement mode) that determine whether a vulnerable kernel is actually exploitable. Gap = ~21 days at typical orgs between drift event and review. For environments using GitOps-deployed kernel parameters, drift can occur faster than monitoring catches. UK CAF Principles B4 (System Security) and B5 (Resilient Networks & Systems) treat OS hardening as a system-security outcome assessable via baseline-and-change-management evidence, without binding to continuous attestation of specific kernel hardening flags (kptr_restrict, unprivileged_userns_clone, unprivileged_bpf_disabled, yama.ptrace_scope, dmesg_restrict, kernel.lockdown, MAC enforcement) — CAF evidence passes against a host whose flags drifted hours after the last review. AU Essential 8 ML2 Patch Operating Systems (E8 M.2) + Restrict Admin Privileges (E8 M.5) and ACSC ISM-1144 / ISM-1493 (vulnerability management) reference OS-hardening posture but do not enumerate the per-sysctl flag set whose state determines whether a vulnerable kernel is actually exploitable. EU NIS2 Art.21(2)(c) `secure development lifecycle` and DORA Art.9(4) `secure configuration` both reference hardening posture obligation but stop short of requiring per-flag kernel-hardening attestation; the gap is that operators can claim compliance with `vm.mmap_min_addr` defaulted-but-not-attested, because neither framework requires the per-sysctl evidence that distinguishes a hardened kernel from a default-shipped one.",
230
230
  "skill_chain": [
231
231
  {
232
232
  "skill": "kernel-lpe-triage",
@@ -477,7 +477,7 @@
477
477
  "monitor": 50,
478
478
  "close": 30
479
479
  },
480
- "framework_lag_declaration": "NIST 800-53 SR-3/4/5, NIST 800-218 SSDF PS.3.2 / PW.4 / RV.2, ISO 27001:2022 A.8.30 / A.5.20, SOC 2 CC8.1 / CC9.2, NIS2 Art.21(2)(d), DORA Art.28, UK CAF C1.b, AU Essential 8 E1, and CMMC SI.L2-3.4.4 all permit publisher posture that omits (a) OIDC-based publish, (b) Sigstore / Rekor transparency-log entries per release, (c) signed + distributed SBOM, (d) transitive completeness, (e) VEX feed for filed CVEs, (f) coordinated-disclosure intake at /.well-known/security.txt, (g) tag-protection on release refs, (h) reproducible builds, (i) skill / plugin signature verification gated in the install path. Operational tooling for (a)-(i) shipped 2023-2025; framework lag = ~540 days behind tooling and ~18 months ahead of EU CRA Art.10/13/14 binding (2027). Compensating controls MUST close (a)-(i) before SSDF-only compliance can be accepted as CRA-readiness.",
480
+ "framework_lag_declaration": "NIST 800-53 SR-3/4/5, NIST 800-218 SSDF PS.3.2 / PW.4 / RV.2, ISO 27001:2022 A.8.30 / A.5.20, SOC 2 CC8.1 / CC9.2, NIS2 Art.21(2)(d), DORA Art.28, and CMMC SI.L2-3.4.4 all permit publisher posture that omits (a) OIDC-based publish, (b) Sigstore / Rekor transparency-log entries per release, (c) signed + distributed SBOM, (d) transitive completeness, (e) VEX feed for filed CVEs, (f) coordinated-disclosure intake at /.well-known/security.txt, (g) tag-protection on release refs, (h) reproducible builds, (i) skill / plugin signature verification gated in the install path. Operational tooling for (a)-(i) shipped 2023-2025; framework lag = ~540 days behind tooling and ~18 months ahead of EU CRA Art.10/13/14 binding (2027). UK CAF C1.b (Identity and Access Management — Privileged user management) is designed to govern human privileged users and does not require SLSA L3+ provenance attestation on every release artifact; the gap is that a compromised publisher token (no human-MFA gate, no provenance attestation) yields an attacker-signed release that passes C1.b's evidence surface. AU Essential 8 Strategy 5 (Restrict Admin Privileges, E8 M.5) is designed for runtime admin-account scoping and does not reach build-time admin privileges on signing-key material: a CI runner holding the publish credential is a build-time admin that E8 M.5 evidence does not enumerate. Compensating controls MUST close (a)-(i) before SSDF-only compliance can be accepted as CRA-readiness.",
481
481
  "skill_chain": [
482
482
  {
483
483
  "skill": "supply-chain-integrity",
@@ -224,6 +224,30 @@
224
224
  "control_id": "B3 — Data security",
225
225
  "designed_for": "NCSC CAF outcome that data important to the essential function is protected from compromise, including credential and key material.",
226
226
  "insufficient_because": "Outcome can be assessed against managed secret-store contents. Plaintext credentials leaked into source-code repositories, CI logs, IaC files, and AI assistant context windows do not register on the outcome's evidence surface."
227
+ },
228
+ {
229
+ "framework": "au-essential-8",
230
+ "control_id": "Strategy 4 — Multi-factor authentication (E8 M.4)",
231
+ "designed_for": "MFA on privileged + internet-facing accounts.",
232
+ "insufficient_because": "MFA defends the interactive auth flow; bearer tokens / API keys / OAuth refresh tokens committed to source artifacts bypass MFA entirely — scraper-bot exploitation against cloud-provider APIs uses the static credential and never reaches an interactive auth surface. Compliance-theater test: audit the last 90 days of admin actions and count those executed by service-account tokens vs human-MFA sessions; if service tokens dominate, Strategy 4 compliance is paper."
233
+ },
234
+ {
235
+ "framework": "au-essential-8",
236
+ "control_id": "Strategy 1 — Application Control (E8 M.1)",
237
+ "designed_for": "Execution allowlisting on workstations and servers.",
238
+ "insufficient_because": "Application Control governs runtime execution but does not constrain build agents reading from arbitrary secret stores or environment variables at build time — CI runners with broad env-var access defeat the intent. Compliance-theater test: inventory CI runner environment variables; any token-shaped string (regex /[A-Za-z0-9]{32,}/) means ML2 compliance leaks at build time."
239
+ },
240
+ {
241
+ "framework": "au-ism",
242
+ "control_id": "ISM-1546",
243
+ "designed_for": "Multi-factor authentication for privileged users and remote access.",
244
+ "insufficient_because": "ISM-1546 covers human authentication; CI/CD service identities (which now hold the actual privileges) are out of scope. Stolen GitHub Actions OIDC tokens grant identical blast radius without ever crossing the human-MFA gate. Compliance-theater test: list CI runner OIDC token TTLs and audience scopes; any token with TTL > 1h or wildcard audience defeats ISM-1546's intent."
245
+ },
246
+ {
247
+ "framework": "au-ism",
248
+ "control_id": "ISM-1559",
249
+ "designed_for": "Privileged account credential management.",
250
+ "insufficient_because": "ISM-1559 names key/credential storage but treats source-code-embedded credentials as a policy violation, not a detection control. Modern exfiltration reads credentials post-decryption from /proc, container layers, or build artifacts. Compliance-theater test: run a process listing inside one production container and grep env for tokens; any hit means storage encryption was bypassed at the runtime boundary."
227
251
  }
228
252
  ]
229
253
  },
@@ -241,7 +265,7 @@
241
265
  "monitor": 60,
242
266
  "close": 30
243
267
  },
244
- "framework_lag_declaration": "GDPR Art.32, ISO A.8.24, PCI-DSS Req.3, SOC 2 CC6.1 collectively cover encryption + key management + access control in production storage backends. None of them name 'secret in source artifact' as a distinct exposure category, despite this being the dominant 2025-2026 cloud-compromise vector. NIS2 Art.21(2)(j) names cryptography but not development-workflow exposure. Gap = ~30 days between developer commit and framework's quarterly access-review cadence; in practice, scraper bots exploit faster than any framework cadence. UK CAF Principle B2 (Identity and Access Control) treats credential lifecycle as an outcome reviewable on cycle, not as a per-commit scanning obligation — secrets embedded in source artifacts do not register against B2 evidence. AU Essential 8 ML2 MFA (E8 M.4) + Restrict Admin Privileges (E8 M.5) and ISM-1546 cover MFA + admin-account hygiene but provide no signal on long-lived bearer-token exposure inside developer repositories.",
268
+ "framework_lag_declaration": "GDPR Art.32, ISO A.8.24, PCI-DSS Req.3, SOC 2 CC6.1 collectively cover encryption + key management + access control in production storage backends. None of them name 'secret in source artifact' as a distinct exposure category, despite this being the dominant 2025-2026 cloud-compromise vector. NIST 800-53 IA-5 (Authenticator Management) lags because it mandates authenticator protection in storage but does not require detection of authenticators committed to source repositories or build artifacts — IA-5 evidence is satisfied by a vault inventory while bearer tokens leak through `.env`, IaC tfvars, and CI logs unscanned. NIS2 Art.21(2)(j) names cryptography but not development-workflow exposure. Gap = ~30 days between developer commit and framework's quarterly access-review cadence; in practice, scraper bots exploit faster than any framework cadence. UK CAF Principle B2 (Identity and Access Control) treats credential lifecycle as an outcome reviewable on cycle, not as a per-commit scanning obligation — secrets embedded in source artifacts do not register against B2 evidence. AU Essential 8 Strategy 1 (Application Control, E8 M.1) is the relevant lever restricting build agents from accessing arbitrary secret stores or environment variables at build time — but Essential 8 evidence does not enumerate CI-runner secret-store reachability as a sub-control; ISM-1546 covers MFA + admin-account hygiene but bearer tokens / API keys / OAuth refresh tokens in source artifacts bypass MFA entirely, never crossing the interactive auth surface ISM-1546 protects.",
245
269
  "skill_chain": [
246
270
  {
247
271
  "skill": "dlp-gap-analysis",
@@ -365,7 +365,16 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
365
365
 
366
366
  // --- RFC discovery -----------------------------------------------------
367
367
 
368
- async function fetchDatatracker(url) {
368
+ async function fetchDatatracker(url, ctx) {
369
+ // Air-gap refusal — Datatracker is a live IETF service. When the operator
370
+ // (or the caller's ctx) declares air-gap, return a structured refusal so
371
+ // refresh-external can surface "discovery skipped" rather than logging a
372
+ // generic network error. Caller's signature already accepts a nullable
373
+ // return; the structured object is distinguishable from a successful
374
+ // payload by `ok:false`.
375
+ if ((ctx && ctx.airGap === true) || process.env.EXCEPTD_AIR_GAP === "1") {
376
+ return { ok: false, error: "air-gap-blocked", source: "datatracker" };
377
+ }
369
378
  const ac = new AbortController();
370
379
  const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
371
380
  try {
@@ -532,7 +541,22 @@ async function discoverNewRfcs(ctx, opts = {}) {
532
541
  `https://datatracker.ietf.org/api/v1/doc/document/` +
533
542
  `?type=rfc&group__acronym=${encodeURIComponent(wg)}` +
534
543
  `&time__gt=${cutoff}&order_by=-time&limit=20&format=json`;
535
- const payload = await fetchDatatracker(url);
544
+ const payload = await fetchDatatracker(url, ctx);
545
+ // Treat the air-gap structured refusal as a discovery skip — not an
546
+ // error. The existing `!payload || !Array.isArray(payload.objects)`
547
+ // path would have classed it as `errors++`, which misreports an
548
+ // operator-chosen offline posture as a fault. Short-circuit the whole
549
+ // WG loop on first air-gap refusal because every subsequent fetch will
550
+ // refuse identically.
551
+ if (payload && payload.ok === false && payload.error === "air-gap-blocked") {
552
+ return {
553
+ diffs: [],
554
+ errors: 0,
555
+ spilled: 0,
556
+ summary: "RFC discovery: skipped (air-gap mode)",
557
+ skipped: "air-gap",
558
+ };
559
+ }
536
560
  if (!payload || !Array.isArray(payload.objects)) {
537
561
  errors++;
538
562
  continue;
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Canonical exit-code constants for every CLI verb.
5
+ *
6
+ * Every `process.exitCode = N` / `process.exit(N)` site in `bin/exceptd.js`
7
+ * (and any library that wants to set an exit code via emit() ok:false bodies)
8
+ * should reference one of these constants rather than a bare number literal.
9
+ * The map is the source of truth for help text — `exceptd doctor --exit-codes`
10
+ * dumps it as JSON so operator-facing docs cannot drift from runtime.
11
+ *
12
+ * History: prior to v0.12.24 codes were bare magic numbers scattered across
13
+ * ~30 sites. Code 3 in particular meant both "session-id collision" (cmdRun)
14
+ * and "ran-but-no-evidence" (cmdCi) — two semantics, one code, no doc surface.
15
+ * v0.12.24 splits them and centralises so a new verb cannot regress by typo.
16
+ */
17
+
18
+ const EXIT_CODES = Object.freeze({
19
+ SUCCESS: 0,
20
+ GENERIC_FAILURE: 1,
21
+ DETECTED_ESCALATE: 2,
22
+ RAN_NO_EVIDENCE: 3,
23
+ BLOCKED: 4,
24
+ JURISDICTION_CLOCK_STARTED: 5,
25
+ TAMPERED: 6,
26
+ SESSION_ID_COLLISION: 7,
27
+ LOCK_CONTENTION: 8,
28
+ STORAGE_EXHAUSTED: 9,
29
+ });
30
+
31
+ /**
32
+ * Human-readable + machine-stable description per code. Source for the
33
+ * `exceptd doctor --exit-codes` dump and for help-text rendering.
34
+ */
35
+ const EXIT_CODE_DESCRIPTIONS = Object.freeze({
36
+ 0: { name: 'SUCCESS', summary: 'Verb completed successfully.' },
37
+ 1: { name: 'GENERIC_FAILURE', summary: 'Unhandled error or validation failure.' },
38
+ 2: { name: 'DETECTED_ESCALATE', summary: 'CI gate: classification === detected, operator action required.' },
39
+ 3: { name: 'RAN_NO_EVIDENCE', summary: 'CI gate: verb ran but produced no actionable evidence.' },
40
+ 4: { name: 'BLOCKED', summary: 'CI gate: ok:false body — precondition refusal or hard error.' },
41
+ 5: { name: 'JURISDICTION_CLOCK_STARTED', summary: 'Jurisdictional notification window opened (e.g. NIS2 24h, DORA 4h, GDPR 72h).' },
42
+ 6: { name: 'TAMPERED', summary: 'Attestation sidecar verification failed (signed-but-invalid, corrupt, unsigned-substitution, algorithm-unsupported).' },
43
+ 7: { name: 'SESSION_ID_COLLISION', summary: 'Persisting attestation would overwrite an existing session; pass --force-overwrite to replace or supply a fresh --session-id.' },
44
+ 8: { name: 'LOCK_CONTENTION', summary: 'Concurrent invocation holds the per-playbook attestation lock; retry after the busy run releases.' },
45
+ 9: { name: 'STORAGE_EXHAUSTED', summary: 'Disk full, quota exceeded, or read-only filesystem prevented attestation write (ENOSPC, EDQUOT, EROFS).' },
46
+ });
47
+
48
+ /**
49
+ * Return the human-readable name for a numeric exit code.
50
+ */
51
+ function exitCodeName(code) {
52
+ const e = EXIT_CODE_DESCRIPTIONS[code];
53
+ return e ? e.name : 'UNKNOWN';
54
+ }
55
+
56
+ /**
57
+ * Return all exit codes as a stable-shape array suitable for JSON dump.
58
+ */
59
+ function listExitCodes() {
60
+ return Object.entries(EXIT_CODE_DESCRIPTIONS).map(([code, info]) => ({
61
+ code: Number(code),
62
+ name: info.name,
63
+ summary: info.summary,
64
+ }));
65
+ }
66
+
67
+ module.exports = {
68
+ EXIT_CODES,
69
+ EXIT_CODE_DESCRIPTIONS,
70
+ exitCodeName,
71
+ listExitCodes,
72
+ };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Levenshtein-distance flag-typo suggestions.
5
+ *
6
+ * Operator typos `--evidnce` / `--csaf-stats` / `--bundle-epohc` were silently
7
+ * absorbed by the argv parser, falling through as boolean true flags with no
8
+ * value, then producing cryptic downstream errors. This helper compares an
9
+ * unknown flag to a verb-scoped allowlist and returns the closest match at
10
+ * distance ≤ 2 AND ≤ floor(flag.length / 2).
11
+ *
12
+ * Per-verb allowlists are the canonical CLI surface. Adding a new flag to a
13
+ * verb means appending to the allowlist here AND updating the printPlaybookVerbHelp
14
+ * block; a test asserts the two sets agree.
15
+ */
16
+
17
+ function editDistance(a, b) {
18
+ if (a === b) return 0;
19
+ if (a.length === 0) return b.length;
20
+ if (b.length === 0) return a.length;
21
+ const prev = new Array(b.length + 1);
22
+ const curr = new Array(b.length + 1);
23
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
24
+ for (let i = 1; i <= a.length; i++) {
25
+ curr[0] = i;
26
+ for (let j = 1; j <= b.length; j++) {
27
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
28
+ curr[j] = Math.min(
29
+ curr[j - 1] + 1,
30
+ prev[j] + 1,
31
+ prev[j - 1] + cost,
32
+ );
33
+ }
34
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
35
+ }
36
+ return prev[b.length];
37
+ }
38
+
39
+ /**
40
+ * Suggest the closest allowlisted flag to a given unknown flag.
41
+ *
42
+ * @param {string} flag - operator-supplied flag name without leading --
43
+ * @param {string[]} allowlist - known flag names for the active verb
44
+ * @returns {string|null} the suggested flag name or null when no close match
45
+ */
46
+ function suggestFlag(flag, allowlist) {
47
+ if (typeof flag !== 'string' || flag.length === 0) return null;
48
+ if (!Array.isArray(allowlist) || allowlist.length === 0) return null;
49
+ const probe = flag.toLowerCase();
50
+ const cap = Math.min(2, Math.floor(flag.length / 2));
51
+ let bestDist = Infinity;
52
+ let best = null;
53
+ for (const candidate of allowlist) {
54
+ const d = editDistance(probe, candidate.toLowerCase());
55
+ if (d < bestDist && d <= cap) {
56
+ bestDist = d;
57
+ best = candidate;
58
+ }
59
+ }
60
+ return best;
61
+ }
62
+
63
+ /**
64
+ * Per-verb known-flag allowlist. Every operator-facing flag should appear
65
+ * exactly once per verb where it is consumed. Flags consumed by every verb
66
+ * (e.g. `pretty`, `json`, `help`) live under '_global'.
67
+ */
68
+ const VERB_FLAG_ALLOWLIST = Object.freeze({
69
+ _global: ['help', 'pretty', 'json', 'verbose'],
70
+ run: [
71
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
72
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
73
+ 'publisher-namespace', 'vex', 'diff-from-latest', 'all', 'scope',
74
+ 'strict-preconditions', 'ci', 'block-on-jurisdiction-clock', 'upstream-check',
75
+ 'session-key', 'tlp', 'bundle-deterministic', 'bundle-epoch',
76
+ ],
77
+ ci: [
78
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
79
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
80
+ 'publisher-namespace', 'vex', 'all', 'scope', 'required', 'format',
81
+ 'strict-preconditions', 'block-on-jurisdiction-clock', 'tlp',
82
+ ],
83
+ 'run-all': [
84
+ 'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
85
+ 'mode', 'air-gap', 'force-stale', 'operator', 'ack', 'csaf-status',
86
+ 'publisher-namespace', 'vex', 'scope', 'strict-preconditions', 'tlp',
87
+ ],
88
+ 'ai-run': [
89
+ 'evidence', 'no-stream', 'session-id', 'force-overwrite', 'attestation-root',
90
+ 'operator', 'ack', 'csaf-status', 'publisher-namespace', 'air-gap',
91
+ 'mode', 'force-stale', 'tlp',
92
+ ],
93
+ ingest: [
94
+ 'evidence', 'session-id', 'force-overwrite', 'attestation-root', 'operator',
95
+ 'ack', 'csaf-status', 'publisher-namespace', 'air-gap', 'force-stale',
96
+ 'strict-preconditions',
97
+ ],
98
+ brief: ['all', 'scope', 'directives', 'flat', 'phase'],
99
+ discover: ['scan-only', 'scope'],
100
+ ask: [],
101
+ attest: [
102
+ 'against', 'playbook', 'since', 'latest', 'format', 'force', 'dry-run',
103
+ 'all-older-than',
104
+ ],
105
+ reattest: [
106
+ 'playbook', 'since', 'latest', 'force-replay', 'attestation-root',
107
+ ],
108
+ doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes'],
109
+ lint: ['evidence'],
110
+ refresh: [
111
+ 'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
112
+ 'advisory', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
113
+ ],
114
+ prefetch: ['source', 'cache-dir', 'max-age', 'force', 'no-network', 'quiet'],
115
+ });
116
+
117
+ /**
118
+ * Return the allowlist for a verb (global flags always included).
119
+ */
120
+ function flagsFor(verb) {
121
+ const verbFlags = VERB_FLAG_ALLOWLIST[verb] || [];
122
+ return [...VERB_FLAG_ALLOWLIST._global, ...verbFlags];
123
+ }
124
+
125
+ module.exports = {
126
+ editDistance,
127
+ suggestFlag,
128
+ flagsFor,
129
+ VERB_FLAG_ALLOWLIST,
130
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared validation for path-component-shaped operator inputs.
5
+ *
6
+ * Six sites in `bin/exceptd.js` previously hand-rolled regexes of the form
7
+ * /^[A-Za-z0-9._-]{1,64}$/ for `--session-id`, `--playbook`, attestation
8
+ * filenames, and `--evidence-dir` filenames. Each regex was slightly
9
+ * different in character class ordering; each grew its own follow-on checks
10
+ * (all-dots refusal, length cap, leading-dot refusal) at different rates.
11
+ *
12
+ * This module is the single source of truth. Adding a new path-component
13
+ * input means calling `validateIdComponent(value, role)` and propagating
14
+ * the returned {ok, reason} pair to the caller's emit-error path.
15
+ *
16
+ * Three role types, three character classes:
17
+ * - 'session' — sessions live under `.exceptd/attestations/<sid>/`. Allow
18
+ * lower+upper alpha, digit, dot, underscore, hyphen, 1-64
19
+ * chars. Refuse all-dots.
20
+ * - 'playbook' — playbook ids index `data/playbooks/<id>.json`. Stricter:
21
+ * lowercase-only, must start with a letter, no dots (all
22
+ * catalogued playbook ids match `/^[a-z][a-z0-9-]{0,63}$/`).
23
+ * - 'filename' — attestation filename inside a session directory. Same
24
+ * charset as 'session' but length cap reflects filename
25
+ * policy (no path separators ever).
26
+ *
27
+ * The function never reads the filesystem; combine with realpathSync at
28
+ * the caller for full path-traversal defense.
29
+ */
30
+
31
+ const SESSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
32
+ const PLAYBOOK_RE = /^[a-z][a-z0-9-]{0,63}$/;
33
+ const FILENAME_RE = /^[A-Za-z0-9._-]{1,80}$/;
34
+ const ALL_DOTS_RE = /^\.+$/;
35
+
36
+ function validateIdComponent(value, role) {
37
+ if (typeof value !== 'string') {
38
+ return { ok: false, reason: `expected string, got ${typeof value}` };
39
+ }
40
+ if (value.length === 0) {
41
+ return { ok: false, reason: 'must not be empty' };
42
+ }
43
+ let re;
44
+ let constraint;
45
+ switch (role) {
46
+ case 'session':
47
+ re = SESSION_RE;
48
+ constraint = '^[A-Za-z0-9._-]{1,64}$';
49
+ break;
50
+ case 'playbook':
51
+ re = PLAYBOOK_RE;
52
+ constraint = '^[a-z][a-z0-9-]{0,63}$ (lowercase, starts with letter, no dots)';
53
+ break;
54
+ case 'filename':
55
+ re = FILENAME_RE;
56
+ constraint = '^[A-Za-z0-9._-]{1,80}$';
57
+ break;
58
+ default:
59
+ return { ok: false, reason: `unknown role: ${role}` };
60
+ }
61
+ if (!re.test(value)) {
62
+ return { ok: false, reason: `must match ${constraint}` };
63
+ }
64
+ // All-dots refusal applies after the character-class regex because the
65
+ // session/filename classes admit any string of dots (`.`, `..`, `...`),
66
+ // each of which path-resolves into or above the intended directory.
67
+ if (ALL_DOTS_RE.test(value)) {
68
+ return { ok: false, reason: 'must not consist entirely of dots' };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ /**
74
+ * Cheap typed-throw wrapper for callers that prefer exceptions over result
75
+ * objects (lib/playbook-runner.js uses this shape for loadPlaybook).
76
+ */
77
+ function assertIdComponent(value, role) {
78
+ const r = validateIdComponent(value, role);
79
+ if (!r.ok) {
80
+ const err = new Error(`invalid ${role} id (${r.reason}): ${typeof value === 'string' ? value.slice(0, 80) : typeof value}`);
81
+ err.code = 'EXCEPTD_INVALID_ID';
82
+ err.role = role;
83
+ err.reason = r.reason;
84
+ throw err;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ module.exports = {
90
+ validateIdComponent,
91
+ assertIdComponent,
92
+ SESSION_RE,
93
+ PLAYBOOK_RE,
94
+ FILENAME_RE,
95
+ };
@@ -655,6 +655,59 @@ function findOrphanSkillFiles(manifestSkills) {
655
655
  return orphans;
656
656
  }
657
657
 
658
+ // Substrings that indicate an artifact `source` makes a network call. Used
659
+ // by lintPlaybookAirGap() to flag artifacts that lack an air_gap_alternative.
660
+ // Conservative-by-design — false positives are surfaced as `warn` (not
661
+ // `error`) and a playbook author who has reviewed the source can suppress
662
+ // by adding an air_gap_alternative even when the source itself is offline.
663
+ const PLAYBOOK_NET_PATTERNS = [
664
+ 'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
665
+ ];
666
+
667
+ const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
668
+
669
+ /**
670
+ * Air-gap completeness lint for shipped playbooks. Walks every
671
+ * data/playbooks/*.json file, examines phases.look.artifacts[], and warns
672
+ * when an artifact's `source` contains a network-call substring without a
673
+ * sibling `air_gap_alternative`. The playbook schema's hard `if/then`
674
+ * conditional (added v0.12.24) catches this for playbooks marked
675
+ * `_meta.air_gap_mode: true`; this lint surfaces the gap for every
676
+ * playbook, on the principle that a non-air-gap playbook may still be
677
+ * invoked under `exceptd --air-gap` and operators deserve the warning.
678
+ *
679
+ * Returns an array of `{ playbook, artifact_id, source }` warning records.
680
+ */
681
+ function lintPlaybookAirGap() {
682
+ const warnings = [];
683
+ if (!fs.existsSync(PLAYBOOK_DIR)) return warnings;
684
+ const files = fs.readdirSync(PLAYBOOK_DIR).filter(f => f.endsWith('.json') && !f.startsWith('_'));
685
+ for (const f of files) {
686
+ let playbook;
687
+ try {
688
+ playbook = readJson(path.join(PLAYBOOK_DIR, f));
689
+ } catch {
690
+ continue; // schema validator catches parse errors separately
691
+ }
692
+ const arts = playbook && playbook.phases && playbook.phases.look && playbook.phases.look.artifacts;
693
+ if (!Array.isArray(arts)) continue;
694
+ for (const a of arts) {
695
+ if (!a || typeof a !== 'object') continue;
696
+ const src = a.source;
697
+ if (typeof src !== 'string') continue;
698
+ const isNet = PLAYBOOK_NET_PATTERNS.some(p => src.includes(p));
699
+ if (isNet && !a.air_gap_alternative) {
700
+ warnings.push({
701
+ playbook: playbook._meta && playbook._meta.id ? playbook._meta.id : f.replace(/\.json$/, ''),
702
+ artifact_id: a.id || '<unknown>',
703
+ source: src,
704
+ });
705
+ }
706
+ }
707
+ }
708
+ return warnings;
709
+ }
710
+
658
711
  function main() {
659
712
  const opts = parseArgs(process.argv);
660
713
  const manifest = readJson(MANIFEST_PATH);
@@ -700,6 +753,7 @@ function main() {
700
753
  // A targeted single-skill lint is for diagnosing one entry; running
701
754
  // the orphan walk there would surface unrelated findings.
702
755
  let orphans = [];
756
+ let airGapWarnings = [];
703
757
  if (!opts.skill) {
704
758
  orphans = findOrphanSkillFiles(manifest.skills);
705
759
  for (const o of orphans) {
@@ -707,14 +761,25 @@ function main() {
707
761
  console.log(` - skill.md exists on disk but not in manifest: ${o}`);
708
762
  console.log(` fix: re-run \`node lib/sign.js sign-all\` after adding it to manifest.json, OR delete the orphan directory`);
709
763
  }
764
+ // P4 — air-gap completeness lint over data/playbooks/*.json.
765
+ airGapWarnings = lintPlaybookAirGap();
766
+ for (const w of airGapWarnings) {
767
+ console.log(`WARN playbook:${w.playbook}`);
768
+ console.log(` - [warn] artifact "${w.artifact_id}" source contains a network call but has no air_gap_alternative`);
769
+ console.log(` source: ${w.source}`);
770
+ console.log(` fix: add an air_gap_alternative source (offline file path / packaged dataset / pre-staged artifact)`);
771
+ }
710
772
  }
711
773
 
712
774
  const total = results.length;
713
775
  const passed = total - failed - warned;
714
776
  const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
715
777
  const warnSummary = warned ? `, ${warned} with warnings` : '';
778
+ const airGapSummary = airGapWarnings && airGapWarnings.length
779
+ ? `, ${airGapWarnings.length} playbook artifact(s) missing air_gap_alternative`
780
+ : '';
716
781
  console.log(
717
- `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}.`,
782
+ `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${airGapSummary}.`,
718
783
  );
719
784
  process.exit(failed === 0 && orphans.length === 0 ? 0 : 1);
720
785
  }
@@ -727,6 +792,8 @@ module.exports = {
727
792
  unquote,
728
793
  findOrphanSkillFiles,
729
794
  findMissingSections,
795
+ lintPlaybookAirGap,
796
+ PLAYBOOK_NET_PATTERNS,
730
797
  REQUIRED_SECTIONS,
731
798
  COUNTERMEASURE_SECTION,
732
799
  COUNTERMEASURE_CUTOFF,