security-mcp 1.1.0 → 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 (56) hide show
  1. package/README.md +963 -193
  2. package/defaults/agent-run-schema.json +98 -0
  3. package/dist/cli/install.js +69 -2
  4. package/dist/cli/onboarding.js +4 -4
  5. package/dist/cli/update.js +83 -15
  6. package/dist/gate/checks/ai-redteam.js +83 -59
  7. package/dist/gate/checks/runtime.js +55 -2
  8. package/dist/gate/checks/scanners.js +6 -1
  9. package/dist/gate/exceptions.js +6 -1
  10. package/dist/mcp/orchestration.js +586 -0
  11. package/dist/mcp/server.js +69 -12
  12. package/dist/repo/search.js +5 -7
  13. package/dist/review/store.js +5 -0
  14. package/dist/types/agent-run.js +8 -0
  15. package/package.json +5 -5
  16. package/skills/agentic-loop-exploiter/SKILL.md +69 -0
  17. package/skills/ai-llm-redteam/SKILL.md +118 -0
  18. package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
  19. package/skills/android-penetration-tester/SKILL.md +83 -0
  20. package/skills/appsec-code-auditor/SKILL.md +86 -0
  21. package/skills/artifact-integrity-analyst/SKILL.md +68 -0
  22. package/skills/attack-navigator/SKILL.md +64 -0
  23. package/skills/auth-session-hacker/SKILL.md +87 -0
  24. package/skills/aws-penetration-tester/SKILL.md +60 -0
  25. package/skills/azure-penetration-tester/SKILL.md +64 -0
  26. package/skills/business-logic-attacker/SKILL.md +76 -0
  27. package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
  28. package/skills/ciso-orchestrator/SKILL.md +165 -0
  29. package/skills/cloud-infra-specialist/SKILL.md +85 -0
  30. package/skills/compliance-gap-analyst/SKILL.md +77 -0
  31. package/skills/compliance-grc/SKILL.md +148 -0
  32. package/skills/crypto-pki-specialist/SKILL.md +136 -0
  33. package/skills/dependency-confusion-attacker/SKILL.md +78 -0
  34. package/skills/evidence-collector/SKILL.md +86 -0
  35. package/skills/gcp-penetration-tester/SKILL.md +63 -0
  36. package/skills/injection-specialist/SKILL.md +62 -0
  37. package/skills/ios-security-auditor/SKILL.md +77 -0
  38. package/skills/k8s-container-escaper/SKILL.md +74 -0
  39. package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
  40. package/skills/logic-race-fuzzer/SKILL.md +67 -0
  41. package/skills/mobile-api-network-attacker/SKILL.md +81 -0
  42. package/skills/mobile-security-specialist/SKILL.md +124 -0
  43. package/skills/model-extraction-attacker/SKILL.md +68 -0
  44. package/skills/pentest-infra/SKILL.md +69 -0
  45. package/skills/pentest-social/SKILL.md +72 -0
  46. package/skills/pentest-team/SKILL.md +126 -0
  47. package/skills/pentest-web-api/SKILL.md +71 -0
  48. package/skills/privacy-flow-analyst/SKILL.md +70 -0
  49. package/skills/prompt-injection-specialist/SKILL.md +76 -0
  50. package/skills/rag-poisoning-specialist/SKILL.md +71 -0
  51. package/skills/senior-security-engineer/SKILL.md +42 -12
  52. package/skills/serialization-memory-attacker/SKILL.md +78 -0
  53. package/skills/stride-pasta-analyst/SKILL.md +72 -0
  54. package/skills/supply-chain-devsecops/SKILL.md +82 -0
  55. package/skills/threat-modeler/SKILL.md +116 -0
  56. package/skills/tls-certificate-auditor/SKILL.md +76 -0
