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.
Files changed (81) hide show
  1. package/README.md +963 -193
  2. package/defaults/agent-run-schema.json +98 -0
  3. package/defaults/checklists/ai.json +25 -0
  4. package/defaults/checklists/api.json +27 -0
  5. package/defaults/checklists/infra.json +27 -0
  6. package/defaults/checklists/mobile.json +25 -0
  7. package/defaults/checklists/payments.json +25 -0
  8. package/defaults/checklists/web.json +30 -0
  9. package/defaults/control-catalog.json +392 -0
  10. package/defaults/evidence-map.json +194 -0
  11. package/defaults/security-policy.json +41 -2
  12. package/dist/cli/index.js +13 -8
  13. package/dist/cli/install.js +80 -2
  14. package/dist/cli/onboarding.js +590 -0
  15. package/dist/cli/update.js +83 -15
  16. package/dist/gate/baseline.js +115 -0
  17. package/dist/gate/checks/ai-redteam.js +398 -0
  18. package/dist/gate/checks/api.js +93 -0
  19. package/dist/gate/checks/crypto.js +153 -0
  20. package/dist/gate/checks/database.js +144 -0
  21. package/dist/gate/checks/dependencies.js +126 -0
  22. package/dist/gate/checks/dlp.js +153 -0
  23. package/dist/gate/checks/graphql.js +122 -0
  24. package/dist/gate/checks/infra.js +126 -12
  25. package/dist/gate/checks/k8s.js +190 -0
  26. package/dist/gate/checks/playbook.js +160 -0
  27. package/dist/gate/checks/runtime.js +316 -0
  28. package/dist/gate/checks/sbom.js +199 -0
  29. package/dist/gate/checks/scanners.js +379 -8
  30. package/dist/gate/checks/secrets.js +85 -20
  31. package/dist/gate/exceptions.js +6 -1
  32. package/dist/gate/policy.js +85 -19
  33. package/dist/gate/threat-intel.js +157 -0
  34. package/dist/mcp/orchestration.js +586 -0
  35. package/dist/mcp/server.js +568 -16
  36. package/dist/repo/search.js +11 -1
  37. package/dist/review/store.js +133 -0
  38. package/dist/types/agent-run.js +8 -0
  39. package/package.json +5 -5
  40. package/prompts/SECURITY_PROMPT.md +415 -1
  41. package/skills/agentic-loop-exploiter/SKILL.md +69 -0
  42. package/skills/ai-llm-redteam/SKILL.md +118 -0
  43. package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
  44. package/skills/android-penetration-tester/SKILL.md +83 -0
  45. package/skills/appsec-code-auditor/SKILL.md +86 -0
  46. package/skills/artifact-integrity-analyst/SKILL.md +68 -0
  47. package/skills/attack-navigator/SKILL.md +64 -0
  48. package/skills/auth-session-hacker/SKILL.md +87 -0
  49. package/skills/aws-penetration-tester/SKILL.md +60 -0
  50. package/skills/azure-penetration-tester/SKILL.md +64 -0
  51. package/skills/business-logic-attacker/SKILL.md +76 -0
  52. package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
  53. package/skills/ciso-orchestrator/SKILL.md +165 -0
  54. package/skills/cloud-infra-specialist/SKILL.md +85 -0
  55. package/skills/compliance-gap-analyst/SKILL.md +77 -0
  56. package/skills/compliance-grc/SKILL.md +148 -0
  57. package/skills/crypto-pki-specialist/SKILL.md +136 -0
  58. package/skills/dependency-confusion-attacker/SKILL.md +78 -0
  59. package/skills/evidence-collector/SKILL.md +86 -0
  60. package/skills/gcp-penetration-tester/SKILL.md +63 -0
  61. package/skills/injection-specialist/SKILL.md +62 -0
  62. package/skills/ios-security-auditor/SKILL.md +77 -0
  63. package/skills/k8s-container-escaper/SKILL.md +74 -0
  64. package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
  65. package/skills/logic-race-fuzzer/SKILL.md +67 -0
  66. package/skills/mobile-api-network-attacker/SKILL.md +81 -0
  67. package/skills/mobile-security-specialist/SKILL.md +124 -0
  68. package/skills/model-extraction-attacker/SKILL.md +68 -0
  69. package/skills/pentest-infra/SKILL.md +69 -0
  70. package/skills/pentest-social/SKILL.md +72 -0
  71. package/skills/pentest-team/SKILL.md +126 -0
  72. package/skills/pentest-web-api/SKILL.md +71 -0
  73. package/skills/privacy-flow-analyst/SKILL.md +70 -0
  74. package/skills/prompt-injection-specialist/SKILL.md +76 -0
  75. package/skills/rag-poisoning-specialist/SKILL.md +71 -0
  76. package/skills/senior-security-engineer/SKILL.md +75 -13
  77. package/skills/serialization-memory-attacker/SKILL.md +78 -0
  78. package/skills/stride-pasta-analyst/SKILL.md +72 -0
  79. package/skills/supply-chain-devsecops/SKILL.md +82 -0
  80. package/skills/threat-modeler/SKILL.md +116 -0
  81. package/skills/tls-certificate-auditor/SKILL.md +76 -0
@@ -1,10 +1,21 @@
1
- import { readFile } from "node:fs/promises";
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
- import { execa } from "execa";
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
- const raw = await readFile(join(process.cwd(), overridePath), "utf-8");
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, args) {
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 result = await execa(command, args, { reject: false });
52
- return result.exitCode === 0;
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
- return false;
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, scanner.args))) {
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
- { name: "private_key_pem", regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
5
- { name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
6
- { name: "google_api_key", regex: /\bAIza[0-9A-Za-z\-_]{35}\b/ },
7
- { name: "slack_bot_token", regex: /\bxoxb-[0-9A-Za-z-]{20,}\b/ },
8
- { name: "llm_api_key", regex: /\bsk-[A-Za-z0-9]{20,}\b/ },
9
- { name: "secret_key_assignment", regex: /\bSECRET_KEY\s*[:=]\s*["'][^"'\n]{8,}["']/ },
10
- { name: "private_key_assignment", regex: /\bPRIVATE_KEY\s*[:=]\s*["'][^"'\n]{16,}["']/ }
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
- const evidence = [];
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
- evidence.push(`${file}:${pattern.name}:${previewLine(text, match.index)}`);
45
- if (evidence.length >= 25)
46
- break;
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
- if (evidence.length > 0) {
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: "Potential secret material detected by whole-repo heuristic scan",
117
+ title: `Hardcoded secret detected: ${description}`,
55
118
  severity: "CRITICAL",
56
- evidence,
119
+ files: hits.map((h) => h.split(":")[0]).filter(Boolean),
120
+ evidence: hits,
57
121
  requiredActions: [
58
- "Remove secrets from the affected files immediately.",
59
- "Rotate any exposed credentials.",
60
- "Store secrets only in a dedicated secret manager and keep them out of logs."
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
  }
@@ -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
- return await readFile(join(process.cwd(), overridePath), "utf-8");
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");