security-mcp 1.0.4 → 1.1.0

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 (47) hide show
  1. package/README.md +77 -21
  2. package/defaults/checklists/ai.json +25 -0
  3. package/defaults/checklists/api.json +27 -0
  4. package/defaults/checklists/infra.json +27 -0
  5. package/defaults/checklists/mobile.json +25 -0
  6. package/defaults/checklists/payments.json +25 -0
  7. package/defaults/checklists/web.json +30 -0
  8. package/defaults/control-catalog.json +549 -0
  9. package/defaults/evidence-map.json +194 -0
  10. package/defaults/security-exceptions.json +4 -0
  11. package/defaults/security-policy.json +41 -2
  12. package/defaults/security-tools.json +41 -0
  13. package/dist/ci/pr-gate.js +2 -3
  14. package/dist/cli/index.js +63 -23
  15. package/dist/cli/install.js +47 -15
  16. package/dist/cli/onboarding.js +590 -0
  17. package/dist/cli/update.js +124 -0
  18. package/dist/gate/baseline.js +115 -0
  19. package/dist/gate/catalog.js +55 -0
  20. package/dist/gate/checks/ai-redteam.js +374 -0
  21. package/dist/gate/checks/ai.js +45 -14
  22. package/dist/gate/checks/api.js +93 -0
  23. package/dist/gate/checks/crypto.js +153 -0
  24. package/dist/gate/checks/database.js +144 -0
  25. package/dist/gate/checks/dependencies.js +130 -0
  26. package/dist/gate/checks/dlp.js +153 -0
  27. package/dist/gate/checks/graphql.js +122 -0
  28. package/dist/gate/checks/infra.js +126 -12
  29. package/dist/gate/checks/k8s.js +190 -0
  30. package/dist/gate/checks/playbook.js +160 -0
  31. package/dist/gate/checks/runtime.js +263 -0
  32. package/dist/gate/checks/sbom.js +199 -0
  33. package/dist/gate/checks/scanners.js +450 -0
  34. package/dist/gate/checks/secrets.js +119 -27
  35. package/dist/gate/diff.js +2 -2
  36. package/dist/gate/evidence.js +116 -0
  37. package/dist/gate/exceptions.js +85 -0
  38. package/dist/gate/policy.js +189 -17
  39. package/dist/gate/threat-intel.js +157 -0
  40. package/dist/mcp/server.js +938 -9
  41. package/dist/repo/fs.js +10 -5
  42. package/dist/repo/search.js +13 -1
  43. package/dist/review/store.js +208 -0
  44. package/dist/tests/run.js +103 -0
  45. package/package.json +13 -3
  46. package/prompts/SECURITY_PROMPT.md +455 -1
  47. package/skills/senior-security-engineer/SKILL.md +81 -4
