security-mcp 1.0.3 → 1.0.5
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/control-catalog.json +157 -0
- package/defaults/security-exceptions.json +4 -0
- package/defaults/security-tools.json +41 -0
- package/dist/ci/pr-gate.js +2 -3
- package/dist/cli/index.js +51 -16
- package/dist/cli/install.js +39 -17
- package/dist/cli/update.js +124 -0
- package/dist/gate/catalog.js +55 -0
- package/dist/gate/checks/ai.js +45 -14
- package/dist/gate/checks/dependencies.js +4 -0
- package/dist/gate/checks/scanners.js +84 -0
- package/dist/gate/checks/secrets.js +53 -26
- 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 +110 -4
- package/dist/mcp/server.js +440 -6
- package/dist/repo/fs.js +10 -5
- package/dist/review/store.js +80 -0
- package/dist/tests/run.js +103 -0
- package/package.json +13 -3
- package/prompts/SECURITY_PROMPT.md +40 -0
- package/skills/senior-security-engineer/SKILL.md +46 -1
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,123 @@ 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.", 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
|
+
nextSteps: [
|
|
86
|
+
"Run security.threat_model with this runId.",
|
|
87
|
+
"Run security.checklist with this runId.",
|
|
88
|
+
"Run security.run_pr_gate with this runId.",
|
|
89
|
+
"Run security.attest_review after remediation is complete."
|
|
90
|
+
]
|
|
91
|
+
});
|
|
92
|
+
}));
|
|
93
|
+
const AttestReviewParams = {
|
|
94
|
+
runId: z.string().uuid().describe("Security review run ID."),
|
|
95
|
+
signatureEnvVar: z.string().optional().describe("Optional environment variable containing an HMAC key for attestation signing.")
|
|
96
|
+
};
|
|
97
|
+
const AttestReviewSchema = z.object(AttestReviewParams);
|
|
98
|
+
tool("security.attest_review", "Generate a security review attestation with integrity hash and optional HMAC signature.", AttestReviewParams, safeTool(async (args, _extra) => {
|
|
99
|
+
const { runId, signatureEnvVar } = AttestReviewSchema.parse(args);
|
|
100
|
+
const run = await readReviewRun(runId);
|
|
101
|
+
const required = new Set(run.requiredSteps);
|
|
102
|
+
const completed = Array.from(required).filter((step) => {
|
|
103
|
+
const status = run.steps[step]?.status;
|
|
104
|
+
return status === "completed" || status === "approved";
|
|
105
|
+
});
|
|
106
|
+
const missing = Array.from(required).filter((step) => !completed.includes(step));
|
|
107
|
+
const latestGate = run.steps["run_pr_gate"]?.details ?? {};
|
|
108
|
+
const payload = {
|
|
109
|
+
runId: run.id,
|
|
110
|
+
createdAt: run.createdAt,
|
|
111
|
+
updatedAt: run.updatedAt,
|
|
112
|
+
mode: run.mode,
|
|
113
|
+
targets: run.targets,
|
|
114
|
+
steps: run.steps,
|
|
115
|
+
coverage: {
|
|
116
|
+
required: Array.from(required),
|
|
117
|
+
completed,
|
|
118
|
+
missing
|
|
119
|
+
},
|
|
120
|
+
latestGate
|
|
121
|
+
};
|
|
122
|
+
const signatureKey = signatureEnvVar ? process.env[signatureEnvVar] : undefined;
|
|
123
|
+
const attestation = await createReviewAttestation(runId, payload, signatureKey);
|
|
124
|
+
return asTextResponse({
|
|
125
|
+
attestationPath: attestation.path,
|
|
126
|
+
sha256: attestation.sha256,
|
|
127
|
+
...(attestation.hmacSha256 ? { hmacSha256: attestation.hmacSha256 } : {}),
|
|
128
|
+
completedSteps: completed,
|
|
129
|
+
missingSteps: missing,
|
|
130
|
+
confidence: latestGate["confidence"] ?? null
|
|
131
|
+
});
|
|
132
|
+
}));
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Existing tools
|
|
54
135
|
// ---------------------------------------------------------------------------
|
|
55
136
|
const RunPrGateParams = {
|
|
137
|
+
...ReviewRunIdParam,
|
|
138
|
+
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."),
|
|
139
|
+
targets: z.array(z.string()).optional().describe("Required for folder_by_folder and file_by_file modes. Relative folders/files to evaluate."),
|
|
56
140
|
baseRef: z.string().optional().describe("Base git ref for diff (e.g. origin/main). Optional."),
|
|
57
141
|
headRef: z.string().optional().describe("Head git ref for diff (e.g. HEAD). Optional."),
|
|
58
142
|
policyPath: z.string().optional().describe("Override policy path. Default: .mcp/policies/security-policy.json")
|
|
59
143
|
};
|
|
60
144
|
const RunPrGateSchema = z.object(RunPrGateParams);
|
|
61
|
-
tool("security.run_pr_gate", "Run the security policy gate
|
|
62
|
-
const { baseRef, headRef, policyPath } = RunPrGateSchema.parse(args);
|
|
145
|
+
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) => {
|
|
146
|
+
const { runId, mode, targets, baseRef, headRef, policyPath } = RunPrGateSchema.parse(args);
|
|
147
|
+
if (!runId) {
|
|
148
|
+
return asTextResponse({
|
|
149
|
+
requires_run_id: true,
|
|
150
|
+
question: "Start the review with security.start_review before running the gate.",
|
|
151
|
+
next_step: "Call security.start_review, then re-run security.run_pr_gate with the returned runId."
|
|
152
|
+
});
|
|
153
|
+
}
|
|
63
154
|
const result = await runPrGate({
|
|
155
|
+
mode,
|
|
156
|
+
targets,
|
|
64
157
|
baseRef,
|
|
65
158
|
headRef,
|
|
66
159
|
policyPath: policyPath ?? ".mcp/policies/security-policy.json"
|
|
67
160
|
});
|
|
161
|
+
await updateReviewStep(runId, "run_pr_gate", "completed", {
|
|
162
|
+
status: result.status,
|
|
163
|
+
confidence: result.confidence,
|
|
164
|
+
findings: result.findings.map((finding) => ({ id: finding.id, severity: finding.severity })),
|
|
165
|
+
suppressedFindings: result.suppressedFindings?.map((entry) => ({
|
|
166
|
+
id: entry.finding.id,
|
|
167
|
+
exceptionId: entry.exceptionId
|
|
168
|
+
})) ?? []
|
|
169
|
+
});
|
|
68
170
|
return asTextResponse(result);
|
|
69
171
|
}));
|
|
70
172
|
const ReadFileParams = {
|
|
@@ -124,13 +226,14 @@ tool("security.get_system_prompt", "Return the full security engineering system
|
|
|
124
226
|
// New tool: security.threat_model
|
|
125
227
|
// ---------------------------------------------------------------------------
|
|
126
228
|
const ThreatModelParams = {
|
|
229
|
+
...ReviewRunIdParam,
|
|
127
230
|
feature: z.string().describe("One or two sentences describing the feature or component to threat-model. " +
|
|
128
231
|
"Example: 'OAuth 2.0 login flow with PKCE and session cookies'."),
|
|
129
232
|
surfaces: z.array(z.enum(["web", "api", "mobile", "ai", "infra", "data"])).optional().describe("Attack surfaces involved. Defaults to all.")
|
|
130
233
|
};
|
|
131
234
|
const ThreatModelSchema = z.object(ThreatModelParams);
|
|
132
235
|
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);
|
|
236
|
+
const { runId, feature, surfaces } = ThreatModelSchema.parse(args);
|
|
134
237
|
const surfaceList = surfaces ?? ["web", "api", "mobile", "ai", "infra", "data"];
|
|
135
238
|
const template = `# Threat Model: ${feature}
|
|
136
239
|
|
|
@@ -241,12 +344,19 @@ Describe Level 0 (context) and Level 1 (process) flows in prose or embed a diagr
|
|
|
241
344
|
- [ ] IR playbook updated if new attack surface introduced
|
|
242
345
|
- [ ] Compliance requirements addressed and documented
|
|
243
346
|
`;
|
|
347
|
+
if (runId) {
|
|
348
|
+
await updateReviewStep(runId, "threat_model", "completed", {
|
|
349
|
+
feature,
|
|
350
|
+
surfaces: surfaceList
|
|
351
|
+
});
|
|
352
|
+
}
|
|
244
353
|
return asTextResponse(template);
|
|
245
354
|
}));
|
|
246
355
|
// ---------------------------------------------------------------------------
|
|
247
356
|
// New tool: security.checklist
|
|
248
357
|
// ---------------------------------------------------------------------------
|
|
249
358
|
const ChecklistParams = {
|
|
359
|
+
...ReviewRunIdParam,
|
|
250
360
|
surface: z.enum(["web", "api", "mobile", "ai", "infra", "payments", "all"]).optional()
|
|
251
361
|
.describe("Filter checklist by attack surface. Default: all.")
|
|
252
362
|
};
|
|
@@ -334,8 +444,11 @@ Use before every production release. All items must be checked or explicitly ris
|
|
|
334
444
|
- [ ] Audit trail maintained for all payment operations
|
|
335
445
|
`;
|
|
336
446
|
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);
|
|
447
|
+
const { runId, surface } = ChecklistSchema.parse(args);
|
|
338
448
|
if (!surface || surface === "all") {
|
|
449
|
+
if (runId) {
|
|
450
|
+
await updateReviewStep(runId, "checklist", "completed", { surface: "all" });
|
|
451
|
+
}
|
|
339
452
|
return asTextResponse(CHECKLIST_ALL);
|
|
340
453
|
}
|
|
341
454
|
// Extract the relevant section
|
|
@@ -358,6 +471,9 @@ tool("security.checklist", "Return the pre-release security checklist, optionall
|
|
|
358
471
|
const allSurfaces = lines.slice(0, allSurfacesEnd).join("\n");
|
|
359
472
|
const sectionEnd = lines.findIndex((l, i) => i > start + 1 && l.startsWith("## "));
|
|
360
473
|
const section = lines.slice(start, sectionEnd === -1 ? undefined : sectionEnd).join("\n");
|
|
474
|
+
if (runId) {
|
|
475
|
+
await updateReviewStep(runId, "checklist", "completed", { surface });
|
|
476
|
+
}
|
|
361
477
|
return asTextResponse(`# Pre-Release Security Checklist (${surface})\n\n${allSurfaces}\n\n${section}`);
|
|
362
478
|
}));
|
|
363
479
|
// ---------------------------------------------------------------------------
|
|
@@ -434,6 +550,324 @@ tool("security.generate_policy", "Generate a security-policy.json for your proje
|
|
|
434
550
|
return asTextResponse(comment + JSON.stringify(policy, null, 2));
|
|
435
551
|
}));
|
|
436
552
|
// ---------------------------------------------------------------------------
|
|
553
|
+
// New tool: security.scan_strategy
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
const ScanStrategyParams = {
|
|
556
|
+
...ReviewRunIdParam,
|
|
557
|
+
mode: z.enum(["folder_by_folder", "file_by_file", "recent_changes"]).optional().describe("Required scan mode. Ask the user to choose before starting review."),
|
|
558
|
+
targets: z.array(z.string()).optional().describe("Required for folder_by_folder and file_by_file. Relative folders/files to evaluate."),
|
|
559
|
+
baseRef: z.string().optional().describe("Only for recent_changes mode. Base git ref, default origin/main."),
|
|
560
|
+
headRef: z.string().optional().describe("Only for recent_changes mode. Head git ref, default HEAD.")
|
|
561
|
+
};
|
|
562
|
+
const ScanStrategySchema = z.object(ScanStrategyParams);
|
|
563
|
+
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) => {
|
|
564
|
+
const { runId, mode, targets, baseRef, headRef } = ScanStrategySchema.parse(args);
|
|
565
|
+
if (!mode) {
|
|
566
|
+
return asTextResponse({
|
|
567
|
+
required_user_decision: true,
|
|
568
|
+
question: "Choose scan mode before running security checks.",
|
|
569
|
+
options: ["folder_by_folder", "file_by_file", "recent_changes"],
|
|
570
|
+
next_step: "Call security.scan_strategy again with the selected mode."
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const cleanTargets = (targets ?? []).map((t) => t.trim()).filter(Boolean);
|
|
574
|
+
if ((mode === "folder_by_folder" || mode === "file_by_file") && cleanTargets.length === 0) {
|
|
575
|
+
return asTextResponse({
|
|
576
|
+
required_user_decision: true,
|
|
577
|
+
question: `Mode "${mode}" requires explicit targets. Provide relative ${mode === "folder_by_folder" ? "folders" : "files"}.`,
|
|
578
|
+
next_step: "Call security.scan_strategy with mode + targets."
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
const frameworkCoverage = {
|
|
582
|
+
threat_modeling: ["STRIDE", "PASTA", "LINDDUN", "DREAD", "ATT&CK Navigator", "Attack Trees", "TRIKE"],
|
|
583
|
+
appsec_and_adversary: [
|
|
584
|
+
"OWASP Top 10 (Web/API)",
|
|
585
|
+
"OWASP ASVS L2/L3",
|
|
586
|
+
"OWASP MASVS",
|
|
587
|
+
"MITRE ATT&CK",
|
|
588
|
+
"MITRE D3FEND",
|
|
589
|
+
"MITRE CAPEC",
|
|
590
|
+
"MITRE ATLAS"
|
|
591
|
+
],
|
|
592
|
+
governance_and_compliance: [
|
|
593
|
+
"NIST 800-53 Rev5",
|
|
594
|
+
"NIST CSF 2.0",
|
|
595
|
+
"NIST 800-207 (Zero Trust)",
|
|
596
|
+
"NIST 800-218 (SSDF)",
|
|
597
|
+
"PCI DSS 4.0",
|
|
598
|
+
"SOC 2 Type II",
|
|
599
|
+
"ISO 27001/27002/42001",
|
|
600
|
+
"GDPR/CCPA"
|
|
601
|
+
],
|
|
602
|
+
pipeline_controls: [
|
|
603
|
+
"SAST",
|
|
604
|
+
"SCA",
|
|
605
|
+
"Secrets Scanning",
|
|
606
|
+
"IaC Scanning",
|
|
607
|
+
"Container Scanning",
|
|
608
|
+
"DAST",
|
|
609
|
+
"SBOM + Provenance"
|
|
610
|
+
]
|
|
611
|
+
};
|
|
612
|
+
const runGateTemplate = mode === "recent_changes"
|
|
613
|
+
? {
|
|
614
|
+
tool: "security.run_pr_gate",
|
|
615
|
+
args: {
|
|
616
|
+
mode: "recent_changes",
|
|
617
|
+
baseRef: baseRef ?? "origin/main",
|
|
618
|
+
headRef: headRef ?? "HEAD"
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
: {
|
|
622
|
+
tool: "security.run_pr_gate",
|
|
623
|
+
args: {
|
|
624
|
+
mode,
|
|
625
|
+
targets: cleanTargets
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
if (runId) {
|
|
629
|
+
await updateReviewStep(runId, "scan_strategy", "completed", {
|
|
630
|
+
mode,
|
|
631
|
+
targets: cleanTargets,
|
|
632
|
+
baseRef: baseRef ?? "origin/main",
|
|
633
|
+
headRef: headRef ?? "HEAD"
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return asTextResponse({
|
|
637
|
+
decision_confirmed: true,
|
|
638
|
+
mode,
|
|
639
|
+
targets: cleanTargets,
|
|
640
|
+
git_range: mode === "recent_changes" ? { baseRef: baseRef ?? "origin/main", headRef: headRef ?? "HEAD" } : null,
|
|
641
|
+
execution_plan: [
|
|
642
|
+
"1) Inventory scope and adjacent blast radius components.",
|
|
643
|
+
"2) Run threat model coverage (STRIDE + PASTA + ATT&CK + D3FEND).",
|
|
644
|
+
"3) Run policy gate + static/dynamic/IaC/container/security checks.",
|
|
645
|
+
"4) Map findings to OWASP/NIST/PCI/SOC2/ISO controls.",
|
|
646
|
+
"5) Apply code/config fixes immediately and re-run gate until PASS.",
|
|
647
|
+
"6) Produce residual-risk register with owner, date, and review cadence."
|
|
648
|
+
],
|
|
649
|
+
framework_coverage: frameworkCoverage,
|
|
650
|
+
run_gate_template: runGateTemplate,
|
|
651
|
+
completion_rule: "No section is complete until all required controls are either implemented or formally risk-accepted."
|
|
652
|
+
});
|
|
653
|
+
}));
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// New tool: security.terraform_hardening_blueprint
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
const TerraformHardeningParams = {
|
|
658
|
+
cloud: z.enum(["aws", "gcp", "azure", "multi"]).optional().describe("Target cloud platform. Default: multi."),
|
|
659
|
+
criticality: z.enum(["standard", "high", "regulated"]).optional().describe("Security strictness profile."),
|
|
660
|
+
environment: z.string().optional().describe("Environment name (e.g., prod, staging).")
|
|
661
|
+
};
|
|
662
|
+
const TerraformHardeningSchema = z.object(TerraformHardeningParams);
|
|
663
|
+
tool("security.terraform_hardening_blueprint", "Generate an advanced Terraform hardening blueprint with secure module design, guardrails, and control mappings.", TerraformHardeningParams, safeTool(async (args, _extra) => {
|
|
664
|
+
const { cloud, criticality, environment } = TerraformHardeningSchema.parse(args);
|
|
665
|
+
const selectedCloud = cloud ?? "multi";
|
|
666
|
+
const selectedCriticality = criticality ?? "high";
|
|
667
|
+
const blueprint = {
|
|
668
|
+
target: { cloud: selectedCloud, criticality: selectedCriticality, environment: environment ?? "unspecified" },
|
|
669
|
+
module_layout: [
|
|
670
|
+
"modules/network: private subnets, no default public ingress, egress allowlists",
|
|
671
|
+
"modules/identity: least-privilege IAM roles, short-lived credentials, no wildcard actions",
|
|
672
|
+
"modules/data: encryption at rest with CMEK/KMS, backup + PITR, private endpoints",
|
|
673
|
+
"modules/observability: audit logs + flow logs + SIEM forwarding + immutable retention",
|
|
674
|
+
"modules/security: WAF, DDoS controls, threat detection, guardrail SCP/org-policies"
|
|
675
|
+
],
|
|
676
|
+
mandatory_terraform_controls: [
|
|
677
|
+
"Pin providers and modules to exact versions; no floating ranges.",
|
|
678
|
+
"Use remote state with encryption + locking + restricted access.",
|
|
679
|
+
"Enforce policy checks: Checkov/tfsec/Terrascan + OPA Conftest in CI.",
|
|
680
|
+
"Block 0.0.0.0/0 ingress/egress unless explicit risk acceptance.",
|
|
681
|
+
"Disable public object storage by default.",
|
|
682
|
+
"Require tags/labels for owner, data classification, and environment.",
|
|
683
|
+
"Enable cloud audit logging on every managed resource."
|
|
684
|
+
],
|
|
685
|
+
secure_cicd_flow: [
|
|
686
|
+
"terraform fmt/validate -> terraform plan -> policy checks (OPA/Checkov/tfsec) -> manual approval -> terraform apply",
|
|
687
|
+
"Store plan output artifact and sign provenance before apply.",
|
|
688
|
+
"Run drift detection nightly and alert on unauthorized changes."
|
|
689
|
+
],
|
|
690
|
+
control_mapping: {
|
|
691
|
+
nist_800_53: ["AC-3", "AC-6", "AU-2", "AU-12", "SC-7", "SC-8", "SC-12", "SI-4"],
|
|
692
|
+
cis: ["CIS cloud benchmark level 2", "CIS IaC policy enforcement"],
|
|
693
|
+
zero_trust: ["explicit authn/authz for service paths", "micro-segmentation", "continuous verification"]
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
return asTextResponse(blueprint);
|
|
697
|
+
}));
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// New tool: security.generate_opa_rego
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
const GenerateOpaRegoParams = {
|
|
702
|
+
...ReviewRunIdParam,
|
|
703
|
+
policyPack: z.enum(["terraform_plan", "ci_pipeline", "kubernetes"]).optional().describe("Policy pack to generate. Default: terraform_plan."),
|
|
704
|
+
cloud: z.enum(["aws", "gcp", "azure", "multi"]).optional().describe("Cloud context for policy wording."),
|
|
705
|
+
applySuggestion: z.boolean().optional().describe("Must be true before generating policy code. This forces explicit user consent.")
|
|
706
|
+
};
|
|
707
|
+
const GenerateOpaRegoSchema = z.object(GenerateOpaRegoParams);
|
|
708
|
+
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) => {
|
|
709
|
+
const { runId, policyPack, cloud, applySuggestion } = GenerateOpaRegoSchema.parse(args);
|
|
710
|
+
const selectedPack = policyPack ?? "terraform_plan";
|
|
711
|
+
if (!applySuggestion) {
|
|
712
|
+
return asTextResponse({
|
|
713
|
+
requires_user_confirmation: true,
|
|
714
|
+
question: "Do you want security-mcp to generate preventive OPA/Rego policies for your pipeline and Terraform plan checks?",
|
|
715
|
+
next_step: "Re-run security.generate_opa_rego with applySuggestion=true."
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const terraformPolicy = `package security.terraform
|
|
719
|
+
|
|
720
|
+
import rego.v1
|
|
721
|
+
|
|
722
|
+
deny contains msg if {
|
|
723
|
+
some rc in input.resource_changes
|
|
724
|
+
rc.type == "aws_security_group_rule"
|
|
725
|
+
lower(rc.change.after.type) == "ingress"
|
|
726
|
+
rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
|
|
727
|
+
msg := "deny: public ingress 0.0.0.0/0 is not allowed"
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
deny contains msg if {
|
|
731
|
+
some rc in input.resource_changes
|
|
732
|
+
rc.type in {"aws_s3_bucket", "google_storage_bucket", "azurerm_storage_account"}
|
|
733
|
+
not is_private_storage(rc.change.after)
|
|
734
|
+
msg := sprintf("deny: storage resource %s must not be public", [rc.address])
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
deny contains msg if {
|
|
738
|
+
some rc in input.resource_changes
|
|
739
|
+
is_data_resource(rc.type)
|
|
740
|
+
not encryption_enabled(rc.change.after)
|
|
741
|
+
msg := sprintf("deny: encryption at rest is required for %s", [rc.address])
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
is_private_storage(after) if {
|
|
745
|
+
not after.public
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
encryption_enabled(after) if {
|
|
749
|
+
after.encryption == true
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
is_data_resource(kind) if {
|
|
753
|
+
kind in {"aws_db_instance", "google_sql_database_instance", "azurerm_postgresql_flexible_server"}
|
|
754
|
+
}`;
|
|
755
|
+
const ciPolicy = `package security.cicd
|
|
756
|
+
|
|
757
|
+
import rego.v1
|
|
758
|
+
|
|
759
|
+
required_jobs := {"sast", "sca", "secrets", "iac", "container", "dast"}
|
|
760
|
+
|
|
761
|
+
deny contains msg if {
|
|
762
|
+
some job in required_jobs
|
|
763
|
+
not input.pipeline.jobs[job]
|
|
764
|
+
msg := sprintf("deny: missing required security job '%s'", [job])
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
deny contains msg if {
|
|
768
|
+
input.pipeline.context.allow_high_findings == true
|
|
769
|
+
msg := "deny: pipeline cannot allow HIGH/CRITICAL findings by default"
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
deny contains msg if {
|
|
773
|
+
not input.pipeline.provenance.signed
|
|
774
|
+
msg := "deny: release artifacts must include signed provenance/SBOM attestations"
|
|
775
|
+
}`;
|
|
776
|
+
const k8sPolicy = `package security.kubernetes
|
|
777
|
+
|
|
778
|
+
import rego.v1
|
|
779
|
+
|
|
780
|
+
deny contains msg if {
|
|
781
|
+
input.kind == "Deployment"
|
|
782
|
+
some c in input.spec.template.spec.containers
|
|
783
|
+
not c.securityContext.runAsNonRoot
|
|
784
|
+
msg := sprintf("deny: container '%s' must run as non-root", [c.name])
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
deny contains msg if {
|
|
788
|
+
input.kind == "Deployment"
|
|
789
|
+
some c in input.spec.template.spec.containers
|
|
790
|
+
c.securityContext.privileged == true
|
|
791
|
+
msg := sprintf("deny: privileged container '%s' is not allowed", [c.name])
|
|
792
|
+
}`;
|
|
793
|
+
const policyByPack = {
|
|
794
|
+
terraform_plan: {
|
|
795
|
+
path: "policy/terraform/security.rego",
|
|
796
|
+
policy: terraformPolicy,
|
|
797
|
+
conftest_command: "terraform show -json tfplan.binary > tfplan.json && conftest test tfplan.json -p policy/terraform"
|
|
798
|
+
},
|
|
799
|
+
ci_pipeline: {
|
|
800
|
+
path: "policy/ci/security.rego",
|
|
801
|
+
policy: ciPolicy,
|
|
802
|
+
conftest_command: "conftest test pipeline-input.json -p policy/ci"
|
|
803
|
+
},
|
|
804
|
+
kubernetes: {
|
|
805
|
+
path: "policy/kubernetes/security.rego",
|
|
806
|
+
policy: k8sPolicy,
|
|
807
|
+
conftest_command: "conftest test k8s-manifest.yaml -p policy/kubernetes"
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
const selected = policyByPack[selectedPack];
|
|
811
|
+
if (runId) {
|
|
812
|
+
await updateReviewStep(runId, "generate_opa_rego", "approved", {
|
|
813
|
+
policyPack: selectedPack,
|
|
814
|
+
cloud: cloud ?? "multi"
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return asTextResponse({
|
|
818
|
+
generated_for: { policyPack: selectedPack, cloud: cloud ?? "multi" },
|
|
819
|
+
files: [selected],
|
|
820
|
+
install_notes: [
|
|
821
|
+
"Run this in CI before deployment apply/admission.",
|
|
822
|
+
"Fail the pipeline when any deny rules are returned.",
|
|
823
|
+
"Version-control the policy and require security-owner approval for policy exceptions."
|
|
824
|
+
]
|
|
825
|
+
});
|
|
826
|
+
}));
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// New tool: security.self_heal_loop
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
const SelfHealLoopParams = {
|
|
831
|
+
...ReviewRunIdParam,
|
|
832
|
+
useCase: z.string().optional().describe("Short description of recurring security issues in this codebase."),
|
|
833
|
+
findings: z.array(z.string()).optional().describe("Recent recurring findings or control gaps."),
|
|
834
|
+
approveAdaptiveUpdates: z.boolean().optional().describe("Must be true before suggesting any adaptive improvement. Human approval is mandatory.")
|
|
835
|
+
};
|
|
836
|
+
const SelfHealLoopSchema = z.object(SelfHealLoopParams);
|
|
837
|
+
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) => {
|
|
838
|
+
const { runId, useCase, findings, approveAdaptiveUpdates } = SelfHealLoopSchema.parse(args);
|
|
839
|
+
if (!approveAdaptiveUpdates) {
|
|
840
|
+
return asTextResponse({
|
|
841
|
+
requires_human_approval: true,
|
|
842
|
+
question: "Do you want security-mcp to propose adaptive updates to policies/checklists based on recurring findings in your use case?",
|
|
843
|
+
next_step: "Re-run security.self_heal_loop with approveAdaptiveUpdates=true."
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
if (runId) {
|
|
847
|
+
await updateReviewStep(runId, "self_heal_loop", "approved", {
|
|
848
|
+
useCase: useCase ?? "unspecified"
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return asTextResponse({
|
|
852
|
+
adaptive_security_loop: [
|
|
853
|
+
"1) Capture repeated findings from gate outputs and incident reports.",
|
|
854
|
+
"2) Cluster by root cause (authz gaps, IaC misconfig, secrets, AI injection, dependency risk).",
|
|
855
|
+
"3) Propose updates to .mcp/policies/security-policy.json and .mcp/mappings/evidence-map.json.",
|
|
856
|
+
"4) Require explicit human approval before applying any policy, prompt, or checklist mutation.",
|
|
857
|
+
"5) Re-run security.run_pr_gate in the selected scan mode and compare residual risk trend."
|
|
858
|
+
],
|
|
859
|
+
guardrails: [
|
|
860
|
+
"No autonomous code or policy mutation without explicit human approval.",
|
|
861
|
+
"No weakening of controls without signed risk acceptance metadata.",
|
|
862
|
+
"Every approved adaptive update must be logged with owner, date, rationale, and rollback path."
|
|
863
|
+
],
|
|
864
|
+
input_summary: {
|
|
865
|
+
useCase: useCase ?? "unspecified",
|
|
866
|
+
findings: findings ?? []
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
}));
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
437
871
|
// MCP Prompts capability
|
|
438
872
|
// ---------------------------------------------------------------------------
|
|
439
873
|
server.prompt("security-engineer", "Activate the security-mcp system prompt. Sets up the model as an elite, threat-informed security engineer applying OWASP, MITRE ATT&CK, NIST 800-53, Zero Trust, PCI DSS, SOC 2, and ISO 27001 to every code and architecture decision.", async () => ({
|
package/dist/repo/fs.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
function getWorkspaceRoot() {
|
|
4
|
+
return process.cwd();
|
|
5
|
+
}
|
|
6
|
+
function getWorkspacePrefix(root) {
|
|
7
|
+
return root.endsWith(path.sep) ? root : root + path.sep;
|
|
8
|
+
}
|
|
6
9
|
export async function readFileSafe(relPath) {
|
|
7
|
-
const
|
|
10
|
+
const root = getWorkspaceRoot();
|
|
11
|
+
const rootPrefix = getWorkspacePrefix(root);
|
|
12
|
+
const p = path.resolve(root, relPath);
|
|
8
13
|
// Allow exact match to ROOT itself or any path strictly under it.
|
|
9
14
|
// Using ROOT_PREFIX prevents the classic prefix-collision bypass
|
|
10
15
|
// (e.g. /app-sibling matching /app as a prefix). CWE-22.
|
|
11
|
-
if (p !==
|
|
16
|
+
if (p !== root && !p.startsWith(rootPrefix)) {
|
|
12
17
|
throw new Error("Path traversal blocked");
|
|
13
18
|
}
|
|
14
19
|
return await readFile(p, "utf8");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const REVIEW_DIR = path.join(".mcp", "reviews");
|
|
5
|
+
const REPORT_DIR = path.join(".mcp", "reports");
|
|
6
|
+
async function ensureDir(dirPath) {
|
|
7
|
+
await mkdir(dirPath, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
function reviewPath(runId) {
|
|
10
|
+
return path.join(process.cwd(), REVIEW_DIR, `${runId}.json`);
|
|
11
|
+
}
|
|
12
|
+
function reportPath(runId) {
|
|
13
|
+
return path.join(process.cwd(), REPORT_DIR, `${runId}.attestation.json`);
|
|
14
|
+
}
|
|
15
|
+
async function writeJson(filePath, value) {
|
|
16
|
+
await ensureDir(path.dirname(filePath));
|
|
17
|
+
await writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf-8");
|
|
18
|
+
}
|
|
19
|
+
export async function createReviewRun(opts) {
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
const cleanTargets = (opts.targets ?? []).map((target) => target.trim()).filter(Boolean);
|
|
22
|
+
const run = {
|
|
23
|
+
id: randomUUID(),
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
mode: opts.mode,
|
|
27
|
+
targets: cleanTargets,
|
|
28
|
+
baseRef: opts.baseRef,
|
|
29
|
+
headRef: opts.headRef,
|
|
30
|
+
requiredSteps: ["scan_strategy", "threat_model", "checklist", "run_pr_gate"],
|
|
31
|
+
steps: {
|
|
32
|
+
start_review: {
|
|
33
|
+
status: "completed",
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
details: {
|
|
36
|
+
mode: opts.mode,
|
|
37
|
+
targets: cleanTargets,
|
|
38
|
+
baseRef: opts.baseRef,
|
|
39
|
+
headRef: opts.headRef
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
await writeJson(reviewPath(run.id), run);
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
export async function readReviewRun(runId) {
|
|
48
|
+
const raw = await readFile(reviewPath(runId), "utf-8");
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
export async function updateReviewStep(runId, step, status, details) {
|
|
52
|
+
const run = await readReviewRun(runId);
|
|
53
|
+
run.steps[step] = {
|
|
54
|
+
status,
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
details
|
|
57
|
+
};
|
|
58
|
+
run.updatedAt = new Date().toISOString();
|
|
59
|
+
await writeJson(reviewPath(run.id), run);
|
|
60
|
+
return run;
|
|
61
|
+
}
|
|
62
|
+
export async function createReviewAttestation(runId, payload, signatureKey) {
|
|
63
|
+
const digestInput = JSON.stringify(payload);
|
|
64
|
+
const sha256 = createHash("sha256").update(digestInput).digest("hex");
|
|
65
|
+
const hmacSha256 = signatureKey
|
|
66
|
+
? createHmac("sha256", signatureKey).update(digestInput).digest("hex")
|
|
67
|
+
: undefined;
|
|
68
|
+
await writeJson(reportPath(runId), {
|
|
69
|
+
...payload,
|
|
70
|
+
integrity: {
|
|
71
|
+
sha256,
|
|
72
|
+
...(hmacSha256 ? { hmacSha256 } : {})
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
path: reportPath(runId),
|
|
77
|
+
sha256,
|
|
78
|
+
hmacSha256
|
|
79
|
+
};
|
|
80
|
+
}
|