security-mcp 1.1.4 → 1.3.3

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 (158) hide show
  1. package/README.md +341 -1018
  2. package/defaults/checklists/ai.json +20 -1
  3. package/defaults/checklists/api.json +35 -1
  4. package/defaults/checklists/infra.json +34 -1
  5. package/defaults/checklists/mobile.json +23 -1
  6. package/defaults/checklists/payments.json +15 -1
  7. package/defaults/checklists/web.json +11 -1
  8. package/defaults/cloud-controls/aws.json +10712 -0
  9. package/defaults/cloud-controls/azure.json +7201 -0
  10. package/defaults/cloud-controls/gcp.json +4061 -0
  11. package/defaults/control-catalog.json +24 -0
  12. package/defaults/security-policy.json +2 -2
  13. package/dist/ci/pr-gate.js +22 -5
  14. package/dist/cli/index.js +73 -2
  15. package/dist/cli/install.js +4 -55
  16. package/dist/cli/onboarding.js +18 -10
  17. package/dist/gate/baseline.js +82 -7
  18. package/dist/gate/catalog.js +10 -2
  19. package/dist/gate/checks/agentic-instructions.js +515 -0
  20. package/dist/gate/checks/ai-governance.js +132 -0
  21. package/dist/gate/checks/ai.js +757 -39
  22. package/dist/gate/checks/auth-deep.js +920 -216
  23. package/dist/gate/checks/business-logic.js +751 -0
  24. package/dist/gate/checks/ci-pipeline.js +399 -4
  25. package/dist/gate/checks/cloud-controls.js +69 -0
  26. package/dist/gate/checks/crypto.js +423 -2
  27. package/dist/gate/checks/data-platform.js +954 -0
  28. package/dist/gate/checks/dependencies.js +582 -15
  29. package/dist/gate/checks/docker-deep.js +1236 -0
  30. package/dist/gate/checks/gitops.js +724 -0
  31. package/dist/gate/checks/graphql.js +201 -19
  32. package/dist/gate/checks/iac.js +1230 -0
  33. package/dist/gate/checks/infra.js +246 -1
  34. package/dist/gate/checks/injection-deep.js +827 -184
  35. package/dist/gate/checks/k8s.js +955 -2
  36. package/dist/gate/checks/mobile-android.js +917 -3
  37. package/dist/gate/checks/mobile-ios.js +797 -5
  38. package/dist/gate/checks/required-artifacts.js +194 -0
  39. package/dist/gate/checks/runtime.js +178 -0
  40. package/dist/gate/checks/secrets.js +256 -13
  41. package/dist/gate/checks/supply-chain-deep.js +787 -0
  42. package/dist/gate/checks/web-nextjs.js +572 -48
  43. package/dist/gate/cloud-controls/apply.js +115 -0
  44. package/dist/gate/cloud-controls/bicep.js +36 -0
  45. package/dist/gate/cloud-controls/cfn.js +125 -0
  46. package/dist/gate/cloud-controls/detect.js +104 -0
  47. package/dist/gate/cloud-controls/hcl.js +140 -0
  48. package/dist/gate/cloud-controls/types.js +87 -0
  49. package/dist/gate/diff.js +17 -5
  50. package/dist/gate/evidence.js +8 -1
  51. package/dist/gate/exceptions.js +202 -9
  52. package/dist/gate/findings.js +15 -2
  53. package/dist/gate/policy.js +316 -130
  54. package/dist/gate/threat-intel.js +6 -0
  55. package/dist/mcp/audit-chain.js +131 -28
  56. package/dist/mcp/auth.js +169 -0
  57. package/dist/mcp/learning.js +129 -4
  58. package/dist/mcp/model-router.js +161 -24
  59. package/dist/mcp/orchestration.js +377 -89
  60. package/dist/mcp/server.js +460 -69
  61. package/dist/mcp/tool-audit.js +193 -0
  62. package/dist/repo/fs.js +37 -1
  63. package/dist/repo/search.js +31 -6
  64. package/dist/review/store.js +56 -3
  65. package/dist/tests/run.js +124 -1
  66. package/package.json +9 -9
  67. package/skills/_TEMPLATE/SKILL.md +99 -0
  68. package/skills/advanced-dos-tester/SKILL.md +118 -0
  69. package/skills/agentic-instruction-auditor/SKILL.md +111 -0
  70. package/skills/agentic-loop-exploiter/SKILL.md +377 -0
  71. package/skills/ai-llm-redteam/SKILL.md +113 -0
  72. package/skills/ai-model-supply-chain-agent/SKILL.md +112 -0
  73. package/skills/algorithm-implementation-reviewer/SKILL.md +107 -0
  74. package/skills/android-penetration-tester/SKILL.md +464 -46
  75. package/skills/anti-replay-tester/SKILL.md +115 -0
  76. package/skills/appsec-code-auditor/SKILL.md +94 -0
  77. package/skills/artifact-integrity-analyst/SKILL.md +450 -0
  78. package/skills/attack-navigator/SKILL.md +476 -8
  79. package/skills/auth-session-hacker/SKILL.md +111 -0
  80. package/skills/aws-penetration-tester/SKILL.md +510 -0
  81. package/skills/azure-penetration-tester/SKILL.md +542 -3
  82. package/skills/binary-auth-validator/SKILL.md +120 -0
  83. package/skills/bot-detection-specialist/SKILL.md +118 -0
  84. package/skills/business-logic-attacker/SKILL.md +240 -0
  85. package/skills/capec-code-mapper/SKILL.md +93 -0
  86. package/skills/cert-pin-rotation-specialist/SKILL.md +121 -0
  87. package/skills/cicd-pipeline-hijacker/SKILL.md +414 -0
  88. package/skills/ciso-orchestrator/SKILL.md +465 -43
  89. package/skills/cloud-infra-specialist/SKILL.md +127 -0
  90. package/skills/compliance-gap-analyst/SKILL.md +431 -0
  91. package/skills/compliance-grc/SKILL.md +94 -0
  92. package/skills/compliance-lifecycle-tracker/SKILL.md +93 -0
  93. package/skills/container-hardening-auditor/SKILL.md +125 -0
  94. package/skills/credential-stuffing-specialist/SKILL.md +111 -0
  95. package/skills/crypto-pki-specialist/SKILL.md +96 -0
  96. package/skills/csa-ccm-mapper/SKILL.md +93 -0
  97. package/skills/csf2-governance-mapper/SKILL.md +93 -0
  98. package/skills/data-platform-auditor/SKILL.md +125 -0
  99. package/skills/deep-link-fuzzer/SKILL.md +118 -0
  100. package/skills/dependency-confusion-attacker/SKILL.md +424 -0
  101. package/skills/device-integrity-aggregator/SKILL.md +117 -0
  102. package/skills/dos-resilience-tester/SKILL.md +106 -0
  103. package/skills/dread-scorer/SKILL.md +93 -0
  104. package/skills/egress-policy-enforcer/SKILL.md +108 -0
  105. package/skills/evidence-collector/SKILL.md +107 -0
  106. package/skills/file-upload-attacker/SKILL.md +118 -0
  107. package/skills/gcp-penetration-tester/SKILL.md +510 -2
  108. package/skills/git-history-secret-scanner/SKILL.md +115 -0
  109. package/skills/gitops-delivery-auditor/SKILL.md +120 -0
  110. package/skills/iac-security-auditor/SKILL.md +125 -0
  111. package/skills/iam-privesc-graph-builder/SKILL.md +161 -0
  112. package/skills/incident-responder/SKILL.md +120 -0
  113. package/skills/injection-specialist/SKILL.md +111 -0
  114. package/skills/ios-security-auditor/SKILL.md +291 -0
  115. package/skills/json-ambiguity-tester/SKILL.md +145 -0
  116. package/skills/k8s-container-escaper/SKILL.md +406 -0
  117. package/skills/key-management-lifecycle-analyst/SKILL.md +107 -0
  118. package/skills/kill-switch-engineer/SKILL.md +111 -0
  119. package/skills/linddun-privacy-analyst/SKILL.md +111 -0
  120. package/skills/logic-race-fuzzer/SKILL.md +452 -0
  121. package/skills/mobile-api-network-attacker/SKILL.md +430 -0
  122. package/skills/mobile-binary-hardener/SKILL.md +111 -0
  123. package/skills/mobile-security-specialist/SKILL.md +94 -0
  124. package/skills/mobile-webview-auditor/SKILL.md +105 -0
  125. package/skills/model-extraction-attacker/SKILL.md +228 -0
  126. package/skills/multipart-abuse-tester/SKILL.md +93 -0
  127. package/skills/oauth-pkce-specialist/SKILL.md +113 -0
  128. package/skills/parser-exhaustion-tester/SKILL.md +151 -0
  129. package/skills/pentest-infra/SKILL.md +107 -0
  130. package/skills/pentest-social/SKILL.md +210 -0
  131. package/skills/pentest-team/SKILL.md +96 -0
  132. package/skills/pentest-web-api/SKILL.md +107 -0
  133. package/skills/privacy-flow-analyst/SKILL.md +243 -0
  134. package/skills/prompt-injection-specialist/SKILL.md +403 -0
  135. package/skills/quantum-migration-planner/SKILL.md +105 -0
  136. package/skills/rag-poisoning-specialist/SKILL.md +367 -0
  137. package/skills/registry-mirror-enforcer/SKILL.md +93 -0
  138. package/skills/rotation-validation-agent/SKILL.md +121 -0
  139. package/skills/samm-assessor/SKILL.md +94 -0
  140. package/skills/secrets-mask-bypass-tester/SKILL.md +109 -0
  141. package/skills/senior-security-engineer/SKILL.md +178 -0
  142. package/skills/serialization-memory-attacker/SKILL.md +341 -0
  143. package/skills/session-timeout-tester/SKILL.md +170 -0
  144. package/skills/slsa-level3-enforcer/SKILL.md +121 -0
  145. package/skills/slsa-provenance-enforcer/SKILL.md +111 -0
  146. package/skills/ssrf-detection-validator/SKILL.md +117 -0
  147. package/skills/step-up-auth-enforcer/SKILL.md +93 -0
  148. package/skills/stride-pasta-analyst/SKILL.md +429 -0
  149. package/skills/supply-chain-devsecops/SKILL.md +107 -0
  150. package/skills/threat-infrastructure-analyst/SKILL.md +93 -0
  151. package/skills/threat-modeler/SKILL.md +94 -0
  152. package/skills/tls-certificate-auditor/SKILL.md +582 -18
  153. package/skills/token-reuse-detector/SKILL.md +104 -0
  154. package/skills/trike-risk-modeler/SKILL.md +93 -0
  155. package/skills/unicode-homograph-tester/SKILL.md +93 -0
  156. package/skills/waf-rule-lifecycle-agent/SKILL.md +106 -0
  157. package/skills/webhook-security-tester/SKILL.md +111 -0
  158. package/skills/zero-trust-architect/SKILL.md +118 -0
