@blamejs/exceptd-skills 0.16.25 → 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.
Files changed (79) hide show
  1. package/AGENTS.md +5 -5
  2. package/ARCHITECTURE.md +3 -3
  3. package/CHANGELOG.md +28 -0
  4. package/CONTEXT.md +2 -2
  5. package/README.md +6 -6
  6. package/agents/threat-researcher.md +2 -2
  7. package/bin/exceptd.js +41 -8
  8. package/data/_indexes/_meta.json +41 -40
  9. package/data/_indexes/activity-feed.json +240 -240
  10. package/data/_indexes/catalog-summaries.json +3 -3
  11. package/data/_indexes/currency.json +64 -64
  12. package/data/_indexes/jurisdiction-map.json +31 -158
  13. package/data/_indexes/recipes.json +1 -1
  14. package/data/_indexes/section-offsets.json +510 -510
  15. package/data/_indexes/summary-cards.json +33 -33
  16. package/data/_indexes/token-budget.json +200 -200
  17. package/data/atlas-ttps.json +7 -7
  18. package/data/attack-techniques.json +5 -5
  19. package/data/framework-control-gaps.json +3 -3
  20. package/lib/auto-discovery.js +15 -9
  21. package/lib/collectors/library-author.js +26 -9
  22. package/lib/collectors/secrets.js +8 -1
  23. package/lib/cvss.js +108 -0
  24. package/lib/lint-skills.js +6 -1
  25. package/lib/playbook-runner.js +17 -4
  26. package/lib/prefetch.js +97 -5
  27. package/lib/refresh-external.js +25 -13
  28. package/lib/schemas/manifest.schema.json +1 -1
  29. package/lib/schemas/skill-frontmatter.schema.json +1 -1
  30. package/lib/validate-indexes.js +5 -0
  31. package/lib/version-pins.js +3 -3
  32. package/manifest-snapshot.json +2 -2
  33. package/manifest-snapshot.sha256 +1 -1
  34. package/manifest.json +124 -124
  35. package/orchestrator/pipeline.js +16 -4
  36. package/package.json +1 -1
  37. package/sbom.cdx.json +170 -140
  38. package/scripts/build-indexes.js +12 -1
  39. package/scripts/builders/catalog-summaries.js +1 -1
  40. package/scripts/builders/recipes.js +1 -1
  41. package/scripts/check-sbom-currency.js +76 -14
  42. package/scripts/refresh-sbom.js +1 -1
  43. package/scripts/run-e2e-scenarios.js +48 -17
  44. package/scripts/sync-package-description.js +74 -0
  45. package/scripts/verify-shipped-tarball.js +18 -7
  46. package/skills/age-gates-child-safety/skill.md +3 -3
  47. package/skills/ai-attack-surface/skill.md +4 -4
  48. package/skills/ai-c2-detection/skill.md +5 -5
  49. package/skills/api-security/skill.md +2 -2
  50. package/skills/attack-surface-pentest/skill.md +4 -4
  51. package/skills/cloud-security/skill.md +3 -3
  52. package/skills/compliance-theater/skill.md +3 -3
  53. package/skills/container-runtime-security/skill.md +3 -3
  54. package/skills/coordinated-vuln-disclosure/skill.md +2 -2
  55. package/skills/defensive-countermeasure-mapping/skill.md +3 -3
  56. package/skills/dlp-gap-analysis/skill.md +5 -5
  57. package/skills/exploit-scoring/skill.md +2 -2
  58. package/skills/framework-gap-analysis/skill.md +4 -4
  59. package/skills/fuzz-testing-strategy/skill.md +2 -2
  60. package/skills/incident-response-playbook/skill.md +3 -3
  61. package/skills/mcp-agent-trust/skill.md +2 -2
  62. package/skills/mlops-security/skill.md +3 -3
  63. package/skills/ot-ics-security/skill.md +3 -3
  64. package/skills/policy-exception-gen/skill.md +3 -3
  65. package/skills/pqc-first/skill.md +2 -2
  66. package/skills/rag-pipeline-security/skill.md +4 -4
  67. package/skills/ransomware-response/skill.md +2 -2
  68. package/skills/sector-energy/skill.md +2 -2
  69. package/skills/sector-federal-government/skill.md +2 -2
  70. package/skills/sector-financial/skill.md +4 -4
  71. package/skills/sector-healthcare/skill.md +3 -3
  72. package/skills/security-maturity-tiers/skill.md +1 -1
  73. package/skills/skill-update-loop/skill.md +6 -6
  74. package/skills/supply-chain-integrity/skill.md +2 -2
  75. package/skills/threat-model-currency/skill.md +8 -8
  76. package/skills/threat-modeling-methodology/skill.md +2 -2
  77. package/skills/webapp-security/skill.md +2 -2
  78. package/skills/zeroday-gap-learn/skill.md +3 -3
  79. package/sources/validators/cve-validator.js +27 -18
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
- "atlas_version": "5.6.0",
5
- "atlas_release_date": "2026-05-08",
4
+ "atlas_version": "2026.05",
5
+ "atlas_release_date": "2026-05-27",
6
6
  "secure_ai_v2_release_date": "2026-05-06",