@@ -0,0 +1,116 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import fg from "fast-glob";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadControlCatalog, controlApplies } from "./catalog.js";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const PKG_ROOT = resolve(__dirname, "../..");
8
+ async function loadEvidenceMap() {
9
+ const overridePath = process.env["SECURITY_GATE_EVIDENCE_MAP"];
10
+ if (overridePath) {
11
+ const raw = await readFile(join(process.cwd(), overridePath), "utf-8");
12
+ return JSON.parse(raw);
13
+ }
14
+ try {
15
+ const raw = await readFile(join(process.cwd(), ".mcp", "mappings", "evidence-map.json"), "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ const raw = await readFile(join(PKG_ROOT, "defaults", "evidence-map.json"), "utf-8");
20
+ return JSON.parse(raw);
21
+ }
22
+ }
23
+ function getPolicyControl(policy, control) {
24
+ return policy.requirements.find((requirement) => requirement.id === control.id);
25
+ }
26
+ export async function evaluateEvidenceCoverage(opts) {
27
+ const evidenceMap = await loadEvidenceMap();
28
+ const catalog = await loadControlCatalog();
29
+ const findings = [];
30
+ const controls = [];
31
+ for (const control of catalog.controls) {
32
+ if (!controlApplies(control, opts.surfaces)) {
33
+ controls.push({
34
+ id: control.id,
35
+ description: control.description,
36
+ automation: control.automation,
37
+ frameworks: control.frameworks,
38
+ status: "not_applicable",
39
+ details: ["Surface not in scope for this review."]
40
+ });
41
+ continue;
42
+ }
43
+ if (control.automation !== "evidence") {
44
+ controls.push({
45
+ id: control.id,
46
+ description: control.description,
47
+ automation: control.automation,
48
+ frameworks: control.frameworks,
49
+ status: "not_applicable",
50
+ details: ["Resolved outside evidence coverage evaluation."]
51
+ });
52
+ continue;
53
+ }
54
+ const policyControl = getPolicyControl(opts.policy, control);
55
+ const evidenceIds = policyControl?.evidence ?? control.evidence ?? [];
56
+ const missingMappings = evidenceIds.filter((evidenceId) => !evidenceMap[evidenceId]);
57
+ if (missingMappings.length > 0) {
58
+ findings.push({
59
+ id: "EVIDENCE_MAPPING_MISSING",
60
+ title: `Evidence mapping missing for control ${control.id}`,
61
+ severity: "HIGH",
62
+ evidence: missingMappings,
63
+ requiredActions: [
64
+ "Add the missing evidence IDs to .mcp/mappings/evidence-map.json.",
65
+ "Map each control to file globs that prove the control exists."
66
+ ]
67
+ });
68
+ }
69
+ const matchedEvidence = [];
70
+ const missingEvidence = [];
71
+ for (const evidenceId of evidenceIds) {
72
+ const globs = evidenceMap[evidenceId] ?? [];
73
+ const matches = await fg(globs, {
74
+ dot: true,
75
+ onlyFiles: true,
76
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
77
+ });
78
+ if (matches.length === 0) {
79
+ missingEvidence.push(evidenceId);
80
+ }
81
+ else {
82
+ matchedEvidence.push(`${evidenceId}: ${matches[0]}`);
83
+ }
84
+ }
85
+ if (missingEvidence.length > 0) {
86
+ findings.push({
87
+ id: "CONTROL_EVIDENCE_MISSING",
88
+ title: `Required evidence missing for control ${control.id}`,
89
+ severity: "HIGH",
90
+ evidence: missingEvidence,
91
+ requiredActions: [
92
+ `Implement or surface evidence for control ${control.id}.`,
93
+ "Add or update code, tests, or config so the evidence globs resolve."
94
+ ]
95
+ });
96
+ controls.push({
97
+ id: control.id,
98
+ description: control.description,
99
+ automation: control.automation,
100
+ frameworks: control.frameworks,
101
+ status: "missing",
102
+ details: missingEvidence
103
+ });
104
+ continue;
105
+ }
106
+ controls.push({
107
+ id: control.id,
108
+ description: control.description,
109
+ automation: control.automation,
110
+ frameworks: control.frameworks,
111
+ status: "satisfied",
112
+ details: matchedEvidence
113
+ });
114
+ }
115
+ return { findings, controls };
116
+ }
@@ -0,0 +1,85 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const PKG_ROOT = resolve(__dirname, "../..");
7
+ const ExceptionSchema = z.object({
8
+ id: z.string(),
9
+ finding_ids: z.array(z.string()).default([]),
10
+ control_ids: z.array(z.string()).default([]),
11
+ justification: z.string(),
12
+ ticket: z.string().optional(),
13
+ owner: z.string(),
14
+ approver: z.string(),
15
+ approval_role: z.string(),
16
+ expires_on: z.string()
17
+ });
18
+ const ExceptionFileSchema = z.object({
19
+ version: z.string(),
20
+ exceptions: z.array(ExceptionSchema).default([])
21
+ });
22
+ async function readExceptionsJson() {
23
+ const overridePath = process.env["SECURITY_GATE_EXCEPTIONS"];
24
+ if (overridePath) {
25
+ return await readFile(join(process.cwd(), overridePath), "utf-8");
26
+ }
27
+ try {
28
+ return await readFile(join(process.cwd(), ".mcp", "exceptions", "security-exceptions.json"), "utf-8");
29
+ }
30
+ catch {
31
+ return await readFile(join(PKG_ROOT, "defaults", "security-exceptions.json"), "utf-8");
32
+ }
33
+ }
34
+ export async function loadSecurityExceptions() {
35
+ const raw = await readExceptionsJson();
36
+ return ExceptionFileSchema.parse(JSON.parse(raw)).exceptions;
37
+ }
38
+ export async function applySecurityExceptions(findings) {
39
+ const exceptions = await loadSecurityExceptions();
40
+ const active = [];
41
+ const suppressed = [];
42
+ const exceptionFindings = [];
43
+ const activeControlExceptionIds = new Set();
44
+ for (const entry of exceptions) {
45
+ const expiresAt = new Date(entry.expires_on);
46
+ if (!Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() >= Date.now()) {
47
+ for (const controlId of entry.control_ids) {
48
+ activeControlExceptionIds.add(controlId);
49
+ }
50
+ }
51
+ }
52
+ for (const finding of findings) {
53
+ const match = exceptions.find((entry) => entry.finding_ids.includes(finding.id));
54
+ if (!match) {
55
+ active.push(finding);
56
+ continue;
57
+ }
58
+ const expiresAt = new Date(match.expires_on);
59
+ if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) {
60
+ active.push(finding);
61
+ exceptionFindings.push({
62
+ id: "SECURITY_EXCEPTION_EXPIRED",
63
+ title: `Security exception ${match.id} is expired or invalid`,
64
+ severity: "HIGH",
65
+ evidence: [`Finding: ${finding.id}`, `Owner: ${match.owner}`, `Expires: ${match.expires_on}`],
66
+ requiredActions: [
67
+ "Renew or remove the expired exception.",
68
+ "Resolve the underlying finding or obtain a new approved exception."
69
+ ]
70
+ });
71
+ continue;
72
+ }
73
+ suppressed.push({
74
+ finding,
75
+ exceptionId: match.id,
76
+ expiresOn: match.expires_on
77
+ });
78
+ }
79
+ return {
80
+ findings: active,
81
+ suppressed,
82
+ exceptionFindings,
83
+ activeControlExceptionIds: Array.from(activeControlExceptionIds)
84
+ };
85
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import fg from "fast-glob";
2
3
  import { getChangedFiles } from "./diff.js";
3
4
  import { detectSurfaces } from "./findings.js";
4
5
  import { checkRequiredArtifacts } from "./checks/required-artifacts.js";
@@ -10,7 +11,20 @@ import { checkInfra } from "./checks/infra.js";
10
11
  import { checkMobileIos } from "./checks/mobile-ios.js";
11
12
  import { checkMobileAndroid } from "./checks/mobile-android.js";
12
13
  import { checkAi } from "./checks/ai.js";
14
+ import { checkScannerReadiness } from "./checks/scanners.js";
15
+ import { evaluateEvidenceCoverage } from "./evidence.js";
16
+ import { applySecurityExceptions } from "./exceptions.js";
17
+ import { controlApplies, loadControlCatalog } from "./catalog.js";
13
18
  import { readFileSafe } from "../repo/fs.js";
19
+ import { checkGraphQL } from "./checks/graphql.js";
20
+ import { checkKubernetes } from "./checks/k8s.js";
21
+ import { checkDatabase } from "./checks/database.js";
22
+ import { checkCrypto } from "./checks/crypto.js";
23
+ import { checkDlp } from "./checks/dlp.js";
24
+ import { runSbomChecks } from "./checks/sbom.js";
25
+ import { runPlaybookChecks } from "./checks/playbook.js";
26
+ import { runAiRedteamChecks } from "./checks/ai-redteam.js";
27
+ import { runRuntimeChecks } from "./checks/runtime.js";
14
28
  const PolicySchema = z.object({
15
29
  name: z.string(),
16
30
  version: z.string(),
@@ -29,40 +43,198 @@ const PolicySchema = z.object({
29
43
  }))
30
44
  .default([])
31
45
  });
