security-mcp 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -21
- package/defaults/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 +549 -0
- package/defaults/evidence-map.json +194 -0
- package/defaults/security-exceptions.json +4 -0
- package/defaults/security-policy.json +41 -2
- package/defaults/security-tools.json +41 -0
- package/dist/ci/pr-gate.js +2 -3
- package/dist/cli/index.js +63 -23
- package/dist/cli/install.js +47 -15
- package/dist/cli/onboarding.js +590 -0
- package/dist/cli/update.js +124 -0
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/catalog.js +55 -0
- package/dist/gate/checks/ai-redteam.js +374 -0
- package/dist/gate/checks/ai.js +45 -14
- 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 +130 -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 +263 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +450 -0
- package/dist/gate/checks/secrets.js +119 -27
- package/dist/gate/diff.js +2 -2
- package/dist/gate/evidence.js +116 -0
- package/dist/gate/exceptions.js +85 -0
- package/dist/gate/policy.js +189 -17
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/server.js +938 -9
- package/dist/repo/fs.js +10 -5
- package/dist/repo/search.js +13 -1
- package/dist/review/store.js +208 -0
- package/dist/tests/run.js +103 -0
- package/package.json +13 -3
- package/prompts/SECURITY_PROMPT.md +455 -1
- package/skills/senior-security-engineer/SKILL.md +81 -4
package/dist/mcp/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|
|
7
7
|
import { runPrGate } from "../gate/policy.js";
|
|
8
8
|
import { readFileSafe } from "../repo/fs.js";
|
|
9
9
|
import { searchRepo } from "../repo/search.js";
|
|
10
|
+
import { createReviewAttestation, createReviewRun, readReviewRun, updateReviewStep } from "../review/store.js";
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const PKG_ROOT = resolve(__dirname, "../..");
|
|
12
13
|
const PROMPTS_DIR = join(PKG_ROOT, "prompts");
|
|
@@ -20,7 +21,6 @@ function loadPromptFile(name) {
|
|
|
20
21
|
return `[security-mcp] Prompt file not found: ${name}. Run "npm run build" from the package root.`;
|
|
21
22
|
}
|
|
22
23
|
const SECURITY_PROMPT = loadPromptFile("SECURITY_PROMPT.md");
|
|
23
|
-
/* eslint-disable deprecation/deprecation */
|
|
24
24
|
const server = new McpServer({
|
|
25
25
|
name: "security-mcp",
|
|
26
26
|
version: "1.0.0"
|
|
@@ -50,21 +50,124 @@ function safeTool(handler) {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
53
|
+
// Review workflow
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
const ReviewRunIdParam = {
|
|
56
|
+
runId: z.string().uuid().optional().describe("Optional security review run ID created by security.start_review.")
|
|
57
|
+
};
|
|
58
|
+
const StartReviewParams = {
|
|
59
|
+
mode: z.enum(["recent_changes", "folder_by_folder", "file_by_file"]).describe("Required scan scope mode for this review."),
|
|
60
|
+
targets: z.array(z.string()).optional().describe("Required for folder_by_folder and file_by_file modes. Relative folders/files to evaluate."),
|
|
61
|
+
baseRef: z.string().optional().describe("Only for recent_changes mode. Base git ref, default origin/main."),
|
|
62
|
+
headRef: z.string().optional().describe("Only for recent_changes mode. Head git ref, default HEAD.")
|
|
63
|
+
};
|
|
64
|
+
const StartReviewSchema = z.object(StartReviewParams);
|
|
65
|
+
tool("security.start_review", "Start a stateful security review run, lock the scan mode, and return a run ID for ordered execution and attestation. OPERATING MANDATE: 90% fixing, 10% advisory. You do not list vulnerabilities and walk away — you write the fix, implement the control, and enforce the policy.", StartReviewParams, safeTool(async (args, _extra) => {
|
|
66
|
+
const { mode, targets, baseRef, headRef } = StartReviewSchema.parse(args);
|
|
67
|
+
const cleanTargets = (targets ?? []).map((target) => target.trim()).filter(Boolean);
|
|
68
|
+
if ((mode === "folder_by_folder" || mode === "file_by_file") && cleanTargets.length === 0) {
|
|
69
|
+
throw new Error(`Mode "${mode}" requires one or more relative targets.`);
|
|
70
|
+
}
|
|
71
|
+
const run = await createReviewRun({ mode, targets, baseRef, headRef });
|
|
72
|
+
await updateReviewStep(run.id, "scan_strategy", "completed", {
|
|
73
|
+
mode,
|
|
74
|
+
targets: cleanTargets,
|
|
75
|
+
baseRef: baseRef ?? "origin/main",
|
|
76
|
+
headRef: headRef ?? "HEAD"
|
|
77
|
+
});
|
|
78
|
+
return asTextResponse({
|
|
79
|
+
runId: run.id,
|
|
80
|
+
mode,
|
|
81
|
+
targets: cleanTargets,
|
|
82
|
+
baseRef: baseRef ?? "origin/main",
|
|
83
|
+
headRef: headRef ?? "HEAD",
|
|
84
|
+
requiredSteps: run.requiredSteps,
|
|
85
|
+
operatingMandate: "90% fixing, 10% advisory. Write the fix. Implement the control. Enforce the policy. Do not list vulnerabilities and walk away.",
|
|
86
|
+
nextSteps: [
|
|
87
|
+
"Run security.threat_model with this runId.",
|
|
88
|
+
"Run security.checklist with this runId.",
|
|
89
|
+
"Run security.run_pr_gate with this runId.",
|
|
90
|
+
"Run security.attest_review after remediation is complete."
|
|
91
|
+
]
|
|
92
|
+
});
|
|
93
|
+
}));
|
|
94
|
+
const AttestReviewParams = {
|
|
95
|
+
runId: z.string().uuid().describe("Security review run ID."),
|
|
96
|
+
signatureEnvVar: z.string().optional().describe("Optional environment variable containing an HMAC key for attestation signing.")
|
|
97
|
+
};
|
|
98
|
+
const AttestReviewSchema = z.object(AttestReviewParams);
|
|
99
|
+
tool("security.attest_review", "Generate a security review attestation with integrity hash and optional HMAC signature.", AttestReviewParams, safeTool(async (args, _extra) => {
|
|
100
|
+
const { runId, signatureEnvVar } = AttestReviewSchema.parse(args);
|
|
101
|
+
const run = await readReviewRun(runId);
|
|
102
|
+
const required = new Set(run.requiredSteps);
|
|
103
|
+
const completed = Array.from(required).filter((step) => {
|
|
104
|
+
const status = run.steps[step]?.status;
|
|
105
|
+
return status === "completed" || status === "approved";
|
|
106
|
+
});
|
|
107
|
+
const missing = Array.from(required).filter((step) => !completed.includes(step));
|
|
108
|
+
const latestGate = run.steps["run_pr_gate"]?.details ?? {};
|
|
109
|
+
const payload = {
|
|
110
|
+
runId: run.id,
|
|
111
|
+
createdAt: run.createdAt,
|
|
112
|
+
updatedAt: run.updatedAt,
|
|
113
|
+
mode: run.mode,
|
|
114
|
+
targets: run.targets,
|
|
115
|
+
steps: run.steps,
|
|
116
|
+
coverage: {
|
|
117
|
+
required: Array.from(required),
|
|
118
|
+
completed,
|
|
119
|
+
missing
|
|
120
|
+
},
|
|
121
|
+
latestGate
|
|
122
|
+
};
|
|
123
|
+
const signatureKey = signatureEnvVar ? process.env[signatureEnvVar] : undefined;
|
|
124
|
+
const attestation = await createReviewAttestation(runId, payload, signatureKey);
|
|
125
|
+
return asTextResponse({
|
|
126
|
+
attestationPath: attestation.path,
|
|
127
|
+
sha256: attestation.sha256,
|
|
128
|
+
...(attestation.hmacSha256 ? { hmacSha256: attestation.hmacSha256 } : {}),
|
|
129
|
+
completedSteps: completed,
|
|
130
|
+
missingSteps: missing,
|
|
131
|
+
confidence: latestGate["confidence"] ?? null
|
|
132
|
+
});
|
|
133
|
+
}));
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Existing tools
|
|
54
136
|
// ---------------------------------------------------------------------------
|
|
55
137
|
const RunPrGateParams = {
|
|
138
|
+
...ReviewRunIdParam,
|
|
139
|
+
mode: z.enum(["recent_changes", "folder_by_folder", "file_by_file"]).optional().describe("Scan scope mode. recent_changes (default) uses git diff; folder_by_folder scans one or more folders; file_by_file scans explicit files."),
|
|
140
|
+
targets: z.array(z.string()).optional().describe("Required for folder_by_folder and file_by_file modes. Relative folders/files to evaluate."),
|
|
56
141
|
baseRef: z.string().optional().describe("Base git ref for diff (e.g. origin/main). Optional."),
|
|
57
142
|
headRef: z.string().optional().describe("Head git ref for diff (e.g. HEAD). Optional."),
|
|
58
143
|
policyPath: z.string().optional().describe("Override policy path. Default: .mcp/policies/security-policy.json")
|
|
59
144
|
};
|
|
60
145
|
const RunPrGateSchema = z.object(RunPrGateParams);
|
|
61
|
-
tool("security.run_pr_gate", "Run the security policy gate
|
|
62
|
-
const { baseRef, headRef, policyPath } = RunPrGateSchema.parse(args);
|
|
146
|
+
tool("security.run_pr_gate", "Run the security policy gate for recent changes, selected folders, or selected files. Returns PASS/FAIL plus findings and required actions.", RunPrGateParams, safeTool(async (args, _extra) => {
|
|
147
|
+
const { runId, mode, targets, baseRef, headRef, policyPath } = RunPrGateSchema.parse(args);
|
|
148
|
+
if (!runId) {
|
|
149
|
+
return asTextResponse({
|
|
150
|
+
requires_run_id: true,
|
|
151
|
+
question: "Start the review with security.start_review before running the gate.",
|
|
152
|
+
next_step: "Call security.start_review, then re-run security.run_pr_gate with the returned runId."
|
|
153
|
+
});
|
|
154
|
+
}
|
|
63
155
|
const result = await runPrGate({
|
|
156
|
+
mode,
|
|
157
|
+
targets,
|
|
64
158
|
baseRef,
|
|
65
159
|
headRef,
|
|
66
160
|
policyPath: policyPath ?? ".mcp/policies/security-policy.json"
|
|
67
161
|
});
|
|
162
|
+
await updateReviewStep(runId, "run_pr_gate", "completed", {
|
|
163
|
+
status: result.status,
|
|
164
|
+
confidence: result.confidence,
|
|
165
|
+
findings: result.findings.map((finding) => ({ id: finding.id, severity: finding.severity })),
|
|
166
|
+
suppressedFindings: result.suppressedFindings?.map((entry) => ({
|
|
167
|
+
id: entry.finding.id,
|
|
168
|
+
exceptionId: entry.exceptionId
|
|
169
|
+
})) ?? []
|
|
170
|
+
});
|
|
68
171
|
return asTextResponse(result);
|
|
69
172
|
}));
|
|
70
173
|
const ReadFileParams = {
|
|
@@ -97,9 +200,20 @@ const GetSystemPromptParams = {
|
|
|
97
200
|
payment_processor: z.string().optional().describe("Payment processor in use, e.g. 'Stripe', 'Braintree', 'Adyen', or 'none'.")
|
|
98
201
|
};
|
|
99
202
|
const GetSystemPromptSchema = z.object(GetSystemPromptParams);
|
|
100
|
-
tool("security.get_system_prompt", "Return the full security engineering system prompt. Optionally customized with your stack, cloud provider, and payment processor. Use this as the system prompt to configure Claude as an elite security engineer for your project.", GetSystemPromptParams, safeTool(async (args, _extra) => {
|
|
203
|
+
tool("security.get_system_prompt", "Return the full security engineering system prompt. Optionally customized with your stack, cloud provider, and payment processor. Use this as the system prompt to configure Claude as an elite security engineer for your project. Core operating ratio: 90% fixing, 10% advisory — write the fix, implement the control, enforce the policy.", GetSystemPromptParams, safeTool(async (args, _extra) => {
|
|
101
204
|
const { stack, cloud, payment_processor } = GetSystemPromptSchema.parse(args);
|
|
102
|
-
|
|
205
|
+
// Prepend the operating mandate so it is the first instruction the model reads,
|
|
206
|
+
// regardless of which part of the prompt file is loaded or truncated.
|
|
207
|
+
const OPERATING_MANDATE = "# CORE OPERATING MANDATE — READ FIRST\n\n" +
|
|
208
|
+
"Your operating ratio is **90% fixing, 10% advisory**.\n" +
|
|
209
|
+
"You do NOT list vulnerabilities and walk away.\n" +
|
|
210
|
+
"You write the fix. You implement the control. You enforce the policy.\n\n" +
|
|
211
|
+
"**90% action:** Write the secure code directly. Implement validation, middleware, " +
|
|
212
|
+
"access controls, and secret management. Produce production-ready fixes every time.\n\n" +
|
|
213
|
+
"**10% explanation:** One line — what was wrong, what attack it prevents, which framework " +
|
|
214
|
+
"control applies (OWASP, ATT&CK, NIST). Then move on.\n\n" +
|
|
215
|
+
"---\n\n";
|
|
216
|
+
let prompt = OPERATING_MANDATE + SECURITY_PROMPT;
|
|
103
217
|
// Append a project-specific scope section if any context was provided
|
|
104
218
|
if (stack ?? cloud ?? payment_processor) {
|
|
105
219
|
const scopeLines = [
|
|
@@ -124,13 +238,14 @@ tool("security.get_system_prompt", "Return the full security engineering system
|
|
|
124
238
|
// New tool: security.threat_model
|
|
125
239
|
// ---------------------------------------------------------------------------
|
|
126
240
|
const ThreatModelParams = {
|
|
241
|
+
...ReviewRunIdParam,
|
|
127
242
|
feature: z.string().describe("One or two sentences describing the feature or component to threat-model. " +
|
|
128
243
|
"Example: 'OAuth 2.0 login flow with PKCE and session cookies'."),
|
|
129
244
|
surfaces: z.array(z.enum(["web", "api", "mobile", "ai", "infra", "data"])).optional().describe("Attack surfaces involved. Defaults to all.")
|
|
130
245
|
};
|
|
131
246
|
const ThreatModelSchema = z.object(ThreatModelParams);
|
|
132
247
|
tool("security.threat_model", "Generate a STRIDE + PASTA + ATT&CK threat model template for a described feature or component. Returns a structured Markdown document ready to fill in.", ThreatModelParams, safeTool(async (args, _extra) => {
|
|
133
|
-
const { feature, surfaces } = ThreatModelSchema.parse(args);
|
|
248
|
+
const { runId, feature, surfaces } = ThreatModelSchema.parse(args);
|
|
134
249
|
const surfaceList = surfaces ?? ["web", "api", "mobile", "ai", "infra", "data"];
|
|
135
250
|
const template = `# Threat Model: ${feature}
|
|
136
251
|
|
|
@@ -241,12 +356,19 @@ Describe Level 0 (context) and Level 1 (process) flows in prose or embed a diagr
|
|
|
241
356
|
- [ ] IR playbook updated if new attack surface introduced
|
|
242
357
|
- [ ] Compliance requirements addressed and documented
|
|
243
358
|
`;
|
|
359
|
+
if (runId) {
|
|
360
|
+
await updateReviewStep(runId, "threat_model", "completed", {
|
|
361
|
+
feature,
|
|
362
|
+
surfaces: surfaceList
|
|
363
|
+
});
|
|
364
|
+
}
|
|
244
365
|
return asTextResponse(template);
|
|
245
366
|
}));
|
|
246
367
|
// ---------------------------------------------------------------------------
|
|
247
368
|
// New tool: security.checklist
|
|
248
369
|
// ---------------------------------------------------------------------------
|
|
249
370
|
const ChecklistParams = {
|
|
371
|
+
...ReviewRunIdParam,
|
|
250
372
|
surface: z.enum(["web", "api", "mobile", "ai", "infra", "payments", "all"]).optional()
|
|
251
373
|
.describe("Filter checklist by attack surface. Default: all.")
|
|
252
374
|
};
|
|
@@ -334,8 +456,11 @@ Use before every production release. All items must be checked or explicitly ris
|
|
|
334
456
|
- [ ] Audit trail maintained for all payment operations
|
|
335
457
|
`;
|
|
336
458
|
tool("security.checklist", "Return the pre-release security checklist, optionally filtered by attack surface (web, api, mobile, ai, infra, payments, all).", ChecklistParams, safeTool(async (args, _extra) => {
|
|
337
|
-
const { surface } = ChecklistSchema.parse(args);
|
|
459
|
+
const { runId, surface } = ChecklistSchema.parse(args);
|
|
338
460
|
if (!surface || surface === "all") {
|
|
461
|
+
if (runId) {
|
|
462
|
+
await updateReviewStep(runId, "checklist", "completed", { surface: "all" });
|
|
463
|
+
}
|
|
339
464
|
return asTextResponse(CHECKLIST_ALL);
|
|
340
465
|
}
|
|
341
466
|
// Extract the relevant section
|
|
@@ -358,6 +483,9 @@ tool("security.checklist", "Return the pre-release security checklist, optionall
|
|
|
358
483
|
const allSurfaces = lines.slice(0, allSurfacesEnd).join("\n");
|
|
359
484
|
const sectionEnd = lines.findIndex((l, i) => i > start + 1 && l.startsWith("## "));
|
|
360
485
|
const section = lines.slice(start, sectionEnd === -1 ? undefined : sectionEnd).join("\n");
|
|
486
|
+
if (runId) {
|
|
487
|
+
await updateReviewStep(runId, "checklist", "completed", { surface });
|
|
488
|
+
}
|
|
361
489
|
return asTextResponse(`# Pre-Release Security Checklist (${surface})\n\n${allSurfaces}\n\n${section}`);
|
|
362
490
|
}));
|
|
363
491
|
// ---------------------------------------------------------------------------
|
|
@@ -434,9 +562,810 @@ tool("security.generate_policy", "Generate a security-policy.json for your proje
|
|
|
434
562
|
return asTextResponse(comment + JSON.stringify(policy, null, 2));
|
|
435
563
|
}));
|
|
436
564
|
// ---------------------------------------------------------------------------
|
|
565
|
+
// New tool: security.scan_strategy
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
const ScanStrategyParams = {
|
|
568
|
+
...ReviewRunIdParam,
|
|
569
|
+
mode: z.enum(["folder_by_folder", "file_by_file", "recent_changes"]).optional().describe("Required scan mode. Ask the user to choose before starting review."),
|
|
570
|
+
targets: z.array(z.string()).optional().describe("Required for folder_by_folder and file_by_file. Relative folders/files to evaluate."),
|
|
571
|
+
baseRef: z.string().optional().describe("Only for recent_changes mode. Base git ref, default origin/main."),
|
|
572
|
+
headRef: z.string().optional().describe("Only for recent_changes mode. Head git ref, default HEAD.")
|
|
573
|
+
};
|
|
574
|
+
const ScanStrategySchema = z.object(ScanStrategyParams);
|
|
575
|
+
tool("security.scan_strategy", "Create an exhaustive security scan plan and enforce a required user choice: folder_by_folder, file_by_file, or recent_changes.", ScanStrategyParams, safeTool(async (args, _extra) => {
|
|
576
|
+
const { runId, mode, targets, baseRef, headRef } = ScanStrategySchema.parse(args);
|
|
577
|
+
if (!mode) {
|
|
578
|
+
return asTextResponse({
|
|
579
|
+
required_user_decision: true,
|
|
580
|
+
question: "Choose scan mode before running security checks.",
|
|
581
|
+
options: ["folder_by_folder", "file_by_file", "recent_changes"],
|
|
582
|
+
next_step: "Call security.scan_strategy again with the selected mode."
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const cleanTargets = (targets ?? []).map((t) => t.trim()).filter(Boolean);
|
|
586
|
+
if ((mode === "folder_by_folder" || mode === "file_by_file") && cleanTargets.length === 0) {
|
|
587
|
+
return asTextResponse({
|
|
588
|
+
required_user_decision: true,
|
|
589
|
+
question: `Mode "${mode}" requires explicit targets. Provide relative ${mode === "folder_by_folder" ? "folders" : "files"}.`,
|
|
590
|
+
next_step: "Call security.scan_strategy with mode + targets."
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
const frameworkCoverage = {
|
|
594
|
+
threat_modeling: ["STRIDE", "PASTA", "LINDDUN", "DREAD", "ATT&CK Navigator", "Attack Trees", "TRIKE"],
|
|
595
|
+
appsec_and_adversary: [
|
|
596
|
+
"OWASP Top 10 (Web/API)",
|
|
597
|
+
"OWASP ASVS L2/L3",
|
|
598
|
+
"OWASP MASVS",
|
|
599
|
+
"MITRE ATT&CK",
|
|
600
|
+
"MITRE D3FEND",
|
|
601
|
+
"MITRE CAPEC",
|
|
602
|
+
"MITRE ATLAS"
|
|
603
|
+
],
|
|
604
|
+
governance_and_compliance: [
|
|
605
|
+
"NIST 800-53 Rev5",
|
|
606
|
+
"NIST CSF 2.0",
|
|
607
|
+
"NIST 800-207 (Zero Trust)",
|
|
608
|
+
"NIST 800-218 (SSDF)",
|
|
609
|
+
"PCI DSS 4.0",
|
|
610
|
+
"SOC 2 Type II",
|
|
611
|
+
"ISO 27001/27002/42001",
|
|
612
|
+
"GDPR/CCPA"
|
|
613
|
+
],
|
|
614
|
+
pipeline_controls: [
|
|
615
|
+
"SAST",
|
|
616
|
+
"SCA",
|
|
617
|
+
"Secrets Scanning",
|
|
618
|
+
"IaC Scanning",
|
|
619
|
+
"Container Scanning",
|
|
620
|
+
"DAST",
|
|
621
|
+
"SBOM + Provenance"
|
|
622
|
+
]
|
|
623
|
+
};
|
|
624
|
+
const runGateTemplate = mode === "recent_changes"
|
|
625
|
+
? {
|
|
626
|
+
tool: "security.run_pr_gate",
|
|
627
|
+
args: {
|
|
628
|
+
mode: "recent_changes",
|
|
629
|
+
baseRef: baseRef ?? "origin/main",
|
|
630
|
+
headRef: headRef ?? "HEAD"
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
: {
|
|
634
|
+
tool: "security.run_pr_gate",
|
|
635
|
+
args: {
|
|
636
|
+
mode,
|
|
637
|
+
targets: cleanTargets
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
if (runId) {
|
|
641
|
+
await updateReviewStep(runId, "scan_strategy", "completed", {
|
|
642
|
+
mode,
|
|
643
|
+
targets: cleanTargets,
|
|
644
|
+
baseRef: baseRef ?? "origin/main",
|
|
645
|
+
headRef: headRef ?? "HEAD"
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return asTextResponse({
|
|
649
|
+
decision_confirmed: true,
|
|
650
|
+
mode,
|
|
651
|
+
targets: cleanTargets,
|
|
652
|
+
git_range: mode === "recent_changes" ? { baseRef: baseRef ?? "origin/main", headRef: headRef ?? "HEAD" } : null,
|
|
653
|
+
execution_plan: [
|
|
654
|
+
"1) Inventory scope and adjacent blast radius components.",
|
|
655
|
+
"2) Run threat model coverage (STRIDE + PASTA + ATT&CK + D3FEND).",
|
|
656
|
+
"3) Run policy gate + static/dynamic/IaC/container/security checks.",
|
|
657
|
+
"4) Map findings to OWASP/NIST/PCI/SOC2/ISO controls.",
|
|
658
|
+
"5) Apply code/config fixes immediately and re-run gate until PASS.",
|
|
659
|
+
"6) Produce residual-risk register with owner, date, and review cadence."
|
|
660
|
+
],
|
|
661
|
+
framework_coverage: frameworkCoverage,
|
|
662
|
+
run_gate_template: runGateTemplate,
|
|
663
|
+
completion_rule: "No section is complete until all required controls are either implemented or formally risk-accepted."
|
|
664
|
+
});
|
|
665
|
+
}));
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// New tool: security.terraform_hardening_blueprint
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
const TerraformHardeningParams = {
|
|
670
|
+
cloud: z.enum(["aws", "gcp", "azure", "multi"]).optional().describe("Target cloud platform. Default: multi."),
|
|
671
|
+
criticality: z.enum(["standard", "high", "regulated"]).optional().describe("Security strictness profile."),
|
|
672
|
+
environment: z.string().optional().describe("Environment name (e.g., prod, staging).")
|
|
673
|
+
};
|
|
674
|
+
const TerraformHardeningSchema = z.object(TerraformHardeningParams);
|
|
675
|
+
tool("security.terraform_hardening_blueprint", "Generate an advanced Terraform hardening blueprint with secure module design, guardrails, and control mappings.", TerraformHardeningParams, safeTool(async (args, _extra) => {
|
|
676
|
+
const { cloud, criticality, environment } = TerraformHardeningSchema.parse(args);
|
|
677
|
+
const selectedCloud = cloud ?? "multi";
|
|
678
|
+
const selectedCriticality = criticality ?? "high";
|
|
679
|
+
const blueprint = {
|
|
680
|
+
target: { cloud: selectedCloud, criticality: selectedCriticality, environment: environment ?? "unspecified" },
|
|
681
|
+
module_layout: [
|
|
682
|
+
"modules/network: private subnets, no default public ingress, egress allowlists",
|
|
683
|
+
"modules/identity: least-privilege IAM roles, short-lived credentials, no wildcard actions",
|
|
684
|
+
"modules/data: encryption at rest with CMEK/KMS, backup + PITR, private endpoints",
|
|
685
|
+
"modules/observability: audit logs + flow logs + SIEM forwarding + immutable retention",
|
|
686
|
+
"modules/security: WAF, DDoS controls, threat detection, guardrail SCP/org-policies"
|
|
687
|
+
],
|
|
688
|
+
mandatory_terraform_controls: [
|
|
689
|
+
"Pin providers and modules to exact versions; no floating ranges.",
|
|
690
|
+
"Use remote state with encryption + locking + restricted access.",
|
|
691
|
+
"Enforce policy checks: Checkov/tfsec/Terrascan + OPA Conftest in CI.",
|
|
692
|
+
"Block 0.0.0.0/0 ingress/egress unless explicit risk acceptance.",
|
|
693
|
+
"Disable public object storage by default.",
|
|
694
|
+
"Require tags/labels for owner, data classification, and environment.",
|
|
695
|
+
"Enable cloud audit logging on every managed resource."
|
|
696
|
+
],
|
|
697
|
+
secure_cicd_flow: [
|
|
698
|
+
"terraform fmt/validate -> terraform plan -> policy checks (OPA/Checkov/tfsec) -> manual approval -> terraform apply",
|
|
699
|
+
"Store plan output artifact and sign provenance before apply.",
|
|
700
|
+
"Run drift detection nightly and alert on unauthorized changes."
|
|
701
|
+
],
|
|
702
|
+
control_mapping: {
|
|
703
|
+
nist_800_53: ["AC-3", "AC-6", "AU-2", "AU-12", "SC-7", "SC-8", "SC-12", "SI-4"],
|
|
704
|
+
cis: ["CIS cloud benchmark level 2", "CIS IaC policy enforcement"],
|
|
705
|
+
zero_trust: ["explicit authn/authz for service paths", "micro-segmentation", "continuous verification"]
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
return asTextResponse(blueprint);
|
|
709
|
+
}));
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
// New tool: security.generate_opa_rego
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
const GenerateOpaRegoParams = {
|
|
714
|
+
...ReviewRunIdParam,
|
|
715
|
+
policyPack: z.enum(["terraform_plan", "ci_pipeline", "kubernetes"]).optional().describe("Policy pack to generate. Default: terraform_plan."),
|
|
716
|
+
cloud: z.enum(["aws", "gcp", "azure", "multi"]).optional().describe("Cloud context for policy wording."),
|
|
717
|
+
applySuggestion: z.boolean().optional().describe("Must be true before generating policy code. This forces explicit user consent.")
|
|
718
|
+
};
|
|
719
|
+
const GenerateOpaRegoSchema = z.object(GenerateOpaRegoParams);
|
|
720
|
+
tool("security.generate_opa_rego", "Generate preventive OPA/Rego policy code for Terraform plans or CI pipelines. Requires explicit user consent first.", GenerateOpaRegoParams, safeTool(async (args, _extra) => {
|
|
721
|
+
const { runId, policyPack, cloud, applySuggestion } = GenerateOpaRegoSchema.parse(args);
|
|
722
|
+
const selectedPack = policyPack ?? "terraform_plan";
|
|
723
|
+
if (!applySuggestion) {
|
|
724
|
+
return asTextResponse({
|
|
725
|
+
requires_user_confirmation: true,
|
|
726
|
+
question: "Do you want security-mcp to generate preventive OPA/Rego policies for your pipeline and Terraform plan checks?",
|
|
727
|
+
next_step: "Re-run security.generate_opa_rego with applySuggestion=true."
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const terraformPolicy = `package security.terraform
|
|
731
|
+
|
|
732
|
+
import rego.v1
|
|
733
|
+
|
|
734
|
+
deny contains msg if {
|
|
735
|
+
some rc in input.resource_changes
|
|
736
|
+
rc.type == "aws_security_group_rule"
|
|
737
|
+
lower(rc.change.after.type) == "ingress"
|
|
738
|
+
rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
|
|
739
|
+
msg := "deny: public ingress 0.0.0.0/0 is not allowed"
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
deny contains msg if {
|
|
743
|
+
some rc in input.resource_changes
|
|
744
|
+
rc.type in {"aws_s3_bucket", "google_storage_bucket", "azurerm_storage_account"}
|
|
745
|
+
not is_private_storage(rc.change.after)
|
|
746
|
+
msg := sprintf("deny: storage resource %s must not be public", [rc.address])
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
deny contains msg if {
|
|
750
|
+
some rc in input.resource_changes
|
|
751
|
+
is_data_resource(rc.type)
|
|
752
|
+
not encryption_enabled(rc.change.after)
|
|
753
|
+
msg := sprintf("deny: encryption at rest is required for %s", [rc.address])
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
is_private_storage(after) if {
|
|
757
|
+
not after.public
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
encryption_enabled(after) if {
|
|
761
|
+
after.encryption == true
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
is_data_resource(kind) if {
|
|
765
|
+
kind in {"aws_db_instance", "google_sql_database_instance", "azurerm_postgresql_flexible_server"}
|
|
766
|
+
}`;
|
|
767
|
+
const ciPolicy = `package security.cicd
|
|
768
|
+
|
|
769
|
+
import rego.v1
|
|
770
|
+
|
|
771
|
+
required_jobs := {"sast", "sca", "secrets", "iac", "container", "dast"}
|
|
772
|
+
|
|
773
|
+
deny contains msg if {
|
|
774
|
+
some job in required_jobs
|
|
775
|
+
not input.pipeline.jobs[job]
|
|
776
|
+
msg := sprintf("deny: missing required security job '%s'", [job])
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
deny contains msg if {
|
|
780
|
+
input.pipeline.context.allow_high_findings == true
|
|
781
|
+
msg := "deny: pipeline cannot allow HIGH/CRITICAL findings by default"
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
deny contains msg if {
|
|
785
|
+
not input.pipeline.provenance.signed
|
|
786
|
+
msg := "deny: release artifacts must include signed provenance/SBOM attestations"
|
|
787
|
+
}`;
|
|
788
|
+
const k8sPolicy = `package security.kubernetes
|
|
789
|
+
|
|
790
|
+
import rego.v1
|
|
791
|
+
|
|
792
|
+
deny contains msg if {
|
|
793
|
+
input.kind == "Deployment"
|
|
794
|
+
some c in input.spec.template.spec.containers
|
|
795
|
+
not c.securityContext.runAsNonRoot
|
|
796
|
+
msg := sprintf("deny: container '%s' must run as non-root", [c.name])
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
deny contains msg if {
|
|
800
|
+
input.kind == "Deployment"
|
|
801
|
+
some c in input.spec.template.spec.containers
|
|
802
|
+
c.securityContext.privileged == true
|
|
803
|
+
msg := sprintf("deny: privileged container '%s' is not allowed", [c.name])
|
|
804
|
+
}`;
|
|
805
|
+
const policyByPack = {
|
|
806
|
+
terraform_plan: {
|
|
807
|
+
path: "policy/terraform/security.rego",
|
|
808
|
+
policy: terraformPolicy,
|
|
809
|
+
conftest_command: "terraform show -json tfplan.binary > tfplan.json && conftest test tfplan.json -p policy/terraform"
|
|
810
|
+
},
|
|
811
|
+
ci_pipeline: {
|
|
812
|
+
path: "policy/ci/security.rego",
|
|
813
|
+
policy: ciPolicy,
|
|
814
|
+
conftest_command: "conftest test pipeline-input.json -p policy/ci"
|
|
815
|
+
},
|
|
816
|
+
kubernetes: {
|
|
817
|
+
path: "policy/kubernetes/security.rego",
|
|
818
|
+
policy: k8sPolicy,
|
|
819
|
+
conftest_command: "conftest test k8s-manifest.yaml -p policy/kubernetes"
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
const selected = policyByPack[selectedPack];
|
|
823
|
+
// Generate test file for the selected policy pack
|
|
824
|
+
const testPackageName = `security.${selectedPack.replace(/_/g, "")}_test`;
|
|
825
|
+
const testPolicy = `package ${testPackageName}
|
|
826
|
+
|
|
827
|
+
import rego.v1
|
|
828
|
+
|
|
829
|
+
# --- Allow cases (should NOT produce deny) ---
|
|
830
|
+
|
|
831
|
+
test_allow_valid_resource if {
|
|
832
|
+
count(deny) == 0 with input as {
|
|
833
|
+
"resource_changes": []
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
test_allow_encrypted_storage if {
|
|
838
|
+
count(deny) == 0 with input as {
|
|
839
|
+
"resource_changes": [{
|
|
840
|
+
"type": "aws_s3_bucket",
|
|
841
|
+
"address": "aws_s3_bucket.secure",
|
|
842
|
+
"change": { "after": { "public": false, "encryption": true } }
|
|
843
|
+
}]
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
test_allow_private_ingress if {
|
|
848
|
+
count(deny) == 0 with input as {
|
|
849
|
+
"resource_changes": [{
|
|
850
|
+
"type": "aws_security_group_rule",
|
|
851
|
+
"change": { "after": { "type": "ingress", "cidr_blocks": ["10.0.0.0/8"] } }
|
|
852
|
+
}]
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
# --- Deny cases (should produce deny) ---
|
|
857
|
+
|
|
858
|
+
test_deny_public_ingress if {
|
|
859
|
+
count(deny) > 0 with input as {
|
|
860
|
+
"resource_changes": [{
|
|
861
|
+
"type": "aws_security_group_rule",
|
|
862
|
+
"change": { "after": { "type": "ingress", "cidr_blocks": ["0.0.0.0/0"] } }
|
|
863
|
+
}]
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
test_deny_public_storage if {
|
|
868
|
+
count(deny) > 0 with input as {
|
|
869
|
+
"resource_changes": [{
|
|
870
|
+
"type": "aws_s3_bucket",
|
|
871
|
+
"address": "aws_s3_bucket.bad",
|
|
872
|
+
"change": { "after": { "public": true, "encryption": false } }
|
|
873
|
+
}]
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
test_deny_unencrypted_database if {
|
|
878
|
+
count(deny) > 0 with input as {
|
|
879
|
+
"resource_changes": [{
|
|
880
|
+
"type": "aws_db_instance",
|
|
881
|
+
"address": "aws_db_instance.bad",
|
|
882
|
+
"change": { "after": { "encryption": false } }
|
|
883
|
+
}]
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
# --- Edge cases ---
|
|
888
|
+
|
|
889
|
+
test_empty_input if {
|
|
890
|
+
count(deny) == 0 with input as {}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
test_null_resource_changes if {
|
|
894
|
+
count(deny) == 0 with input as { "resource_changes": [] }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
test_missing_required_fields if {
|
|
898
|
+
count(deny) == 0 with input as { "resource_changes": [{ "type": "unknown_type", "change": {} }] }
|
|
899
|
+
}
|
|
900
|
+
`;
|
|
901
|
+
const testFilePath = selected.path.replace(".rego", "_test.rego");
|
|
902
|
+
if (runId) {
|
|
903
|
+
await updateReviewStep(runId, "generate_opa_rego", "approved", {
|
|
904
|
+
policyPack: selectedPack,
|
|
905
|
+
cloud: cloud ?? "multi"
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return asTextResponse({
|
|
909
|
+
generated_for: { policyPack: selectedPack, cloud: cloud ?? "multi" },
|
|
910
|
+
files: [
|
|
911
|
+
selected,
|
|
912
|
+
{ path: testFilePath, policy: testPolicy, description: "OPA test file — run with: opa test policy/ -v" }
|
|
913
|
+
],
|
|
914
|
+
install_notes: [
|
|
915
|
+
"Run this in CI before deployment apply/admission.",
|
|
916
|
+
"Fail the pipeline when any deny rules are returned.",
|
|
917
|
+
"Run tests with: opa test policy/ -v",
|
|
918
|
+
"Version-control the policy and require security-owner approval for policy exceptions."
|
|
919
|
+
]
|
|
920
|
+
});
|
|
921
|
+
}));
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
// New tool: security.self_heal_loop
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
const SelfHealLoopParams = {
|
|
926
|
+
...ReviewRunIdParam,
|
|
927
|
+
useCase: z.string().optional().describe("Short description of recurring security issues in this codebase."),
|
|
928
|
+
findings: z.array(z.string()).optional().describe("Recent recurring findings or control gaps."),
|
|
929
|
+
approveAdaptiveUpdates: z.boolean().optional().describe("Must be true before suggesting any adaptive improvement. Human approval is mandatory.")
|
|
930
|
+
};
|
|
931
|
+
const SelfHealLoopSchema = z.object(SelfHealLoopParams);
|
|
932
|
+
tool("security.self_heal_loop", "Propose a human-approved self-healing improvement loop for this security setup. No adaptive change may be applied without explicit human approval.", SelfHealLoopParams, safeTool(async (args, _extra) => {
|
|
933
|
+
const { runId, useCase, findings, approveAdaptiveUpdates } = SelfHealLoopSchema.parse(args);
|
|
934
|
+
if (!approveAdaptiveUpdates) {
|
|
935
|
+
return asTextResponse({
|
|
936
|
+
requires_human_approval: true,
|
|
937
|
+
question: "Do you want security-mcp to propose adaptive updates to policies/checklists based on recurring findings in your use case?",
|
|
938
|
+
next_step: "Re-run security.self_heal_loop with approveAdaptiveUpdates=true."
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (runId) {
|
|
942
|
+
await updateReviewStep(runId, "self_heal_loop", "approved", {
|
|
943
|
+
useCase: useCase ?? "unspecified"
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return asTextResponse({
|
|
947
|
+
adaptive_security_loop: [
|
|
948
|
+
"1) Capture repeated findings from gate outputs and incident reports.",
|
|
949
|
+
"2) Cluster by root cause (authz gaps, IaC misconfig, secrets, AI injection, dependency risk).",
|
|
950
|
+
"3) Propose updates to .mcp/policies/security-policy.json and .mcp/mappings/evidence-map.json.",
|
|
951
|
+
"4) Require explicit human approval before applying any policy, prompt, or checklist mutation.",
|
|
952
|
+
"5) Re-run security.run_pr_gate in the selected scan mode and compare residual risk trend."
|
|
953
|
+
],
|
|
954
|
+
guardrails: [
|
|
955
|
+
"No autonomous code or policy mutation without explicit human approval.",
|
|
956
|
+
"No weakening of controls without signed risk acceptance metadata.",
|
|
957
|
+
"Every approved adaptive update must be logged with owner, date, rationale, and rollback path."
|
|
958
|
+
],
|
|
959
|
+
input_summary: {
|
|
960
|
+
useCase: useCase ?? "unspecified",
|
|
961
|
+
findings: findings ?? []
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}));
|
|
965
|
+
// ---------------------------------------------------------------------------
|
|
966
|
+
// New tool: security.generate_compliance_report
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
const GenerateComplianceReportParams = {
|
|
969
|
+
...ReviewRunIdParam,
|
|
970
|
+
framework: z.enum(["SOC2", "PCI-DSS", "ISO27001", "NIST-800-53", "HIPAA", "GDPR"]).describe("Compliance framework to evaluate against."),
|
|
971
|
+
outputFormat: z.enum(["json", "markdown"]).default("markdown").describe("Output format.")
|
|
972
|
+
};
|
|
973
|
+
const GenerateComplianceReportSchema = z.object(GenerateComplianceReportParams);
|
|
974
|
+
tool("security.generate_compliance_report", "Generate a compliance gap analysis report mapping gate results to a specific framework's controls. Identifies satisfied, missing, and partially-satisfied controls with evidence artifacts.", GenerateComplianceReportParams, safeTool(async (args, _extra) => {
|
|
975
|
+
const { runId, framework, outputFormat } = GenerateComplianceReportSchema.parse(args);
|
|
976
|
+
// Framework → control prefix/tag mapping
|
|
977
|
+
const frameworkFilters = {
|
|
978
|
+
"SOC2": ["SOC2_", "SOC 2"],
|
|
979
|
+
"PCI-DSS": ["PCI_", "PCI DSS"],
|
|
980
|
+
"ISO27001": ["ISO_", "ISO 27001"],
|
|
981
|
+
"NIST-800-53": ["NIST_", "NIST 800-53"],
|
|
982
|
+
"HIPAA": ["HIPAA"],
|
|
983
|
+
"GDPR": ["GDPR"]
|
|
984
|
+
};
|
|
985
|
+
const filters = frameworkFilters[framework] ?? [];
|
|
986
|
+
// Load gate result from run if provided
|
|
987
|
+
let gateFindings = [];
|
|
988
|
+
let gateStatus = "UNKNOWN";
|
|
989
|
+
if (runId) {
|
|
990
|
+
try {
|
|
991
|
+
const { readReviewRun } = await import("../review/store.js");
|
|
992
|
+
const run = await readReviewRun(runId);
|
|
993
|
+
const gateStep = run.steps["run_pr_gate"];
|
|
994
|
+
if (gateStep?.details) {
|
|
995
|
+
const details = gateStep.details;
|
|
996
|
+
gateStatus = String(details["status"] ?? "UNKNOWN");
|
|
997
|
+
gateFindings = details["findings"] ?? [];
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// run not found — proceed without gate data
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Load control catalog
|
|
1005
|
+
const { loadControlCatalog } = await import("../gate/catalog.js");
|
|
1006
|
+
const catalog = await loadControlCatalog();
|
|
1007
|
+
// Filter controls by framework
|
|
1008
|
+
const frameworkControls = catalog.controls.filter((c) => filters.some((f) => c.id.startsWith(f) || c.frameworks.some((fw) => fw.includes(f.trim()))));
|
|
1009
|
+
const controlStatuses = frameworkControls.map((c) => {
|
|
1010
|
+
const matchingFinding = gateFindings.find((f) => f.id.startsWith(c.id) || c.id.includes(f.id));
|
|
1011
|
+
if (matchingFinding) {
|
|
1012
|
+
return { id: c.id, description: c.description, status: "missing", evidence: [`Finding: ${matchingFinding.id} (${matchingFinding.severity})`] };
|
|
1013
|
+
}
|
|
1014
|
+
// If no adverse finding, consider it tentatively satisfied
|
|
1015
|
+
return { id: c.id, description: c.description, status: "satisfied", evidence: c.evidence ?? [] };
|
|
1016
|
+
});
|
|
1017
|
+
const total = controlStatuses.length;
|
|
1018
|
+
const satisfied = controlStatuses.filter((c) => c.status === "satisfied").length;
|
|
1019
|
+
const missing = controlStatuses.filter((c) => c.status === "missing").length;
|
|
1020
|
+
const partial = controlStatuses.filter((c) => c.status === "partial").length;
|
|
1021
|
+
if (outputFormat === "json") {
|
|
1022
|
+
return asTextResponse({
|
|
1023
|
+
framework,
|
|
1024
|
+
runId: runId ?? null,
|
|
1025
|
+
gateStatus,
|
|
1026
|
+
summary: { total, satisfied, missing, partial },
|
|
1027
|
+
controls: controlStatuses
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
// Markdown output
|
|
1031
|
+
const rows = controlStatuses.map((c) => {
|
|
1032
|
+
const icon = c.status === "satisfied" ? "✓" : c.status === "missing" ? "✗" : "~";
|
|
1033
|
+
const evidence = c.evidence.slice(0, 2).join("; ") || "-";
|
|
1034
|
+
return `| ${c.id} | ${c.description.slice(0, 60)} | ${icon} ${c.status} | ${evidence} |`;
|
|
1035
|
+
}).join("\n");
|
|
1036
|
+
const report = `# Compliance Gap Analysis: ${framework}
|
|
1037
|
+
|
|
1038
|
+
**Run ID**: ${runId ?? "not provided"}
|
|
1039
|
+
**Gate Status**: ${gateStatus}
|
|
1040
|
+
**Generated**: ${new Date().toISOString()}
|
|
1041
|
+
|
|
1042
|
+
## Summary
|
|
1043
|
+
|
|
1044
|
+
| Metric | Count |
|
|
1045
|
+
|---|---|
|
|
1046
|
+
| Total Controls | ${total} |
|
|
1047
|
+
| Satisfied | ${satisfied} |
|
|
1048
|
+
| Missing | ${missing} |
|
|
1049
|
+
| Partial | ${partial} |
|
|
1050
|
+
| Coverage | ${total > 0 ? Math.round((satisfied / total) * 100) : 0}% |
|
|
1051
|
+
|
|
1052
|
+
## Control Details
|
|
1053
|
+
|
|
1054
|
+
| Control ID | Description | Status | Evidence |
|
|
1055
|
+
|---|---|---|---|
|
|
1056
|
+
${rows}
|
|
1057
|
+
`;
|
|
1058
|
+
return asTextResponse(report);
|
|
1059
|
+
}));
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
// New tool: security.notify_webhooks
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
const NotifyWebhooksParams = {
|
|
1064
|
+
runId: z.string().uuid().describe("Security review run ID whose findings to send."),
|
|
1065
|
+
gateFailed: z.boolean().describe("Whether the gate failed (determines alert severity)."),
|
|
1066
|
+
findingCount: z.number().int().describe("Total number of findings."),
|
|
1067
|
+
criticalCount: z.number().int().describe("Number of CRITICAL findings.")
|
|
1068
|
+
};
|
|
1069
|
+
const NotifyWebhooksSchema = z.object(NotifyWebhooksParams);
|
|
1070
|
+
tool("security.notify_webhooks", "Send security gate findings to configured external systems (Slack, Jira, PagerDuty, generic webhook). Configure endpoints via environment variables: SECURITY_SLACK_WEBHOOK, SECURITY_JIRA_URL+SECURITY_JIRA_TOKEN, SECURITY_PAGERDUTY_KEY, SECURITY_WEBHOOK_URL.", NotifyWebhooksParams, safeTool(async (args, _extra) => {
|
|
1071
|
+
const { runId, gateFailed, findingCount, criticalCount } = NotifyWebhooksSchema.parse(args);
|
|
1072
|
+
const notified = [];
|
|
1073
|
+
const errors = [];
|
|
1074
|
+
// Slack
|
|
1075
|
+
const slackWebhook = process.env["SECURITY_SLACK_WEBHOOK"];
|
|
1076
|
+
if (slackWebhook) {
|
|
1077
|
+
try {
|
|
1078
|
+
const color = gateFailed ? "#d32f2f" : "#388e3c";
|
|
1079
|
+
const statusEmoji = gateFailed ? ":red_circle:" : ":large_green_circle:";
|
|
1080
|
+
const body = {
|
|
1081
|
+
blocks: [
|
|
1082
|
+
{
|
|
1083
|
+
type: "header",
|
|
1084
|
+
text: { type: "plain_text", text: `${statusEmoji} Security Gate ${gateFailed ? "FAILED" : "PASSED"}` }
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
type: "section",
|
|
1088
|
+
fields: [
|
|
1089
|
+
{ type: "mrkdwn", text: `*Run ID*: ${runId}` },
|
|
1090
|
+
{ type: "mrkdwn", text: `*Total Findings*: ${findingCount}` },
|
|
1091
|
+
{ type: "mrkdwn", text: `*Critical Findings*: ${criticalCount}` }
|
|
1092
|
+
]
|
|
1093
|
+
}
|
|
1094
|
+
],
|
|
1095
|
+
attachments: [{ color }]
|
|
1096
|
+
};
|
|
1097
|
+
const controller = new AbortController();
|
|
1098
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1099
|
+
try {
|
|
1100
|
+
const resp = await fetch(slackWebhook, {
|
|
1101
|
+
method: "POST",
|
|
1102
|
+
headers: { "Content-Type": "application/json" },
|
|
1103
|
+
body: JSON.stringify(body),
|
|
1104
|
+
signal: controller.signal
|
|
1105
|
+
});
|
|
1106
|
+
if (resp.ok)
|
|
1107
|
+
notified.push("slack");
|
|
1108
|
+
else
|
|
1109
|
+
errors.push(`slack: HTTP ${resp.status}`);
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
clearTimeout(timeout);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
errors.push(`slack: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// PagerDuty
|
|
1120
|
+
const pdKey = process.env["SECURITY_PAGERDUTY_KEY"];
|
|
1121
|
+
if (pdKey && gateFailed && criticalCount > 0) {
|
|
1122
|
+
try {
|
|
1123
|
+
const body = {
|
|
1124
|
+
routing_key: pdKey,
|
|
1125
|
+
event_action: "trigger",
|
|
1126
|
+
payload: {
|
|
1127
|
+
summary: `Security Gate FAILED — ${criticalCount} critical findings (run: ${runId})`,
|
|
1128
|
+
severity: "critical",
|
|
1129
|
+
source: "security-mcp",
|
|
1130
|
+
custom_details: { runId, findingCount, criticalCount }
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
const controller = new AbortController();
|
|
1134
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1135
|
+
try {
|
|
1136
|
+
const resp = await fetch("https://events.pagerduty.com/v2/enqueue", {
|
|
1137
|
+
method: "POST",
|
|
1138
|
+
headers: { "Content-Type": "application/json" },
|
|
1139
|
+
body: JSON.stringify(body),
|
|
1140
|
+
signal: controller.signal
|
|
1141
|
+
});
|
|
1142
|
+
if (resp.ok)
|
|
1143
|
+
notified.push("pagerduty");
|
|
1144
|
+
else
|
|
1145
|
+
errors.push(`pagerduty: HTTP ${resp.status}`);
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
clearTimeout(timeout);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
catch (e) {
|
|
1152
|
+
errors.push(`pagerduty: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Generic webhook
|
|
1156
|
+
const genericWebhook = process.env["SECURITY_WEBHOOK_URL"];
|
|
1157
|
+
if (genericWebhook) {
|
|
1158
|
+
try {
|
|
1159
|
+
const body = { runId, gateFailed, findingCount, criticalCount, timestamp: new Date().toISOString() };
|
|
1160
|
+
const controller = new AbortController();
|
|
1161
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1162
|
+
try {
|
|
1163
|
+
const resp = await fetch(genericWebhook, {
|
|
1164
|
+
method: "POST",
|
|
1165
|
+
headers: { "Content-Type": "application/json" },
|
|
1166
|
+
body: JSON.stringify(body),
|
|
1167
|
+
signal: controller.signal
|
|
1168
|
+
});
|
|
1169
|
+
if (resp.ok)
|
|
1170
|
+
notified.push("webhook");
|
|
1171
|
+
else
|
|
1172
|
+
errors.push(`webhook: HTTP ${resp.status}`);
|
|
1173
|
+
}
|
|
1174
|
+
finally {
|
|
1175
|
+
clearTimeout(timeout);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
errors.push(`webhook: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
// Jira
|
|
1183
|
+
const jiraUrl = process.env["SECURITY_JIRA_URL"];
|
|
1184
|
+
const jiraToken = process.env["SECURITY_JIRA_TOKEN"];
|
|
1185
|
+
const jiraProject = process.env["SECURITY_JIRA_PROJECT"] ?? "SECURITY";
|
|
1186
|
+
if (jiraUrl && jiraToken && gateFailed) {
|
|
1187
|
+
try {
|
|
1188
|
+
const body = {
|
|
1189
|
+
fields: {
|
|
1190
|
+
project: { key: jiraProject },
|
|
1191
|
+
summary: `Security Gate FAILED - ${criticalCount} critical findings`,
|
|
1192
|
+
description: {
|
|
1193
|
+
type: "doc",
|
|
1194
|
+
version: 1,
|
|
1195
|
+
content: [{
|
|
1196
|
+
type: "paragraph",
|
|
1197
|
+
content: [{ type: "text", text: `Run ID: ${runId}. Total findings: ${findingCount}. Critical: ${criticalCount}.` }]
|
|
1198
|
+
}]
|
|
1199
|
+
},
|
|
1200
|
+
issuetype: { name: "Bug" },
|
|
1201
|
+
priority: { name: criticalCount > 0 ? "Critical" : "High" }
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
const controller = new AbortController();
|
|
1205
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1206
|
+
try {
|
|
1207
|
+
const resp = await fetch(`${jiraUrl}/rest/api/3/issue`, {
|
|
1208
|
+
method: "POST",
|
|
1209
|
+
headers: {
|
|
1210
|
+
"Content-Type": "application/json",
|
|
1211
|
+
// Never log the token — pass it only in the header
|
|
1212
|
+
"Authorization": `Bearer ${jiraToken}`
|
|
1213
|
+
},
|
|
1214
|
+
body: JSON.stringify(body),
|
|
1215
|
+
signal: controller.signal
|
|
1216
|
+
});
|
|
1217
|
+
if (resp.ok)
|
|
1218
|
+
notified.push("jira");
|
|
1219
|
+
else
|
|
1220
|
+
errors.push(`jira: HTTP ${resp.status}`);
|
|
1221
|
+
}
|
|
1222
|
+
finally {
|
|
1223
|
+
clearTimeout(timeout);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
catch (e) {
|
|
1227
|
+
errors.push(`jira: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return asTextResponse({
|
|
1231
|
+
notified,
|
|
1232
|
+
errors,
|
|
1233
|
+
summary: notified.length > 0
|
|
1234
|
+
? `Notified: ${notified.join(", ")}`
|
|
1235
|
+
: "No webhook integrations configured. Set SECURITY_SLACK_WEBHOOK, SECURITY_PAGERDUTY_KEY, SECURITY_WEBHOOK_URL, or SECURITY_JIRA_URL+SECURITY_JIRA_TOKEN."
|
|
1236
|
+
});
|
|
1237
|
+
}));
|
|
1238
|
+
const REMEDIATION_MAP = {
|
|
1239
|
+
"POSSIBLE_SECRET": {
|
|
1240
|
+
pattern: "const API_KEY = 'sk-...' // hardcoded secret",
|
|
1241
|
+
fix: "const API_KEY = process.env['API_KEY']; // loaded from secret manager",
|
|
1242
|
+
explanation: "Hardcoded secrets are exposed in source control and logs. Load secrets from environment variables backed by a secret manager (AWS Secrets Manager, HashiCorp Vault, etc.).",
|
|
1243
|
+
references: ["CWE-798", "OWASP Top 10 A07:2021", "NIST 800-53 IA-5"]
|
|
1244
|
+
},
|
|
1245
|
+
"CRYPTO_WEAK_HASH": {
|
|
1246
|
+
pattern: "crypto.createHash('md5').update(data).digest('hex')",
|
|
1247
|
+
fix: "crypto.createHash('sha256').update(data).digest('hex')",
|
|
1248
|
+
explanation: "MD5 and SHA-1 are cryptographically broken. Use SHA-256 or higher.",
|
|
1249
|
+
references: ["NIST SP 800-131A Rev 2", "CWE-327"]
|
|
1250
|
+
},
|
|
1251
|
+
"CRYPTO_WEAK_CIPHER": {
|
|
1252
|
+
pattern: "crypto.createCipheriv('des', key, iv)",
|
|
1253
|
+
fix: "crypto.createCipheriv('aes-256-gcm', key, nonce)",
|
|
1254
|
+
explanation: "DES/RC4/3DES are prohibited by NIST. Use AES-256-GCM for authenticated encryption.",
|
|
1255
|
+
references: ["NIST SP 800-131A Rev 2", "CWE-327", "FIPS 140-3"]
|
|
1256
|
+
},
|
|
1257
|
+
"CRYPTO_INSECURE_RANDOM": {
|
|
1258
|
+
pattern: "const token = Math.random().toString(36).slice(2)",
|
|
1259
|
+
fix: "const token = crypto.randomBytes(32).toString('hex')",
|
|
1260
|
+
explanation: "Math.random() is not cryptographically secure. Use crypto.randomBytes() for tokens, keys, and nonces.",
|
|
1261
|
+
references: ["CWE-338", "OWASP ASVS 2.3.1"]
|
|
1262
|
+
},
|
|
1263
|
+
"CRYPTO_WEAK_JWT_ALGO": {
|
|
1264
|
+
pattern: "jwt.sign(payload, secret, { algorithm: 'HS256' })",
|
|
1265
|
+
fix: "jwt.sign(payload, privateKey, { algorithm: 'RS256' })",
|
|
1266
|
+
explanation: "HS256 requires sharing the signing secret with every verifier. RS256/ES256 use asymmetric keys so verifiers only need the public key.",
|
|
1267
|
+
references: ["RFC 7518", "OWASP JWT Security Cheat Sheet"]
|
|
1268
|
+
},
|
|
1269
|
+
"DB_TLS_DISABLED": {
|
|
1270
|
+
pattern: "postgresql://user:pass@host/db?sslmode=disable",
|
|
1271
|
+
fix: "postgresql://user:pass@host/db?sslmode=verify-full",
|
|
1272
|
+
explanation: "Disabling TLS exposes credentials and data in transit. Always require and verify TLS.",
|
|
1273
|
+
references: ["PCI DSS 4.0 Req 4.2", "NIST 800-53 SC-8", "CWE-319"]
|
|
1274
|
+
},
|
|
1275
|
+
"DB_SQL_INJECTION_RISK": {
|
|
1276
|
+
pattern: "db.query('SELECT * FROM users WHERE id = ' + req.params.id)",
|
|
1277
|
+
fix: "db.query('SELECT * FROM users WHERE id = $1', [req.params.id])",
|
|
1278
|
+
explanation: "Never concatenate user input into SQL. Use parameterized queries or ORM query builders.",
|
|
1279
|
+
references: ["OWASP Top 10 A03:2021", "CWE-89", "NIST 800-53 SI-10"]
|
|
1280
|
+
},
|
|
1281
|
+
"GRAPHQL_INTROSPECTION_ENABLED": {
|
|
1282
|
+
pattern: "new ApolloServer({ introspection: true })",
|
|
1283
|
+
fix: "new ApolloServer({ introspection: process.env.NODE_ENV !== 'production' })",
|
|
1284
|
+
explanation: "GraphQL introspection exposes the full schema to attackers. Disable it in non-dev environments.",
|
|
1285
|
+
references: ["OWASP API Security Top 10 API8:2023", "CWE-200"]
|
|
1286
|
+
},
|
|
1287
|
+
"GRAPHQL_NO_DEPTH_LIMIT": {
|
|
1288
|
+
pattern: "new ApolloServer({ schema })",
|
|
1289
|
+
fix: "import depthLimit from 'graphql-depth-limit';\nnew ApolloServer({ schema, validationRules: [depthLimit(10)] })",
|
|
1290
|
+
explanation: "Without depth limiting, attackers can send deeply nested queries to exhaust server resources.",
|
|
1291
|
+
references: ["OWASP API Security Top 10 API4:2023"]
|
|
1292
|
+
},
|
|
1293
|
+
"K8S_PRIVILEGED_CONTAINER": {
|
|
1294
|
+
pattern: "securityContext:\n privileged: true",
|
|
1295
|
+
fix: "securityContext:\n privileged: false\n allowPrivilegeEscalation: false\n runAsNonRoot: true\n capabilities:\n drop: [\"ALL\"]",
|
|
1296
|
+
explanation: "Privileged containers have unrestricted access to the host kernel. Remove privileged mode and drop all capabilities.",
|
|
1297
|
+
references: ["CIS Kubernetes Benchmark 5.2.1", "NIST 800-190"]
|
|
1298
|
+
},
|
|
1299
|
+
"K8S_NO_SECURITY_CONTEXT": {
|
|
1300
|
+
pattern: "containers:\n - name: app\n image: myapp:1.0",
|
|
1301
|
+
fix: "containers:\n - name: app\n image: myapp:1.0\n securityContext:\n runAsNonRoot: true\n runAsUser: 1000\n readOnlyRootFilesystem: true\n allowPrivilegeEscalation: false\n capabilities:\n drop: [\"ALL\"]",
|
|
1302
|
+
explanation: "Always set a securityContext to enforce least-privilege container execution.",
|
|
1303
|
+
references: ["CIS Kubernetes Benchmark", "NIST 800-190", "OWASP Kubernetes Security Cheat Sheet"]
|
|
1304
|
+
},
|
|
1305
|
+
"DLP_REQUEST_BODY_LOGGED": {
|
|
1306
|
+
pattern: "console.log(req.body)",
|
|
1307
|
+
fix: "const { password, token, ...safeFields } = req.body;\nconsole.log({ requestId, safeFields })",
|
|
1308
|
+
explanation: "Full request bodies may contain PII, passwords, or tokens. Log only allowlisted non-sensitive fields.",
|
|
1309
|
+
references: ["GDPR Article 5", "HIPAA 45 CFR 164.312", "CWE-532"]
|
|
1310
|
+
},
|
|
1311
|
+
"DLP_STACK_TRACE_IN_RESPONSE": {
|
|
1312
|
+
pattern: "res.json({ error: err.message, stack: err.stack })",
|
|
1313
|
+
fix: "logger.error({ err, requestId }); // log internally\nres.json({ error: 'An internal error occurred', requestId })",
|
|
1314
|
+
explanation: "Stack traces in API responses disclose internal architecture to attackers (CWE-209). Log internally, return only a safe message.",
|
|
1315
|
+
references: ["CWE-209", "OWASP Top 10 A05:2021", "PCI DSS 4.0 Req 6.2.4"]
|
|
1316
|
+
},
|
|
1317
|
+
"API_TENANT_ID_FROM_INPUT": {
|
|
1318
|
+
pattern: "const tenantId = req.query.tenantId",
|
|
1319
|
+
fix: "const tenantId = req.auth.tenantId // from verified JWT claims",
|
|
1320
|
+
explanation: "Tenant ID must come from the authenticated session/JWT claims. User-supplied tenant IDs allow cross-tenant data access.",
|
|
1321
|
+
references: ["OWASP API Security Top 10 API1:2023", "CWE-639"]
|
|
1322
|
+
},
|
|
1323
|
+
"LOCKFILE_MISSING": {
|
|
1324
|
+
pattern: "# No package-lock.json in repository",
|
|
1325
|
+
fix: "npm install # generates package-lock.json\ngit add package-lock.json\ngit commit -m 'chore: add lockfile'",
|
|
1326
|
+
explanation: "Without a lockfile, npm install resolves the latest matching version on each run, opening the door to supply chain attacks.",
|
|
1327
|
+
references: ["SLSA L1", "NIST 800-218 PS-3", "CWE-829"]
|
|
1328
|
+
},
|
|
1329
|
+
"DEP_FLOATING_VERSION": {
|
|
1330
|
+
pattern: "\"some-package\": \"^1.0.0\"",
|
|
1331
|
+
fix: "\"some-package\": \"1.2.3\" // exact pin\n// or use npm shrinkwrap / lockfile",
|
|
1332
|
+
explanation: "Floating version ranges allow unexpected major/minor updates that may introduce vulnerabilities or breaking changes.",
|
|
1333
|
+
references: ["SLSA L1", "OWASP Top 10 A06:2021"]
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
const GenerateRemediationsParams = {
|
|
1337
|
+
findings: z.array(z.object({
|
|
1338
|
+
id: z.string(),
|
|
1339
|
+
title: z.string(),
|
|
1340
|
+
severity: z.string(),
|
|
1341
|
+
files: z.array(z.string()).optional(),
|
|
1342
|
+
evidence: z.array(z.string()).optional()
|
|
1343
|
+
})).describe("Findings array from a gate run result.")
|
|
1344
|
+
};
|
|
1345
|
+
const GenerateRemediationsSchema = z.object(GenerateRemediationsParams);
|
|
1346
|
+
tool("security.generate_remediations", "Maps each gate finding to a specific, actionable code-level remediation template. Called automatically after every gate FAIL. Returns ready-to-apply fix templates keyed by finding ID.", GenerateRemediationsParams, safeTool(async (args, _extra) => {
|
|
1347
|
+
const { findings } = GenerateRemediationsSchema.parse(args);
|
|
1348
|
+
const result = {};
|
|
1349
|
+
for (const finding of findings) {
|
|
1350
|
+
// Try exact match first, then prefix match
|
|
1351
|
+
const exactMatch = REMEDIATION_MAP[finding.id];
|
|
1352
|
+
const prefixMatch = Object.keys(REMEDIATION_MAP).find((k) => finding.id.startsWith(k) || k.startsWith(finding.id));
|
|
1353
|
+
result[finding.id] = {
|
|
1354
|
+
finding,
|
|
1355
|
+
remediation: exactMatch ?? (prefixMatch ? REMEDIATION_MAP[prefixMatch] : null)
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
const withRemediation = Object.values(result).filter((r) => r.remediation !== null).length;
|
|
1359
|
+
const without = findings.length - withRemediation;
|
|
1360
|
+
return asTextResponse({
|
|
1361
|
+
summary: { total: findings.length, withRemediation, withoutRemediationTemplate: without },
|
|
1362
|
+
remediations: result
|
|
1363
|
+
});
|
|
1364
|
+
}));
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
437
1366
|
// MCP Prompts capability
|
|
438
1367
|
// ---------------------------------------------------------------------------
|
|
439
|
-
server.prompt("security-engineer", "Activate the security-mcp system prompt.
|
|
1368
|
+
server.prompt("security-engineer", "Activate the security-mcp system prompt. Operating ratio: 90% fixing, 10% advisory — writes the fix, implements the control, enforces the policy. Does NOT list vulnerabilities and walk away. Applies OWASP, MITRE ATT&CK, NIST 800-53, Zero Trust, PCI DSS, SOC 2, and ISO 27001 to every code and architecture decision.", async () => ({
|
|
440
1369
|
messages: [
|
|
441
1370
|
{
|
|
442
1371
|
role: "user",
|