7
7
  "secure_ai_v2_source": "https://ctid.mitre.org/blog/2026/05/06/secure-ai-v2-release",
8
- "last_updated": "2026-05-19",
9
- "last_threat_review": "2026-05-19",
8
+ "last_updated": "2026-06-10",
9
+ "last_threat_review": "2026-06-10",
10
10
  "source": "https://atlas.mitre.org",
11
- "note": "AI-relevant ATLAS v5.6.0 TTPs with framework_gap field. framework_gap: no framework has a control that addresses this TTP. secure_ai_v2_layer flags entries included in the CTID Secure AI v2 layered set (May 2026); maturity reflects CTID's technique-maturity classification (low / moderate / high).",
11
+ "note": "AI-relevant ATLAS v2026.05 TTPs with framework_gap field. ATLAS content now follows a YYYY.MM calendar-versioning scheme; v2026.05 adds platform tags (Predictive AI, Generative AI, Agentic AI, Enterprise) to every technique, with no technique-ID removals or renames. framework_gap: no framework has a control that addresses this TTP. secure_ai_v2_layer flags entries included in the CTID Secure AI v2 layered set (May 2026); maturity reflects CTID's technique-maturity classification (low / moderate / high).",
12
12
  "tlp": "CLEAR",
13
13
  "source_confidence": {
14
14
  "scheme": "Admiralty (A-F + 1-6)",
@@ -21,7 +21,7 @@
21
21
  "rebuild_after_days": 365,
22
22
  "note": "Per-entry last_verified governs decay. Skills depending on this catalog must check entry freshness before high-stakes use."
23
23
  },
24
- "bulk_intake_note": " v0.13.18: +137 AML.* techniques from MITRE ATLAS STIX bundle (atlas-navigator-data dist/stix-atlas.json). Now tracking ATLAS v5.6.0 (May 2026 release). Auto-imported entries flagged _auto_imported:true + _intake_method:v0.13.18-bulk-mitre-atlas-stix for downstream curation."
24
+ "bulk_intake_note": " v0.13.18: +137 AML.* techniques from MITRE ATLAS STIX bundle (atlas-navigator-data dist/stix-atlas.json). Now tracking ATLAS v2026.05 (May 2026 release). Auto-imported entries flagged _auto_imported:true + _intake_method:v0.13.18-bulk-mitre-atlas-stix for downstream curation."
25
25
  },
26
26
  "AML.T0001": {
27
27
  "id": "AML.T0001",
@@ -536,7 +536,7 @@
536
536
  "Multiple production AI assistant prompt injection incidents 2025-2026"
537
537
  ],
538
538
  "framework_gap": true,