46
+ const SCOPE_IGNORE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
47
+ const SAFE_SCOPE_TARGET_RE = /^[a-zA-Z0-9_./-]+$/;
48
+ function validateScopeTarget(target) {
49
+ if (!target || target.includes("..") || target.startsWith("/") || !SAFE_SCOPE_TARGET_RE.test(target)) {
50
+ throw new Error(`Invalid scope target "${target}". Use a relative file/folder path with alphanumerics, "_", "-", ".", "/".`);
51
+ }
52
+ }
53
+ function normalizeTargets(targets) {
54
+ return (targets ?? []).map((t) => t.trim()).filter(Boolean);
55
+ }
56
+ async function resolveScopedFiles(opts) {
57
+ if (opts.mode === "recent_changes") {
58
+ return await getChangedFiles({ baseRef: opts.baseRef, headRef: opts.headRef });
59
+ }
60
+ const targets = normalizeTargets(opts.targets);
61
+ if (targets.length === 0) {
62
+ throw new Error(`Scan mode "${opts.mode}" requires "targets". ` +
63
+ `Provide one or more relative paths (folders for folder_by_folder, files for file_by_file).`);
64
+ }
65
+ for (const target of targets)
66
+ validateScopeTarget(target);
67
+ if (opts.mode === "file_by_file") {
68
+ const files = await fg(targets, {
69
+ onlyFiles: true,
70
+ dot: true,
71
+ ignore: SCOPE_IGNORE_GLOBS
72
+ });
73
+ return Array.from(new Set(files)).sort();
74
+ }
75
+ const folderGlobs = targets.map((target) => `${target.replace(/\/+$/, "")}/**/*`);
76
+ const files = await fg(folderGlobs, {
77
+ onlyFiles: true,
78
+ dot: true,
79
+ ignore: SCOPE_IGNORE_GLOBS
80
+ });
81
+ return Array.from(new Set(files)).sort();
82
+ }
32
83
  export async function loadPolicy(policyPath) {
33
84
  const raw = await readFileSafe(policyPath);
34
85
  const parsed = JSON.parse(raw);
35
86
  return PolicySchema.parse(parsed);
36
87
  }