@@ -417,6 +417,30 @@
417
417
  "frameworks": ["OWASP LLM Top 10 2025", "NIST AI RMF"],
418
418
  "evidence": ["human_in_loop_for_actions", "tool_allowlist_router"]
419
419
  },
420
+ {
421
+ "id": "AI_AGENTIC_INSTRUCTION_INTEGRITY",
422
+ "description": "Agentic-instruction files (SKILL.md, .claude, CLAUDE.md, AGENTS.md, .cursorrules, .mcp.json, copilot-instructions) are scanned for prompt-override, exfiltration, tool-poisoning, persistence, hidden-character, and credential-harvest payloads before any AI agent ingests the repository.",
423
+ "automation": "tooling",
424
+ "surfaces": ["ai", "agentic"],
425
+ "frameworks": ["OWASP LLM Top 10 2025", "MITRE ATLAS"],
426
+ "required_scanners": []
427
+ },
428
+ {
429
+ "id": "AI_LLM_ALGORITHMIC_FAIRNESS",
430
+ "description": "ML decision systems affecting people carry fairness/bias evaluation (disparate impact, equalized odds, demographic parity) and representativeness evidence.",
431
+ "automation": "evidence",
432
+ "surfaces": ["ai"],
433
+ "frameworks": ["EU AI Act", "NIST AI RMF", "ISO 42001"],
434
+ "evidence": ["fairness_evaluation", "bias_testing_present"]
435
+ },
436
+ {
437
+ "id": "AI_SHADOW_AI_DATA_LEAKAGE",
438
+ "description": "Secrets and PII are redacted/tokenized before reaching any LLM payload, preventing shadow-AI data leakage to model providers.",
439
+ "automation": "evidence",
440
+ "surfaces": ["ai"],
441
+ "frameworks": ["NIST AI RMF", "OWASP LLM Top 10 2025"],
442
+ "evidence": ["pii_redaction_before_llm", "secret_dlp_guard"]
443
+ },
420
444
  {
421
445
  "id": "SLSA_L3_PROVENANCE",
422
446
  "description": "Build artifacts have signed SLSA Level 3 provenance from a hermetic, ephemeral CI build.",
@@ -4,8 +4,8 @@
4
4
  "description": "Default security gate policy for security-mcp. Copy to .mcp/policies/security-policy.json and customize for your project.",
5
5
  "required_checks": {
6
6
  "secrets_scan": { "severity_block": ["HIGH", "CRITICAL"] },
7
- "dependency_scan": { "severity_block": ["CRITICAL"] },
8
- "sast": { "severity_block": ["CRITICAL"] },
7
+ "dependency_scan": { "severity_block": ["HIGH", "CRITICAL"] },
8
+ "sast": { "severity_block": ["HIGH", "CRITICAL"] },
9
9
  "iac_scan": { "severity_block": ["HIGH", "CRITICAL"] }
10
10
  },
11
11
  "environments": {
@@ -1,3 +1,5 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { resolve } from "node:path";
1
3
  import { runPrGate } from "../gate/policy.js";
2
4
  // Allow safe git revision operators (~ and ^) plus ref/path characters. CWE-88.
3
5
  const SAFE_REF_RE = /^[a-zA-Z0-9_./~^-]+$/;
@@ -24,7 +26,13 @@ function safeEnvTargets(envVar) {
24
26
  return true;
25
27
  });
26
28
  }
