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
@@ -15,8 +15,9 @@
15
15
  * The genesis block (link 0) contains only the agentRunId and a timestamp —
16
16
  * its parent hash is all-zeros.
17
17
  */
18
- import { createHash } from "node:crypto";
19
- import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
19
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
20
21
  import { join } from "node:path";
21
22
  import { z } from "zod";
22
23
  // ---------------------------------------------------------------------------
@@ -33,30 +34,84 @@ function validateAgentRunId(agentRunId) {
33
34
  }
34
35
  }
35
36
  // ---------------------------------------------------------------------------
37
+ // HMAC key reader
38
+ // ---------------------------------------------------------------------------
39
+ const AUDIT_HMAC_MIN_KEY_BYTES = 32;
40
+ function getAuditHmacKey() {
41
+ const key = process.env.SECURITY_AUDIT_HMAC_KEY ?? process.env.SECURITY_POLICY_HMAC_KEY;
42
+ if (!key)
43
+ return null;
44
+ const buf = Buffer.from(key, "hex");
45
+ // Guard against invalid hex strings (Buffer.from silently drops non-hex chars,
46
+ // potentially producing a 0-length key) and keys that are too short.
47
+ if (buf.length < AUDIT_HMAC_MIN_KEY_BYTES) {
48
+ throw new Error(`SECURITY_AUDIT_HMAC_KEY decoded to ${buf.length} bytes — minimum ${AUDIT_HMAC_MIN_KEY_BYTES} bytes required. ` +
49
+ `Ensure the value is a valid hex-encoded string of at least ${AUDIT_HMAC_MIN_KEY_BYTES * 2} hex characters.`);
50
+ }
51
+ return buf;
52
+ }
53
+ // ---------------------------------------------------------------------------
36
54
  // Hash helpers
37
55
  // ---------------------------------------------------------------------------
38
56
  function sha256(data) {
39
57
  return createHash("sha256").update(data, "utf-8").digest("hex");
40
58
  }
59
+ function hmacSha256(key, data) {
60
+ return createHmac("sha256", key).update(data, "utf-8").digest("hex");
61
+ }
41
62
  function hashFindings(findings) {
42
63
  return sha256(JSON.stringify(findings));
43
64
  }
