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.
@@ -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
- // 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.", 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 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);
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
- const ROOT = process.cwd();
4
- // ROOT_PREFIX ensures /home/u/project-adjacent doesn't pass a startsWith check for /home/u/project
5
- const ROOT_PREFIX = ROOT.endsWith(path.sep) ? ROOT : ROOT + path.sep;
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 p = path.resolve(ROOT, relPath);
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 !== ROOT && !p.startsWith(ROOT_PREFIX)) {
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
+ }