27
- async function main() {
29
+ /**
30
+ * Run the policy gate using configuration from environment variables.
31
+ * Exported so the `security-mcp ci:pr-gate` CLI subcommand can invoke it,
32
+ * while `node dist/ci/pr-gate.js` (and `npm run ci:pr-gate`) still run it directly.
33
+ * Exits the process: code 2 when the gate fails, 0 when it passes.
34
+ */
35
+ export async function runGateFromEnv() {
28
36
  const baseRef = safeEnvRef("SECURITY_GATE_BASE_REF", "origin/main");
29
37
  const headRef = safeEnvRef("SECURITY_GATE_HEAD_REF", "HEAD");
30
38
  const policyPath = process.env.SECURITY_GATE_POLICY || ".mcp/policies/security-policy.json";
@@ -37,7 +45,16 @@ async function main() {
37
45
  process.exit(2);
38
46
  }
39
47
  }
40
- main().catch((err) => {
41
- console.error("security gate crashed:", err);
42
- process.exit(3);
43
- });
48
+ // Auto-run only when executed directly (node dist/ci/pr-gate.js / npm run ci:pr-gate),
49
+ // not when imported by the CLI dispatcher.
50
+ const invokedDirectly = process.argv[1] !== undefined &&
51
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
52
+ if (invokedDirectly) {
53
+ try {
54
+ await runGateFromEnv();
55
+ }
56
+ catch (err) {
57
+ console.error("security gate crashed:", err);
58
+ process.exit(3);
59
+ }
60
+ }
package/dist/cli/index.js CHANGED
@@ -12,11 +12,14 @@
12
12
  import { createRequire } from "node:module";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { dirname, resolve } from "node:path";
15
- import { existsSync } from "node:fs";
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { homedir, platform } from "node:os";
17
17
  import { runInstall } from "./install.js";
18
18
  import { main as runServer } from "../mcp/server.js";
19
19
  import { notifyIfUpdateAvailable } from "./update.js";
20
+ import { autoHardenTree } from "../gate/cloud-controls/apply.js";
21
+ import { runGateFromEnv } from "../ci/pr-gate.js";
22
+ import { signPolicyFile } from "../gate/policy.js";
20
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
24
  const require = createRequire(import.meta.url);
22
25
  function getVersion() {
@@ -58,6 +61,9 @@ COMMANDS
58
61
  install-global Install using the globally installed security-mcp binary
59
62
  config Print MCP config JSON for manual editor setup
60
63
  doctor Verify the installation is working correctly
64
+ autoharden Auto-apply FSBP/CIS hardening fixes to Terraform (use --dry-run to preview)
65
+ ci:pr-gate Run the policy gate against the current diff (for CI/pre-commit)
66
+ sign-policy Sign the policy file with SECURITY_POLICY_HMAC_KEY for tamper protection
61
67
 
62
68
  OPTIONS (install)
63
69
  --claude-code Write config for Claude Code only
@@ -93,6 +99,13 @@ EXAMPLES
93
99
  # Verify installation health:
94
100
  npx -y security-mcp@latest doctor
95
101
 
102
+ # Run the policy gate in CI (fails the build on HIGH/CRITICAL findings):
103
+ npx -y security-mcp@latest ci:pr-gate
104
+
105
+ # Sign the policy file so tampering is detected at gate startup:
106
+ export SECURITY_POLICY_HMAC_KEY="$(openssl rand -hex 32)"
107
+ npx -y security-mcp@latest sign-policy
108
+
96
109
  # Print JSON config snippet:
97
110
  npx -y security-mcp@latest config
98
111
  security-mcp config --use-global-binary
@@ -179,6 +192,50 @@ function runDoctor() {
179
192
  process.exit(1);
180
193
  }
181
194
  }
195
+ async function runAutoHarden(dryRun) {
196
+ const report = await autoHardenTree({ write: !dryRun });
197
+ const verb = dryRun ? "Would apply" : "Applied";
198
+ process.stdout.write(`\nsecurity-mcp autoharden v${VERSION}\n`);
199
+ process.stdout.write("=".repeat(40) + "\n\n");
200
+ process.stdout.write(`${verb} ${report.applied.length} fix(es) across ${report.filesChanged.length} file(s).\n`);
201
+ for (const fix of report.applied) {
202
+ process.stdout.write(` [FIX] ${fix.ruleId} ${fix.resource} (${fix.file})\n`);
203
+ }
204
+ for (const m of report.manual) {
205
+ process.stdout.write(` [MANUAL] ${m.ruleId} ${m.resource} (${m.file}) — ${m.reason}\n`);
206
+ if (m.snippet)
207
+ process.stdout.write(` ${m.snippet}\n`);
208
+ }
209
+ if (dryRun)
210
+ process.stdout.write("\nDry run — no files were modified. Re-run without --dry-run to apply.\n");
211
+ process.stdout.write("\n");
212
+ }
213
+ // Minimum HMAC key length, mirrors POLICY_HMAC_MIN_KEY_BYTES in src/gate/policy.ts.
214
+ const POLICY_HMAC_MIN_KEY_BYTES = 32;
215
+ function runSignPolicy() {
216
+ const key = process.env["SECURITY_POLICY_HMAC_KEY"];
217
+ if (!key || Buffer.byteLength(key, "utf-8") < POLICY_HMAC_MIN_KEY_BYTES) {
218
+ process.stderr.write(`Error: SECURITY_POLICY_HMAC_KEY must be set and at least ${POLICY_HMAC_MIN_KEY_BYTES} bytes.\n` +
219
+ "Generate one with: openssl rand -hex 32\n");
220
+ process.exit(1);
221
+ }
222
+ const policyPath = process.env["SECURITY_GATE_POLICY"] || ".mcp/policies/security-policy.json";
223
+ if (!existsSync(policyPath)) {
224
+ process.stderr.write(`Error: policy file not found at "${policyPath}".\n` +
225
+ "Create one first (cp node_modules/security-mcp/defaults/security-policy.json .mcp/policies/), " +
226
+ "or set SECURITY_GATE_POLICY to its path.\n");
227
+ process.exit(1);
228
+ }
229
+ const raw = readFileSync(policyPath, "utf-8");
230
+ const signature = signPolicyFile(raw, key);
231
+ // 0o600 — keep the sidecar non-world-readable, consistent with data-at-rest hardening.
232
+ writeFileSync(`${policyPath}.hmac`, signature + "\n", { mode: 0o600 });
233
+ process.stdout.write(`\nsecurity-mcp sign-policy v${VERSION}\n`);
234
+ process.stdout.write("=".repeat(40) + "\n\n");
235
+ process.stdout.write(` [SIGNED] ${policyPath}\n`);
236
+ process.stdout.write(` [WROTE] ${policyPath}.hmac\n\n`);
237
+ process.stdout.write("Commit both files so CI can verify policy integrity at gate startup.\n\n");
238
+ }
182
239
  async function main() {
183
240
  const args = process.argv.slice(2);
184
241
  const useGlobalBinary = args.includes("--use-global-binary");
@@ -191,7 +248,8 @@ async function main() {
191
248
  process.exit(0);
192
249
  }
193
250
  const command = args[0] ?? "serve";
194
- if (command === "serve") {
251
+ if (command === "serve" || command === "ci:pr-gate") {
252
+ // Non-blocking: keep stdout reserved for protocol/JSON output.
195
253
  void notifyIfUpdateAvailable(VERSION);
196
254
  }
197
255
  else {
@@ -245,6 +303,19 @@ async function main() {
245
303
  runDoctor();
246
304
  break;
247
305
  }
306
+ case "autoharden": {
307
+ await runAutoHarden(args.includes("--dry-run"));
308
+ break;
309
+ }
310
+ case "ci:pr-gate": {
311
+ // Reads SECURITY_GATE_* env vars; exits non-zero when the gate fails.
312
+ await runGateFromEnv();
313
+ break;
314
+ }
315
+ case "sign-policy": {
316
+ runSignPolicy();
317
+ break;
318
+ }
248
319
  default: {
249
320
  process.stderr.write(`Unknown command: ${command}\nRun with --help for usage.\n`);
250
321
  process.exit(1);
@@ -6,7 +6,6 @@
6
6
  import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { homedir, platform } from "node:os";
9
- import * as https from "node:https";
10
9
  import { fileURLToPath } from "node:url";
11
10
  import { runOnboarding, installSecurityTools, commandExists, SECURITY_TOOLS } from "./onboarding.js";
12
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -172,60 +171,10 @@ function installSkill(dryRun) {
172
171
  * Mirrors the same pattern used for security tool binary downloads in onboarding.ts.
173
172
  */
174
173
  // CWE-22: only alphanumeric, hyphens, and dots allowed in skill names
175
- const SAFE_SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
176
- // CWE-918: allowlist for skill download hosts no user-controlled URLs reach the network
177
- const ALLOWED_SKILL_HOSTS = new Set(["raw.githubusercontent.com", "github.com"]);
178
- export async function downloadSkill(skillName, url, dryRun = false) {
179
- if (!SAFE_SKILL_NAME_RE.test(skillName)) {
180
- process.stdout.write(` [error] invalid skill name "${skillName}" — skipping download\n`);
181
- return;
182
- }
183
- try {
184
- const { hostname } = new URL(url);
185
- if (!ALLOWED_SKILL_HOSTS.has(hostname)) {
186
- process.stdout.write(` [error] blocked skill download from unauthorized host "${hostname}"\n`);
187
- return;
188
- }
189
- }
190
- catch {
191
- process.stdout.write(` [error] invalid skill URL "${url}" — skipping download\n`);
192
- return;
193
- }
194
- const skillDest = resolveHome(`~/.claude/skills/${skillName}/SKILL.md`);
195
- if (dryRun) {
196
- process.stdout.write(` [dry-run] would download skill "${skillName}" from ${url} → ${skillDest}\n`);
197
- return;
198
- }
199
- const MAX_SKILL_BYTES = 512 * 1024; // 512 KB — skills are markdown files
200
- const content = await new Promise((resolve) => {
201
- const req = https.get(url, { headers: { "User-Agent": "security-mcp" } }, (res) => {
202
- if ((res.statusCode ?? 500) >= 400) {
203
- res.resume();
204
- resolve(null);
205
- return;
206
- }
207
- let body = "";
208
- res.setEncoding("utf8");
209
- res.on("data", (chunk) => {
210
- body += chunk;
211
- if (Buffer.byteLength(body, "utf8") > MAX_SKILL_BYTES) {
212
- req.destroy();
213
- resolve(null);
214
- }
215
- });
216
- res.on("end", () => resolve(body));
217
- });
218
- req.on("error", () => resolve(null));
219
- req.setTimeout(10000, () => { req.destroy(); resolve(null); });
220
- });
221
- if (!content) {
222
- process.stdout.write(` [error] failed to download skill "${skillName}" from ${url}\n`);
223
- return;
224
- }
225
- mkdirSync(dirname(skillDest), { recursive: true });
226
- writeFileSync(skillDest, content, "utf-8");
227
- process.stdout.write(` installed skill: ${skillDest}\n`);
228
- }
174
+ // REMOVED downloadSkill(): an unused, integrity-free network skill installer
175
+ // (no sha256, no content sanitization) that, if ever wired up, would bypass every
176
+ // protection in orchestration.ensureSkill. Skills are bundled in the package and
177
+ // resolved locally by ensureSkill; there is no need for an unauthenticated fetcher.
229
178
  /**
230
179
  * Eagerly install the orchestrator skill (bundled in the package) plus record
231
180
  * its version so orchestration.ensure_skill can detect future updates.
@@ -340,11 +340,21 @@ async function installFromGitHub(tool, os) {
340
340
  print(` Integrity verified (SHA-256 matched).`);
341
341
  }
342
342
  else {
343
- print(` Warning: checksum file found but no entry for ${fileName} — proceeding without verification.`);
343
+ print(` ABORT: checksum file found but no entry for ${fileName} — refusing to install an unverified binary (CWE-494).`);
344
+ try {
345
+ unlinkSync(tmpFile);
346
+ }
347
+ catch { /* ignore cleanup failure */ }
348
+ return false;
344
349
  }
345
350
  }
346
351
  else {
347
- print(` Warning: no checksum file in release assets — cannot verify binary integrity.`);
352
+ print(` ABORT: no checksum file in release assets — refusing to install an unverified binary (CWE-494).`);
353
+ try {
354
+ unlinkSync(tmpFile);
355
+ }
356
+ catch { /* ignore cleanup failure */ }
357
+ return false;
348
358
  }
349
359
  const destDir = "/usr/local/bin";
350
360
  if (tool.tarball) {
@@ -432,12 +442,10 @@ async function tryDnf(tool) {
432
442
  print(` sudo ${mgr} install -y trivy`);
433
443
  return run("sudo", [mgr, "install", "-y", "trivy"]);
434
444
  }
435
- async function tryInstallScript(tool) {
436
- if (!tool.installScript || !commandExists("curl") || !commandExists("sh"))
437
- return false;
438
- print(` Running official install script for ${tool.displayName}...`);
439
- return run("bash", ["-c", tool.installScript]);
440
- }
445
+ // REMOVED (CWE-78/CWE-494): the `curl … | sudo sh` install-script strategy piped an
446
+ // unpinned, live-fetched script into root with no checksum — a compromise of the upstream
447
+ // repo or a MITM yielded root RCE on `security-mcp install`. Tools are now installed only
448
+ // via OS package managers or the checksum-verified GitHub-release path (installFromGitHub).
441
449
  async function tryWinget(tool) {
442
450
  if (!tool.winget || !commandExists("winget"))
443
451
  return false;
@@ -461,10 +469,10 @@ async function installSingleTool(tool, os) {
461
469
  print(`\n Installing ${tool.displayName}...`);
462
470
  const strategies = [];
463
471
  if (os === "macos") {
464
- strategies.push(() => tryBrew(tool), () => tryPip(tool), () => tryGoInstall(tool), () => tryInstallScript(tool), () => installFromGitHub(tool, os));
472
+ strategies.push(() => tryBrew(tool), () => tryPip(tool), () => tryGoInstall(tool), () => installFromGitHub(tool, os));
465
473
  }
466
474
  else if (os === "linux") {
467
- strategies.push(() => tryApt(tool), () => tryDnf(tool), () => tryPip(tool), () => tryGoInstall(tool), () => tryInstallScript(tool), () => installFromGitHub(tool, os));
475
+ strategies.push(() => tryApt(tool), () => tryDnf(tool), () => tryPip(tool), () => tryGoInstall(tool), () => installFromGitHub(tool, os));
468
476
  }
469
477
  else {
470
478
  // Windows
@@ -5,7 +5,39 @@
5
5
  import { execFile } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
9
  import { join } from "node:path";
10
+ // ---------------------------------------------------------------------------
11
+ // HMAC integrity helpers — TM-013 fix
12
+ // ---------------------------------------------------------------------------
13
+ // HMAC-SHA256 requires at least 32 bytes (256 bits) per NIST SP 800-107 §5.3.4.
14
+ const HMAC_MIN_KEY_BYTES = 32;
15
+ /**
16
+ * Returns the HMAC key from env, or null if not configured.
17
+ * Throws if the key is present but too short.
18
+ */
19
+ function getHmacKey() {
20
+ const key = process.env["SECURITY_POLICY_HMAC_KEY"];
21
+ if (!key)
22
+ return null;
23
+ if (Buffer.byteLength(key, "utf-8") < HMAC_MIN_KEY_BYTES) {
24
+ throw new Error(`SECURITY_POLICY_HMAC_KEY is too short (${Buffer.byteLength(key, "utf-8")} bytes). ` +
25
+ `Provide at least ${HMAC_MIN_KEY_BYTES} bytes — generate one with: ` +
26
+ `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`);
27
+ }
28
+ return key;
29
+ }
30
+ function signBaseline(json, key) {
31
+ return createHmac("sha256", key).update(json, "utf-8").digest("hex");
32
+ }
33
+ function verifyBaselineHmac(json, stored, key) {
34
+ const expected = createHmac("sha256", key).update(json, "utf-8").digest("hex");
35
+ const storedBuf = Buffer.from(stored, "hex");
36
+ const expectedBuf = Buffer.from(expected, "hex");
37
+ if (storedBuf.length !== expectedBuf.length)
38
+ return false;
39
+ return timingSafeEqual(storedBuf, expectedBuf);
40
+ }
9
41
  const execFileAsync = promisify(execFile);
10
42
  const BASELINE_DIR = join(process.cwd(), ".mcp", "baselines");
11
43
  async function ensureDir(dir) {
@@ -32,37 +64,53 @@ export async function getCommitHash() {
32
64
  /**
33
65
  * Saves a gate result as baseline for the given commit hash.
34
66
  * Also updates the latest baseline copy.
67
+ *
68
+ * TM-013 fix: When SECURITY_POLICY_HMAC_KEY is set, the serialised payload is
69
+ * HMAC-SHA256 signed and the signature is stored in the envelope. Unsigned
70
+ * writes are still permitted when no key is configured (graceful degradation),
71
+ * but loadBaseline will reject a previously-signed file whose signature no
72
+ * longer matches (tamper detection).
35
73
  */
36
74
  export async function saveBaseline(runId, result, commitHash) {
37
75
  await ensureDir(BASELINE_DIR);
38
76
  const payload = { runId, commitHash, savedAt: new Date().toISOString(), result };
39
77
  const json = JSON.stringify(payload, null, 2);
78
+ // Sign if a key is available
79
+ const hmacKey = getHmacKey();
80
+ const envelope = hmacKey
81
+ ? JSON.stringify({ payload, hmacSha256: signBaseline(json, hmacKey) }, null, 2)
82
+ : json;
40
83
  // Write to temp file then rename (atomic)
41
84
  const safehash = commitHash.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
42
85
  const targetPath = join(BASELINE_DIR, `${safehash}.json`);
43
86
  const latestPath = join(BASELINE_DIR, "latest.json");
44
- const tmpPath = `${targetPath}.tmp`;
87
+ const tmpPath = `${targetPath}.${randomBytes(8).toString("hex")}.tmp`;
45
88
  try {
46
- await writeFile(tmpPath, json, "utf-8");
89
+ await writeFile(tmpPath, envelope, "utf-8");
47
90
  await rename(tmpPath, targetPath);
48
91
  }
49
92
  catch {
50
93
  // fallback: write directly
51
- await writeFile(targetPath, json, "utf-8").catch(() => { });
94
+ await writeFile(targetPath, envelope, "utf-8").catch(() => { });
52
95
  }
53
96
  // Update latest (best-effort atomic)
54
- const latestTmp = `${latestPath}.tmp`;
97
+ const latestTmp = `${latestPath}.${randomBytes(8).toString("hex")}.tmp`;
55
98
  try {
56
- await writeFile(latestTmp, json, "utf-8");
99
+ await writeFile(latestTmp, envelope, "utf-8");
57
100
  await rename(latestTmp, latestPath);
58
101
  }
59
102
  catch {
60
- await writeFile(latestPath, json, "utf-8").catch(() => { });
103
+ await writeFile(latestPath, envelope, "utf-8").catch(() => { });
61
104
  }
62
105
  }
63
106
  /**
64
107
  * Loads a baseline by commit hash, or the latest baseline if no hash given.
65
108
  * Returns null if no baseline exists or it's corrupted.
109
+ *
110
+ * TM-013 fix: If the file is stored in the HMAC envelope format AND
111
+ * SECURITY_POLICY_HMAC_KEY is configured, the HMAC is verified before the
112
+ * payload is returned. A tampered baseline (missing or wrong HMAC) is
113
+ * rejected — the gate will run without a baseline rather than trust forged data.
66
114
  */
67
115
  export async function loadBaseline(commitHash) {
68
116
  await ensureDir(BASELINE_DIR);
@@ -76,7 +124,34 @@ export async function loadBaseline(commitHash) {
76
124
  }
77
125
  try {
78
126
  const raw = await readFile(filePath, "utf-8");
79
- const parsed = JSON.parse(raw);
127
+ const top = JSON.parse(raw);
128
+ // Detect envelope format (has both "payload" and "hmacSha256")
129
+ if ("payload" in top && "hmacSha256" in top) {
130
+ const envelope = top;
131
+ const hmacKey = getHmacKey();
132
+ if (hmacKey) {
133
+ // Re-serialise the inner payload the same way saveBaseline did
134
+ const expectedInput = JSON.stringify(envelope.payload, null, 2);
135
+ if (!verifyBaselineHmac(expectedInput, envelope.hmacSha256, hmacKey)) {
136
+ console.error("[baseline] HMAC verification failed — baseline may have been tampered. Ignoring.");
137
+ return null;
138
+ }
139
+ }
140
+ else {
141
+ // Key not configured: we can't verify, but we can warn
142
+ console.warn("[baseline] Baseline is signed but SECURITY_POLICY_HMAC_KEY is not set — skipping HMAC verification.");
143
+ }
144
+ return envelope.payload.result ?? null;
145
+ }
146
+ // Legacy format (unsigned) — parse directly
147
+ const parsed = top;
148
+ const hmacKey = getHmacKey();
149
+ if (hmacKey) {
150
+ // A key is configured but the file is unsigned — reject it to prevent
151
+ // an attacker from stripping the HMAC wrapper to bypass verification.
152
+ console.error("[baseline] SECURITY_POLICY_HMAC_KEY is set but baseline is unsigned — ignoring to prevent tampering bypass.");
153
+ return null;
154
+ }
80
155
  return parsed.result ?? null;
81
156
  }
82
157
  catch {
@@ -23,8 +23,16 @@ async function readJsonWithFallback(relPath, fallbackName) {
23
23
  ".mcp/catalog/control-catalog.json": "SECURITY_GATE_CONTROL_CATALOG"
24
24
  };
25
25
  const overrideEnv = overrideEnvMap[relPath];
26
- if (overrideEnv && process.env[overrideEnv]) {
27
- return await readFile(join(process.cwd(), process.env[overrideEnv]), "utf-8");
26
+ const overridePath = overrideEnv ? process.env[overrideEnv] : undefined;
27
+ if (overridePath) {
28
+ // Guard against path traversal (VULN-003 / CWE-22): resolve() + startsWith() is required;
29
+ // join() alone normalises '..' but does not prevent escape from the project directory.
30
+ const cwd = process.cwd();
31
+ const resolved = resolve(cwd, overridePath);
32
+ if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
33
+ throw new Error(`${overrideEnv} path escapes the project directory`);
34
+ }
35
+ return await readFile(resolved, "utf-8");
28
36
  }
29
37
  try {
30
38
  return await readFile(join(process.cwd(), relPath), "utf-8");