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.
Files changed (47) hide show
  1. package/README.md +77 -21
  2. package/defaults/checklists/ai.json +25 -0
  3. package/defaults/checklists/api.json +27 -0
  4. package/defaults/checklists/infra.json +27 -0
  5. package/defaults/checklists/mobile.json +25 -0
  6. package/defaults/checklists/payments.json +25 -0
  7. package/defaults/checklists/web.json +30 -0
  8. package/defaults/control-catalog.json +549 -0
  9. package/defaults/evidence-map.json +194 -0
  10. package/defaults/security-exceptions.json +4 -0
  11. package/defaults/security-policy.json +41 -2
  12. package/defaults/security-tools.json +41 -0
  13. package/dist/ci/pr-gate.js +2 -3
  14. package/dist/cli/index.js +63 -23
  15. package/dist/cli/install.js +47 -15
  16. package/dist/cli/onboarding.js +590 -0
  17. package/dist/cli/update.js +124 -0
  18. package/dist/gate/baseline.js +115 -0
  19. package/dist/gate/catalog.js +55 -0
  20. package/dist/gate/checks/ai-redteam.js +374 -0
  21. package/dist/gate/checks/ai.js +45 -14
  22. package/dist/gate/checks/api.js +93 -0
  23. package/dist/gate/checks/crypto.js +153 -0
  24. package/dist/gate/checks/database.js +144 -0
  25. package/dist/gate/checks/dependencies.js +130 -0
  26. package/dist/gate/checks/dlp.js +153 -0
  27. package/dist/gate/checks/graphql.js +122 -0
  28. package/dist/gate/checks/infra.js +126 -12
  29. package/dist/gate/checks/k8s.js +190 -0
  30. package/dist/gate/checks/playbook.js +160 -0
  31. package/dist/gate/checks/runtime.js +263 -0
  32. package/dist/gate/checks/sbom.js +199 -0
  33. package/dist/gate/checks/scanners.js +450 -0
  34. package/dist/gate/checks/secrets.js +119 -27
  35. package/dist/gate/diff.js +2 -2
  36. package/dist/gate/evidence.js +116 -0
  37. package/dist/gate/exceptions.js +85 -0
  38. package/dist/gate/policy.js +189 -17
  39. package/dist/gate/threat-intel.js +157 -0
  40. package/dist/mcp/server.js +938 -9
  41. package/dist/repo/fs.js +10 -5
  42. package/dist/repo/search.js +13 -1
  43. package/dist/review/store.js +208 -0
  44. package/dist/tests/run.js +103 -0
  45. package/package.json +13 -3
  46. package/prompts/SECURITY_PROMPT.md +455 -1
  47. package/skills/senior-security-engineer/SKILL.md +81 -4
@@ -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
- // Existing tools (unchanged)
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 against the current workspace. Returns PASS/FAIL plus findings and required actions.", RunPrGateParams, safeTool(async (args, _extra) => {
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
- let prompt = SECURITY_PROMPT;
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. 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 () => ({
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",