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.
- package/README.md +77 -21
- package/defaults/checklists/ai.json +25 -0
- package/defaults/checklists/api.json +27 -0
- package/defaults/checklists/infra.json +27 -0
- package/defaults/checklists/mobile.json +25 -0
- package/defaults/checklists/payments.json +25 -0
- package/defaults/checklists/web.json +30 -0
- package/defaults/control-catalog.json +549 -0
- package/defaults/evidence-map.json +194 -0
- package/defaults/security-exceptions.json +4 -0
- package/defaults/security-policy.json +41 -2
- package/defaults/security-tools.json +41 -0
- package/dist/ci/pr-gate.js +2 -3
- package/dist/cli/index.js +63 -23
- package/dist/cli/install.js +47 -15
- package/dist/cli/onboarding.js +590 -0
- package/dist/cli/update.js +124 -0
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/catalog.js +55 -0
- package/dist/gate/checks/ai-redteam.js +374 -0
- package/dist/gate/checks/ai.js +45 -14
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/crypto.js +153 -0
- package/dist/gate/checks/database.js +144 -0
- package/dist/gate/checks/dependencies.js +130 -0
- package/dist/gate/checks/dlp.js +153 -0
- package/dist/gate/checks/graphql.js +122 -0
- package/dist/gate/checks/infra.js +126 -12
- package/dist/gate/checks/k8s.js +190 -0
- package/dist/gate/checks/playbook.js +160 -0
- package/dist/gate/checks/runtime.js +263 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +450 -0
- package/dist/gate/checks/secrets.js +119 -27
- package/dist/gate/diff.js +2 -2
- package/dist/gate/evidence.js +116 -0
- package/dist/gate/exceptions.js +85 -0
- package/dist/gate/policy.js +189 -17
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/server.js +938 -9
- package/dist/repo/fs.js +10 -5
- package/dist/repo/search.js +13 -1
- package/dist/review/store.js +208 -0
- package/dist/tests/run.js +103 -0
- package/package.json +13 -3
- package/prompts/SECURITY_PROMPT.md +455 -1
- 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
|
+
}
|
package/dist/gate/policy.js
CHANGED
|
@@ -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
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
+
}
|