539
- "framework_gap_detail": "No framework has a control for prompt injection as an access control failure vector. The attack uses the AI service account's authorized permissions — from AC-2's perspective, the access is authorized. MITRE ATLAS v5.6.0 documents the technique; no framework has implemented controls. OWASP LLM Top 10 documents the class; it is not incorporated in any compliance framework.",
539
+ "framework_gap_detail": "No framework has a control for prompt injection as an access control failure vector. The attack uses the AI service account's authorized permissions — from AC-2's perspective, the access is authorized. MITRE ATLAS v2026.05 documents the technique; no framework has implemented controls. OWASP LLM Top 10 documents the class; it is not incorporated in any compliance framework.",
540
540
  "controls_that_partially_help": [
541
541
  "NIST-800-53-AC-2",
542
542
  "ISO-27001-2022-A.8.28"
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "_meta": {
3
3
  "schema_version": "1.0.0",
4
- "last_updated": "2026-05-19",
5
- "last_threat_review": "2026-05-19",
6
- "attack_version": "19.0",
7
- "attack_version_date": "2026-04-28",
4
+ "last_updated": "2026-06-10",
5
+ "last_threat_review": "2026-06-10",
6
+ "attack_version": "19.1",
7
+ "attack_version_date": "2026-05-12",
8
8
  "tactic_split_note": "ATT&CK v19 split Defense Evasion (TA0005) into Stealth (TA0005) and Defense Impairment (TA0112). Entries here record their post-split tactic; entries whose tactic moved carry `tactic_moved_from: \"Defense Evasion\"` for traceability.",
9
9
  "detection_strategies_note": "ATT&CK v18 introduced Detection Strategies as first-class objects (691 strategies, 1739 analytics at v18 release). Entries reference applicable strategy IDs (DSxxxx) where the technique has canonical strategy coverage; absence does not imply lack of detection.",
10
- "source": "https://attack.mitre.org — MITRE ATT&CK Enterprise + ICS, v19.0 (April 2026). Only techniques currently referenced by shipped exceptd skills and playbooks. The full ATT&CK matrix is intentionally not duplicated here; this is a resolution catalog for cross-reference validation, not a substitute for attack.mitre.org. See `npm run refresh-attack-techniques` (v0.13.0+) for the full corpus.",
10
+ "source": "https://attack.mitre.org — MITRE ATT&CK Enterprise + ICS, v19.1 (May 2026). Only techniques currently referenced by shipped exceptd skills and playbooks. The full ATT&CK matrix is intentionally not duplicated here; this is a resolution catalog for cross-reference validation, not a substitute for attack.mitre.org. See `npm run refresh-attack-techniques` (v0.13.0+) for the full corpus.",
11
11
  "tlp": "CLEAR",
12
12
  "source_confidence": {
13
13
  "scheme": "Admiralty (A-F + 1-6)",
@@ -468,7 +468,7 @@
468
468
  "Treating 'Top 25 addressed' as a compliance signal creates a compliance-theatre risk for organisations with significant AI surface",
469
469
  "No cross-walk requirement to ATLAS TTPs — CWE addresses weaknesses; ATLAS addresses adversary techniques. Both are needed for AI coverage"
470
470
  ],
471
- "real_requirement": "Programmes that claim 'Top 25 addressed' as compliance evidence must additionally: (1) enumerate AI-relevant CWEs outside the Top 25 (CWE-1426 Improper Output Validation, CWE-1039 Inadequate Detection of Adversarial Input, CWE-1230 Exposure of Sensitive Info Through Metadata) with explicit treatment, (2) cross-walk to ATLAS v5.6.0 TTPs for adversarial coverage, (3) re-baseline against the next-published Top 25 with delta analysis. Aligns with EU CRA Annex I, UK NCSC, AU ISM, ISO 27001 A.8.28.",
471
+ "real_requirement": "Programmes that claim 'Top 25 addressed' as compliance evidence must additionally: (1) enumerate AI-relevant CWEs outside the Top 25 (CWE-1426 Improper Output Validation, CWE-1039 Inadequate Detection of Adversarial Input, CWE-1230 Exposure of Sensitive Info Through Metadata) with explicit treatment, (2) cross-walk to ATLAS v2026.05 TTPs for adversarial coverage, (3) re-baseline against the next-published Top 25 with delta analysis. Aligns with EU CRA Annex I, UK NCSC, AU ISM, ISO 27001 A.8.28.",
472
472
  "status": "open",
473
473
  "opened_date": "2026-05-11",
474
474
  "evidence_cves": [],
@@ -1804,7 +1804,7 @@
1804
1804
  "LLM-API-as-C2 (SesameOp pattern, ATLAS AML.T0096) is not in the clause 6.1.2 example threat list — risk register templates omit it",
1805
1805
  "No requirement to link AI risk register entries to specific TTP IDs (ATLAS / ATT&CK) — risks remain framework-internal abstractions"
1806
1806
  ],
1807
- "real_requirement": "Clause 6.1.2 risk registers must (1) ingest ATLAS v5.6.0 TTPs as enumerated AI-specific threat sources, (2) cross-reference jurisdictional obligations (EU AI Act Annex III, NIS2 Art. 21, DORA Art. 28, UK CAF B4, AU ISM AI annex, ISO 27001:2022 A.5.7), (3) include AI-API-as-C2 and prompt-injection-as-RCE as named scenarios, (4) be re-run on threat-intel triggers, not only on calendar cycles.",
1807
+ "real_requirement": "Clause 6.1.2 risk registers must (1) ingest ATLAS v2026.05 TTPs as enumerated AI-specific threat sources, (2) cross-reference jurisdictional obligations (EU AI Act Annex III, NIS2 Art. 21, DORA Art. 28, UK CAF B4, AU ISM AI annex, ISO 27001:2022 A.5.7), (3) include AI-API-as-C2 and prompt-injection-as-RCE as named scenarios, (4) be re-run on threat-intel triggers, not only on calendar cycles.",
1808
1808
  "status": "open",
1809
1809
  "opened_date": "2026-05-11",
1810
1810
  "evidence_cves": [],
@@ -7145,7 +7145,7 @@
7145
7145
  }
7146
7146
  },
