security-mcp 1.0.3 → 1.0.5
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/control-catalog.json +157 -0
- package/defaults/security-exceptions.json +4 -0
- package/defaults/security-tools.json +41 -0
- package/dist/ci/pr-gate.js +2 -3
- package/dist/cli/index.js +51 -16
- package/dist/cli/install.js +39 -17
- package/dist/cli/update.js +124 -0
- package/dist/gate/catalog.js +55 -0
- package/dist/gate/checks/ai.js +45 -14
- package/dist/gate/checks/dependencies.js +4 -0
- package/dist/gate/checks/scanners.js +84 -0
- package/dist/gate/checks/secrets.js +53 -26
- 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 +110 -4
- package/dist/mcp/server.js +440 -6
- package/dist/repo/fs.js +10 -5
- package/dist/review/store.js +80 -0
- package/dist/tests/run.js +103 -0
- package/package.json +13 -3
- package/prompts/SECURITY_PROMPT.md +40 -0
- package/skills/senior-security-engineer/SKILL.md +46 -1
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,6 +11,10 @@ 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";
|
|
14
19
|
const PolicySchema = z.object({
|
|
15
20
|
name: z.string(),
|
|
@@ -29,6 +34,43 @@ const PolicySchema = z.object({
|
|
|
29
34
|
}))
|
|
30
35
|
.default([])
|
|
31
36
|
});
|
|
37
|
+
const SCOPE_IGNORE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
|
|
38
|
+
const SAFE_SCOPE_TARGET_RE = /^[a-zA-Z0-9_./-]+$/;
|
|
39
|
+
function validateScopeTarget(target) {
|
|
40
|
+
if (!target || target.includes("..") || target.startsWith("/") || !SAFE_SCOPE_TARGET_RE.test(target)) {
|
|
41
|
+
throw new Error(`Invalid scope target "${target}". Use a relative file/folder path with alphanumerics, "_", "-", ".", "/".`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizeTargets(targets) {
|
|
45
|
+
return (targets ?? []).map((t) => t.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
async function resolveScopedFiles(opts) {
|
|
48
|
+
if (opts.mode === "recent_changes") {
|
|
49
|
+
return await getChangedFiles({ baseRef: opts.baseRef, headRef: opts.headRef });
|
|
50
|
+
}
|
|
51
|
+
const targets = normalizeTargets(opts.targets);
|
|
52
|
+
if (targets.length === 0) {
|
|
53
|
+
throw new Error(`Scan mode "${opts.mode}" requires "targets". ` +
|
|
54
|
+
`Provide one or more relative paths (folders for folder_by_folder, files for file_by_file).`);
|
|
55
|
+
}
|
|
56
|
+
for (const target of targets)
|
|
57
|
+
validateScopeTarget(target);
|
|
58
|
+
if (opts.mode === "file_by_file") {
|
|
59
|
+
const files = await fg(targets, {
|
|
60
|
+
onlyFiles: true,
|
|
61
|
+
dot: true,
|
|
62
|
+
ignore: SCOPE_IGNORE_GLOBS
|
|
63
|
+
});
|
|
64
|
+
return Array.from(new Set(files)).sort();
|
|
65
|
+
}
|
|
66
|
+
const folderGlobs = targets.map((target) => `${target.replace(/\/+$/, "")}/**/*`);
|
|
67
|
+
const files = await fg(folderGlobs, {
|
|
68
|
+
onlyFiles: true,
|
|
69
|
+
dot: true,
|
|
70
|
+
ignore: SCOPE_IGNORE_GLOBS
|
|
71
|
+
});
|
|
72
|
+
return Array.from(new Set(files)).sort();
|
|
73
|
+
}
|
|
32
74
|
export async function loadPolicy(policyPath) {
|
|
33
75
|
const raw = await readFileSafe(policyPath);
|
|
34
76
|
const parsed = JSON.parse(raw);
|
|
@@ -36,17 +78,26 @@ export async function loadPolicy(policyPath) {
|
|
|
36
78
|
}
|
|
37
79
|
export async function runPrGate(opts) {
|
|
38
80
|
const policy = await loadPolicy(opts.policyPath);
|
|
39
|
-
const
|
|
81
|
+
const mode = opts.mode ?? "recent_changes";
|
|
82
|
+
const targets = normalizeTargets(opts.targets);
|
|
83
|
+
const changedFiles = await resolveScopedFiles({
|
|
84
|
+
mode,
|
|
85
|
+
targets,
|
|
40
86
|
baseRef: opts.baseRef ?? "origin/main",
|
|
41
87
|
headRef: opts.headRef ?? "HEAD"
|
|
42
88
|
});
|
|
43
89
|
const surfaces = detectSurfaces(changedFiles);
|
|
44
|
-
const
|
|
90
|
+
const catalog = await loadControlCatalog();
|
|
91
|
+
const scannerReadiness = await checkScannerReadiness({ surfaces });
|
|
92
|
+
const evidenceCoverage = await evaluateEvidenceCoverage({ policy, surfaces });
|
|
93
|
+
const rawFindings = [
|
|
45
94
|
// Required artifacts first: threat models/checklists.
|
|
46
95
|
...(await checkRequiredArtifacts({ policy, changedFiles })),
|
|
47
96
|
// Baseline scans / checks
|
|
48
97
|
...(await checkSecrets({ changedFiles })),
|
|
49
98
|
...(await checkDependencies({ changedFiles })),
|
|
99
|
+
...scannerReadiness.findings,
|
|
100
|
+
...evidenceCoverage.findings,
|
|
50
101
|
// Surface-specific checks (only run if that surface is impacted or exists)
|
|
51
102
|
...(surfaces.web ? await checkWebNextjs({ changedFiles }) : []),
|
|
52
103
|
...(surfaces.api ? await checkApi({ changedFiles }) : []),
|
|
@@ -55,6 +106,47 @@ export async function runPrGate(opts) {
|
|
|
55
106
|
...(surfaces.mobileAndroid ? await checkMobileAndroid({ changedFiles }) : []),
|
|
56
107
|
...(surfaces.ai ? await checkAi({ changedFiles }) : [])
|
|
57
108
|
];
|
|
109
|
+
const toolingCoverage = catalog.controls
|
|
110
|
+
.filter((control) => control.automation === "tooling" && controlApplies(control, surfaces))
|
|
111
|
+
.map((control) => {
|
|
112
|
+
const required = control.required_scanners ?? [];
|
|
113
|
+
const missing = required.filter((scannerId) => !scannerReadiness.configured.includes(scannerId) || scannerReadiness.missing.includes(scannerId));
|
|
114
|
+
return {
|
|
115
|
+
id: control.id,
|
|
116
|
+
description: control.description,
|
|
117
|
+
automation: control.automation,
|
|
118
|
+
frameworks: control.frameworks,
|
|
119
|
+
status: missing.length > 0 ? "missing" : "satisfied",
|
|
120
|
+
details: missing.length > 0 ? missing : required
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
const controlCoverage = [
|
|
124
|
+
...evidenceCoverage.controls.filter((control) => control.automation === "evidence"),
|
|
125
|
+
...toolingCoverage
|
|
126
|
+
];
|
|
127
|
+
const exceptionResult = await applySecurityExceptions(rawFindings);
|
|
128
|
+
const controlCoverageWithExceptions = controlCoverage.map((control) => {
|
|
129
|
+
if (exceptionResult.activeControlExceptionIds.includes(control.id) && control.status === "missing") {
|
|
130
|
+
return {
|
|
131
|
+
...control,
|
|
132
|
+
status: "risk_accepted",
|
|
133
|
+
details: [...control.details, "Covered by an active approved control exception."]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return control;
|
|
137
|
+
});
|
|
138
|
+
const findings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings];
|
|
139
|
+
const relevantControls = controlCoverageWithExceptions.filter((control) => control.status !== "not_applicable");
|
|
140
|
+
const satisfiedControls = relevantControls.filter((control) => control.status === "satisfied").length;
|
|
141
|
+
const riskAcceptedControls = relevantControls.filter((control) => control.status === "risk_accepted").length;
|
|
142
|
+
const automatedCoverage = relevantControls.length === 0
|
|
143
|
+
? 100
|
|
144
|
+
: Math.round((((satisfiedControls) + (riskAcceptedControls * 0.5)) / relevantControls.length) * 100);
|
|
145
|
+
const scannerScore = scannerReadiness.configured.length === 0
|
|
146
|
+
? 0
|
|
147
|
+
: Math.round(((scannerReadiness.configured.length - scannerReadiness.missing.length) / scannerReadiness.configured.length) * 100);
|
|
148
|
+
const confidenceScore = Math.max(0, Math.min(100, Math.round((automatedCoverage * 0.7) + (scannerScore * 0.3))));
|
|
149
|
+
const missingControls = relevantControls.filter((control) => control.status === "missing").length;
|
|
58
150
|
const status = findings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
|
|
59
151
|
? "FAIL"
|
|
60
152
|
: "PASS";
|
|
@@ -62,7 +154,21 @@ export async function runPrGate(opts) {
|
|
|
62
154
|
status,
|
|
63
155
|
policyVersion: policy.version,
|
|
64
156
|
evaluatedAt: new Date().toISOString(),
|
|
65
|
-
scope: { changedFiles, surfaces },
|
|
66
|
-
findings
|
|
157
|
+
scope: { mode, targets, changedFiles, surfaces },
|
|
158
|
+
findings,
|
|
159
|
+
suppressedFindings: exceptionResult.suppressed,
|
|
160
|
+
controlCoverage: controlCoverageWithExceptions,
|
|
161
|
+
scannerReadiness: {
|
|
162
|
+
configured: scannerReadiness.configured,
|
|
163
|
+
missing: scannerReadiness.missing
|
|
164
|
+
},
|
|
165
|
+
confidence: {
|
|
166
|
+
score: confidenceScore,
|
|
167
|
+
automatedCoverage,
|
|
168
|
+
missingControls,
|
|
169
|
+
riskAcceptedControls,
|
|
170
|
+
scannerReadiness: scannerScore,
|
|
171
|
+
summary: `Automated coverage ${automatedCoverage}%, scanner readiness ${scannerScore}%, missing controls ${missingControls}, risk-accepted controls ${riskAcceptedControls}.`
|
|
172
|
+
}
|
|
67
173
|
};
|
|
68
174
|
}
|