security-mcp 1.0.5 → 1.1.1
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 +963 -193
- package/defaults/agent-run-schema.json +98 -0
- 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 +392 -0
- package/defaults/evidence-map.json +194 -0
- package/defaults/security-policy.json +41 -2
- package/dist/cli/index.js +13 -8
- package/dist/cli/install.js +80 -2
- package/dist/cli/onboarding.js +590 -0
- package/dist/cli/update.js +83 -15
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/checks/ai-redteam.js +398 -0
- 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 +126 -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 +316 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +379 -8
- package/dist/gate/checks/secrets.js +85 -20
- package/dist/gate/exceptions.js +6 -1
- package/dist/gate/policy.js +85 -19
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/orchestration.js +586 -0
- package/dist/mcp/server.js +568 -16
- package/dist/repo/search.js +11 -1
- package/dist/review/store.js +133 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/prompts/SECURITY_PROMPT.md +415 -1
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -0
- package/skills/appsec-code-auditor/SKILL.md +86 -0
- package/skills/artifact-integrity-analyst/SKILL.md +68 -0
- package/skills/attack-navigator/SKILL.md +64 -0
- package/skills/auth-session-hacker/SKILL.md +87 -0
- package/skills/aws-penetration-tester/SKILL.md +60 -0
- package/skills/azure-penetration-tester/SKILL.md +64 -0
- package/skills/business-logic-attacker/SKILL.md +76 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
- package/skills/ciso-orchestrator/SKILL.md +165 -0
- package/skills/cloud-infra-specialist/SKILL.md +85 -0
- package/skills/compliance-gap-analyst/SKILL.md +77 -0
- package/skills/compliance-grc/SKILL.md +148 -0
- package/skills/crypto-pki-specialist/SKILL.md +136 -0
- package/skills/dependency-confusion-attacker/SKILL.md +78 -0
- package/skills/evidence-collector/SKILL.md +86 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -0
- package/skills/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -0
- package/skills/pentest-infra/SKILL.md +69 -0
- package/skills/pentest-social/SKILL.md +72 -0
- package/skills/pentest-team/SKILL.md +126 -0
- package/skills/pentest-web-api/SKILL.md +71 -0
- package/skills/privacy-flow-analyst/SKILL.md +70 -0
- package/skills/prompt-injection-specialist/SKILL.md +76 -0
- package/skills/rag-poisoning-specialist/SKILL.md +71 -0
- package/skills/senior-security-engineer/SKILL.md +75 -13
- package/skills/serialization-memory-attacker/SKILL.md +78 -0
- package/skills/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -0
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Scanner execution module.
|
|
3
|
+
* Runs real security scanners and parses their JSON output into Finding[].
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
2
6
|
import { dirname, join, resolve } from "node:path";
|
|
3
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
4
11
|
import { z } from "zod";
|
|
5
|
-
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
6
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
14
|
const PKG_ROOT = resolve(__dirname, "../../..");
|
|
15
|
+
const SECRET_SANITIZE_RE = /\b(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z\-_]{35}|xoxb-[0-9A-Za-z-]{20,}|-----BEGIN[^-]*PRIVATE KEY-----)/g;
|
|
16
|
+
function sanitize(s) {
|
|
17
|
+
return s.replace(SECRET_SANITIZE_RE, "[REDACTED]");
|
|
18
|
+
}
|
|
8
19
|
const ScannerSchema = z.object({
|
|
9
20
|
command: z.string(),
|
|
10
21
|
args: z.array(z.string()).default(["--version"]),
|
|
@@ -18,7 +29,12 @@ const ScannerConfigSchema = z.object({
|
|
|
18
29
|
async function loadScannerConfig() {
|
|
19
30
|
const overridePath = process.env["SECURITY_GATE_SCANNERS"];
|
|
20
31
|
if (overridePath) {
|
|
21
|
-
|
|
32
|
+
// CWE-22: resolve to absolute path and ensure it stays within cwd
|
|
33
|
+
const resolved = resolve(process.cwd(), overridePath);
|
|
34
|
+
if (!resolved.startsWith(process.cwd() + "/") && resolved !== process.cwd()) {
|
|
35
|
+
throw new Error(`SECURITY_GATE_SCANNERS path '${overridePath}' escapes the project directory`);
|
|
36
|
+
}
|
|
37
|
+
const raw = await readFile(resolved, "utf-8");
|
|
22
38
|
return ScannerConfigSchema.parse(JSON.parse(raw));
|
|
23
39
|
}
|
|
24
40
|
try {
|
|
@@ -46,15 +62,306 @@ function scannerApplies(requiredFor, surfaces) {
|
|
|
46
62
|
return true;
|
|
47
63
|
return false;
|
|
48
64
|
}
|
|
49
|
-
async function commandExists(command
|
|
65
|
+
async function commandExists(command) {
|
|
66
|
+
try {
|
|
67
|
+
await execFileAsync(command, ["--version"], { timeout: 5000 });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
try {
|
|
72
|
+
await execFileAsync(command, ["-version"], { timeout: 5000 });
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function dedupeFindings(findings) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
return findings.filter((f) => {
|
|
83
|
+
const key = `${f.id}:${(f.files ?? []).join(",")}:${(f.evidence ?? []).slice(0, 1).join(",")}`;
|
|
84
|
+
if (seen.has(key))
|
|
85
|
+
return false;
|
|
86
|
+
seen.add(key);
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Run a command and capture JSON output file, with timeout. */
|
|
91
|
+
async function runScannerToFile(command, args, timeoutMs) {
|
|
92
|
+
await execFileAsync(command, args, {
|
|
93
|
+
timeout: timeoutMs,
|
|
94
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB
|
|
95
|
+
cwd: process.cwd()
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async function readJsonFile(path) {
|
|
99
|
+
const raw = await readFile(path, "utf-8");
|
|
100
|
+
return JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
async function runGitleaks(timeoutMs, changedFiles) {
|
|
103
|
+
const outFile = join(tmpdir(), `gl-report-${Date.now()}.json`);
|
|
104
|
+
try {
|
|
105
|
+
await runScannerToFile("gitleaks", [
|
|
106
|
+
"detect",
|
|
107
|
+
"--source",
|
|
108
|
+
process.cwd(),
|
|
109
|
+
"--report-format",
|
|
110
|
+
"json",
|
|
111
|
+
"--report-path",
|
|
112
|
+
outFile,
|
|
113
|
+
"--no-git",
|
|
114
|
+
"--exit-code",
|
|
115
|
+
"0"
|
|
116
|
+
], timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// gitleaks exits non-zero when it finds leaks — that's expected, try to read output
|
|
120
|
+
}
|
|
121
|
+
let data;
|
|
122
|
+
try {
|
|
123
|
+
data = await readJsonFile(outFile);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(data))
|
|
129
|
+
return [];
|
|
130
|
+
const findings = [];
|
|
131
|
+
const leaks = data;
|
|
132
|
+
// Filter to changedFiles if provided
|
|
133
|
+
const changedSet = new Set(changedFiles.map((f) => f.replace(/^\.\//, "")));
|
|
134
|
+
for (const leak of leaks) {
|
|
135
|
+
const file = sanitize(leak.File ?? "");
|
|
136
|
+
if (changedFiles.length > 0 && !changedSet.has(file) && !changedSet.has(`./${file}`))
|
|
137
|
+
continue;
|
|
138
|
+
findings.push({
|
|
139
|
+
id: "POSSIBLE_SECRET",
|
|
140
|
+
title: `Secret detected by Gitleaks: ${sanitize(leak.Description ?? leak.RuleID ?? "unknown")}`,
|
|
141
|
+
severity: "CRITICAL",
|
|
142
|
+
files: file ? [file] : undefined,
|
|
143
|
+
evidence: [
|
|
144
|
+
`Line: ${leak.StartLine ?? "unknown"}`,
|
|
145
|
+
`Rule: ${sanitize(leak.RuleID ?? "unknown")}`
|
|
146
|
+
],
|
|
147
|
+
requiredActions: [
|
|
148
|
+
"Remove the secret from source code immediately.",
|
|
149
|
+
"Rotate any exposed credentials.",
|
|
150
|
+
"Store secrets only in a dedicated secret manager."
|
|
151
|
+
]
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return dedupeFindings(findings);
|
|
155
|
+
}
|
|
156
|
+
function semgrepSeverity(sev) {
|
|
157
|
+
switch ((sev ?? "").toUpperCase()) {
|
|
158
|
+
case "ERROR":
|
|
159
|
+
case "CRITICAL":
|
|
160
|
+
return "CRITICAL";
|
|
161
|
+
case "WARNING":
|
|
162
|
+
case "HIGH":
|
|
163
|
+
return "HIGH";
|
|
164
|
+
case "INFO":
|
|
165
|
+
case "LOW":
|
|
166
|
+
return "LOW";
|
|
167
|
+
default:
|
|
168
|
+
return "MEDIUM";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function runSemgrep(timeoutMs, changedFiles) {
|
|
172
|
+
const outFile = join(tmpdir(), `semgrep-${Date.now()}.json`);
|
|
50
173
|
try {
|
|
51
|
-
const
|
|
52
|
-
|
|
174
|
+
const args = [
|
|
175
|
+
"--config=p/owasp-top-ten",
|
|
176
|
+
"--config=p/secrets",
|
|
177
|
+
"--json",
|
|
178
|
+
`--output=${outFile}`,
|
|
179
|
+
"."
|
|
180
|
+
];
|
|
181
|
+
await runScannerToFile("semgrep", args, timeoutMs);
|
|
53
182
|
}
|
|
54
183
|
catch {
|
|
55
|
-
|
|
184
|
+
// non-zero exit is fine
|
|
185
|
+
}
|
|
186
|
+
let data;
|
|
187
|
+
try {
|
|
188
|
+
data = await readJsonFile(outFile);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const parsed = data;
|
|
194
|
+
const results = parsed.results ?? [];
|
|
195
|
+
const changedSet = new Set(changedFiles.map((f) => f.replace(/^\.\//, "")));
|
|
196
|
+
const findings = [];
|
|
197
|
+
for (const r of results) {
|
|
198
|
+
const file = r.path ?? "";
|
|
199
|
+
if (changedFiles.length > 0 && !changedSet.has(file) && !changedSet.has(`./${file}`))
|
|
200
|
+
continue;
|
|
201
|
+
const sev = semgrepSeverity(r.extra?.severity);
|
|
202
|
+
findings.push({
|
|
203
|
+
id: `SEMGREP_${(r.check_id ?? "FINDING").replace(/[^A-Z0-9_]/gi, "_").toUpperCase()}`,
|
|
204
|
+
title: sanitize(r.extra?.message ?? r.check_id ?? "Semgrep finding"),
|
|
205
|
+
severity: sev,
|
|
206
|
+
files: file ? [sanitize(file)] : undefined,
|
|
207
|
+
evidence: [
|
|
208
|
+
`Line: ${r.start?.line ?? "unknown"}`,
|
|
209
|
+
...(r.extra?.metadata?.cwe ?? []),
|
|
210
|
+
...(r.extra?.metadata?.owasp ?? [])
|
|
211
|
+
],
|
|
212
|
+
requiredActions: [
|
|
213
|
+
"Review the semgrep finding and apply the recommended fix.",
|
|
214
|
+
"See semgrep documentation for the rule for remediation guidance."
|
|
215
|
+
]
|
|
216
|
+
});
|
|
56
217
|
}
|
|
218
|
+
return dedupeFindings(findings);
|
|
57
219
|
}
|
|
220
|
+
function trivyGetCvss(vuln) {
|
|
221
|
+
const cvss = vuln.CVSS ?? {};
|
|
222
|
+
let max = 0;
|
|
223
|
+
for (const source of Object.values(cvss)) {
|
|
224
|
+
if (source.V3Score && source.V3Score > max)
|
|
225
|
+
max = source.V3Score;
|
|
226
|
+
}
|
|
227
|
+
return max;
|
|
228
|
+
}
|
|
229
|
+
async function runTrivy(timeoutMs) {
|
|
230
|
+
const outFile = join(tmpdir(), `trivy-${Date.now()}.json`);
|
|
231
|
+
try {
|
|
232
|
+
await runScannerToFile("trivy", ["fs", "--format", "json", "--output", outFile, "."], timeoutMs);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// non-zero is fine
|
|
236
|
+
}
|
|
237
|
+
let data;
|
|
238
|
+
try {
|
|
239
|
+
data = await readJsonFile(outFile);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const parsed = data;
|
|
245
|
+
const findings = [];
|
|
246
|
+
for (const result of parsed.Results ?? []) {
|
|
247
|
+
for (const vuln of result.Vulnerabilities ?? []) {
|
|
248
|
+
const cvss = trivyGetCvss(vuln);
|
|
249
|
+
const sev = (vuln.Severity ?? "").toUpperCase();
|
|
250
|
+
let severity;
|
|
251
|
+
let findingId;
|
|
252
|
+
if (cvss >= 9.0 || sev === "CRITICAL") {
|
|
253
|
+
severity = "CRITICAL";
|
|
254
|
+
findingId = "SCANNER_CRITICAL_CVE";
|
|
255
|
+
}
|
|
256
|
+
else if (cvss >= 7.0 || sev === "HIGH") {
|
|
257
|
+
severity = "HIGH";
|
|
258
|
+
findingId = "SCANNER_HIGH_CVE";
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
continue; // skip MEDIUM/LOW from scanner results
|
|
262
|
+
}
|
|
263
|
+
findings.push({
|
|
264
|
+
id: findingId,
|
|
265
|
+
title: `Trivy: ${sanitize(vuln.Title ?? vuln.VulnerabilityID ?? "CVE")} in ${vuln.PkgName ?? "unknown"}`,
|
|
266
|
+
severity,
|
|
267
|
+
evidence: [
|
|
268
|
+
`CVE: ${vuln.VulnerabilityID ?? "unknown"}`,
|
|
269
|
+
`Package: ${vuln.PkgName ?? "unknown"}@${vuln.InstalledVersion ?? "unknown"}`,
|
|
270
|
+
`CVSS: ${cvss}`,
|
|
271
|
+
`Target: ${sanitize(result.Target ?? "")}`
|
|
272
|
+
],
|
|
273
|
+
requiredActions: [
|
|
274
|
+
"Update the affected package to a patched version.",
|
|
275
|
+
"If no patch is available, apply mitigations and add a security exception with justification."
|
|
276
|
+
]
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return dedupeFindings(findings);
|
|
281
|
+
}
|
|
282
|
+
async function runCheckov(timeoutMs) {
|
|
283
|
+
const outFile = join(tmpdir(), `checkov-${Date.now()}.json`);
|
|
284
|
+
try {
|
|
285
|
+
await runScannerToFile("checkov", ["-d", ".", "--output", "json", "--output-file", outFile, "--quiet"], timeoutMs);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// non-zero exit is expected when findings exist
|
|
289
|
+
}
|
|
290
|
+
let data;
|
|
291
|
+
try {
|
|
292
|
+
data = await readJsonFile(outFile);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
// Checkov can return array or object
|
|
298
|
+
const parsed = Array.isArray(data)
|
|
299
|
+
? { results: { failed_checks: data.flatMap((d) => d.results?.failed_checks ?? []) } }
|
|
300
|
+
: data;
|
|
301
|
+
const failed = parsed.results?.failed_checks ?? [];
|
|
302
|
+
const findings = [];
|
|
303
|
+
for (const check of failed) {
|
|
304
|
+
const sev = (check.severity ?? "").toUpperCase();
|
|
305
|
+
const severity = sev === "CRITICAL" ? "CRITICAL" : sev === "HIGH" ? "HIGH" : "MEDIUM";
|
|
306
|
+
findings.push({
|
|
307
|
+
id: `CHECKOV_${(check.check_id ?? "FINDING").replace(/[^A-Z0-9_]/gi, "_").toUpperCase()}`,
|
|
308
|
+
title: sanitize(`Checkov: ${check.check?.name ?? check.check_id ?? "IaC misconfiguration"} in ${check.resource ?? ""}`),
|
|
309
|
+
severity,
|
|
310
|
+
files: check.file_path ? [sanitize(check.file_path)] : undefined,
|
|
311
|
+
evidence: [
|
|
312
|
+
`Check: ${check.check_id ?? "unknown"}`,
|
|
313
|
+
`Type: ${check.check_type ?? "unknown"}`
|
|
314
|
+
],
|
|
315
|
+
requiredActions: [
|
|
316
|
+
"Fix the IaC misconfiguration identified by Checkov.",
|
|
317
|
+
"See Checkov documentation for the check rule for remediation guidance."
|
|
318
|
+
]
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return dedupeFindings(findings);
|
|
322
|
+
}
|
|
323
|
+
async function runOsvScanner(timeoutMs) {
|
|
324
|
+
const outFile = join(tmpdir(), `osv-${Date.now()}.json`);
|
|
325
|
+
try {
|
|
326
|
+
await runScannerToFile("osv-scanner", ["--format", "json", "--output", outFile, "."], timeoutMs);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// non-zero when vulns found
|
|
330
|
+
}
|
|
331
|
+
let data;
|
|
332
|
+
try {
|
|
333
|
+
data = await readJsonFile(outFile);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
const parsed = data;
|
|
339
|
+
const findings = [];
|
|
340
|
+
for (const result of parsed.results ?? []) {
|
|
341
|
+
for (const pkg of result.packages ?? []) {
|
|
342
|
+
for (const group of pkg.groups ?? []) {
|
|
343
|
+
const ids = group.ids ?? [];
|
|
344
|
+
const pkgName = group.packages?.[0]?.package?.name ?? "unknown";
|
|
345
|
+
const pkgVer = group.packages?.[0]?.package?.version ?? "unknown";
|
|
346
|
+
// Assume HIGH severity for OSV findings (no CVSS in basic output)
|
|
347
|
+
findings.push({
|
|
348
|
+
id: "SCANNER_HIGH_CVE",
|
|
349
|
+
title: `OSV-Scanner: vulnerability in ${pkgName}@${pkgVer}`,
|
|
350
|
+
severity: "HIGH",
|
|
351
|
+
evidence: ids.slice(0, 5),
|
|
352
|
+
requiredActions: [
|
|
353
|
+
"Update the affected package to a non-vulnerable version.",
|
|
354
|
+
"Check OSV.dev for patch availability and workarounds."
|
|
355
|
+
]
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return dedupeFindings(findings);
|
|
361
|
+
}
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Main export: checkScannerReadiness (backwards compatible) + runScanners
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
58
365
|
export async function checkScannerReadiness(opts) {
|
|
59
366
|
const config = await loadScannerConfig();
|
|
60
367
|
const configured = [];
|
|
@@ -64,7 +371,7 @@ export async function checkScannerReadiness(opts) {
|
|
|
64
371
|
if (!scannerApplies(scanner.required_for, opts.surfaces))
|
|
65
372
|
continue;
|
|
66
373
|
configured.push(scannerId);
|
|
67
|
-
if (!(await commandExists(scanner.command
|
|
374
|
+
if (!(await commandExists(scanner.command))) {
|
|
68
375
|
missing.push(scannerId);
|
|
69
376
|
}
|
|
70
377
|
}
|
|
@@ -82,3 +389,67 @@ export async function checkScannerReadiness(opts) {
|
|
|
82
389
|
}
|
|
83
390
|
return { findings, configured, missing };
|
|
84
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Actually execute scanners and parse their output into Finding[].
|
|
394
|
+
* Uses Promise.allSettled so one scanner failure doesn't kill others.
|
|
395
|
+
*/
|
|
396
|
+
export async function runScanners(opts) {
|
|
397
|
+
const config = await loadScannerConfig();
|
|
398
|
+
const timeout = opts.timeoutMs ?? 120_000;
|
|
399
|
+
// Ensure tmp dir exists for output files
|
|
400
|
+
try {
|
|
401
|
+
await mkdir(tmpdir(), { recursive: true });
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// already exists
|
|
405
|
+
}
|
|
406
|
+
const tasks = [];
|
|
407
|
+
if (config.scanners["gitleaks"] && scannerApplies(config.scanners["gitleaks"].required_for, opts.surfaces)) {
|
|
408
|
+
if (await commandExists("gitleaks")) {
|
|
409
|
+
tasks.push({ id: "gitleaks", task: () => runGitleaks(timeout, opts.changedFiles) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (config.scanners["semgrep"] && scannerApplies(config.scanners["semgrep"].required_for, opts.surfaces)) {
|
|
413
|
+
if (await commandExists("semgrep")) {
|
|
414
|
+
tasks.push({ id: "semgrep", task: () => runSemgrep(timeout, opts.changedFiles) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (config.scanners["trivy"] && scannerApplies(config.scanners["trivy"].required_for, opts.surfaces)) {
|
|
418
|
+
if (await commandExists("trivy")) {
|
|
419
|
+
tasks.push({ id: "trivy", task: () => runTrivy(timeout) });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (config.scanners["checkov"] && scannerApplies(config.scanners["checkov"].required_for, opts.surfaces)) {
|
|
423
|
+
if (await commandExists("checkov")) {
|
|
424
|
+
tasks.push({ id: "checkov", task: () => runCheckov(timeout) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (config.scanners["osv-scanner"] && scannerApplies(config.scanners["osv-scanner"].required_for, opts.surfaces)) {
|
|
428
|
+
if (await commandExists("osv-scanner")) {
|
|
429
|
+
tasks.push({ id: "osv-scanner", task: () => runOsvScanner(timeout) });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const results = await Promise.allSettled(tasks.map((t) => t.task()));
|
|
433
|
+
const allFindings = [];
|
|
434
|
+
for (let i = 0; i < results.length; i++) {
|
|
435
|
+
const res = results[i];
|
|
436
|
+
const taskId = tasks[i]?.id ?? "unknown";
|
|
437
|
+
if (res.status === "fulfilled") {
|
|
438
|
+
allFindings.push(...res.value);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
console.warn(`[scanners] Scanner ${taskId} failed: ${String(res.reason)}`);
|
|
442
|
+
allFindings.push({
|
|
443
|
+
id: "SCANNER_EXECUTION_ERROR",
|
|
444
|
+
title: `Security scanner '${taskId}' failed unexpectedly`,
|
|
445
|
+
severity: "MEDIUM",
|
|
446
|
+
evidence: [sanitize(String(res.reason))],
|
|
447
|
+
requiredActions: [
|
|
448
|
+
`Investigate why scanner '${taskId}' failed.`,
|
|
449
|
+
"Check scanner installation and permissions."
|
|
450
|
+
]
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return dedupeFindings(allFindings);
|
|
455
|
+
}
|
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
import fg from "fast-glob";
|
|
2
2
|
import { readFileSafe } from "../../repo/fs.js";
|
|
3
3
|
const SECRET_PATTERNS = [
|
|
4
|
-
|
|
5
|
-
{ name: "
|
|
6
|
-
{ name: "
|
|
7
|
-
|
|
8
|
-
{ name: "
|
|
9
|
-
{ name: "
|
|
10
|
-
{ name: "
|
|
4
|
+
// Private keys
|
|
5
|
+
{ name: "private_key_pem", regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, description: "PEM private key" },
|
|
6
|
+
{ name: "private_key_pkcs8", regex: /-----BEGIN ENCRYPTED PRIVATE KEY-----/, description: "Encrypted PKCS8 private key" },
|
|
7
|
+
// AWS
|
|
8
|
+
{ name: "aws_access_key_id", regex: /\bAKIA[0-9A-Z]{16}\b/, description: "AWS access key ID" },
|
|
9
|
+
{ name: "aws_secret_access_key", regex: /\bAWS_SECRET(?:_ACCESS)?_KEY\s*[:=]\s*["']?[A-Za-z0-9/+]{40}["']?/, description: "AWS secret access key" },
|
|
10
|
+
{ name: "aws_session_token", regex: /\bAWS_SESSION_TOKEN\s*[:=]\s*["'][A-Za-z0-9/+]{100,}["']/, description: "AWS session token" },
|
|
11
|
+
{ name: "aws_mws_key", regex: /\bamzn\.mws\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/, description: "AWS MWS key" },
|
|
12
|
+
// GCP
|
|
13
|
+
{ name: "google_api_key", regex: /\bAIza[0-9A-Za-z\-_]{35}\b/, description: "Google API key" },
|
|
14
|
+
{ name: "gcp_service_account", regex: /"type"\s*:\s*"service_account"/, description: "GCP service account JSON" },
|
|
15
|
+
{ name: "gcp_oauth_client", regex: /\d+-\w{32}\.apps\.googleusercontent\.com/, description: "GCP OAuth client ID" },
|
|
16
|
+
// Azure
|
|
17
|
+
{ name: "azure_connection_string", regex: /DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/]{86}==/, description: "Azure storage connection string" },
|
|
18
|
+
{ name: "azure_sas_token", regex: /\bsig=[A-Za-z0-9%+/]{43,}%3D/, description: "Azure SAS token" },
|
|
19
|
+
{ name: "azure_client_secret", regex: /\bAZURE_CLIENT_SECRET\s*[:=]\s*["'][^"'\n]{20,}["']/, description: "Azure client secret" },
|
|
20
|
+
{ name: "azure_subscription_key", regex: /\bOcp-Apim-Subscription-Key\s*[:=]\s*["'][0-9a-f]{32}["']/, description: "Azure APIM subscription key" },
|
|
21
|
+
// GitHub / GitLab / Bitbucket
|
|
22
|
+
{ name: "github_personal_token", regex: /\bghp_[A-Za-z0-9]{36}\b/, description: "GitHub personal access token" },
|
|
23
|
+
{ name: "github_oauth_token", regex: /\bgho_[A-Za-z0-9]{36}\b/, description: "GitHub OAuth token" },
|
|
24
|
+
{ name: "github_actions_token", regex: /\bghs_[A-Za-z0-9]{36}\b/, description: "GitHub Actions token" },
|
|
25
|
+
{ name: "github_refresh_token", regex: /\bghr_[A-Za-z0-9]{76}\b/, description: "GitHub refresh token" },
|
|
26
|
+
{ name: "gitlab_token", regex: /\bglpat-[A-Za-z0-9\-_]{20}\b/, description: "GitLab personal access token" },
|
|
27
|
+
{ name: "bitbucket_token", regex: /\bATBB[A-Za-z0-9]{28}\b/, description: "Bitbucket access token" },
|
|
28
|
+
// Slack
|
|
29
|
+
{ name: "slack_bot_token", regex: /\bxoxb-[0-9A-Za-z-]{20,}\b/, description: "Slack bot token" },
|
|
30
|
+
{ name: "slack_user_token", regex: /\bxoxp-[0-9A-Za-z-]{20,}\b/, description: "Slack user token" },
|
|
31
|
+
{ name: "slack_workspace_token", regex: /\bxoxa-[0-9A-Za-z-]{20,}\b/, description: "Slack workspace token" },
|
|
32
|
+
{ name: "slack_webhook", regex: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, description: "Slack webhook URL" },
|
|
33
|
+
// Stripe / Payment
|
|
34
|
+
{ name: "stripe_secret_key", regex: /\bsk_live_[A-Za-z0-9]{24,}\b/, description: "Stripe live secret key" },
|
|
35
|
+
{ name: "stripe_restricted_key", regex: /\brk_live_[A-Za-z0-9]{24,}\b/, description: "Stripe restricted key" },
|
|
36
|
+
{ name: "stripe_webhook_secret", regex: /\bwhsec_[A-Za-z0-9]{32,}\b/, description: "Stripe webhook secret" },
|
|
37
|
+
{ name: "paypal_braintree_key", regex: /\baccess_token\$production\$[A-Za-z0-9]{16}\$[A-Za-z0-9]{32}\b/, description: "PayPal/Braintree access token" },
|
|
38
|
+
{ name: "square_access_token", regex: /\bEAAAE[A-Za-z0-9\-_]{60,}\b/, description: "Square access token" },
|
|
39
|
+
// Communication
|
|
40
|
+
{ name: "twilio_account_sid", regex: /\bAC[a-fA-F0-9]{32}\b/, description: "Twilio account SID" },
|
|
41
|
+
{ name: "twilio_auth_token", regex: /\bTWILIO_AUTH_TOKEN\s*[:=]\s*["'][a-fA-F0-9]{32}["']/, description: "Twilio auth token" },
|
|
42
|
+
{ name: "sendgrid_api_key", regex: /\bSG\.[A-Za-z0-9\-_]{22}\.[A-Za-z0-9\-_]{43}\b/, description: "SendGrid API key" },
|
|
43
|
+
{ name: "mailgun_api_key", regex: /\bkey-[A-Za-z0-9]{32}\b/, description: "Mailgun API key" },
|
|
44
|
+
// LLM / AI providers
|
|
45
|
+
{ name: "openai_api_key", regex: /\bsk-[A-Za-z0-9]{20,}\b/, description: "OpenAI API key" },
|
|
46
|
+
{ name: "anthropic_api_key", regex: /\bsk-ant-[A-Za-z0-9\-_]{40,}\b/, description: "Anthropic API key" },
|
|
47
|
+
{ name: "huggingface_token", regex: /\bhf_[A-Za-z0-9]{34}\b/, description: "HuggingFace token" },
|
|
48
|
+
{ name: "cohere_api_key", regex: /\bCOHERE_API_KEY\s*[:=]\s*["'][A-Za-z0-9]{40}["']/, description: "Cohere API key" },
|
|
49
|
+
// Database connection strings with embedded credentials
|
|
50
|
+
{ name: "db_connection_string", regex: /(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|mssql):\/\/[^:]+:[^@\s]{6,}@/, description: "Database connection string with embedded credentials" },
|
|
51
|
+
{ name: "jdbc_credentials", regex: /jdbc:[a-z]+:\/\/[^;]+;?[Pp]assword=[^;\s"']{6,}/, description: "JDBC connection string with password" },
|
|
52
|
+
// Infrastructure tokens
|
|
53
|
+
{ name: "hashicorp_vault_token", regex: /\bhvs\.[A-Za-z0-9]{24,}\b/, description: "HashiCorp Vault service token" },
|
|
54
|
+
{ name: "npm_token", regex: /\bnpm_[A-Za-z0-9]{36}\b/, description: "npm access token" },
|
|
55
|
+
{ name: "docker_hub_pat", regex: /\bdckr_pat_[A-Za-z0-9\-_]{27}\b/, description: "Docker Hub personal access token" },
|
|
56
|
+
{ name: "terraform_cloud_token", regex: /\b[A-Za-z0-9]{14}\.atlasv1\.[A-Za-z0-9]{60,}\b/, description: "Terraform Cloud token" },
|
|
57
|
+
{ name: "datadog_api_key", regex: /\bDD_API_KEY\s*[:=]\s*["'][a-fA-F0-9]{32}["']/, description: "Datadog API key" },
|
|
58
|
+
{ name: "new_relic_key", regex: /\bNEW_RELIC_LICENSE_KEY\s*[:=]\s*["'][A-Za-z0-9]{40}["']/, description: "New Relic license key" },
|
|
59
|
+
// Generic high-confidence patterns
|
|
60
|
+
{ name: "secret_key_assignment", regex: /\b(?:SECRET|API)_KEY\s*[:=]\s*["'][^"'\n]{16,}["']/, description: "Generic secret/API key assignment" },
|
|
61
|
+
{ name: "password_assignment", regex: /\b(?:PASSWORD|PASSWD|PWD)\s*[:=]\s*["'][^"'\n]{8,}["']/, description: "Hardcoded password assignment" },
|
|
62
|
+
{ name: "private_key_assignment", regex: /\bPRIVATE_KEY\s*[:=]\s*["'][^"'\n]{16,}["']/, description: "Private key value assignment" },
|
|
63
|
+
{ name: "bearer_token_literal", regex: /Authorization['"]?\s*[:=]\s*['"]Bearer [A-Za-z0-9\-_=.]{20,}['"]/, description: "Hardcoded Bearer token" },
|
|
11
64
|
];
|
|
12
65
|
function previewLine(text, index) {
|
|
13
66
|
const lineStart = text.lastIndexOf("\n", index);
|
|
@@ -25,10 +78,14 @@ export async function checkSecrets(_) {
|
|
|
25
78
|
"**/dist/**",
|
|
26
79
|
"**/fixtures/**",
|
|
27
80
|
"**/.mcp/reviews/**",
|
|
28
|
-
"**/.mcp/reports/**"
|
|
81
|
+
"**/.mcp/reports/**",
|
|
82
|
+
"**/.claude/**",
|
|
83
|
+
// Exclude detection source — contains regex patterns that match their own rules
|
|
84
|
+
"src/gate/checks/secrets.ts"
|
|
29
85
|
]
|
|
30
86
|
});
|
|
31
|
-
|
|
87
|
+
// Track hits per pattern so each type gets its own finding with specific guidance
|
|
88
|
+
const hitsByPattern = new Map();
|
|
32
89
|
for (const file of files) {
|
|
33
90
|
let text = "";
|
|
34
91
|
try {
|
|
@@ -41,23 +98,31 @@ export async function checkSecrets(_) {
|
|
|
41
98
|
const match = pattern.regex.exec(text);
|
|
42
99
|
if (!match || match.index === undefined)
|
|
43
100
|
continue;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
101
|
+
const preview = previewLine(text, match.index);
|
|
102
|
+
// Redact the matched value itself — only expose location and pattern name
|
|
103
|
+
const redacted = preview.replace(pattern.regex, "[REDACTED]");
|
|
104
|
+
const hit = `${file}: ${redacted}`;
|
|
105
|
+
const existing = hitsByPattern.get(pattern.name) ?? [];
|
|
106
|
+
if (existing.length < 5) {
|
|
107
|
+
existing.push(hit);
|
|
108
|
+
hitsByPattern.set(pattern.name, existing);
|
|
109
|
+
}
|
|
47
110
|
}
|
|
48
|
-
if (evidence.length >= 25)
|
|
49
|
-
break;
|
|
50
111
|
}
|
|
51
|
-
|
|
112
|
+
for (const [patternName, hits] of hitsByPattern) {
|
|
113
|
+
const pattern = SECRET_PATTERNS.find((p) => p.name === patternName);
|
|
114
|
+
const description = pattern?.description ?? patternName;
|
|
52
115
|
findings.push({
|
|
53
116
|
id: "POSSIBLE_SECRET",
|
|
54
|
-
title:
|
|
117
|
+
title: `Hardcoded secret detected: ${description}`,
|
|
55
118
|
severity: "CRITICAL",
|
|
56
|
-
|
|
119
|
+
files: hits.map((h) => h.split(":")[0]).filter(Boolean),
|
|
120
|
+
evidence: hits,
|
|
57
121
|
requiredActions: [
|
|
58
|
-
|
|
59
|
-
"Rotate
|
|
60
|
-
"Store
|
|
122
|
+
`Remove the ${description} from source code immediately.`,
|
|
123
|
+
"Rotate the exposed credential — treat it as compromised.",
|
|
124
|
+
"Store the secret in your cloud secret manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault, Doppler, or 1Password Secrets Automation).",
|
|
125
|
+
"Add a pre-commit hook or CI check with gitleaks to prevent future secret commits."
|
|
61
126
|
]
|
|
62
127
|
});
|
|
63
128
|
}
|
package/dist/gate/exceptions.js
CHANGED
|
@@ -22,7 +22,12 @@ const ExceptionFileSchema = z.object({
|
|
|
22
22
|
async function readExceptionsJson() {
|
|
23
23
|
const overridePath = process.env["SECURITY_GATE_EXCEPTIONS"];
|
|
24
24
|
if (overridePath) {
|
|
25
|
-
|
|
25
|
+
// CWE-22: ensure path stays within the project directory
|
|
26
|
+
const resolved = resolve(process.cwd(), overridePath);
|
|
27
|
+
if (!resolved.startsWith(process.cwd() + "/") && resolved !== process.cwd()) {
|
|
28
|
+
throw new Error(`SECURITY_GATE_EXCEPTIONS path '${overridePath}' escapes the project directory`);
|
|
29
|
+
}
|
|
30
|
+
return await readFile(resolved, "utf-8");
|
|
26
31
|
}
|
|
27
32
|
try {
|
|
28
33
|
return await readFile(join(process.cwd(), ".mcp", "exceptions", "security-exceptions.json"), "utf-8");
|