88
+ /**
89
+ * Classify the change type based on file paths to apply appropriate gate tier.
90
+ */
91
+ function classifyChangeType(files) {
92
+ if (files.length === 0)
93
+ return "general";
94
+ const allMatch = (pattern) => files.every((f) => pattern.test(f));
95
+ const anyMatch = (pattern) => files.some((f) => pattern.test(f));
96
+ if (allMatch(/\.(md|txt|rst)$|\/docs\/|README/i))
97
+ return "docs";
98
+ if (anyMatch(/\/payment|\/stripe|\/checkout|\/billing|\/invoice/i))
99
+ return "payment";
100
+ if (anyMatch(/\/auth|\/login|\/session|\/token|\/jwt|\/oauth|\/permission/i))
101
+ return "auth";
102
+ if (anyMatch(/\.tf$|Dockerfile|\.yaml$|\.yml$|\/k8s\/|\/helm\//))
103
+ return "infra";
104
+ if (anyMatch(/\/ai\/|\/llm\/|\/agent\/|\/prompt/i))
105
+ return "ai";
106
+ if (allMatch(/\.(json|env|config\..+|toml|yaml|yml)$/))
107
+ return "config";
108
+ return "general";
109
+ }
37
110
  export async function runPrGate(opts) {
38
111
  const policy = await loadPolicy(opts.policyPath);
39
- const changedFiles = await getChangedFiles({
112
+ const mode = opts.mode ?? "recent_changes";
113
+ const targets = normalizeTargets(opts.targets);
114
+ const changedFiles = await resolveScopedFiles({
115
+ mode,
116
+ targets,
40
117
  baseRef: opts.baseRef ?? "origin/main",
41
118
  headRef: opts.headRef ?? "HEAD"
42
119
  });
120
+ // Classify the change type to apply appropriate gate tier
121
+ const changeType = classifyChangeType(changedFiles);
43
122
  const surfaces = detectSurfaces(changedFiles);
44
- const findings = [
45
- // Required artifacts first: threat models/checklists.
46
- ...(await checkRequiredArtifacts({ policy, changedFiles })),
47
- // Baseline scans / checks
48
- ...(await checkSecrets({ changedFiles })),
49
- ...(await checkDependencies({ changedFiles })),
50
- // Surface-specific checks (only run if that surface is impacted or exists)
51
- ...(surfaces.web ? await checkWebNextjs({ changedFiles }) : []),
52
- ...(surfaces.api ? await checkApi({ changedFiles }) : []),
53
- ...(surfaces.infra ? await checkInfra({ changedFiles }) : []),
54
- ...(surfaces.mobileIos ? await checkMobileIos({ changedFiles }) : []),
55
- ...(surfaces.mobileAndroid ? await checkMobileAndroid({ changedFiles }) : []),
56
- ...(surfaces.ai ? await checkAi({ changedFiles }) : [])
123
+ const catalog = await loadControlCatalog();
124
+ const scannerReadiness = await checkScannerReadiness({ surfaces });
125
+ const evidenceCoverage = await evaluateEvidenceCoverage({ policy, surfaces });
126
+ let rawFindings;
127
+ // "docs" tier: only run secrets check to avoid unnecessary overhead
128
+ if (changeType === "docs") {
129
+ rawFindings = await checkSecrets({ changedFiles });
130
+ }
131
+ else {
132
+ // Run all independent checks in parallel
133
+ const checkResults = await Promise.allSettled([
134
+ checkRequiredArtifacts({ policy, changedFiles }),
135
+ checkSecrets({ changedFiles }),
136
+ checkDependencies({ changedFiles }),
137
+ Promise.resolve(scannerReadiness.findings),
138
+ Promise.resolve(evidenceCoverage.findings),
139
+ surfaces.web ? checkWebNextjs({ changedFiles }) : Promise.resolve([]),
140
+ surfaces.api ? checkApi({ changedFiles }) : Promise.resolve([]),
141
+ surfaces.infra ? checkInfra({ changedFiles }) : Promise.resolve([]),
142
+ surfaces.mobileIos ? checkMobileIos({ changedFiles }) : Promise.resolve([]),
143
+ surfaces.mobileAndroid ? checkMobileAndroid({ changedFiles }) : Promise.resolve([]),
144
+ surfaces.ai ? checkAi({ changedFiles }) : Promise.resolve([]),
145
+ checkGraphQL({ changedFiles }),
146
+ checkKubernetes({ changedFiles }),
147
+ checkDatabase({ changedFiles }),
148
+ checkCrypto({ changedFiles }),
149
+ checkDlp({ changedFiles }),
150
+ runSbomChecks({ changedFiles, targets }),
151
+ runPlaybookChecks({ changedFiles, surfaces }),
152
+ surfaces.ai ? runAiRedteamChecks({ changedFiles }) : Promise.resolve([]),
153
+ process.env["SECURITY_STAGING_URL"] ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([])
154
+ ]);
155
+ rawFindings = [];
156
+ for (const result of checkResults) {
157
+ if (result.status === "fulfilled") {
158
+ rawFindings.push(...result.value);
159
+ }
160
+ else {
161
+ console.warn("[policy] Check failed:", result.reason);
162
+ }
163
+ }
164
+ }
165
+ const toolingCoverage = catalog.controls
166
+ .filter((control) => control.automation === "tooling" && controlApplies(control, surfaces))
167
+ .map((control) => {
168
+ const required = control.required_scanners ?? [];
169
+ const missing = required.filter((scannerId) => !scannerReadiness.configured.includes(scannerId) || scannerReadiness.missing.includes(scannerId));
170
+ return {
171
+ id: control.id,
172
+ description: control.description,
173
+ automation: control.automation,
174
+ frameworks: control.frameworks,
175
+ status: missing.length > 0 ? "missing" : "satisfied",
176
+ details: missing.length > 0 ? missing : required
177
+ };
178
+ });
179
+ const controlCoverage = [
180
+ ...evidenceCoverage.controls.filter((control) => control.automation === "evidence"),
181
+ ...toolingCoverage
57
182
  ];
58
- const status = findings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
183
+ const exceptionResult = await applySecurityExceptions(rawFindings);
184
+ const controlCoverageWithExceptions = controlCoverage.map((control) => {
185
+ if (exceptionResult.activeControlExceptionIds.includes(control.id) && control.status === "missing") {
186
+ return {
187
+ ...control,
188
+ status: "risk_accepted",
189
+ details: [...control.details, "Covered by an active approved control exception."]
190
+ };
191
+ }
192
+ return control;
193
+ });
194
+ const findings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings];
195
+ // Apply risk-based adaptive gating tier overrides
196
+ let effectiveFindings = findings;
197
+ if (changeType === "payment") {
198
+ // Payment changes: treat as prod-equivalent — block on all HIGH+
199
+ effectiveFindings = findings;
200
+ }
201
+ else if (changeType === "auth") {
202
+ // Auth changes: always block on HIGH+ even in dev
203
+ effectiveFindings = findings;
204
+ }
205
+ const relevantControls = controlCoverageWithExceptions.filter((control) => control.status !== "not_applicable");
206
+ const satisfiedControls = relevantControls.filter((control) => control.status === "satisfied").length;
207
+ const riskAcceptedControls = relevantControls.filter((control) => control.status === "risk_accepted").length;
208
+ const automatedCoverage = relevantControls.length === 0
209
+ ? 100
210
+ : Math.round((((satisfiedControls) + (riskAcceptedControls * 0.5)) / relevantControls.length) * 100);
211
+ const scannerScore = scannerReadiness.configured.length === 0
212
+ ? 0
213
+ : Math.round(((scannerReadiness.configured.length - scannerReadiness.missing.length) / scannerReadiness.configured.length) * 100);
214
+ const confidenceScore = Math.max(0, Math.min(100, Math.round((automatedCoverage * 0.7) + (scannerScore * 0.3))));
215
+ const missingControls = relevantControls.filter((control) => control.status === "missing").length;
216
+ const status = effectiveFindings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
59
217
  ? "FAIL"
60
218
  : "PASS";
61
219
  return {
62
220
  status,
63
221
  policyVersion: policy.version,
64
222
  evaluatedAt: new Date().toISOString(),
65
- scope: { changedFiles, surfaces },
66
- findings
223
+ scope: { mode, targets, changedFiles, surfaces },
224
+ findings: effectiveFindings,
225
+ suppressedFindings: exceptionResult.suppressed,
226
+ controlCoverage: controlCoverageWithExceptions,
227
+ scannerReadiness: {
228
+ configured: scannerReadiness.configured,
229
+ missing: scannerReadiness.missing
230
+ },
231
+ confidence: {
232
+ score: confidenceScore,
233
+ automatedCoverage,
234
+ missingControls,
235
+ riskAcceptedControls,
236
+ scannerReadiness: scannerScore,
237
+ summary: `Automated coverage ${automatedCoverage}%, scanner readiness ${scannerScore}%, missing controls ${missingControls}, risk-accepted controls ${riskAcceptedControls}. Change type: ${changeType}.`
238
+ }
67
239
  };
68
240
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Threat Intelligence Feed Integration
3
+ * Fetches CISA KEV and EPSS scores for CVE prioritization.
4
+ */
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ const CISA_KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
8
+ const EPSS_API_BASE = "https://api.first.org/data/v1/epss";
9
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
10
+ async function ensureDir(dir) {
11
+ try {
12
+ await mkdir(dir, { recursive: true });
13
+ }
14
+ catch {
15
+ // ignore
16
+ }
17
+ }
18
+ async function readCacheJson(cachePath) {
19
+ try {
20
+ const raw = await readFile(cachePath, "utf-8");
21
+ const parsed = JSON.parse(raw);
22
+ if (Date.now() - parsed.ts < CACHE_TTL_MS) {
23
+ return parsed.data;
24
+ }
25
+ }
26
+ catch {
27
+ // cache miss or corrupt
28
+ }
29
+ return null;
30
+ }
31
+ async function writeCacheJson(cachePath, data) {
32
+ try {
33
+ await writeFile(cachePath, JSON.stringify({ ts: Date.now(), data }, null, 2), "utf-8");
34
+ }
35
+ catch {
36
+ // best-effort cache write
37
+ }
38
+ }
39
+ async function fetchWithTimeout(url, timeoutMs = 10_000) {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
42
+ try {
43
+ const res = await fetch(url, { signal: controller.signal });
44
+ return res;
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ /**
51
+ * Fetches the CISA Known Exploited Vulnerabilities catalog.
52
+ * Returns a Set of CVE IDs. Returns empty set on failure.
53
+ */
54
+ export async function fetchCisaKev(cacheDir) {
55
+ await ensureDir(cacheDir);
56
+ const cachePath = join(cacheDir, "cisa-kev.json");
57
+ const cached = await readCacheJson(cachePath);
58
+ if (cached)
59
+ return new Set(cached);
60
+ try {
61
+ const res = await fetchWithTimeout(CISA_KEV_URL, 10_000);
62
+ if (!res.ok) {
63
+ console.warn(`[threat-intel] CISA KEV fetch failed: HTTP ${res.status}`);
64
+ return new Set();
65
+ }
66
+ const json = (await res.json());
67
+ const vulns = Array.isArray(json?.vulnerabilities)
68
+ ? json.vulnerabilities
69
+ .map((v) => v.cveID ?? "")
70
+ .filter(Boolean)
71
+ : [];
72
+ await writeCacheJson(cachePath, vulns);
73
+ return new Set(vulns);
74
+ }
75
+ catch (err) {
76
+ console.warn(`[threat-intel] CISA KEV fetch error: ${String(err)}`);
77
+ return new Set();
78
+ }
79
+ }
80
+ /**
81
+ * Fetches EPSS scores for a list of CVE IDs.
82
+ * Batches up to 100 CVEs per request. Returns a Map of CVE → score.
83
+ */
84
+ export async function fetchEpssScores(cveIds, cacheDir) {
85
+ if (cveIds.length === 0)
86
+ return new Map();
87
+ await ensureDir(join(cacheDir, "epss"));
88
+ const result = new Map();
89
+ const today = new Date().toISOString().slice(0, 10);
90
+ const cachePath = join(cacheDir, "epss", `${today}.json`);
91
+ const cached = await readCacheJson(cachePath);
92
+ const cachedMap = cached ? new Map(Object.entries(cached)) : new Map();
93
+ const needed = cveIds.filter((id) => !cachedMap.has(id));
94
+ for (const [k, v] of cachedMap)
95
+ result.set(k, v);
96
+ if (needed.length === 0)
97
+ return result;
98
+ // Batch in chunks of 100
99
+ for (let i = 0; i < needed.length; i += 100) {
100
+ const chunk = needed.slice(i, i + 100);
101
+ const url = `${EPSS_API_BASE}?cve=${chunk.join(",")}`;
102
+ let retried = false;
103
+ while (true) {
104
+ try {
105
+ const res = await fetchWithTimeout(url, 10_000);
106
+ if (res.status === 429 && !retried) {
107
+ retried = true;
108
+ await new Promise((r) => setTimeout(r, 2000));
109
+ continue;
110
+ }
111
+ if (!res.ok)
112
+ break;
113
+ const json = (await res.json());
114
+ if (Array.isArray(json?.data)) {
115
+ for (const item of json.data) {
116
+ if (item.cve && item.epss !== undefined) {
117
+ result.set(item.cve, parseFloat(item.epss));
118
+ }
119
+ }
120
+ }
121
+ break;
122
+ }
123
+ catch {
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ // Persist updated cache
129
+ const mergedCache = {};
130
+ for (const [k, v] of result)
131
+ mergedCache[k] = v;
132
+ await writeCacheJson(cachePath, mergedCache);
133
+ return result;
134
+ }
135
+ /**
136
+ * Main entry point: check CVEs against KEV and EPSS.
137
+ */
138
+ export async function checkActiveExploitation(cveIds, cacheDir) {
139
+ if (cveIds.length === 0) {
140
+ return { kevMatches: [], highEpss: [], failed: false };
141
+ }
142
+ try {
143
+ const [kevSet, epssMap] = await Promise.all([
144
+ fetchCisaKev(cacheDir),
145
+ fetchEpssScores(cveIds, cacheDir)
146
+ ]);
147
+ const kevMatches = cveIds.filter((id) => kevSet.has(id));
148
+ const highEpss = cveIds
149
+ .map((cve) => ({ cve, score: epssMap.get(cve) ?? 0 }))
150
+ .filter((e) => e.score > 0.5);
151
+ return { kevMatches, highEpss, failed: false };
152
+ }
153
+ catch (err) {
154
+ console.warn(`[threat-intel] checkActiveExploitation failed: ${String(err)}`);
155
+ return { kevMatches: [], highEpss: [], failed: true };
156
+ }
157
+ }