44
- function computeChainHash(record) {
45
- const payload = [
65
+ /**
66
+ * Public helper: compute the canonical SHA-256 of a findings array exactly as
67
+ * `attestAgent` does. Used by orchestration.mergeAgentFindings to verify that an
68
+ * agent's findings file matches the hash that agent attested to — i.e. that the
69
+ * inter-agent payload was not tampered with between attestation and merge.
70
+ */
71
+ export function computeFindingsHash(findings) {
72
+ return hashFindings(findings);
73
+ }
74
+ function buildChainPayload(record) {
75
+ return [
46
76
  record.agentRunId,
47
77
  record.agentName,
48
78
  record.completedAt,
49
79
  record.findingsHash,
50
80
  record.parentHash
51
81
  ].join("|");
52
- return sha256(payload);
82
+ }
83
+ function computeChainHash(record) {
84
+ const payload = buildChainPayload(record);
85
+ const key = getAuditHmacKey();
86
+ if (key) {
87
+ const mac = hmacSha256(key, payload);
88
+ return { chainHash: mac, hmacSha256: mac };
89
+ }
90
+ return { chainHash: sha256(payload) };
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Atomic write helper
94
+ // ---------------------------------------------------------------------------
95
+ async function atomicWrite(targetPath, data) {
96
+ const tmpPath = join(tmpdir(), `audit-chain-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`);
97
+ try {
98
+ await writeFile(tmpPath, data, { encoding: "utf-8", mode: 0o600 });
99
+ await rename(tmpPath, targetPath); // atomic on same filesystem
100
+ }
101
+ catch (e) {
102
+ try {
103
+ await unlink(tmpPath);
104
+ }
105
+ catch { /* ignore cleanup errors */ }
106
+ throw e;
107
+ }
53
108
  }
54
109
  // ---------------------------------------------------------------------------
55
110
  // Storage helpers
56
111
  // ---------------------------------------------------------------------------
57
112
  async function ensureRunDir(agentRunId) {
58
113
  const dir = join(AGENT_RUNS_DIR, agentRunId);
59
- await mkdir(dir, { recursive: true });
114
+ await mkdir(dir, { mode: 0o700, recursive: true });
60
115
  }
61
116
  function chainPath(agentRunId) {
62
117
  return join(AGENT_RUNS_DIR, agentRunId, "attestation-chain.json");
@@ -75,7 +130,7 @@ async function loadChain(agentRunId) {
75
130
  async function saveChain(chain) {
76
131
  await ensureRunDir(chain.agentRunId);
77
132
  chain.updatedAt = new Date().toISOString();
78
- await writeFile(chainPath(chain.agentRunId), JSON.stringify(chain, null, 2) + "\n", "utf-8");
133
+ await atomicWrite(chainPath(chain.agentRunId), JSON.stringify(chain, null, 2) + "\n");
79
134
  }
80
135
  // ---------------------------------------------------------------------------
81
136
  // Core functions
@@ -90,7 +145,7 @@ export async function initChain(agentRunId) {
90
145
  if (chain.links.length > 0)
91
146
  return chain; // already initialised
92
147
  const completedAt = new Date().toISOString();
93
- const genesis = {
148
+ const genesisPartial = {
94
149
  link: 0,
95
150
  agentRunId,
96
151
  agentName: "genesis",
@@ -101,9 +156,11 @@ export async function initChain(agentRunId) {
101
156
  criticalCount: 0,
102
157
  highCount: 0
103
158
  };
159
+ const { chainHash, hmacSha256: mac } = computeChainHash(genesisPartial);
104
160
  const record = {
105
- ...genesis,
106
- chainHash: computeChainHash(genesis)
161
+ ...genesisPartial,
162
+ chainHash,
163
+ ...(mac !== undefined ? { hmacSha256: mac } : {})
107
164
  };
108
165
  chain.links.push(record);
109
166
  await saveChain(chain);
@@ -134,9 +191,11 @@ export async function attestAgent(params) {
134
191
  criticalCount: params.findings.filter((f) => f.severity === "CRITICAL").length,
135
192
  highCount: params.findings.filter((f) => f.severity === "HIGH").length
136
193
  };
194
+ const { chainHash, hmacSha256: mac } = computeChainHash(partial);
137
195
  const record = {
138
196
  ...partial,
139
- chainHash: computeChainHash(partial)
197
+ chainHash,
198
+ ...(mac !== undefined ? { hmacSha256: mac } : {})
140
199
  };
141
200
  chain.links.push(record);
142
201
  await saveChain(chain);
@@ -146,6 +205,11 @@ export async function attestAgent(params) {
146
205
  * Verify the integrity of the entire attestation chain for an agent run.
147
206
  * Recomputes every chain hash from scratch and checks parent linkage.
148
207
  * Returns `valid: true` only if every link is intact.
208
+ *
209
+ * HMAC behaviour:
210
+ * - Key present + links signed: verifies HMAC on every link.
211
+ * - Key absent + links signed: returns valid=false (cannot verify).
212
+ * - Key absent + links unsigned: returns valid=true with a warning.
149
213
  */
150
214
  export async function verifyChain(agentRunId) {
151
215
  const chain = await loadChain(agentRunId);
@@ -163,6 +227,22 @@ export async function verifyChain(agentRunId) {
163
227
  }
164
228
  };
165
229
  }
230
+ const hmacKey = getAuditHmacKey();
231
+ const chainIsSigned = chain.links.some((l) => l.hmacSha256 !== undefined);
232
+ // Key absent but chain is signed — cannot verify
233
+ if (!hmacKey && chainIsSigned) {
234
+ return {
235
+ agentRunId,
236
+ valid: false,
237
+ linkCount: chain.links.length,
238
+ verifiedAt,
239
+ broken: {
240
+ linkIndex: 0,
241
+ agentName: chain.links[0].agentName,
242
+ reason: "Chain is signed but SECURITY_AUDIT_HMAC_KEY is not set — cannot verify integrity."
243
+ }
244
+ };
245
+ }
166
246
  // Verify genesis parent hash
167
247
  if (chain.links[0].parentHash !== GENESIS_PARENT_HASH) {
168
248
  return {
@@ -179,10 +259,16 @@ export async function verifyChain(agentRunId) {
179
259
  }
180
260
  for (let i = 0; i < chain.links.length; i++) {
181
261
  const link = chain.links[i];
182
- // Recompute chain hash
183
- const { chainHash: _stored, ...rest } = link;
184
- const recomputed = computeChainHash(rest);
185
- if (recomputed !== link.chainHash) {
262
+ // Recompute chain hash (HMAC if key present, SHA-256 otherwise)
263
+ const { chainHash: _stored, hmacSha256: _mac, ...rest } = link;
264
+ const payload = buildChainPayload(rest);
265
+ const recomputed = hmacKey ? hmacSha256(hmacKey, payload) : sha256(payload);
266
+ // CWE-208: use constant-time comparison to prevent timing oracle on HMAC values
267
+ const recomputedBuf = Buffer.from(recomputed, "hex");
268
+ const storedBuf = Buffer.from(link.chainHash, "hex");
269
+ const hashMismatch = recomputedBuf.length !== storedBuf.length ||
270
+ !timingSafeEqual(recomputedBuf, storedBuf);
271
+ if (hashMismatch) {
186
272
  return {
187
273
  agentRunId,
188
274
  valid: false,
@@ -195,21 +281,38 @@ export async function verifyChain(agentRunId) {
195
281
  }
196
282
  };
197
283
  }
198
- // Verify parent linkage
199
- if (i > 0 && link.parentHash !== chain.links[i - 1].chainHash) {
200
- return {
201
- agentRunId,
202
- valid: false,
203
- linkCount: chain.links.length,
204
- verifiedAt,
205
- broken: {
206
- linkIndex: i,
207
- agentName: link.agentName,
208
- reason: `Parent hash at link ${i} does not match chain hash of link ${i - 1} — chain is broken.`
209
- }
210
- };
284
+ // Verify parent linkage (CWE-208: constant-time comparison)
285
+ if (i > 0) {
286
+ const parentBuf = Buffer.from(link.parentHash, "hex");
287
+ const prevChainBuf = Buffer.from(chain.links[i - 1].chainHash, "hex");
288
+ const parentMismatch = parentBuf.length !== prevChainBuf.length ||
289
+ !timingSafeEqual(parentBuf, prevChainBuf);
290
+ if (parentMismatch) {
291
+ return {
292
+ agentRunId,
293
+ valid: false,
294
+ linkCount: chain.links.length,
295
+ verifiedAt,
296
+ broken: {
297
+ linkIndex: i,
298
+ agentName: link.agentName,
299
+ reason: `Parent hash at link ${i} does not match chain hash of link ${i - 1} — chain is broken.`
300
+ }
301
+ };
302
+ }
211
303
  }
212
304
  }
305
+ // Key absent and chain unsigned — warn but pass
306
+ if (!hmacKey && !chainIsSigned) {
307
+ return {
308
+ agentRunId,
309
+ valid: true,
310
+ linkCount: chain.links.length,
311
+ verifiedAt,
312
+ warning: "Chain integrity is hash-only, not cryptographically signed. Set SECURITY_AUDIT_HMAC_KEY for tamper protection.",
313
+ broken: null
314
+ };
315
+ }
213
316
  return {
214
317
  agentRunId,
215
318
  valid: true,
@@ -0,0 +1,169 @@
1
+ /**
2
+ * MCP caller authentication for security-mcp.
3
+ *
4
+ * When SECURITY_MCP_SHARED_SECRET is set, every tool call is blocked until
5
+ * security.authenticate is called with the matching token. This provides a
6
+ * process-boundary guard against rogue processes that somehow obtain access
7
+ * to the MCP stdio channel without being the intended AI coding agent.
8
+ *
9
+ * Design notes:
10
+ * - One stdio session = one server process = one auth state (module singleton).
11
+ * - Token comparison uses constant-time HMAC to eliminate length-based timing
12
+ * oracles (CWE-208). Both inputs are hashed to 32-byte digests before compare.
13
+ * - After AUTH_MAX_ATTEMPTS failures the process exits to prevent brute-force.
14
+ * - If SECURITY_MCP_SHARED_SECRET is absent, auth is disabled and all tools are
15
+ * immediately available (backwards-compatible default).
16
+ */
17
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
18
+ /** Domain-separation constant for auth HMAC. Never changes. */
19
+ const HMAC_DOMAIN = "security-mcp-session-auth-v1";
20
+ /**
21
+ * Minimum acceptable secret length (bytes).
22
+ * OWASP ASVS L2 V2.9.1 requires 32 bytes (256 bits) for HMAC secrets.
23
+ * NIST SP 800-107 §5.3.4 / SP 800-131A recommend ≥ 112-bit keys for HMAC-SHA256;
24
+ * we enforce 32 bytes (256-bit) for full ASVS L2 compliance.
25
+ */
26
+ const SECRET_MIN_BYTES = 32;
27
+ /** Maximum failed authentication attempts before the server process exits. */
28
+ const AUTH_MAX_ATTEMPTS = 3;
29
+ /** Unique ID for this server instance (for logging / correlation only). */
30
+ const SESSION_ID = randomBytes(16).toString("hex");
31
+ let _authenticated = false;
32
+ let _authenticatedAt = null;
33
+ let _attempts = 0;
34
+ /** Whether the caller must authenticate before using any other tool. */
35
+ export function isAuthRequired() {
36
+ return typeof process.env["SECURITY_MCP_SHARED_SECRET"] === "string" &&
37
+ process.env["SECURITY_MCP_SHARED_SECRET"].length > 0;
38
+ }
39
+ /**
40
+ * Whether the current session is authenticated.
41
+ * Always returns true when auth is disabled (no SECURITY_MCP_SHARED_SECRET).
42
+ * Enforces session TTL: if the session has exceeded SECURITY_SESSION_TTL_MS
43
+ * (default 8 hours), it is automatically invalidated and false is returned.
44
+ */
45
+ export function isAuthenticated() {
46
+ if (!isAuthRequired())
47
+ return true;
48
+ if (_authenticated && _authenticatedAt) {
49
+ // Guard against NaN/negative from malformed env var — attacker-set "" or "abc"
50
+ // would produce NaN, making the comparison always false and bypassing TTL (CWE-1288).
51
+ // Also cap the TTL at 24 hours (86400000 ms) to prevent an attacker who controls
52
+ // the env from setting an arbitrarily large value that effectively disables TTL expiry.
53
+ // OWASP ASVS V3.7.1: sessions must expire within a reasonable bound.
54
+ const SESSION_TTL_MAX_MS = 86_400_000; // 24 hours absolute maximum
55
+ const parsedTtl = Number.parseInt(process.env["SECURITY_SESSION_TTL_MS"] ?? "28800000", 10);
56
+ const SESSION_TTL_MS = Number.isFinite(parsedTtl) && parsedTtl > 0
57
+ ? Math.min(parsedTtl, SESSION_TTL_MAX_MS)
58
+ : 28800000;
59
+ if (Date.now() - _authenticatedAt > SESSION_TTL_MS) {
60
+ _authenticated = false;
61
+ _authenticatedAt = null;
62
+ return false;
63
+ }
64
+ }
65
+ return _authenticated;
66
+ }
67
+ /**
68
+ * Explicitly log out the current session. Resets authentication state,
69
+ * timestamp, and attempt counter so the next tool call may re-authenticate
70
+ * without being immediately locked out by a prior failed-attempt count.
71
+ *
72
+ * Resetting _attempts on logout is safe: the lockout is per-session (process
73
+ * lifetime). An attacker who already authenticated and then logged out has
74
+ * already passed the auth gate; preventing a legitimate re-auth after logout
75
+ * constitutes a self-inflicted denial of service (CWE-613-adjacent).
76
+ */
77
+ export function logout() {
78
+ _authenticated = false;
79
+ _authenticatedAt = null;
80
+ _attempts = 0;
81
+ }
82
+ /**
83
+ * Increment the failed-attempt counter regardless of whether the input is
84
+ * structurally valid. Call this BEFORE Zod parsing in the authenticate handler
85
+ * so that malformed requests still burn a lockout attempt (fixes CWE-307 bypass
86
+ * via invalid-shape inputs that would otherwise never reach attemptAuth).
87
+ */
88
+ export function recordAttempt() {
89
+ if (isAuthRequired() && !_authenticated) {
90
+ _attempts++;
91
+ }
92
+ }
93
+ export function getSessionId() {
94
+ return SESSION_ID;
95
+ }
96
+ /**
97
+ * Attempt to authenticate the session with the provided token.
98
+ *
99
+ * Uses constant-time HMAC comparison to prevent timing oracles regardless of
100
+ * token length. After AUTH_MAX_ATTEMPTS failures, terminates the process.
101
+ */
102
+ export function attemptAuth(token) {
103
+ if (!isAuthRequired()) {
104
+ return { success: true, sessionId: SESSION_ID };
105
+ }
106
+ if (_authenticated) {
107
+ return { success: true, sessionId: SESSION_ID };
108
+ }
109
+ // NOTE: _attempts is incremented by recordAttempt() called BEFORE Zod parsing
110
+ // in the server.ts handler. Do not increment here again to avoid double-counting.
111
+ const remaining = AUTH_MAX_ATTEMPTS - _attempts;
112
+ // Enforce lockout BEFORE any other check — including misconfiguration — so that
113
+ // the short-secret path cannot bypass the three-strike limit (AUTH-001 / CWE-307).
114
+ // Fix: use <= 0 (not < 0). With < 0, remaining==0 (i.e. _attempts==AUTH_MAX_ATTEMPTS)
115
+ // would still reach the HMAC comparison — granting one extra attempt beyond policy.
116
+ if (remaining <= 0) {
117
+ setTimeout(() => process.exit(1), 200);
118
+ return {
119
+ success: false,
120
+ reason: "Authentication failed."
121
+ };
122
+ }
123
+ const secret = process.env["SECURITY_MCP_SHARED_SECRET"];
124
+ if (Buffer.byteLength(secret, "utf-8") < SECRET_MIN_BYTES) {
125
+ // Server misconfiguration — warn but do not leak the secret value or byte length.
126
+ return {
127
+ success: false,
128
+ reason: "Authentication failed."
129
+ };
130
+ }
131
+ // Hash both inputs to fixed-length 32-byte digests so timingSafeEqual always
132
+ // receives same-length buffers (prevents length-based timing oracle, CWE-208).
133
+ // Keys and messages are swapped relative to the original: the secret/token is
134
+ // used as the HMAC key and HMAC_DOMAIN is the fixed message (AUTH-003 / CWE-327).
135
+ const expected = createHmac("sha256", secret).update(HMAC_DOMAIN, "utf-8").digest();
136
+ const provided = createHmac("sha256", token).update(HMAC_DOMAIN, "utf-8").digest();
137
+ if (!timingSafeEqual(expected, provided)) {
138
+ return {
139
+ success: false,
140
+ // Do not expose attempt count to avoid targeted last-attempt attacks (AUTH-004 / CWE-204).
141
+ reason: "Authentication failed."
142
+ };
143
+ }
144
+ _authenticated = true;
145
+ _authenticatedAt = Date.now();
146
+ return { success: true, sessionId: SESSION_ID };
147
+ }
148
+ /**
149
+ * Returns the preamble to prepend to the system prompt when authentication
150
+ * is required but has not yet been completed.
151
+ */
152
+ export function authSystemPromptPreamble() {
153
+ if (!isAuthRequired())
154
+ return "";
155
+ return [
156
+ "## ⚠️ Authentication Required",
157
+ "",
158
+ "This security-mcp server requires authentication before any security tools can be used.",
159
+ "**Call `security.authenticate` first** with the value of the `SECURITY_MCP_SHARED_SECRET`",
160
+ "environment variable configured on this server.",
161
+ "",
162
+ "```",
163
+ "security.authenticate({ token: \"<value of SECURITY_MCP_SHARED_SECRET>\" })",
164
+ "```",
165
+ "",
166
+ "All other tool calls will be rejected with UNAUTHENTICATED until this step completes.",
167
+ ""
168
+ ].join("\n");
169
+ }
@@ -5,7 +5,8 @@
5
5
  * Routes future findings to the highest-performing agent automatically.
6
6
  * Persists to .mcp/memory/patterns.json (per-project, gitignore-safe).
7
7
  */
8
- import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { createHash, timingSafeEqual } from "node:crypto";
9
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
10
  import { join } from "node:path";
10
11
  import { z } from "zod";
11
12
  // ---------------------------------------------------------------------------
@@ -13,10 +14,62 @@ import { z } from "zod";
13
14
  // ---------------------------------------------------------------------------
14
15
  const MEMORY_DIR = join(".mcp", "memory");
15
16
  const PATTERNS_FILE = join(MEMORY_DIR, "patterns.json");
16
- const MIN_SAMPLE_SIZE = 3; // need ≥3 outcomes before routing is trusted
17
+ const PATTERNS_HASH_FILE = join(MEMORY_DIR, "patterns.sha256");
18
+ const MIN_SAMPLE_SIZE = 10; // need ≥10 outcomes before routing is trusted (was 3 — too easy to manipulate)
17
19
  const HIGH_CONFIDENCE = 0.85; // route automatically above this success rate
18
20
  const LOW_CONFIDENCE = 0.40; // escalate below this success rate
19
21
  // ---------------------------------------------------------------------------
22
+ // Suppression safety caps (OWASP LLM04 / LLM08 — Excessive Agency)
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Maximum number of distinct finding IDs that may be simultaneously suppressed
26
+ * via false-positive rate. Prevents an attacker from suppressing ALL finding
27
+ * types by flooding the learning engine with false-positive reports across many IDs.
28
+ * MITRE ATLAS AML.T0043 (Craft Adversarial Data) mitigation.
29
+ */
30
+ const MAX_SUPPRESSED_FINDING_TYPES = 5;
31
+ /**
32
+ * Maximum cumulative false-positive count any single finding ID may accumulate
33
+ * before further FP submissions are rejected regardless of rate-limit window.
34
+ * Prevents an attacker who controls multiple agents from slowly poisoning a
35
+ * finding type by spreading FP reports across many hourly windows.
36
+ */
37
+ const MAX_FP_COUNT_PER_FINDING = 20;
38
+ // ---------------------------------------------------------------------------
39
+ // Rate limiting — false-positive submissions per finding
40
+ // ---------------------------------------------------------------------------
41
+ const _falsePositiveSubmissions = new Map();
42
+ const FP_RATE_LIMIT = 5; // max 5 false-positive reports per finding per window
43
+ const FP_WINDOW_MS = 3_600_000; // 1 hour window
44
+ /**
45
+ * Returns true (allowed) only when:
46
+ * 1. The per-hour sliding window has not been exhausted.
47
+ * 2. The cumulative all-time FP count for this finding has not reached MAX_FP_COUNT_PER_FINDING.
48
+ * CWE-799 / OWASP LLM04 (Model Denial of Service via learning system abuse).
49
+ */
50
+ function checkFalsePositiveRateLimit(findingId) {
51
+ const now = Date.now();
52
+ const entry = _falsePositiveSubmissions.get(findingId);
53
+ if (!entry || now - entry.windowStart > FP_WINDOW_MS) {
54
+ // Check cumulative cap even when opening a new window.
55
+ const cumulative = entry?.cumulative ?? 0;
56
+ if (cumulative >= MAX_FP_COUNT_PER_FINDING) {
57
+ return { allowed: false, reason: `Cumulative false-positive cap reached for finding ${findingId} (max ${MAX_FP_COUNT_PER_FINDING} all-time). Investigate scanner accuracy before submitting more.` };
58
+ }
59
+ _falsePositiveSubmissions.set(findingId, { count: 1, windowStart: now, cumulative: cumulative + 1 });
60
+ return { allowed: true };
61
+ }
62
+ if (entry.cumulative >= MAX_FP_COUNT_PER_FINDING) {
63
+ return { allowed: false, reason: `Cumulative false-positive cap reached for finding ${findingId} (max ${MAX_FP_COUNT_PER_FINDING} all-time). Investigate scanner accuracy before submitting more.` };
64
+ }
65
+ if (entry.count >= FP_RATE_LIMIT) {
66
+ return { allowed: false, reason: `Rate limit exceeded for false-positive submissions on ${findingId}. Max ${FP_RATE_LIMIT} per hour per finding.` };
67
+ }
68
+ entry.count++;
69
+ entry.cumulative++;
70
+ return { allowed: true };
71
+ }
72
+ // ---------------------------------------------------------------------------
20
73
  // Schemas
21
74
  // ---------------------------------------------------------------------------
22
75
  export const OutcomeSchema = z.object({
@@ -36,6 +89,26 @@ async function ensureMemoryDir() {
36
89
  async function loadStore() {
37
90
  try {
38
91
  const raw = await readFile(PATTERNS_FILE, "utf-8");
92
+ // Integrity check: compare SHA-256 of file content against stored sidecar hash.
93
+ // If the sidecar exists and the hash mismatches, the file may have been tampered with.
94
+ try {
95
+ const storedHash = (await readFile(PATTERNS_HASH_FILE, "utf-8")).trim();
96
+ const actualHash = createHash("sha256").update(raw).digest("hex");
97
+ // Use timingSafeEqual to prevent timing-oracle inference of the stored hash (CWE-208).
98
+ const storedBuf = Buffer.from(storedHash, "hex");
99
+ const actualBuf = Buffer.from(actualHash, "hex");
100
+ const hashMatch = storedBuf.length === actualBuf.length && timingSafeEqual(storedBuf, actualBuf);
101
+ if (!hashMatch) {
102
+ console.warn("[security-mcp] Agent memory patterns.json may have been tampered with. Resetting to empty state.");
103
+ console.log(JSON.stringify({ event: 'LEARNING_INTEGRITY_VIOLATION', severity: 'CRITICAL', timestamp: new Date().toISOString() }));
104
+ return { version: 1, updatedAt: new Date().toISOString(), patterns: {} };
105
+ }
106
+ }
107
+ catch (hashErr) {
108
+ // Sidecar doesn't exist yet (first run after upgrade) — allow and create on next save.
109
+ if (hashErr.code !== "ENOENT")
110
+ throw hashErr;
111
+ }
39
112
  return JSON.parse(raw);
40
113
  }
41
114
  catch {
@@ -45,7 +118,18 @@ async function loadStore() {
45
118
  async function saveStore(store) {
46
119
  await ensureMemoryDir();
47
120
  store.updatedAt = new Date().toISOString();
48
- await writeFile(PATTERNS_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
121
+ const content = JSON.stringify(store, null, 2) + "\n";
122
+ const hash = createHash("sha256").update(content).digest("hex");
123
+ // Write patterns + sidecar atomically: write to temp files first, then rename
124
+ // both into place. This prevents a TOCTOU window where an attacker could replace
125
+ // patterns.json between the two writes and pass integrity on the next load.
126
+ // CWE-367 (TOCTOU Race Condition) / CAPEC-29.
127
+ const tmpPatterns = PATTERNS_FILE + ".tmp";
128
+ const tmpHash = PATTERNS_HASH_FILE + ".tmp";
129
+ await writeFile(tmpPatterns, content, { encoding: "utf-8", mode: 0o600 });
130
+ await writeFile(tmpHash, hash + "\n", { encoding: "utf-8", mode: 0o600 });
131
+ await rename(tmpPatterns, PATTERNS_FILE);
132
+ await rename(tmpHash, PATTERNS_HASH_FILE);
49
133
  }
50
134
  // ---------------------------------------------------------------------------
51
135
  // Core functions
@@ -56,6 +140,18 @@ async function saveStore(store) {
56
140
  */
57
141
  export async function recordOutcome(outcome) {
58
142
  const validated = OutcomeSchema.parse(outcome);
143
+ // Rate-limit false-positive submissions to prevent learning-system abuse (OWASP LLM04 / CWE-799).
144
+ if (validated.falsePositive) {
145
+ const rlCheck = checkFalsePositiveRateLimit(validated.findingId);
146
+ if (!rlCheck.allowed) {
147
+ console.log(JSON.stringify({ event: 'LEARNING_FP_RATE_LIMITED', findingId: validated.findingId, timestamp: new Date().toISOString() }));
148
+ return {
149
+ recorded: false,
150
+ pattern: {},
151
+ warning: rlCheck.reason ?? "Rate limit exceeded for false-positive submissions on this finding."
152
+ };
153
+ }
154
+ }
59
155
  const store = await loadStore();
60
156
  const existing = store.patterns[validated.findingId] ?? {
61
157
  findingId: validated.findingId,
@@ -119,9 +215,38 @@ export async function recordOutcome(outcome) {
119
215
  lastSeen: new Date().toISOString(),
120
216
  agentStats: existing.agentStats
121
217
  };
218
+ // Global suppression cap: count how many distinct finding IDs currently have
219
+ // falsePositiveRate > 0.8 AND sampleSize >= MIN_SAMPLE_SIZE (i.e., are "suppressed").
220
+ // If this update would push us over MAX_SUPPRESSED_FINDING_TYPES, reject it.
221
+ // Prevents an attacker from suppressing ALL finding types simultaneously
222
+ // (OWASP LLM08 — Excessive Agency / MITRE ATLAS AML.T0043).
223
+ if (validated.falsePositive && updated.falsePositiveRate > 0.8 && updated.sampleSize >= MIN_SAMPLE_SIZE) {
224
+ const suppressedCount = Object.values(store.patterns).filter((p) => p.findingId !== validated.findingId && p.falsePositiveRate > 0.8 && p.sampleSize >= MIN_SAMPLE_SIZE).length;
225
+ if (suppressedCount >= MAX_SUPPRESSED_FINDING_TYPES) {
226
+ console.error(`[security-mcp] SECURITY_ALERT: Global suppression cap reached. ${suppressedCount} finding types already suppressed. Rejecting FP update for ${validated.findingId}. Possible learning-system attack.`);
227
+ console.log(JSON.stringify({ event: 'LEARNING_SUPPRESSION_DEACTIVATED', findingId: validated.findingId, reason: 'GLOBAL_SUPPRESSION_CAP_EXCEEDED', timestamp: new Date().toISOString() }));
228
+ return {
229
+ recorded: false,
230
+ pattern: updated,
231
+ warning: `GLOBAL_SUPPRESSION_CAP_EXCEEDED: ${suppressedCount} finding types are already suppressed (max ${MAX_SUPPRESSED_FINDING_TYPES}). Investigate potential learning-system manipulation before submitting more false-positives.`
232
+ };
233
+ }
234
+ }
235
+ const wasAlreadySuppressed = existing.falsePositiveRate > 0.8 && existing.sampleSize >= MIN_SAMPLE_SIZE;
122
236
  store.patterns[validated.findingId] = updated;
123
237
  await saveStore(store);
124
- return { recorded: true, pattern: updated };
238
+ // Emit structured audit event when a finding type enters suppressed state for the first time.
239
+ const isNowSuppressed = updated.falsePositiveRate > 0.8 && updated.sampleSize >= MIN_SAMPLE_SIZE;
240
+ if (isNowSuppressed && !wasAlreadySuppressed) {
241
+ console.log(JSON.stringify({ event: 'LEARNING_SUPPRESSION_ACTIVATED', findingId: validated.findingId, falsePositiveRate: updated.falsePositiveRate, sampleSize: updated.sampleSize, timestamp: new Date().toISOString() }));
242
+ }
243
+ // Anomaly detection: flag unusually high false-positive rate for this finding.
244
+ let warning;
245
+ if (updated.sampleSize > MIN_SAMPLE_SIZE && updated.falsePositiveRate > 0.8) {
246
+ warning = `LEARNING_ANOMALY_HIGH_FP_RATE: Finding ${validated.findingId} has a false-positive rate of ${Math.round(updated.falsePositiveRate * 100)}% across ${updated.sampleSize} samples. Investigate scanner accuracy.`;
247
+ console.warn(`[security-mcp] ${warning}`);
248
+ }
249
+ return { recorded: true, pattern: updated, ...(warning ? { warning } : {}) };
125
250
  }
126
251
  /**
127
252
  * Get the routing recommendation for a finding type.