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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Per-tool-call structured audit log.
3
+ *
4
+ * Every MCP tool invocation is recorded as one structured JSONL line — the
5
+ * "one log per tool call, not per session" requirement for agentic systems.
6
+ * Each record carries the eight mandatory fields:
7
+ *
8
+ * 1. timestamp — ISO-8601 start time of the call
9
+ * 2. agentId — the calling agent (args.agentName) or the session id
10
+ * 3. toolName — the MCP tool that was invoked
11
+ * 4. inputParameters — tool arguments, with secret-bearing keys redacted
12
+ * 5. outputResult — outcome + byte size + a truncated, redacted preview
13
+ * 6. credentialsUsed — the session credential id (never the secret value)
14
+ * 7. userContext — requester/session context
15
+ * 8. outcomeStatus — success | error | unauthenticated
16
+ *
17
+ * Records are appended to `.mcp/audit/tool-calls.jsonl` (mode 0o600). For a
18
+ * tamper-proof deployment, point SECURITY_TOOL_AUDIT_LOG at a path backed by an
19
+ * append-only / write-once sink (e.g. an fs path on a volume with immutability,
20
+ * or a fifo forwarded to S3 Object Lock). Logging never throws: an audit-sink
21
+ * failure must not break tool execution.
22
+ */
23
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { getSessionId, isAuthRequired } from "./auth.js";
26
+ const AUDIT_LOG_PATH = process.env.SECURITY_TOOL_AUDIT_LOG ?? join(".mcp", "audit", "tool-calls.jsonl");
27
+ const MAX_STRING_LEN = 512;
28
+ const MAX_ARRAY_LEN = 100;
29
+ const MAX_DEPTH = 6;
30
+ const MAX_OUTPUT_PREVIEW = 512;
31
+ const MAX_AGENT_ID_LEN = 256;
32
+ const MAX_AUDIT_BYTES = 50 * 1024 * 1024; // rotate the log once it exceeds 50 MB
33
+ // Keys whose values are credentials/secrets. Substring match (not anchored) so
34
+ // decorated variants are caught: sharedSecret, hmacKey, refreshToken, apiKeyHeader,
35
+ // clientSecretValue, SECURITY_MCP_SHARED_SECRET, x-api-key, etc.
36
+ const SENSITIVE_KEY_RE = /(?:secret|token|passw|pwd|api[_-]?key|apikey|authorization|auth|signature|hmac|private[_-]?key|access[_-]?key|bearer|cookie|credential)/i;
37
+ // Secret-shaped patterns scrubbed from string VALUES (and the output preview),
38
+ // regardless of key name — catches secrets embedded in URLs, command strings, and
39
+ // file contents returned by repo.read_file / repo.search.
40
+ const SECRET_VALUE_PATTERNS = [
41
+ /AKIA[0-9A-Z]{16}/g, // AWS access key id
42
+ /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/g, // PEM private key header
43
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT
44
+ /gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub token
45
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack token
46
+ /(?:secret|token|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*["']?[^\s"'`]{6,}/gi, // key=value
47
+ /\b[A-Fa-f0-9]{40,}\b/g, // long hex (keys/digests)
48
+ /\b[A-Za-z0-9+/]{40,}={0,2}\b/g // long base64 blob
49
+ ];
50
+ function scrubSecrets(s) {
51
+ let out = s;
52
+ for (const re of SECRET_VALUE_PATTERNS)
53
+ out = out.replace(re, "[REDACTED]");
54
+ return out;
55
+ }
56
+ /** Deep-clone arguments while masking secret keys and capping size. */
57
+ function redact(value, depth = 0) {
58
+ if (depth > MAX_DEPTH)
59
+ return "[depth-capped]";
60
+ if (Array.isArray(value)) {
61
+ return value.slice(0, MAX_ARRAY_LEN).map((v) => redact(v, depth + 1));
62
+ }
63
+ if (value && typeof value === "object") {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(value)) {
66
+ out[k] = SENSITIVE_KEY_RE.test(k) ? "[REDACTED]" : redact(v, depth + 1);
67
+ }
68
+ return out;
69
+ }
70
+ if (typeof value === "string") {
71
+ const scrubbed = scrubSecrets(value);
72
+ return scrubbed.length > MAX_STRING_LEN ? scrubbed.slice(0, MAX_STRING_LEN) + "…[truncated]" : scrubbed;
73
+ }
74
+ return value;
75
+ }
76
+ /** Classify a tool result (the asTextResponse shape) into an outcome status. */
77
+ export function classifyOutcome(result) {
78
+ try {
79
+ const text = result?.content?.[0]?.text;
80
+ if (typeof text === "string") {
81
+ if (text.startsWith("[security-mcp error]"))
82
+ return "error";
83
+ // Match the structured framings only — not the bare word, which could appear in
84
+ // returned file content (repo.read_file) and poison the outcome field.
85
+ if (/"error"\s*:\s*"UNAUTHENTICATED"/.test(text))
86
+ return "unauthenticated";
87
+ if (/"authenticated"\s*:\s*false/.test(text))
88
+ return "unauthenticated"; // failed auth attempt
89
+ }
90
+ }
91
+ catch {
92
+ /* fall through to success */
93
+ }
94
+ return "success";
95
+ }
96
+ function summarizeOutput(result, outcome) {
97
+ let preview = "";
98
+ let bytes = 0;
99
+ try {
100
+ const text = result?.content?.[0]?.text;
101
+ if (typeof text === "string") {
102
+ bytes = Buffer.byteLength(text, "utf-8");
103
+ // Scrub secrets/PII before previewing — tool outputs include repo file contents.
104
+ const scrubbed = scrubSecrets(text);
105
+ preview = scrubbed.length > MAX_OUTPUT_PREVIEW ? scrubbed.slice(0, MAX_OUTPUT_PREVIEW) + "…[truncated]" : scrubbed;
106
+ }
107
+ }
108
+ catch {
109
+ /* leave defaults */
110
+ }
111
+ return { outcome, bytes, preview };
112
+ }
113
+ function extractAgentId(args) {
114
+ if (args && typeof args === "object" && "agentName" in args) {
115
+ const a = args.agentName;
116
+ if (typeof a === "string" && a.length > 0)
117
+ return a.slice(0, MAX_AGENT_ID_LEN);
118
+ }
119
+ return (getSessionId() ?? "mcp-session").slice(0, MAX_AGENT_ID_LEN);
120
+ }
121
+ function safeStringify(entry) {
122
+ // Coerce BigInt so JSON.stringify never throws — a throw would silently drop the
123
+ // record, which an attacker could weaponize as an audit-evasion primitive.
124
+ return JSON.stringify(entry, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
125
+ }
126
+ /** Append one audit record. Swallows all errors — never breaks tool execution. */
127
+ function recordToolCall(entry) {
128
+ try {
129
+ mkdirSync(dirname(AUDIT_LOG_PATH), { recursive: true, mode: 0o700 });
130
+ // CWE-400: single-rotation size guard so a tight tool-call loop cannot exhaust disk.
131
+ try {
132
+ if (statSync(AUDIT_LOG_PATH).size > MAX_AUDIT_BYTES) {
133
+ renameSync(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.1`);
134
+ }
135
+ }
136
+ catch {
137
+ /* file absent or not rotatable — ignore */
138
+ }
139
+ let line;
140
+ try {
141
+ line = safeStringify(entry);
142
+ }
143
+ catch {
144
+ // Last-resort minimal record so a sensitive call is never invisible in the log.
145
+ line = JSON.stringify({
146
+ timestamp: entry.timestamp,
147
+ toolName: entry.toolName,
148
+ outcomeStatus: entry.outcomeStatus,
149
+ note: "serialize-failed"
150
+ });
151
+ }
152
+ appendFileSync(AUDIT_LOG_PATH, line + "\n", { encoding: "utf-8", mode: 0o600 });
153
+ }
154
+ catch {
155
+ /* audit sink unavailable — do not interrupt the tool call */
156
+ }
157
+ }
158
+ /**
159
+ * Wrap an MCP tool handler so every invocation emits one structured audit
160
+ * record. The handler's behaviour and return value are unchanged.
161
+ */
162
+ export function withToolAudit(toolName, handler) {
163
+ const wrapped = async (args, extra) => {
164
+ const startedAt = new Date().toISOString();
165
+ const start = Date.now();
166
+ let result;
167
+ let outcome = "success";
168
+ try {
169
+ result = await handler(args, extra);
170
+ outcome = classifyOutcome(result);
171
+ return result;
172
+ }
173
+ catch (err) {
174
+ outcome = "error";
175
+ throw err;
176
+ }
177
+ finally {
178
+ const sessionId = getSessionId();
179
+ recordToolCall({
180
+ timestamp: startedAt,
181
+ durationMs: Date.now() - start,
182
+ agentId: extractAgentId(args),
183
+ toolName,
184
+ inputParameters: redact(args),
185
+ outputResult: summarizeOutput(result, outcome),
186
+ credentialsUsed: sessionId ?? (isAuthRequired() ? "unauthenticated" : "no-auth-configured"),
187
+ userContext: `session:${sessionId ?? "anonymous"} pid:${process.pid}`,
188
+ outcomeStatus: outcome
189
+ });
190
+ }
191
+ };
192
+ return wrapped;
193
+ }
package/dist/repo/fs.js CHANGED
@@ -1,5 +1,11 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ // Upper bound on the size of any single file the gate will read into memory.
4
+ // A malicious target repo can otherwise ship multi-GB files (or one huge
5
+ // contiguous token) to exhaust memory, or trigger V8 RangeError in the
6
+ // secret-scanner's global-regex passes. 10 MB comfortably covers real source,
7
+ // lockfiles, and minified bundles while bounding blast radius. CWE-400 / CWE-789.
8
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
3
9
  function getWorkspaceRoot() {
4
10
  return process.cwd();
5
11
  }
@@ -16,5 +22,35 @@ export async function readFileSafe(relPath) {
16
22
  if (p !== root && !p.startsWith(rootPrefix)) {
17
23
  throw new Error("Path traversal blocked");
18
24
  }
25
+ // Resolve symlinks and verify the real path is also within the workspace.
26
+ // This prevents symlink traversal attacks where a symlink inside the workspace
27
+ // points to a file outside it. CWE-61 / CAPEC-132.
28
+ try {
29
+ const realResolved = await realpath(p);
30
+ const realRoot = await realpath(root);
31
+ const realRootPrefix = realRoot + path.sep;
32
+ if (realResolved !== realRoot && !realResolved.startsWith(realRootPrefix)) {
33
+ throw new Error(`Symlink traversal detected: ${relPath} -> ${realResolved}`);
34
+ }
35
+ }
36
+ catch (e) {
37
+ if (e.code === "ENOENT") {
38
+ throw new Error(`File not found: ${relPath}`);
39
+ }
40
+ if (e.message.includes("Symlink traversal"))
41
+ throw e;
42
+ // SECURITY: Any other realpath error (EACCES, ELOOP, etc.) means we could not
43
+ // verify the real path is within the workspace. Deny rather than fall through,
44
+ // because readFile() would follow symlinks using the unverified lexical path,
45
+ // enabling traversal to out-of-workspace targets. CWE-61 / CAPEC-132.
46
+ throw new Error(`Cannot verify path safety for ${relPath}: ${e.message}`);
47
+ }
48
+ // CWE-400/CWE-789: refuse oversized files so a hostile repo cannot exhaust
49
+ // memory or feed a multi-MB contiguous token into a global regex (RangeError).
50
+ // Loop-callers (secret/cloud-controls/search scanners) catch this and skip the file.
51
+ const { size } = await stat(p);
52
+ if (size > MAX_FILE_BYTES) {
53
+ throw new Error(`File too large to scan safely: ${relPath} (${size} bytes > ${MAX_FILE_BYTES})`);
54
+ }
19
55
  return await readFile(p, "utf8");
20
56
  }
@@ -2,10 +2,30 @@ import fg from "fast-glob";
2
2
  import { readFileSafe } from "./fs.js";
3
3
  // Maximum allowed regex pattern length. Longer patterns significantly raise
4
4
  // the risk of catastrophic backtracking (ReDoS). CWE-1333.
5
- const MAX_REGEX_LEN = 256;
6
- // Detects nested quantifiers — the most common ReDoS trigger — without being
7
- // overly complex itself. Matches patterns like (a+)+, (a*)*, (\w+)+.
8
- const NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?{]/;
5
+ const MAX_REGEX_LEN = 500;
6
+ /**
7
+ * Detects regex patterns that risk catastrophic backtracking (ReDoS).
8
+ * Covers nested quantifiers, ambiguous alternation with outer quantifiers,
9
+ * counted repetition inside groups, and overlapping wildcard groups.
10
+ * CWE-1333 / MITRE ATT&CK T1499.
11
+ */
12
+ function isCatastrophicRegex(pattern) {
13
+ // Original: nested quantifiers like (a+)+, (a*)*, (\w+)+
14
+ if (/\([^)]*[+*][^)]*\)[+*?{]/.test(pattern))
15
+ return true;
16
+ // Ambiguous alternation with outer quantifier: (a|aa)+ or (a|b)+
17
+ if (/\([^)]*\|[^)]*\)[+*]/.test(pattern))
18
+ return true;
19
+ // Counted repetition with nested group: (a{2,})+ or (a{1,3})+
20
+ if (/\([^)]*\{[^)]*\}[^)]*\)[+*]/.test(pattern))
21
+ return true;
22
+ // Overlapping alternatives: (.+)+ or (\w+)+
23
+ if (/\(\.[+*][^)]*\)[+*]/.test(pattern))
24
+ return true;
25
+ if (/\(\\[wWdDsS][+*][^)]*\)[+*]/.test(pattern))
26
+ return true;
27
+ return false;
28
+ }
9
29
  /**
10
30
  * Validates and compiles a user-supplied regex string.
11
31
  * Throws if the pattern is dangerously long, contains known ReDoS signatures,
@@ -16,12 +36,16 @@ function compileUserRegex(pattern) {
16
36
  if (pattern.length > MAX_REGEX_LEN) {
17
37
  throw new Error(`Regex pattern too long (max ${MAX_REGEX_LEN} chars)`);
18
38
  }
19
- if (NESTED_QUANTIFIER_RE.test(pattern)) {
39
+ if (isCatastrophicRegex(pattern)) {
20
40
  throw new Error("Regex pattern contains nested quantifiers that risk catastrophic backtracking (ReDoS)");
21
41
  }
22
42
  return new RegExp(pattern, "i"); // throws SyntaxError on invalid patterns
23
43
  }
24
44
  const MAX_PREVIEW_LEN = 240;
45
+ const SECRET_REDACT_RE = /\b(?:AKIA[A-Z0-9]{16}|sk-[A-Za-z0-9]{32,}|ghp_[A-Za-z0-9]{36,}|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]{20,}){2})\b/g;
46
+ function redactSecrets(s) {
47
+ return s.replace(SECRET_REDACT_RE, "[REDACTED]");
48
+ }
25
49
  function isHit(line, query, re) {
26
50
  return re ? re.test(line) : line.includes(query);
27
51
  }
@@ -35,13 +59,14 @@ function scanLines(file, lines, opts, re, matches) {
35
59
  matches.push({
36
60
  file,
37
61
  line: i + 1,
38
- preview: line.slice(0, MAX_PREVIEW_LEN)
62
+ preview: redactSecrets(line.slice(0, MAX_PREVIEW_LEN))
39
63
  });
40
64
  }
41
65
  }
42
66
  export async function searchRepo(opts) {
43
67
  const files = await fg(["**/*.*"], {
44
68
  dot: true,
69
+ followSymbolicLinks: false, // Prevent glob-based symlink traversal outside workspace root.
45
70
  ignore: [
46
71
  "**/node_modules/**",
47
72
  "**/.git/**",
@@ -1,11 +1,11 @@
1
- import { createHash, createHmac, randomUUID } from "node:crypto";
1
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const REVIEW_DIR = path.join(".mcp", "reviews");
5
5
  const REPORT_DIR = path.join(".mcp", "reports");
6
6
  const CHECKLIST_DEFAULTS_DIR = path.join(path.dirname(path.dirname(path.dirname(new URL(import.meta.url).pathname))), "defaults", "checklists");
7
7
  async function ensureDir(dirPath) {
8
- await mkdir(dirPath, { recursive: true });
8
+ await mkdir(dirPath, { recursive: true, mode: 0o700 });
9
9
  }
10
10
  function reviewPath(runId) {
11
11
  return path.join(process.cwd(), REVIEW_DIR, `${runId}.json`);
@@ -15,7 +15,7 @@ function reportPath(runId) {
15
15
  }
16
16
  async function writeJson(filePath, value) {
17
17
  await ensureDir(path.dirname(filePath));
18
- await writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf-8");
18
+ await writeFile(filePath, JSON.stringify(value, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
19
19
  }
20
20
  function checklistPath(runId) {
21
21
  return path.join(process.cwd(), REVIEW_DIR, `${runId}-checklist.json`);
@@ -40,6 +40,7 @@ const SAFE_SURFACE_RE = /^[a-z][a-z0-9_-]{0,63}$/;
40
40
  * Initialize a checklist for a run from the surface template.
41
41
  */
42
42
  export async function initChecklist(runId, surface) {
43
+ assertRunId(runId); // CWE-22: validate UUID format before using as filename component
43
44
  if (!SAFE_SURFACE_RE.test(surface)) {
44
45
  throw new Error(`Invalid surface name "${surface}"`);
45
46
  }
@@ -74,6 +75,7 @@ export async function initChecklist(runId, surface) {
74
75
  * Mark a checklist item as completed.
75
76
  */
76
77
  export async function completeChecklistItem(runId, itemId, completedBy, evidence) {
78
+ assertRunId(runId); // CWE-22
77
79
  const state = await readChecklistRaw(runId);
78
80
  if (!state)
79
81
  throw new Error(`No checklist found for runId: ${runId}`);
@@ -93,6 +95,7 @@ export async function completeChecklistItem(runId, itemId, completedBy, evidence
93
95
  * Mark a checklist item as not applicable.
94
96
  */
95
97
  export async function markChecklistItemNA(runId, itemId, completedBy, reason) {
98
+ assertRunId(runId); // CWE-22
96
99
  const state = await readChecklistRaw(runId);
97
100
  if (!state)
98
101
  throw new Error(`No checklist found for runId: ${runId}`);
@@ -111,6 +114,7 @@ export async function markChecklistItemNA(runId, itemId, completedBy, reason) {
111
114
  * Mark a checklist item as failed.
112
115
  */
113
116
  export async function failChecklistItem(runId, itemId, completedBy, reason) {
117
+ assertRunId(runId); // CWE-22
114
118
  const state = await readChecklistRaw(runId);
115
119
  if (!state)
116
120
  throw new Error(`No checklist found for runId: ${runId}`);
@@ -129,6 +133,7 @@ export async function failChecklistItem(runId, itemId, completedBy, reason) {
129
133
  * Sign off on a checklist. Requires all non-NA critical items to be completed.
130
134
  */
131
135
  export async function signOffChecklist(runId, signedOffBy) {
136
+ assertRunId(runId); // CWE-22
132
137
  const state = await readChecklistRaw(runId);
133
138
  if (!state)
134
139
  throw new Error(`No checklist found for runId: ${runId}`);
@@ -147,6 +152,7 @@ export async function signOffChecklist(runId, signedOffBy) {
147
152
  * Read checklist state for a run.
148
153
  */
149
154
  export async function readChecklist(runId) {
155
+ assertRunId(runId); // CWE-22
150
156
  return readChecklistRaw(runId);
151
157
  }
152
158
  export async function createReviewRun(opts) {
@@ -157,6 +163,7 @@ export async function createReviewRun(opts) {
157
163
  createdAt: now,
158
164
  updatedAt: now,
159
165
  mode: opts.mode,
166
+ remediationMode: opts.remediationMode,
160
167
  targets: cleanTargets,
161
168
  baseRef: opts.baseRef,
162
169
  headRef: opts.headRef,
@@ -167,6 +174,7 @@ export async function createReviewRun(opts) {
167
174
  updatedAt: now,
168
175
  details: {
169
176
  mode: opts.mode,
177
+ remediationMode: opts.remediationMode,
170
178
  targets: cleanTargets,
171
179
  baseRef: opts.baseRef,
172
180
  headRef: opts.headRef
@@ -202,7 +210,16 @@ export async function updateReviewStep(runId, step, status, details) {
202
210
  await writeJson(reviewPath(run.id), run);
203
211
  return run;
204
212
  }
213
+ // HMAC-SHA256 requires a key of at least 32 bytes (256 bits) to provide full
214
+ // security. Keys shorter than the hash output degrade HMAC to effectively a
215
+ // keyed hash with reduced security margin (NIST SP 800-107 §5.3.4).
216
+ const HMAC_MIN_KEY_BYTES = 32;
205
217
  export async function createReviewAttestation(runId, payload, signatureKey) {
218
+ if (signatureKey !== undefined && Buffer.byteLength(signatureKey, "utf-8") < HMAC_MIN_KEY_BYTES) {
219
+ throw new Error(`HMAC signature key is too short (${Buffer.byteLength(signatureKey, "utf-8")} bytes). ` +
220
+ `Provide a key of at least ${HMAC_MIN_KEY_BYTES} bytes (256 bits) — ` +
221
+ `generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`);
222
+ }
206
223
  const digestInput = JSON.stringify(payload);
207
224
  const sha256 = createHash("sha256").update(digestInput).digest("hex");
208
225
  const hmacSha256 = signatureKey
@@ -221,3 +238,39 @@ export async function createReviewAttestation(runId, payload, signatureKey) {
221
238
  hmacSha256
222
239
  };
223
240
  }
241
+ /**
242
+ * Verify a stored attestation HMAC using a timing-safe comparison.
243
+ * Returns true only if the stored hmacSha256 matches the recomputed value.
244
+ * Uses timingSafeEqual to prevent timing oracle attacks on the comparison.
245
+ */
246
+ export async function verifyAttestationHmac(runId, signatureKey) {
247
+ if (Buffer.byteLength(signatureKey, "utf-8") < HMAC_MIN_KEY_BYTES) {
248
+ return { valid: false, reason: "Signature key too short — cannot verify." };
249
+ }
250
+ let stored;
251
+ try {
252
+ const raw = await readFile(reportPath(runId), "utf-8");
253
+ stored = JSON.parse(raw);
254
+ }
255
+ catch {
256
+ return { valid: false, reason: "Attestation file not found or unreadable." };
257
+ }
258
+ const integrity = stored["integrity"];
259
+ const storedHmac = typeof integrity?.["hmacSha256"] === "string" ? integrity["hmacSha256"] : null;
260
+ if (!storedHmac) {
261
+ return { valid: false, reason: "Attestation was not signed — no hmacSha256 field." };
262
+ }
263
+ // Recompute HMAC over payload (everything except the integrity wrapper)
264
+ const { integrity: _stripped, ...payloadOnly } = stored;
265
+ const digestInput = JSON.stringify(payloadOnly);
266
+ const expected = createHmac("sha256", signatureKey).update(digestInput).digest("hex");
267
+ // Timing-safe comparison — prevents oracle attacks that leak the correct HMAC
268
+ // byte-by-byte via response timing differences (CWE-208).
269
+ const storedBuf = Buffer.from(storedHmac, "hex");
270
+ const expectedBuf = Buffer.from(expected, "hex");
271
+ if (storedBuf.length !== expectedBuf.length) {
272
+ return { valid: false, reason: "HMAC length mismatch." };
273
+ }
274
+ const match = timingSafeEqual(storedBuf, expectedBuf);
275
+ return match ? { valid: true } : { valid: false, reason: "HMAC mismatch — attestation may have been tampered." };
276
+ }
package/dist/tests/run.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { runPrGate } from "../gate/policy.js";
6
+ import { autoHardenTree } from "../gate/cloud-controls/apply.js";
7
+ import { checkCloudControls } from "../gate/checks/cloud-controls.js";
5
8
  import { createReviewAttestation, createReviewRun, readReviewRun, updateReviewStep } from "../review/store.js";
6
9
  function repoPath(...parts) {
7
10
  return path.join(process.cwd(), ...parts);
@@ -67,13 +70,115 @@ async function runFixtureGateTests() {
67
70
  });
68
71
  const ids = result.findings.map((finding) => finding.id);
69
72
  assert.ok(ids.includes("AI_OUTPUT_BOUNDS_MISSING"));
73
+ assert.ok(ids.includes("AI_BIAS_TESTING_ABSENT"));
74
+ });
75
+ await withFixture("agentic-malicious", async () => {
76
+ const result = await runPrGate({
77
+ mode: "folder_by_folder",
78
+ targets: ["."],
79
+ policyPath: ".mcp/policies/security-policy.json"
80
+ });
81
+ const ids = result.findings.map((finding) => finding.id);
82
+ assert.ok(ids.includes("AGENT_INSTRUCTION_OVERRIDE"));
83
+ assert.ok(ids.includes("AGENT_INSTRUCTION_EXFIL"));
84
+ assert.ok(ids.includes("AGENT_PERSISTENCE_DIRECTIVE"));
85
+ assert.ok(ids.includes("AGENT_TOOL_POISONING"));
86
+ assert.ok(ids.includes("AGENT_CREDENTIAL_HARVEST"));
87
+ assert.ok(ids.includes("AGENT_MEMORY_POISONING"));
88
+ assert.ok(ids.includes("AGENT_HIDDEN_INSTRUCTION"));
89
+ assert.ok(ids.includes("AGENT_REMOTE_INSTRUCTION_LOAD"));
90
+ assert.ok(ids.includes("AGENT_PERMISSION_ESCALATION"));
91
+ assert.ok(ids.includes("AGENT_BACKDOOR_INSERT"));
92
+ assert.ok(ids.includes("AGENT_PROMPT_LEAK"));
93
+ });
94
+ await withFixture("aws-insecure", async () => {
95
+ const result = await runPrGate({
96
+ mode: "folder_by_folder",
97
+ targets: ["terraform"],
98
+ policyPath: ".mcp/policies/security-policy.json"
99
+ });
100
+ const ids = result.findings.map((finding) => finding.id);
101
+ assert.ok(ids.includes("AWS_EC2_IMDSV2_REQUIRED"));
102
+ assert.ok(ids.includes("AWS_RDS_NOT_PUBLIC"));
103
+ assert.ok(ids.includes("AWS_S3_BUCKET_NO_PUBLIC_ACL"));
104
+ assert.ok(ids.includes("AWS_S3_BLOCK_PUBLIC_ACCESS"));
105
+ assert.ok(ids.includes("AWS_LAMBDA_URL_AUTH_REQUIRED"));
70
106
  });
71
107
  }
108
+ async function runCloudControlRemediationTests() {
109
+ const tmp = mkdtempSync(path.join(tmpdir(), "aws-harden-"));
110
+ const previous = process.cwd();
111
+ try {
112
+ cpSync(repoPath("fixtures", "aws-insecure", "terraform"), path.join(tmp, "terraform"), {
113
+ recursive: true
114
+ });
115
+ process.chdir(tmp);
116
+ const first = await autoHardenTree({ write: true });
117
+ const appliedIds = new Set(first.applied.map((fix) => fix.ruleId));
118
+ assert.ok(appliedIds.has("AWS_EC2_IMDSV2_REQUIRED"));
119
+ assert.ok(appliedIds.has("AWS_RDS_NOT_PUBLIC"));
120
+ assert.ok(appliedIds.has("AWS_S3_BUCKET_NO_PUBLIC_ACL"));
121
+ assert.ok(appliedIds.has("AWS_S3_BLOCK_PUBLIC_ACCESS"));
122
+ assert.ok(appliedIds.has("AWS_KMS_KEY_ROTATION"));
123
+ assert.ok(appliedIds.has("AWS_LAMBDA_URL_AUTH_REQUIRED"));
124
+ const hardened = readFileSync(path.join(tmp, "terraform", "main.tf"), "utf-8");
125
+ assert.match(hardened, /http_tokens\s*=\s*"required"/);
126
+ assert.match(hardened, /publicly_accessible\s*=\s*false/);
127
+ assert.match(hardened, /acl\s*=\s*"private"/);
128
+ assert.match(hardened, /enable_key_rotation\s*=\s*true/);
129
+ assert.match(hardened, /authorization_type\s*=\s*"AWS_IAM"/);
130
+ assert.match(hardened, /aws_s3_bucket_public_access_block/);
131
+ // Idempotent: a second pass over the now-hardened tree applies nothing.
132
+ const second = await autoHardenTree({ write: true });
133
+ assert.equal(second.applied.length, 0);
134
+ assert.equal(second.filesChanged.length, 0);
135
+ }
136
+ finally {
137
+ process.chdir(previous);
138
+ rmSync(tmp, { recursive: true, force: true });
139
+ }
140
+ }
141
+ async function runNestedRemediationTests() {
142
+ const tmp = mkdtempSync(path.join(tmpdir(), "cloud-harden-"));
143
+ const previous = process.cwd();
144
+ try {
145
+ cpSync(repoPath("fixtures", "gcp-insecure", "terraform"), path.join(tmp, "gcp"), {
146
+ recursive: true
147
+ });
148
+ cpSync(repoPath("fixtures", "azure-insecure", "terraform"), path.join(tmp, "azure"), {
149
+ recursive: true
150
+ });
151
+ process.chdir(tmp);
152
+ const report = await autoHardenTree({ write: true });
153
+ const appliedIds = new Set(report.applied.map((fix) => fix.ruleId));
154
+ // GCP: depth-3 nested replace + insert into existing settings/ip_configuration blocks.
155
+ assert.ok(appliedIds.has("GCP_SQL_NO_PUBLIC_IP"));
156
+ assert.ok(appliedIds.has("GCP_SQL_REQUIRE_SSL"));
157
+ assert.ok(appliedIds.has("GCP_STORAGE_UNIFORM_ACCESS"));
158
+ // Azure.
159
+ assert.ok(appliedIds.has("AZURE_STORAGE_HTTPS_ONLY"));
160
+ assert.ok(appliedIds.has("AZURE_KV_PURGE_PROTECTION"));
161
+ const gcp = readFileSync(path.join(tmp, "gcp", "main.tf"), "utf-8");
162
+ assert.match(gcp, /ipv4_enabled\s*=\s*false/);
163
+ assert.match(gcp, /require_ssl\s*=\s*true/);
164
+ const azure = readFileSync(path.join(tmp, "azure", "main.tf"), "utf-8");
165
+ assert.match(azure, /enable_https_traffic_only\s*=\s*true/);
166
+ assert.match(azure, /purge_protection_enabled\s*=\s*true/);
167
+ // Idempotent across both providers.
168
+ const second = await autoHardenTree({ write: true });
169
+ assert.equal(second.applied.length, 0);
170
+ }
171
+ finally {
172
+ process.chdir(previous);
173
+ rmSync(tmp, { recursive: true, force: true });
174
+ }
175
+ }
72
176
  async function runReviewWorkflowTests() {
73
177
  cleanupFixtureReviewArtifacts("web-insecure");
74
178
  await withFixture("web-insecure", async () => {
75
179
  const run = await createReviewRun({
76
180
  mode: "folder_by_folder",
181
+ remediationMode: "auto_apply",
77
182
  targets: ["src"]
78
183
  });
79
184
  await updateReviewStep(run.id, "scan_strategy", "completed", { mode: "folder_by_folder", targets: ["src"] });
@@ -91,9 +196,27 @@ async function runReviewWorkflowTests() {
91
196
  });
92
197
  cleanupFixtureReviewArtifacts("web-insecure");
93
198
  }
199
+ async function runCfnBicepDetectionTests() {
200
+ await withFixture("cfn-insecure", async () => {
201
+ const ids = new Set((await checkCloudControls({ changedFiles: [] })).map((f) => f.id));
202
+ assert.ok(ids.has("CFN_S3_NO_PUBLIC_ACL"));
203
+ assert.ok(ids.has("CFN_RDS_NOT_PUBLIC"));
204
+ assert.ok(ids.has("CFN_RDS_STORAGE_ENCRYPTED"));
205
+ assert.ok(ids.has("CFN_SG_OPEN_INGRESS"));
206
+ });
207
+ await withFixture("bicep-insecure", async () => {
208
+ const ids = new Set((await checkCloudControls({ changedFiles: [] })).map((f) => f.id));
209
+ assert.ok(ids.has("BICEP_STORAGE_HTTPS_ONLY"));
210
+ assert.ok(ids.has("BICEP_STORAGE_MIN_TLS"));
211
+ assert.ok(ids.has("BICEP_SQL_NO_PUBLIC"));
212
+ });
213
+ }
94
214
  async function main() {
95
215
  await runPromptConformanceTests();
96
216
  await runFixtureGateTests();
217
+ await runCloudControlRemediationTests();
218
+ await runNestedRemediationTests();
219
+ await runCfnBicepDetectionTests();
97
220
  await runReviewWorkflowTests();
98
221
  console.log("security-mcp tests passed");
99
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "security-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.3.3",
4
4
  "description": "AI security MCP server and enforcement gate for Claude Code, Cursor, GitHub Copilot, Codex, Replit, and any MCP-compatible editor. Applies OWASP, MITRE ATT&CK, NIST, Zero Trust, PCI DSS, SOC 2, and ISO 27001.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "homepage": "https://github.com/AbrahamOO/security-mcp#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/AbrahamOO/security-mcp.git"
11
+ "url": "git+https://github.com/AbrahamOO/security-mcp.git"
12
12
  },
13
13
  "bugs": {
14
14
  "url": "https://github.com/AbrahamOO/security-mcp/issues"
@@ -41,7 +41,7 @@
41
41
  "model-context-protocol"
42
42
  ],
43
43
  "bin": {
44
- "security-mcp": "./dist/cli/index.js"
44
+ "security-mcp": "dist/cli/index.js"
45
45
  },
46
46
  "files": [
47
47
  "dist/",
@@ -61,24 +61,24 @@
61
61
  "test": "npm run build && node dist/tests/run.js"
62
62
  },
63
63
  "dependencies": {
64
- "@modelcontextprotocol/sdk": "^1.27.1",
64
+ "@modelcontextprotocol/sdk": "^1.29.0",
65
65
  "execa": "^9.5.2",
66
66
  "fast-glob": "^3.3.3",
67
67
  "picomatch": "^4.0.4",
68
68
  "zod": "^3.24.1"
69
69
  },
70
70
  "overrides": {
71
- "express-rate-limit": "^8.2.2",
72
- "hono": "^4.12.7"
71
+ "express-rate-limit": "8.5.2",
72
+ "hono": "4.12.23"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@eslint/js": "^9.22.0",
76
- "@types/node": "^22.13.5",
77
- "@types/picomatch": "^4.0.2",
76
+ "@types/node": "^24.12.4",
77
+ "@types/picomatch": "^4.0.3",
78
78
  "eslint": "^9.22.0",
79
79
  "globals": "^16.0.0",
80
80
  "typescript": "^5.7.3",
81
- "typescript-eslint": "^8.26.0"
81
+ "typescript-eslint": "^8.60.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=20"