@@ -0,0 +1,98 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/AbrahamOO/security-mcp/blob/main/defaults/agent-run-schema.json",
4
+ "title": "AgentRunManifest",
5
+ "description": "Schema for .mcp/agent-runs/{agentRunId}/manifest.json — the coordination state for a multi-agent security run.",
6
+ "type": "object",
7
+ "required": ["agentRunId", "runId", "createdAt", "updatedAt", "phase", "internetPermitted", "stackContext", "scope", "agents"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "agentRunId": {
11
+ "type": "string",
12
+ "minLength": 32,
13
+ "maxLength": 32,
14
+ "description": "32-character hex identifier for this agent run."
15
+ },
16
+ "runId": {
17
+ "type": "string",
18
+ "format": "uuid",
19
+ "description": "UUID of the parent review run from security.start_review."
20
+ },
21
+ "createdAt": {
22
+ "type": "string",
23
+ "format": "date-time"
24
+ },
25
+ "updatedAt": {
26
+ "type": "string",
27
+ "format": "date-time"
28
+ },
29
+ "phase": {
30
+ "type": "integer",
31
+ "enum": [0, 1, 2, 3],
32
+ "description": "Current execution phase: 0=init, 1=parallel discovery, 2=adversarial+compliance, 3=synthesis."
33
+ },
34
+ "internetPermitted": {
35
+ "type": "boolean",
36
+ "description": "Whether the user permitted internet access for this run."
37
+ },
38
+ "stackContext": {
39
+ "type": "object",
40
+ "required": ["languages", "frameworks", "databases", "cloudProvider", "paymentProcessor", "hasAI", "hasMobile", "hasPII", "hasPayments", "packageManagers", "ciPlatform"],
41
+ "additionalProperties": false,
42
+ "properties": {
43
+ "languages": { "type": "array", "items": { "type": "string" } },
44
+ "frameworks": { "type": "array", "items": { "type": "string" } },
45
+ "databases": { "type": "array", "items": { "type": "string" } },
46
+ "cloudProvider": { "type": "array", "items": { "type": "string" } },
47
+ "paymentProcessor":{ "type": "array", "items": { "type": "string" } },
48
+ "hasAI": { "type": "boolean" },
49
+ "hasMobile": { "type": "boolean" },
50
+ "hasPII": { "type": "boolean" },
51
+ "hasPayments": { "type": "boolean" },
52
+ "packageManagers": { "type": "array", "items": { "type": "string" } },
53
+ "ciPlatform": { "type": "array", "items": { "type": "string" } }
54
+ }
55
+ },
56
+ "scope": {
57
+ "type": "object",
58
+ "required": ["mode", "targets", "baseRef", "headRef"],
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "mode": {
62
+ "type": "string",
63
+ "enum": ["recent_changes", "folder_by_folder", "file_by_file"]
64
+ },
65
+ "targets": {
66
+ "type": "array",
67
+ "items": { "type": "string" }
68
+ },
69
+ "baseRef": { "type": "string" },
70
+ "headRef": { "type": "string" }
71
+ }
72
+ },
73
+ "agents": {
74
+ "type": "object",
75
+ "description": "Map of agent name to its lifecycle record.",
76
+ "additionalProperties": {
77
+ "$ref": "#/$defs/AgentRecord"
78
+ }
79
+ }
80
+ },
81
+ "$defs": {
82
+ "AgentRecord": {
83
+ "type": "object",
84
+ "required": ["status", "startedAt", "completedAt", "findingsPath", "summary"],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "status": {
88
+ "type": "string",
89
+ "enum": ["pending", "running", "completed", "completed_partial", "failed"]
90
+ },
91
+ "startedAt": { "type": ["string", "null"], "format": "date-time" },
92
+ "completedAt": { "type": ["string", "null"], "format": "date-time" },
93
+ "findingsPath": { "type": ["string", "null"] },
94
+ "summary": { "type": ["string", "null"] }
95
+ }
96
+ }
97
+ }
98
+ }
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { homedir, platform } from "node:os";
9
+ import * as https from "node:https";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { runOnboarding, installSecurityTools, commandExists, SECURITY_TOOLS } from "./onboarding.js";
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -158,6 +159,71 @@ function installSkill(dryRun) {
158
159
  }
159
160
  process.stdout.write(` ${dryRun ? "[dry-run] would copy" : "installed"} skill: ${skillDest}\n`);
160
161
  }
162
+ /**
163
+ * Download a skill SKILL.md from a remote URL and save it to ~/.claude/skills/{skillName}/SKILL.md.
164
+ * Used for lazy on-demand skill installation — all sub-agents are downloaded this way at first use.
165
+ * Mirrors the same pattern used for security tool binary downloads in onboarding.ts.
166
+ */
167
+ // CWE-22: only alphanumeric, hyphens, and dots allowed in skill names
168
+ const SAFE_SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
169
+ export async function downloadSkill(skillName, url, dryRun = false) {
170
+ if (!SAFE_SKILL_NAME_RE.test(skillName)) {
171
+ process.stdout.write(` [error] invalid skill name "${skillName}" — skipping download\n`);
172
+ return;
173
+ }
174
+ const skillDest = resolveHome(`~/.claude/skills/${skillName}/SKILL.md`);
175
+ if (dryRun) {
176
+ process.stdout.write(` [dry-run] would download skill "${skillName}" from ${url} → ${skillDest}\n`);
177
+ return;
178
+ }
179
+ const MAX_SKILL_BYTES = 512 * 1024; // 512 KB — skills are markdown files
180
+ const content = await new Promise((resolve) => {
181
+ const req = https.get(url, { headers: { "User-Agent": "security-mcp" } }, (res) => {
182
+ if ((res.statusCode ?? 500) >= 400) {
183
+ res.resume();
184
+ resolve(null);
185
+ return;
186
+ }
187
+ let body = "";
188
+ res.setEncoding("utf8");
189
+ res.on("data", (chunk) => {
190
+ body += chunk;
191
+ if (Buffer.byteLength(body, "utf8") > MAX_SKILL_BYTES) {
192
+ req.destroy();
193
+ resolve(null);
194
+ }
195
+ });
196
+ res.on("end", () => resolve(body));
197
+ });
198
+ req.on("error", () => resolve(null));
199
+ req.setTimeout(10000, () => { req.destroy(); resolve(null); });
200
+ });
201
+ if (!content) {
202
+ process.stdout.write(` [error] failed to download skill "${skillName}" from ${url}\n`);
203
+ return;
204
+ }
205
+ mkdirSync(dirname(skillDest), { recursive: true });
206
+ writeFileSync(skillDest, content, "utf-8");
207
+ process.stdout.write(` installed skill: ${skillDest}\n`);
208
+ }
209
+ /**
210
+ * Eagerly install the orchestrator skill (bundled in the package) plus record
211
+ * its version so orchestration.ensure_skill can detect future updates.
212
+ */
213
+ function installOrchestratorSkill(dryRun) {
214
+ const skillName = "ciso-orchestrator";
215
+ const skillSrc = join(PKG_ROOT, "skills", skillName, "SKILL.md");
216
+ const skillDest = resolveHome(`~/.claude/skills/${skillName}/SKILL.md`);
217
+ if (!existsSync(skillSrc)) {
218
+ process.stdout.write(` [skip] skills/${skillName}/SKILL.md not found in package\n`);
219
+ return;
220
+ }
221
+ if (!dryRun) {
222
+ mkdirSync(dirname(skillDest), { recursive: true });
223
+ copyFileSync(skillSrc, skillDest);
224
+ }
225
+ process.stdout.write(` ${dryRun ? "[dry-run] would copy" : "installed"} skill: ${skillDest}\n`);
226
+ }
161
227
  export async function runInstall(opts) {
162
228
  const dryRun = opts.dryRun;
163
229
  // ── Interactive onboarding (skipped when --yes or non-TTY) ──────────────
@@ -195,11 +261,12 @@ export async function runInstall(opts) {
195
261
  process.stdout.write(` [error] ${err instanceof Error ? err.message : String(err)}\n`);
196
262
  }
197
263
  }
