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.
- package/README.md +963 -193
- package/defaults/agent-run-schema.json +98 -0
- package/dist/cli/install.js +69 -2
- package/dist/cli/onboarding.js +4 -4
- package/dist/cli/update.js +83 -15
- package/dist/gate/checks/ai-redteam.js +83 -59
- package/dist/gate/checks/runtime.js +55 -2
- package/dist/gate/checks/scanners.js +6 -1
- package/dist/gate/exceptions.js +6 -1
- package/dist/mcp/orchestration.js +586 -0
- package/dist/mcp/server.js +69 -12
- package/dist/repo/search.js +5 -7
- package/dist/review/store.js +5 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- 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 +42 -12
- 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
|
@@ -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
|
+
}
|
package/dist/cli/install.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/dist/cli/onboarding.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
209
|
+
return spawnSync("where", [cmd], { stdio: "pipe" }).status === 0;
|
|
209
210
|
}
|
|
210
211
|
else {
|
|
211
|
-
|
|
212
|
+
return spawnSync("which", [cmd], { stdio: "pipe" }).status === 0;
|
|
212
213
|
}
|
|
213
|
-
return true;
|
|
214
214
|
}
|
|
215
215
|
catch {
|
|
216
216
|
return false;
|
package/dist/cli/update.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const endpointUrl =
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
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 {
|
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");
|