@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.
- package/AGENTS.md +5 -5
- package/ARCHITECTURE.md +3 -3
- package/CHANGELOG.md +28 -0
- package/CONTEXT.md +2 -2
- package/README.md +6 -6
- package/agents/threat-researcher.md +2 -2
- package/bin/exceptd.js +41 -8
- package/data/_indexes/_meta.json +41 -40
- package/data/_indexes/activity-feed.json +240 -240
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/currency.json +64 -64
- package/data/_indexes/jurisdiction-map.json +31 -158
- package/data/_indexes/recipes.json +1 -1
- package/data/_indexes/section-offsets.json +510 -510
- package/data/_indexes/summary-cards.json +33 -33
- package/data/_indexes/token-budget.json +200 -200
- package/data/atlas-ttps.json +7 -7
- package/data/attack-techniques.json +5 -5
- package/data/framework-control-gaps.json +3 -3
- package/lib/auto-discovery.js +15 -9
- package/lib/collectors/library-author.js +26 -9
- package/lib/collectors/secrets.js +8 -1
- package/lib/cvss.js +108 -0
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +17 -4
- package/lib/prefetch.js +97 -5
- package/lib/refresh-external.js +25 -13
- package/lib/schemas/manifest.schema.json +1 -1
- package/lib/schemas/skill-frontmatter.schema.json +1 -1
- package/lib/validate-indexes.js +5 -0
- package/lib/version-pins.js +3 -3
- package/manifest-snapshot.json +2 -2
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +124 -124
- package/orchestrator/pipeline.js +16 -4
- package/package.json +1 -1
- package/sbom.cdx.json +170 -140
- package/scripts/build-indexes.js +12 -1
- package/scripts/builders/catalog-summaries.js +1 -1
- package/scripts/builders/recipes.js +1 -1
- package/scripts/check-sbom-currency.js +76 -14
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/run-e2e-scenarios.js +48 -17
- package/scripts/sync-package-description.js +74 -0
- package/scripts/verify-shipped-tarball.js +18 -7
- package/skills/age-gates-child-safety/skill.md +3 -3
- package/skills/ai-attack-surface/skill.md +4 -4
- package/skills/ai-c2-detection/skill.md +5 -5
- package/skills/api-security/skill.md +2 -2
- package/skills/attack-surface-pentest/skill.md +4 -4
- package/skills/cloud-security/skill.md +3 -3
- package/skills/compliance-theater/skill.md +3 -3
- package/skills/container-runtime-security/skill.md +3 -3
- package/skills/coordinated-vuln-disclosure/skill.md +2 -2
- package/skills/defensive-countermeasure-mapping/skill.md +3 -3
- package/skills/dlp-gap-analysis/skill.md +5 -5
- package/skills/exploit-scoring/skill.md +2 -2
- package/skills/framework-gap-analysis/skill.md +4 -4
- package/skills/fuzz-testing-strategy/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +3 -3
- package/skills/mcp-agent-trust/skill.md +2 -2
- package/skills/mlops-security/skill.md +3 -3
- package/skills/ot-ics-security/skill.md +3 -3
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +2 -2
- package/skills/rag-pipeline-security/skill.md +4 -4
- package/skills/ransomware-response/skill.md +2 -2
- package/skills/sector-energy/skill.md +2 -2
- package/skills/sector-federal-government/skill.md +2 -2
- package/skills/sector-financial/skill.md +4 -4
- package/skills/sector-healthcare/skill.md +3 -3
- package/skills/security-maturity-tiers/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +6 -6
- package/skills/supply-chain-integrity/skill.md +2 -2
- package/skills/threat-model-currency/skill.md +8 -8
- package/skills/threat-modeling-methodology/skill.md +2 -2
- package/skills/webapp-security/skill.md +2 -2
- package/skills/zeroday-gap-learn/skill.md +3 -3
- package/sources/validators/cve-validator.js +27 -18
package/data/atlas-ttps.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_meta": {
|
|
3
3
|
"schema_version": "1.0.0",
|
|
4
|
-
"atlas_version": "
|
|
5
|
-
"atlas_release_date": "2026-05-
|
|
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-
|
|
9
|
-
"last_threat_review": "2026-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
5
|
-
"last_threat_review": "2026-
|
|
6
|
-
"attack_version": "19.
|
|
7
|
-
"attack_version_date": "2026-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.",
|
package/lib/auto-discovery.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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:
|
|
199
|
-
cvss_vector:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 };
|
package/lib/lint-skills.js
CHANGED
|
@@ -175,7 +175,12 @@ function readJson(p) {
|
|
|
175
175
|
* accept malformed frontmatter.
|
|
176
176
|
*/
|
|
177
177
|
function parseFrontmatter(text) {
|
|
178
|
-
|
|
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
|
package/lib/playbook-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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 };
|