198
- // Install Claude Code skill if Claude Code is in scope
264
+ // Install Claude Code skills if Claude Code is in scope
199
265
  const hasClaudeCode = targets.some((t) => t.name.startsWith("Claude Code"));
200
266
  if (hasClaudeCode || opts.all) {
201
- process.stdout.write("\nInstalling Claude Code skill...\n");
267
+ process.stdout.write("\nInstalling Claude Code skills...\n");
202
268
  installSkill(dryRun);
269
+ installOrchestratorSkill(dryRun);
203
270
  }
204
271
  process.stdout.write("\nInstalling security policy...\n");
205
272
  installPolicy(dryRun);
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { stdin as input, stdout as output } from "node:process";
15
- import { execSync, spawnSync } from "node:child_process";
15
+ import { spawnSync } from "node:child_process";
16
16
  import { platform, arch, homedir } from "node:os";
17
17
  import { mkdirSync, createWriteStream, chmodSync, existsSync } from "node:fs";
18
18
  import { join } from "node:path";
@@ -204,13 +204,13 @@ function getCpuArch() {
204
204
  }
205
205
  export function commandExists(cmd) {
206
206
  try {
207
+ // Use spawnSync (not execSync) to avoid shell injection — cmd is never interpolated into a shell string
207
208
  if (process.platform === "win32") {
208
- execSync(`where "${cmd}"`, { stdio: "pipe" });
209
+ return spawnSync("where", [cmd], { stdio: "pipe" }).status === 0;
209
210
  }
210
211
  else {
211
- execSync(`command -v ${cmd}`, { stdio: "pipe" });
212
+ return spawnSync("which", [cmd], { stdio: "pipe" }).status === 0;
212
213
  }
213
- return true;
214
214
  }
215
215
  catch {
216
216
  return false;
@@ -4,11 +4,13 @@ import { dirname, join } from "node:path";
4
4
  import * as https from "node:https";
5
5
  const CACHE_DIR = join(homedir(), ".security-mcp");
6
6
  const CACHE_PATH = join(CACHE_DIR, "update-check.json");
7
+ const SKILL_VERSIONS_PATH = join(CACHE_DIR, "skill-versions.json");
7
8
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
9
  const PROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000;
9
10
  const REGISTRY_URL = "https://registry.npmjs.org/security-mcp/latest";
11
+ const SKILLS_MANIFEST_URL = "https://raw.githubusercontent.com/AbrahamOO/security-mcp/main/skills-manifest.json";
10
12
  function parseVersion(input) {
11
- const match = input.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
13
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(input.trim());
12
14
  if (!match)
13
15
  return null;
14
16
  return {
@@ -64,10 +66,15 @@ function fetchLatestVersion(timeoutMs = 1500) {
64
66
  resolve(null);
65
67
  return;
66
68
  }
69
+ const MAX_BYTES = 64 * 1024; // 64 KB — npm registry version response
67
70
  let body = "";
68
71
  res.setEncoding("utf8");
69
72
  res.on("data", (chunk) => {
70
73
  body += chunk;
74
+ if (Buffer.byteLength(body, "utf8") > MAX_BYTES) {
75
+ req.destroy();
76
+ resolve(null);
77
+ }
71
78
  });
72
79
  res.on("end", () => {
73
80
  try {
@@ -96,6 +103,71 @@ function shouldPrompt(cache, latestVersion, now) {
96
103
  return true;
97
104
  return now - lastPromptedAt >= PROMPT_INTERVAL_MS;
98
105
  }
106
+ /** Check the skills manifest for skills that have newer versions than what is locally installed. */
107
+ async function checkSkillUpdates() {
108
+ try {
109
+ const body = await new Promise((resolve) => {
110
+ const req = https.get(SKILLS_MANIFEST_URL, { headers: { "User-Agent": "security-mcp-update-checker" } }, (res) => {
111
+ if ((res.statusCode ?? 500) >= 400) {
112
+ res.resume();
113
+ resolve(null);
114
+ return;
115
+ }
116
+ const MAX_MANIFEST_BYTES = 256 * 1024; // 256 KB
117
+ let buf = "";
118
+ res.setEncoding("utf8");
119
+ res.on("data", (c) => {
120
+ buf += c;
121
+ if (Buffer.byteLength(buf, "utf8") > MAX_MANIFEST_BYTES) {
122
+ req.destroy();
123
+ resolve(null);
124
+ }
125
+ });
126
+ res.on("end", () => resolve(buf));
127
+ });
128
+ req.on("error", () => resolve(null));
129
+ req.setTimeout(3000, () => { req.destroy(); resolve(null); });
130
+ });
131
+ if (!body)
132
+ return [];
133
+ const manifest = JSON.parse(body);
134
+ let installed = {};
135
+ try {
136
+ installed = JSON.parse(readFileSync(SKILL_VERSIONS_PATH, "utf-8"));
137
+ }
138
+ catch { /* not installed yet */ }
139
+ const outdated = [];
140
+ for (const [name, entry] of Object.entries(manifest.skills)) {
141
+ const local = installed[name]?.version;
142
+ if (local && local !== entry.version) {
143
+ outdated.push(`${name}: ${local} → ${entry.version}`);
144
+ }
145
+ }
146
+ return outdated;
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ }
152
+ function printUpdateNotices(cache, currentVersion, now) {
153
+ const hasMcpUpdate = cache.latestVersion && compareVersions(currentVersion, cache.latestVersion) < 0;
154
+ const hasSkillUpdates = (cache.skillsWithUpdates?.length ?? 0) > 0;
155
+ if (!hasMcpUpdate && !hasSkillUpdates)
156
+ return;
157
+ if (cache.latestVersion && !shouldPrompt(cache, cache.latestVersion, now))
158
+ return;
159
+ if (hasMcpUpdate && cache.latestVersion) {
160
+ console.error(`\nUpdate available: security-mcp ${currentVersion} → ${cache.latestVersion}\n` +
161
+ "Run the CISO Orchestrator skill and choose option (A) to update automatically, or:\n" +
162
+ ` npm install -g security-mcp@${cache.latestVersion}\n` +
163
+ " security-mcp install\n");
164
+ }
165
+ if (hasSkillUpdates && cache.skillsWithUpdates) {
166
+ console.error("\nSkill updates available:\n" +
167
+ cache.skillsWithUpdates.map((s) => ` • ${s}`).join("\n") +
168
+ "\nRun the CISO Orchestrator skill to apply skill updates automatically.\n");
169
+ }
170
+ }
99
171
  export async function notifyIfUpdateAvailable(currentVersion) {
100
172
  const now = Date.now();
101
173
  const cache = readCache();
@@ -103,22 +175,18 @@ export async function notifyIfUpdateAvailable(currentVersion) {
103
175
  const shouldRefresh = Number.isNaN(lastCheckedAt) || now - lastCheckedAt >= CHECK_INTERVAL_MS;
104
176
  if (shouldRefresh) {
105
177
  const latestVersion = await fetchLatestVersion();
106
- if (latestVersion) {
178
+ if (latestVersion)
107
179
  cache.latestVersion = latestVersion;
108
- }
180
+ const skillUpdates = await checkSkillUpdates();
181
+ if (skillUpdates.length > 0)
182
+ cache.skillsWithUpdates = skillUpdates;
109
183
  cache.lastCheckedAt = new Date(now).toISOString();
110
184
  writeCache(cache);
111
185
  }
112
- if (!cache.latestVersion)
113
- return;
114
- if (compareVersions(currentVersion, cache.latestVersion) >= 0)
115
- return;
116
- if (!shouldPrompt(cache, cache.latestVersion, now))
117
- return;
118
- process.stderr.write(`\nUpdate available: security-mcp ${currentVersion} -> ${cache.latestVersion}\n` +
119
- "Update command: npm install -g security-mcp@latest\n" +
120
- "Then refresh editor config: security-mcp install-global\n\n");
121
- cache.lastPromptedVersion = cache.latestVersion;
122
- cache.lastPromptedAt = new Date(now).toISOString();
123
- writeCache(cache);
186
+ printUpdateNotices(cache, currentVersion, now);
187
+ if (cache.latestVersion) {
188
+ cache.lastPromptedVersion = cache.latestVersion;
189
+ cache.lastPromptedAt = new Date(now).toISOString();
190
+ writeCache(cache);
191
+ }
124
192
  }
@@ -294,16 +294,91 @@ async function runDynamicProbes(endpointUrl, probes) {
294
294
  }
295
295
  return results;
296
296
  }
297
+ // CWE-918: resolve and validate an endpoint URL is not pointing to a private/metadata host.
298
+ const PROBE_PRIVATE_RE = [/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, /^::1$/, /^fc/, /^fd/];
299
+ function isProbePrivateIp(ip) {
300
+ return PROBE_PRIVATE_RE.some((r) => r.test(ip));
301
+ }
302
+ async function resolveSafeEndpoint(rawUrl) {
303
+ try {
304
+ const parsed = new URL(rawUrl);
305
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
306
+ return null;
307
+ const { lookup } = await import("node:dns/promises");
308
+ const { isIP } = await import("node:net");
309
+ const host = parsed.hostname;
310
+ if (isIP(host))
311
+ return isProbePrivateIp(host) ? null : rawUrl;
312
+ if (host === "localhost" || host.endsWith(".internal"))
313
+ return null;
314
+ const resolved = await lookup(host, { all: true });
315
+ if (resolved.some(({ address }) => isProbePrivateIp(address)))
316
+ return null;
317
+ return rawUrl;
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ }
323
+ function probeFailureToFinding(probe) {
324
+ switch (probe.probe) {
325
+ case "jailbreak": return {
326
+ id: "AI_JAILBREAK_SUCCESS",
327
+ title: "Jailbreak probe succeeded — system prompt may have leaked",
328
+ severity: "CRITICAL",
329
+ evidence: ["Probe: jailbreak", probe.detail ?? ""],
330
+ requiredActions: [
331
+ "Implement system prompt protection: use instruction hierarchy, not string concatenation.",
332
+ "Add jailbreak detection and monitoring.",
333
+ "Do not rely on the system prompt for access control."
334
+ ]
335
+ };
336
+ case "injection": return {
337
+ id: "AI_INJECTION_SUCCESS",
338
+ title: "Prompt injection probe succeeded via simulated RAG context",
339
+ severity: "CRITICAL",
340
+ evidence: ["Probe: injection"],
341
+ requiredActions: [
342
+ "Apply content isolation between user instructions and retrieved context.",
343
+ "Treat all RAG-retrieved content as untrusted.",
344
+ "Validate model outputs before acting on them."
345
+ ]
346
+ };
347
+ case "pii_exfil": return {
348
+ id: "AI_PII_LEAK",
349
+ title: "PII exfiltration probe succeeded — model repeated sensitive data",
350
+ severity: "CRITICAL",
351
+ evidence: ["Probe: pii_exfil"],
352
+ requiredActions: [
353
+ "Implement output PII scanning before returning model responses.",
354
+ "Block responses containing SSN, card numbers, or credential patterns.",
355
+ "Add output filtering as a defense-in-depth layer."
356
+ ]
357
+ };
358
+ case "token_flood": return {
359
+ id: "AI_RATE_LIMIT_MISSING",
360
+ title: "Token flooding probe was not rate-limited — DoS risk",
361
+ severity: "HIGH",
362
+ evidence: ["Probe: token_flood"],
363
+ requiredActions: [
364
+ "Implement request size limits and token quotas on AI endpoints.",
365
+ "Return 413 or 429 for oversized requests.",
366
+ "Add per-user token budgets."
367
+ ]
368
+ };
369
+ default: return null;
370
+ }
371
+ }
297
372
  /**
298
373
  * Run AI/LLM red-team checks: static analysis + optional dynamic probes.
299
374
  */
300
375
  export async function runAiRedteamChecks(opts) {
301
376
  const findings = [];
302
- // Static analysis (always runs)
303
- const staticFindings = await runStaticAnalysis(opts.changedFiles);
304
- findings.push(...staticFindings);
305
- // Dynamic probes (only if endpoint is configured)
306
- const endpointUrl = opts.endpointUrl ?? process.env["SECURITY_AI_ENDPOINT"];
377
+ findings.push(...await runStaticAnalysis(opts.changedFiles));
378
+ const rawEndpointUrl = opts.endpointUrl ?? process.env["SECURITY_AI_ENDPOINT"];
379
+ if (!rawEndpointUrl)
380
+ return findings;
381
+ const endpointUrl = await resolveSafeEndpoint(rawEndpointUrl);
307
382
  if (!endpointUrl)
308
383
  return findings;
309
384
  const allProbes = ["jailbreak", "injection", "pii_exfil", "token_flood"];
@@ -314,60 +389,9 @@ export async function runAiRedteamChecks(opts) {
314
389
  for (const probe of result.value) {
315
390
  if (probe.passed)
316
391
  continue;
317
- switch (probe.probe) {
318
- case "jailbreak":
319
- findings.push({
320
- id: "AI_JAILBREAK_SUCCESS",
321
- title: "Jailbreak probe succeeded — system prompt may have leaked",
322
- severity: "CRITICAL",
323
- evidence: ["Probe: jailbreak", probe.detail ?? ""],
324
- requiredActions: [
325
- "Implement system prompt protection: use instruction hierarchy, not string concatenation.",
326
- "Add jailbreak detection and monitoring.",
327
- "Do not rely on the system prompt for access control."
328
- ]
329
- });
330
- break;
331
- case "injection":
332
- findings.push({
333
- id: "AI_INJECTION_SUCCESS",
334
- title: "Prompt injection probe succeeded via simulated RAG context",
335
- severity: "CRITICAL",
336
- evidence: ["Probe: injection"],
337
- requiredActions: [
338
- "Apply content isolation between user instructions and retrieved context.",
339
- "Treat all RAG-retrieved content as untrusted.",
340
- "Validate model outputs before acting on them."
341
- ]
342
- });
343
- break;
344
- case "pii_exfil":
345
- findings.push({
346
- id: "AI_PII_LEAK",
347
- title: "PII exfiltration probe succeeded — model repeated sensitive data",
348
- severity: "CRITICAL",
349
- evidence: ["Probe: pii_exfil"],
350
- requiredActions: [
351
- "Implement output PII scanning before returning model responses.",
352
- "Block responses containing SSN, card numbers, or credential patterns.",
353
- "Add output filtering as a defense-in-depth layer."
354
- ]
355
- });
356
- break;
357
- case "token_flood":
358
- findings.push({
359
- id: "AI_RATE_LIMIT_MISSING",
360
- title: "Token flooding probe was not rate-limited — DoS risk",
361
- severity: "HIGH",
362
- evidence: ["Probe: token_flood"],
363
- requiredActions: [
364
- "Implement request size limits and token quotas on AI endpoints.",
365
- "Return 413 or 429 for oversized requests.",
366
- "Add per-user token budgets."
367
- ]
368
- });
369
- break;
370
- }
392
+ const finding = probeFailureToFinding(probe);
393
+ if (finding)
394
+ findings.push(finding);
371
395
  }
372
396
  }
373
397
  return findings;
@@ -2,8 +2,59 @@
2
2
  * Runtime evidence verification.
3
3
  * Checks HTTP security headers and TLS configuration against a live target.
4
4
  */
5
+ import * as dns from "node:dns/promises";
6
+ import * as net from "node:net";
5
7
  import * as https from "node:https";
6
8
  import * as tls from "node:tls";
9
+ // CWE-918: SSRF guard — block private/link-local/metadata IP ranges
10
+ const PRIVATE_CIDR_PATTERNS = [
11
+ /^127\./, // loopback
12
+ /^10\./, // RFC-1918
13
+ /^172\.(1[6-9]|2\d|3[01])\./, // RFC-1918
14
+ /^192\.168\./, // RFC-1918
15
+ /^169\.254\./, // link-local / cloud metadata (169.254.169.254)
16
+ /^::1$/, // IPv6 loopback
17
+ /^fc/, // IPv6 ULA
18
+ /^fd/, // IPv6 ULA
19
+ /^0\./, // 0.0.0.0/8
20
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // RFC-6598 shared address space
21
+ ];
22
+ function isPrivateIp(ip) {
23
+ return PRIVATE_CIDR_PATTERNS.some((re) => re.test(ip));
24
+ }
25
+ async function isSafeUrl(rawUrl) {
26
+ let parsed;
27
+ try {
28
+ parsed = new URL(rawUrl);
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
34
+ return false;
35
+ const host = parsed.hostname;
36
+ // Block bare IP references
37
+ if (net.isIP(host)) {
38
+ return !isPrivateIp(host);
39
+ }
40
+ // Block known metadata hostnames
41
+ if (host === "localhost" || host === "metadata.google.internal" ||
42
+ host === "169.254.169.254" || host.endsWith(".internal")) {
43
+ return false;
44
+ }
45
+ // Resolve DNS and check all resolved IPs
46
+ try {
47
+ const resolved = await dns.lookup(host, { all: true });
48
+ for (const { address } of resolved) {
49
+ if (isPrivateIp(address))
50
+ return false;
51
+ }
52
+ }
53
+ catch {
54
+ return false; // can't resolve → skip
55
+ }
56
+ return true;
57
+ }
7
58
  const REQUIRED_HEADERS = [
8
59
  {
9
60
  name: "content-security-policy",
@@ -129,8 +180,10 @@ export async function runRuntimeChecks(opts) {
129
180
  const findings = [];
130
181
  // Determine target URL
131
182
  const stagingUrl = process.env["SECURITY_STAGING_URL"];
132
- const targets = stagingUrl ? [stagingUrl, ...opts.targets] : opts.targets;
133
- const uniqueTargets = [...new Set(targets)].filter((t) => t.startsWith("http"));
183
+ const rawTargets = stagingUrl ? [stagingUrl, ...opts.targets] : opts.targets;
184
+ // CWE-918: resolve hostnames and reject private/metadata IPs before making requests
185
+ const safeChecks = await Promise.all(rawTargets.map(async (t) => ({ t, safe: await isSafeUrl(t) })));
186
+ const uniqueTargets = [...new Set(safeChecks.filter((x) => x.safe).map((x) => x.t))];
134
187
  if (uniqueTargets.length === 0)
135
188
  return findings;
136
189
  const timeoutMs = 15_000;
@@ -29,7 +29,12 @@ const ScannerConfigSchema = z.object({
29
29
  async function loadScannerConfig() {
30
30
  const overridePath = process.env["SECURITY_GATE_SCANNERS"];
31
31
  if (overridePath) {
32
- 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");
33
38
  return ScannerConfigSchema.parse(JSON.parse(raw));
34
39
  }
35
40
  try {
@@ -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");