7147
7147
  "ATLAS-AML.T0048": {
7148
- "framework": "MITRE ATLAS v5.6.0",
7148
+ "framework": "MITRE ATLAS v2026.05",
7149
7149
  "control_id": "AML.T0048",
7150
7150
  "control_name": "External Harms — ML Supply Chain Compromise (bundled-codec / inference-server class)",
7151
7151
  "designed_for": "ATLAS AML.T0048 catalogues external harms from ML supply-chain compromise, including malicious model weights, poisoned training data, and compromised ML libraries. The technique-level guidance covers detection and mitigation at the model-artifact and library-consumption layer.",
@@ -31,6 +31,7 @@ const fs = require("fs");
31
31
  const path = require("path");
32
32
  const crypto = require("crypto");
33
33
  const { scoreCustom, RWEP_WEIGHTS, ACTIVE_EXPLOITATION_LADDER } = require("./scoring");
34
+ const { selectNvdCvss } = require("./cvss");
34
35
 
35
36
  // Stored rwep_factors must reproduce the stored rwep_score.
36
37
  // `buildScoringInputs` is the single source of truth for both — it captures
@@ -187,16 +188,13 @@ function readCachedJson(cacheDir, source, id) {
187
188
  function extractNvdMetrics(payload) {
188
189
  const vuln = payload?.vulnerabilities?.[0]?.cve;
189
190
  if (!vuln) return null;
190
- const m = vuln.metrics || {};
191
- const ordered = [
192
- ...(m.cvssMetricV31 || []),
193
- ...(m.cvssMetricV30 || []),
194
- ...(m.cvssMetricV2 || []),
195
- ];
196
- const primary = ordered.find((x) => x.type === "Primary") || ordered[0];
191
+ // Prefer the newest CVSS version (Primary within it) and normalize a bare
192
+ // v2 vector to its canonical prefix so an auto-imported draft never carries
193
+ // an unprefixed vector that the strict catalog validator would reject.
194
+ const up = selectNvdCvss(vuln.metrics);
197
195
  return {
198
- cvss_score: typeof primary?.cvssData?.baseScore === "number" ? primary.cvssData.baseScore : null,
199
- cvss_vector: primary?.cvssData?.vectorString || null,
196
+ cvss_score: up ? up.baseScore : null,
197
+ cvss_vector: up ? up.vector : null,
200
198
  description: (vuln.descriptions || []).find((d) => d.lang === "en")?.value || null,
201
199
  cwe_refs: ((vuln.weaknesses || [])
202
200
  .flatMap((w) => (w.description || []))
@@ -559,6 +557,14 @@ function extractAcronymFromGroupUri(uri) {
559
557
  * @param {object} opts { cap?: number, sinceDays?: number }
560
558
  */
561
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
+ }
562
568
  const cap = opts.cap ?? DEFAULT_CAP;
563
569
  const sinceDays = opts.sinceDays ?? 180;
564
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) ||
@@ -207,7 +207,14 @@ function fpIndicesSatisfied(indicatorId, value, file, window) {
207
207
  const INDICATOR_PATTERNS = [
208
208
  { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
209
209
  { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
210
- { id: "gcp-service-account-json", re: /"type"\s*:\s*"service_account"[\s\S]{0,1200}?"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----/g },
210
+ // Require a full PEM block (header, base64 body, closing marker) — not just
211
+ // the `-----BEGIN PRIVATE KEY-----` header — so a service-account JSON shown
212
+ // as a doc placeholder or a redaction/DLP library's detection-pattern literal
213
+ // does not register as a real embedded key. A real key is JSON-encoded with
214
+ // `\n` escapes, so the body class includes backslash; `-` is excluded so the
215
+ // run halts at `-----END` (no backtracking, ReDoS-safe). Mirrors
216
+ // ssh-private-key-block below.
217
+ { id: "gcp-service-account-json", re: /"type"\s*:\s*"service_account"[\s\S]{0,1200}?"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----[A-Za-z0-9+/=\s\\]{40,4000}-----END/g },
211
218
  { id: "github-personal-access-token", re: /\bghp_[A-Za-z0-9]{36}\b/g },
212
219
  { id: "github-fine-grained-pat", re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
213
220
  { id: "slack-bot-or-user-token", re: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g },
package/lib/cvss.js ADDED
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /**
3
+ * lib/cvss.js
4
+ *
5
+ * Shared CVSS metric-selection and vector-normalization helpers for every
6
+ * site that ingests NIST NVD scoring (the cache-backed refresh diff, the
7
+ * live per-CVE validator, and the KEV auto-discovery importer).
8
+ *
9
+ * Two properties of NVD's data make naive ingestion lossy:
10
+ *
11
+ * 1. NVD tags the legacy CVSS v2 metric as `type: "Primary"` on pre-v3
12
+ * CVEs, while a modern v3.1 re-score (often supplied by a CNA) rides as
13
+ * `type: "Secondary"`. Selecting a metric by `type === "Primary"` alone
14
+ * therefore picks the *older* v2 score over a newer v3.1 one — silently
15
+ * downgrading a curated v3.1 entry to v2 on every refresh.
16
+ *
17
+ * 2. NVD's `cvssMetricV2` entries carry a bare base vector
18
+ * ("AV:N/AC:L/Au:N/C:C/I:C/A:C") with no "CVSS:2.0/" prefix, whereas
19
+ * v3.x/v4.0 carry the prefix. The catalog schema (and
20
+ * validate-cve-catalog --strict) require the canonical "CVSS:<x.y>/"
21
+ * prefix, so writing a bare v2 vector produces an invalid entry.
22
+ *
23
+ * `selectNvdCvss` resolves (1) by preferring the newest CVSS version present
24
+ * and choosing Primary only *within* that version; it resolves (2) by
25
+ * normalizing the returned vector. Callers additionally guard against
26
+ * cross-version downgrades using `cvssVersionOf` on the locally-curated
27
+ * vector.
28
+ *
29
+ * Zero npm deps. Node stdlib only.
30
+ */
31
+
32
+ // A bare (unprefixed) CVSS v2 base vector. v2 is the only version NVD emits
33
+ // without a "CVSS:x/" prefix, and its grammar carries the Au: (Authentication)
34
+ // metric that v3/v4 dropped — the unambiguous discriminator for a bare v2.
35
+ const BARE_V2_RE = /^AV:[NAL]\/AC:[HML]\/Au:[MSN]\//;
36
+
37
+ // The four canonical version prefixes the catalog accepts (mirrors
38
+ // validate-cve-catalog.js STRICT_CVSS_PATTERN).
39
+ const PREFIXED_RE = /^CVSS:(2\.0|3\.0|3\.1|4\.0)\//;
40
+
41
+ /**
42
+ * The CVSS version a vector declares, as a comparable number (2.0 < 3.0 < 3.1
43
+ * < 4.0). Recognizes the four canonical "CVSS:x.y/" prefixes plus NVD's bare
44
+ * v2 base vector. Returns null for anything unrecognized so callers can treat
45
+ * an unknown version as "do not block" rather than mis-suppressing a diff.
46
+ *
47
+ * @param {string} vector
48
+ * @returns {number|null}
49
+ */
50
+ function cvssVersionOf(vector) {
51
+ if (typeof vector !== "string" || vector.length === 0) return null;
52
+ const m = vector.match(PREFIXED_RE);
53
+ if (m) return Number(m[1]);
54
+ if (BARE_V2_RE.test(vector)) return 2.0;
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Ensure a vector carries a canonical "CVSS:x.y/" prefix. A bare v2 base
60
+ * vector is prefixed with "CVSS:2.0/"; already-prefixed vectors (and anything
61
+ * unrecognized) pass through unchanged. The output of a recognized vector
62
+ * always satisfies validate-cve-catalog --strict.
63
+ *
64
+ * @param {string} vector
65
+ * @returns {string}
66
+ */
67
+ function normalizeCvssVector(vector) {
68
+ if (typeof vector !== "string" || vector.length === 0) return vector;
69
+ if (PREFIXED_RE.test(vector)) return vector;
70
+ if (BARE_V2_RE.test(vector)) return `CVSS:2.0/${vector}`;
71
+ return vector;
72
+ }
73
+
74
+ /**
75
+ * Select the most authoritative CVSS metric from an NVD `metrics` object.
76
+ * Prefers the newest CVSS version present (4.0 > 3.1 > 3.0 > 2.0); within the
77
+ * chosen version prefers NVD's "Primary" analyst score over a "Secondary"
78
+ * (CNA) one, falling back to the first entry when no Primary exists in that
79
+ * version. The returned vector is normalized to the canonical prefix form.
80
+ *
81
+ * @param {object} metrics The `vulnerabilities[0].cve.metrics` object.
82
+ * @returns {{version:number|null, baseScore:number|null, vector:string|null, source:string|null}|null}
83
+ */
84
+ function selectNvdCvss(metrics) {
85
+ const m = metrics || {};
86
+ const buckets = [
87
+ [4.0, m.cvssMetricV40],
88
+ [3.1, m.cvssMetricV31],
89
+ [3.0, m.cvssMetricV30],
90
+ [2.0, m.cvssMetricV2],
91
+ ];
92
+ for (const [bucketVersion, bucket] of buckets) {
93
+ const arr = Array.isArray(bucket) ? bucket : [];
94
+ if (arr.length === 0) continue;
95
+ const chosen = arr.find((x) => x && x.type === "Primary") || arr[0];
96
+ const data = chosen && chosen.cvssData ? chosen.cvssData : null;
97
+ const declared = data && data.version != null ? Number(data.version) : null;
98
+ return {
99
+ version: Number.isFinite(declared) ? declared : bucketVersion,
100
+ baseScore: typeof data?.baseScore === "number" ? data.baseScore : null,
101
+ vector: data?.vectorString ? normalizeCvssVector(data.vectorString) : null,
102
+ source: chosen && chosen.source ? chosen.source : null,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ module.exports = { cvssVersionOf, normalizeCvssVector, selectNvdCvss };
@@ -175,7 +175,12 @@ function readJson(p) {
175
175
  * accept malformed frontmatter.
176
176
  */
177
177
  function parseFrontmatter(text) {
178
- const lines = text.split(/\r?\n/);
178
+ // Strip a trailing CR per line: split(/\r?\n/) consumes interior CRLFs, but a
179
+ // dangling `\r` survives on the final frontmatter line (the close marker
180
+ // consumed the `\n`, not the `\r`). `.` does not match `\r`, so that line's
181
+ // value would fail the per-line regex with a misleading "Could not parse
182
+ // frontmatter line N" on an otherwise valid CRLF skill.md.
183
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
179
184
  const result = {};
180
185
  // Track every top-level key we've already assigned. YAML's last-wins
181
186
  // semantics would let a tampered skill set name twice
@@ -1191,10 +1191,21 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1191
1191
  // `factor_cve_source: 'evidence' | 'domain' | 'none'` so operators see
1192
1192
  // which fallback was used.
1193
1193
  let factorCveSource = 'none';
1194
- let factorCve = matchedCves[0] || null;
1194
+ // Prefer an RWEP-eligible (non-VEX-fixed) matched CVE to drive factor
1195
+ // scaling — a vendor-patched CVE must not inflate adjusted RWEP via its
1196
+ // exploitation / KEV / PoC multipliers. Do NOT fall back to matchedCves[0]:
1197
+ // when EVERY evidence-correlated CVE is VEX-fixed (rwepEligible empty but
1198
+ // matchedCves non-empty) the finding is remediated, so factor scaling must be
1199
+ // suppressed entirely — base is already 0 and the fired factors must not
1200
+ // raise the adjusted score (a vendor-fixed CVE's KEV/exploitation/PoC would
1201
+ // otherwise lift it above 0). The domain-CVE and class-weight fallbacks below
1202
+ // are skipped in that case too, so every fired factor scales by 0 via
1203
+ // _factorScale(factor, null, …).
1204
+ const allMatchedVexFixed = matchedCves.length > 0 && rwepEligible.length === 0;
1205
+ let factorCve = rwepEligible[0] || null;
1195
1206
  if (factorCve) {
1196
1207
  factorCveSource = 'evidence';
1197
- } else if (workingCatalogCves.length > 0) {
1208
+ } else if (!allMatchedVexFixed && workingCatalogCves.length > 0) {
1198
1209
  // Highest rwep_score from domain refs.
1199
1210
  factorCve = workingCatalogCves.reduce((worst, c) =>
1200
1211
  (typeof c.rwep_score === 'number' && (!worst || c.rwep_score > worst.rwep_score)) ? c : worst,
@@ -1211,7 +1222,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1211
1222
  // semantics for this case only: apply the declared weight as-is
1212
1223
  // (factor_scale=1, legacy semantics). The factor_cve_source annotation
1213
1224
  // surfaces 'class' so operators see which mode the run used.
1214
- const _classScaleFallback = !factorCve;
1225
+ const _classScaleFallback = !factorCve && !allMatchedVexFixed;
1215
1226
  let adjustedRwep = baseRwep;
1216
1227
  const rwepBreakdown = [];
1217
1228
  for (const input of an.rwep_inputs || []) {
@@ -1972,7 +1983,9 @@ function analyzeFindingShape(a) {
1972
1983
  // CVEs. A .find() lookup would return the first truthy entry — e.g.
1973
1984
  // 'suspected' on CVE #1 when CVE #2 is 'confirmed' — under-stating
1974
1985
  // the threat in notification drafts.
1975
- active_exploitation: worstActiveExploitation(matched),
1986
+ // Exclude VEX-fixed (vendor-patched) CVEs: a notification draft must not
1987
+ // assert active exploitation sourced from an already-remediated CVE.
1988
+ active_exploitation: worstActiveExploitation(matched.filter(c => c.vex_status !== 'fixed')),
1976
1989
  rwep_adjusted: rwepAdjusted,
1977
1990
  rwep_base: a.rwep?.base ?? 0,
1978
1991
  // Severity surface for playbook conditions.
package/lib/prefetch.js CHANGED
@@ -108,7 +108,7 @@ const SOURCES = {
108
108
  };
109
109
 
110
110
  function parseArgs(argv) {
111
- const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false };
111
+ const out = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, help: false, maxErrors: 0 };
112
112
  for (let i = 2; i < argv.length; i++) {
113
113
  const a = argv[i];
114
114
  if (a === "--force") out.force = true;
@@ -121,6 +121,12 @@ function parseArgs(argv) {
121
121
  else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
122
122
  else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
123
123
  else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
124
+ // Per-entry fetch-error tolerance. An integer is an absolute budget; an
125
+ // "<N>%" string is a fraction of the planned fetch count. A malformed
126
+ // value is recorded as an arg error so main() refuses with exit 2 rather
127
+ // than silently falling back to an unbounded tolerance.
128
+ else if (a === "--max-errors") { try { out.maxErrors = parseErrorThreshold(argv[++i]); } catch (e) { out._argError = e.message; } }
129
+ else if (a.startsWith("--max-errors=")) { try { out.maxErrors = parseErrorThreshold(a.slice("--max-errors=".length)); } catch (e) { out._argError = e.message; } }
124
130
  // Any remaining --flag is an unrecognized typo. Record it; main() refuses
125
131
  // before any network work rather than silently dropping it.
126
132
  else if (typeof a === "string" && a.startsWith("--")) {
@@ -145,6 +151,73 @@ function parseDuration(s) {
145
151
  return n * mult;
146
152
  }
147
153
 
154
+ // Parse a --max-errors value into either an absolute integer budget or a
155
+ // percentage marker ("<N>%"). Throws on anything else so a typo can't degrade
156
+ // into an unbounded tolerance.
157
+ function parseErrorThreshold(s) {
158
+ const str = String(s == null ? "" : s).trim();
159
+ const m = str.match(/^(\d+)(%?)$/);
160
+ if (!m) throw new Error(`prefetch: invalid --max-errors "${s}" (expected an integer or a percentage like "50" or "5%")`);
161
+ return m[2] === "%" ? `${m[1]}%` : Number(m[1]);
162
+ }
163
+
164
+ // Total entries a run planned to fetch (fetched + skipped-fresh + errored).
165
+ // The denominator for a percentage error budget.
166
+ function plannedCount(result) {
167
+ if (!result) return 0;
168
+ return (result.fetched || 0) + (result.skipped_fresh || 0) + (result.errors || 0);
169
+ }
170
+
171
+ // Resolve a --max-errors value (absolute number, "<N>%" string, or null) into
172
+ // an absolute count against the planned total.
173
+ function errorBudget(maxErrors, planned) {
174
+ if (maxErrors == null) return 0;
175
+ if (typeof maxErrors === "number") return Number.isFinite(maxErrors) ? maxErrors : 0;
176
+ const m = String(maxErrors).match(/^(\d+)%$/);
177
+ if (m) return Math.floor((Number(m[1]) / 100) * (planned || 0));
178
+ const n = Number(maxErrors);
179
+ return Number.isFinite(n) ? n : 0;
180
+ }
181
+
182
+ // Decide prefetch's 0-vs-1 exit code from a completed run. Per-entry fetch
183
+ // errors are counted only after the job queue exhausts its retries, so they
184
+ // are genuine failures — but a best-effort cache warm should tolerate a few
185
+ // transient upstream misses. `opts.maxErrors` is the budget (default 0, so any
186
+ // error exits 1 — the strict contract a manual operator expects). Fatal
187
+ // errors (bad flags, an unhandled throw) are handled in main() and exit 2.
188
+ function exitCodeForResult(result, opts = {}) {
189
+ const errors = (result && result.errors) || 0;
190
+ if (errors === 0) return 0;
191
+ // A source that landed no usable entries this run — nothing freshly fetched
192
+ // and nothing already fresh in the cache, yet errors recorded — is entirely
193
+ // unreachable, and the refresh would silently skip it. A dead KEV feed is
194
+ // only one error (well under any global budget) but means the run missed
195
+ // every new KEV flag. Fail regardless of the budget so a single fully-dead
196
+ // feed can't pass quietly.
197
+ const bySource = (result && result.by_source) || {};
198
+ for (const s of Object.values(bySource)) {
199
+ if (s && (s.errors || 0) > 0 && (s.fetched || 0) === 0 && (s.skipped_fresh || 0) === 0) {
200
+ return 1;
201
+ }
202
+ }
203
+ const budget = errorBudget(opts.maxErrors, plannedCount(result));
204
+ return errors > budget ? 1 : 0;
205
+ }
206
+
207
+ // One-line run summary. When a run has errors, names the per-source counts so
208
+ // "1 error(s)" in a --quiet log is actionable instead of a blind count.
209
+ function formatSummary(result, opts = {}) {
210
+ let line = `prefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)`;
211
+ if (result.errors > 0 && result.by_source) {
212
+ const parts = Object.entries(result.by_source)
213
+ .filter(([, s]) => s && s.errors > 0)
214
+ .map(([name, s]) => `${name}=${s.errors}`);
215
+ if (parts.length) line += ` [${parts.join(", ")}]`;
216
+ }
217
+ if (opts.noNetwork) line += " (dry-run)";
218
+ return line;
219
+ }
220
+
148
221
  function printHelp() {
149
222
  console.log(`prefetch — warm a local cache of every upstream artifact this project consumes.
150
223
 
@@ -589,7 +662,10 @@ async function prefetch(options = {}) {
589
662
  .catch((err) => {
590
663
  result.errors++;
591
664
  result.by_source[item.source].errors++;
592
- log(` [${item.source}] ${item.id}error: ${err.message}`);
665
+ // Errors go to stderr unconditionally they are diagnostics, not the
666
+ // per-entry success chatter --quiet suppresses. A CI run with --quiet
667
+ // still surfaces which source/id failed.
668
+ console.error(` [${item.source}] ${item.id} — error: ${err.message}`);
593
669
  });
594
670
  });
595
671
 
@@ -618,7 +694,7 @@ async function prefetch(options = {}) {
618
694
  // (the noisy part) but the operator still needs one line confirming success.
619
695
  // Without this, --quiet + --no-network was zero output even on dry-run
620
696
  // success, leaving operators unsure if the command had run at all.
621
- console.log(`prefetch summary: ${result.fetched} fetched, ${result.skipped_fresh} fresh, ${result.errors} error(s)${opts.noNetwork ? " (dry-run)" : ""}`);
697
+ console.log(formatSummary(result, { noNetwork: opts.noNetwork }));
622
698
  return result;
623
699
  }
624
700
 
@@ -683,7 +759,7 @@ function readCached(cacheDir, source, id, opts = {}) {
683
759
  // message's known list.
684
760
  const PREFETCH_KNOWN_FLAGS = Object.freeze([
685
761
  "--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
686
- "--source", "--max-age", "--cache-dir",
762
+ "--source", "--max-age", "--cache-dir", "--max-errors",
687
763
  ]);
688
764
 
689
765
  async function main() {
@@ -693,6 +769,19 @@ async function main() {
693
769
  return;
694
770
  }
695
771
 
772
+ // A malformed --max-errors value is a usage error — refuse with exit 2
773
+ // (prefetch's usage-error convention) rather than running with an
774
+ // unintended tolerance.
775
+ if (opts._argError) {
776
+ process.stderr.write(JSON.stringify({
777
+ ok: false,
778
+ verb: "prefetch",
779
+ error: opts._argError,
780
+ }) + "\n");
781
+ process.exitCode = 2;
782
+ return;
783
+ }
784
+
696
785
  // Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
697
786
  // `--max-aeg 12h`) previously fell through to a default full-cache fetch.
698
787
  // Exit 2 matches prefetch's existing usage-error convention (invalid
@@ -723,7 +812,7 @@ async function main() {
723
812
  // the `ci` #100 stdout-flush regression.
724
813
  try {
725
814
  const result = await prefetch(opts);
726
- process.exitCode = result.errors > 0 ? 1 : 0;
815
+ process.exitCode = exitCodeForResult(result, opts);
727
816
  } catch (err) {
728
817
  console.error(`prefetch: fatal: ${err.message}`);
729
818
  process.exitCode = 2;
@@ -736,6 +825,9 @@ module.exports = {
736
825
  prefetch,
737
826
  readCached,
738
827
  parseArgs,
828
+ parseErrorThreshold,
829
+ exitCodeForResult,
830
+ formatSummary,
739
831
  SOURCES,
740
832
  DEFAULT_CACHE,
741
833
  // Ed25519 _index.json signing + verification. Exported so
@@ -38,6 +38,7 @@
38
38
  const fs = require("fs");
39
39
  const path = require("path");
40
40
  const { execFileSync } = require("child_process");
41
+ const { selectNvdCvss, cvssVersionOf } = require("./cvss");
41
42
 
42
43
  const ROOT = path.join(__dirname, "..");
43
44
  const ABS = (p) => path.join(ROOT, p);
@@ -157,8 +158,9 @@ Modes:
157
158
  --no-network report-only: list what would be fetched, WITHOUT writing
158
159
  the cache (the dry-run opposite of --prefetch).
159
160
  --from-cache [<p>] read from prefetch cache (default .cache/upstream).
160
- Combine with --apply to upsert against cached data
161
- entirely offline. Cache must be pre-populated via --prefetch.
161
+ Combine with --apply to upsert against cached data. New-RFC
162
+ discovery still queries IETF Datatracker live; add --air-gap
163
+ for a fully offline run. Cache must be pre-populated via --prefetch.
162
164
  --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins|ghsa|osv)
163
165
  --check-advisories poll primary-source advisory feeds (Qualys TRU, RHSA, USN,
164
166
  ZDI, kernel.org, oss-security, vendor research blogs) and
@@ -932,17 +934,27 @@ function nvdDiffFromCache(ctx) {
932
934
  if (!payload) { errors++; continue; }
933
935
  const vuln = payload.vulnerabilities?.[0]?.cve;
934
936
  if (!vuln) continue;
935
- const m = vuln.metrics || {};
936
- const ordered = [...(m.cvssMetricV31 || []), ...(m.cvssMetricV30 || []), ...(m.cvssMetricV2 || [])];
937
- const primary = ordered.find((x) => x.type === "Primary") || ordered[0];
938
- const upScore = typeof primary?.cvssData?.baseScore === "number" ? primary.cvssData.baseScore : null;
939
- const upVector = primary?.cvssData?.vectorString || null;
937
+ // Prefer the newest CVSS version NVD publishes (Primary within that
938
+ // version), and normalize a bare v2 vector to its canonical prefix.
939
+ const up = selectNvdCvss(vuln.metrics);
940
+ if (!up) continue;
940
941
  const local = ctx.cveCatalog[id];
941
- if (upScore != null && local.cvss_score != null && Math.abs(upScore - local.cvss_score) > 0.05) {
942
- diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: upScore, severity: "high" });
943
- }
944
- if (upVector && local.cvss_vector && upVector !== local.cvss_vector) {
945
- diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: upVector, severity: "medium" });
942
+ // Never regress a curated higher-version CVSS to an older upstream metric.
943
+ // NVD keeps many pre-2016 CVEs at v2 only (or tags v2 "Primary" over a
944
+ // v3.1 "Secondary"); the catalog has been curated to v3.1. When the
945
+ // selected upstream metric is an older CVSS version than the curated one,
946
+ // suppress both the score and the vector diff. A same-version drift (a
947
+ // genuine NVD re-score) still flows through.
948
+ const localVersion = cvssVersionOf(local.cvss_vector);
949
+ const isDowngrade =
950
+ up.version != null && localVersion != null && up.version < localVersion;
951
+ if (!isDowngrade) {
952
+ if (up.baseScore != null && local.cvss_score != null && Math.abs(up.baseScore - local.cvss_score) > 0.05) {
953
+ diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: up.baseScore, severity: "high" });
954
+ }
955
+ if (up.vector && local.cvss_vector && up.vector !== local.cvss_vector) {
956
+ diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: up.vector, severity: "medium" });
957
+ }
946
958
  }
947
959
  }
948
960
  const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
@@ -1728,4 +1740,4 @@ if (require.main === module) {
1728
1740
  });
1729
1741
  }
1730
1742
 
1731
- module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic };
1743
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache };