security-mcp 1.1.3 → 1.3.1
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.
- package/README.md +164 -185
- package/defaults/checklists/ai.json +20 -1
- package/defaults/checklists/api.json +35 -1
- package/defaults/checklists/infra.json +34 -1
- package/defaults/checklists/mobile.json +23 -1
- package/defaults/checklists/payments.json +15 -1
- package/defaults/checklists/web.json +11 -1
- package/defaults/control-catalog.json +200 -0
- package/defaults/security-policy.json +2 -2
- package/dist/cli/index.js +82 -5
- package/dist/cli/install.js +36 -6
- package/dist/cli/onboarding.js +6 -0
- package/dist/gate/baseline.js +82 -7
- package/dist/gate/catalog.js +10 -2
- package/dist/gate/checks/ai.js +757 -39
- package/dist/gate/checks/auth-deep.js +935 -0
- package/dist/gate/checks/business-logic.js +751 -0
- package/dist/gate/checks/ci-pipeline.js +399 -4
- package/dist/gate/checks/crypto.js +423 -2
- package/dist/gate/checks/dependencies.js +571 -15
- package/dist/gate/checks/graphql.js +201 -19
- package/dist/gate/checks/infra.js +246 -1
- package/dist/gate/checks/injection-deep.js +848 -0
- package/dist/gate/checks/k8s.js +114 -1
- package/dist/gate/checks/mobile-android.js +917 -3
- package/dist/gate/checks/mobile-ios.js +797 -5
- package/dist/gate/checks/required-artifacts.js +194 -0
- package/dist/gate/checks/runtime.js +178 -0
- package/dist/gate/checks/secrets.js +244 -13
- package/dist/gate/checks/supply-chain-deep.js +787 -0
- package/dist/gate/checks/web-nextjs.js +572 -48
- package/dist/gate/diff.js +17 -5
- package/dist/gate/evidence.js +8 -1
- package/dist/gate/exceptions.js +131 -9
- package/dist/gate/policy.js +282 -129
- package/dist/mcp/audit-chain.js +122 -28
- package/dist/mcp/auth.js +169 -0
- package/dist/mcp/learning.js +129 -4
- package/dist/mcp/model-router.js +158 -21
- package/dist/mcp/orchestration.js +186 -51
- package/dist/mcp/server.js +608 -94
- package/dist/repo/fs.js +24 -1
- package/dist/repo/search.js +31 -6
- package/dist/review/store.js +52 -1
- package/package.json +7 -7
- package/prompts/SECURITY_PROMPT.md +73 -0
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +109 -0
- package/skills/agentic-loop-exploiter/SKILL.md +368 -0
- package/skills/ai-llm-redteam/SKILL.md +104 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
- package/skills/android-penetration-tester/SKILL.md +455 -46
- package/skills/anti-replay-tester/SKILL.md +106 -0
- package/skills/appsec-code-auditor/SKILL.md +120 -0
- package/skills/artifact-integrity-analyst/SKILL.md +441 -0
- package/skills/attack-navigator/SKILL.md +467 -8
- package/skills/auth-session-hacker/SKILL.md +128 -0
- package/skills/aws-penetration-tester/SKILL.md +456 -0
- package/skills/azure-penetration-tester/SKILL.md +490 -3
- package/skills/binary-auth-validator/SKILL.md +111 -0
- package/skills/bot-detection-specialist/SKILL.md +109 -0
- package/skills/business-logic-attacker/SKILL.md +231 -0
- package/skills/capec-code-mapper/SKILL.md +84 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
- package/skills/ciso-orchestrator/SKILL.md +454 -43
- package/skills/cloud-infra-specialist/SKILL.md +118 -0
- package/skills/compliance-gap-analyst/SKILL.md +422 -0
- package/skills/compliance-grc/SKILL.md +85 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
- package/skills/credential-stuffing-specialist/SKILL.md +102 -0
- package/skills/crypto-pki-specialist/SKILL.md +87 -0
- package/skills/csa-ccm-mapper/SKILL.md +84 -0
- package/skills/csf2-governance-mapper/SKILL.md +84 -0
- package/skills/deep-link-fuzzer/SKILL.md +109 -0
- package/skills/dependency-confusion-attacker/SKILL.md +415 -0
- package/skills/device-integrity-aggregator/SKILL.md +108 -0
- package/skills/dos-resilience-tester/SKILL.md +97 -0
- package/skills/dread-scorer/SKILL.md +84 -0
- package/skills/egress-policy-enforcer/SKILL.md +99 -0
- package/skills/evidence-collector/SKILL.md +98 -0
- package/skills/file-upload-attacker/SKILL.md +109 -0
- package/skills/gcp-penetration-tester/SKILL.md +459 -2
- package/skills/git-history-secret-scanner/SKILL.md +106 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
- package/skills/incident-responder/SKILL.md +111 -0
- package/skills/injection-specialist/SKILL.md +131 -0
- package/skills/ios-security-auditor/SKILL.md +282 -0
- package/skills/json-ambiguity-tester/SKILL.md +0 -0
- package/skills/k8s-container-escaper/SKILL.md +384 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
- package/skills/kill-switch-engineer/SKILL.md +102 -0
- package/skills/linddun-privacy-analyst/SKILL.md +102 -0
- package/skills/logic-race-fuzzer/SKILL.md +443 -0
- package/skills/mobile-api-network-attacker/SKILL.md +421 -0
- package/skills/mobile-binary-hardener/SKILL.md +102 -0
- package/skills/mobile-security-specialist/SKILL.md +85 -0
- package/skills/mobile-webview-auditor/SKILL.md +96 -0
- package/skills/model-extraction-attacker/SKILL.md +219 -0
- package/skills/multipart-abuse-tester/SKILL.md +84 -0
- package/skills/oauth-pkce-specialist/SKILL.md +104 -0
- package/skills/parser-exhaustion-tester/SKILL.md +142 -0
- package/skills/pentest-infra/SKILL.md +141 -0
- package/skills/pentest-social/SKILL.md +201 -0
- package/skills/pentest-team/SKILL.md +134 -0
- package/skills/pentest-web-api/SKILL.md +151 -0
- package/skills/privacy-flow-analyst/SKILL.md +234 -0
- package/skills/prompt-injection-specialist/SKILL.md +394 -0
- package/skills/quantum-migration-planner/SKILL.md +96 -0
- package/skills/rag-poisoning-specialist/SKILL.md +358 -0
- package/skills/registry-mirror-enforcer/SKILL.md +84 -0
- package/skills/rotation-validation-agent/SKILL.md +112 -0
- package/skills/samm-assessor/SKILL.md +85 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
- package/skills/senior-security-engineer/SKILL.md +370 -2
- package/skills/serialization-memory-attacker/SKILL.md +332 -0
- package/skills/session-timeout-tester/SKILL.md +161 -0
- package/skills/slsa-level3-enforcer/SKILL.md +112 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
- package/skills/ssrf-detection-validator/SKILL.md +108 -0
- package/skills/step-up-auth-enforcer/SKILL.md +84 -0
- package/skills/stride-pasta-analyst/SKILL.md +420 -0
- package/skills/supply-chain-devsecops/SKILL.md +98 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
- package/skills/threat-modeler/SKILL.md +85 -0
- package/skills/tls-certificate-auditor/SKILL.md +573 -18
- package/skills/token-reuse-detector/SKILL.md +95 -0
- package/skills/trike-risk-modeler/SKILL.md +84 -0
- package/skills/unicode-homograph-tester/SKILL.md +84 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
- package/skills/webhook-security-tester/SKILL.md +102 -0
- package/skills/zero-trust-architect/SKILL.md +109 -0
package/dist/mcp/audit-chain.js
CHANGED
|
@@ -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,75 @@ 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
|
|
45
|
-
|
|
65
|
+
function buildChainPayload(record) {
|
|
66
|
+
return [
|
|
46
67
|
record.agentRunId,
|
|
47
68
|
record.agentName,
|
|
48
69
|
record.completedAt,
|
|
49
70
|
record.findingsHash,
|
|
50
71
|
record.parentHash
|
|
51
72
|
].join("|");
|
|
52
|
-
|
|
73
|
+
}
|
|
74
|
+
function computeChainHash(record) {
|
|
75
|
+
const payload = buildChainPayload(record);
|
|
76
|
+
const key = getAuditHmacKey();
|
|
77
|
+
if (key) {
|
|
78
|
+
const mac = hmacSha256(key, payload);
|
|
79
|
+
return { chainHash: mac, hmacSha256: mac };
|
|
80
|
+
}
|
|
81
|
+
return { chainHash: sha256(payload) };
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Atomic write helper
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
async function atomicWrite(targetPath, data) {
|
|
87
|
+
const tmpPath = join(tmpdir(), `audit-chain-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(tmpPath, data, { encoding: "utf-8", mode: 0o600 });
|
|
90
|
+
await rename(tmpPath, targetPath); // atomic on same filesystem
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
try {
|
|
94
|
+
await unlink(tmpPath);
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore cleanup errors */ }
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
53
99
|
}
|
|
54
100
|
// ---------------------------------------------------------------------------
|
|
55
101
|
// Storage helpers
|
|
56
102
|
// ---------------------------------------------------------------------------
|
|
57
103
|
async function ensureRunDir(agentRunId) {
|
|
58
104
|
const dir = join(AGENT_RUNS_DIR, agentRunId);
|
|
59
|
-
await mkdir(dir, { recursive: true });
|
|
105
|
+
await mkdir(dir, { mode: 0o700, recursive: true });
|
|
60
106
|
}
|
|
61
107
|
function chainPath(agentRunId) {
|
|
62
108
|
return join(AGENT_RUNS_DIR, agentRunId, "attestation-chain.json");
|
|
@@ -75,7 +121,7 @@ async function loadChain(agentRunId) {
|
|
|
75
121
|
async function saveChain(chain) {
|
|
76
122
|
await ensureRunDir(chain.agentRunId);
|
|
77
123
|
chain.updatedAt = new Date().toISOString();
|
|
78
|
-
await
|
|
124
|
+
await atomicWrite(chainPath(chain.agentRunId), JSON.stringify(chain, null, 2) + "\n");
|
|
79
125
|
}
|
|
80
126
|
// ---------------------------------------------------------------------------
|
|
81
127
|
// Core functions
|
|
@@ -90,7 +136,7 @@ export async function initChain(agentRunId) {
|
|
|
90
136
|
if (chain.links.length > 0)
|
|
91
137
|
return chain; // already initialised
|
|
92
138
|
const completedAt = new Date().toISOString();
|
|
93
|
-
const
|
|
139
|
+
const genesisPartial = {
|
|
94
140
|
link: 0,
|
|
95
141
|
agentRunId,
|
|
96
142
|
agentName: "genesis",
|
|
@@ -101,9 +147,11 @@ export async function initChain(agentRunId) {
|
|
|
101
147
|
criticalCount: 0,
|
|
102
148
|
highCount: 0
|
|
103
149
|
};
|
|
150
|
+
const { chainHash, hmacSha256: mac } = computeChainHash(genesisPartial);
|
|
104
151
|
const record = {
|
|
105
|
-
...
|
|
106
|
-
chainHash
|
|
152
|
+
...genesisPartial,
|
|
153
|
+
chainHash,
|
|
154
|
+
...(mac !== undefined ? { hmacSha256: mac } : {})
|
|
107
155
|
};
|
|
108
156
|
chain.links.push(record);
|
|
109
157
|
await saveChain(chain);
|
|
@@ -134,9 +182,11 @@ export async function attestAgent(params) {
|
|
|
134
182
|
criticalCount: params.findings.filter((f) => f.severity === "CRITICAL").length,
|
|
135
183
|
highCount: params.findings.filter((f) => f.severity === "HIGH").length
|
|
136
184
|
};
|
|
185
|
+
const { chainHash, hmacSha256: mac } = computeChainHash(partial);
|
|
137
186
|
const record = {
|
|
138
187
|
...partial,
|
|
139
|
-
chainHash
|
|
188
|
+
chainHash,
|
|
189
|
+
...(mac !== undefined ? { hmacSha256: mac } : {})
|
|
140
190
|
};
|
|
141
191
|
chain.links.push(record);
|
|
142
192
|
await saveChain(chain);
|
|
@@ -146,6 +196,11 @@ export async function attestAgent(params) {
|
|
|
146
196
|
* Verify the integrity of the entire attestation chain for an agent run.
|
|
147
197
|
* Recomputes every chain hash from scratch and checks parent linkage.
|
|
148
198
|
* Returns `valid: true` only if every link is intact.
|
|
199
|
+
*
|
|
200
|
+
* HMAC behaviour:
|
|
201
|
+
* - Key present + links signed: verifies HMAC on every link.
|
|
202
|
+
* - Key absent + links signed: returns valid=false (cannot verify).
|
|
203
|
+
* - Key absent + links unsigned: returns valid=true with a warning.
|
|
149
204
|
*/
|
|
150
205
|
export async function verifyChain(agentRunId) {
|
|
151
206
|
const chain = await loadChain(agentRunId);
|
|
@@ -163,6 +218,22 @@ export async function verifyChain(agentRunId) {
|
|
|
163
218
|
}
|
|
164
219
|
};
|
|
165
220
|
}
|
|
221
|
+
const hmacKey = getAuditHmacKey();
|
|
222
|
+
const chainIsSigned = chain.links.some((l) => l.hmacSha256 !== undefined);
|
|
223
|
+
// Key absent but chain is signed — cannot verify
|
|
224
|
+
if (!hmacKey && chainIsSigned) {
|
|
225
|
+
return {
|
|
226
|
+
agentRunId,
|
|
227
|
+
valid: false,
|
|
228
|
+
linkCount: chain.links.length,
|
|
229
|
+
verifiedAt,
|
|
230
|
+
broken: {
|
|
231
|
+
linkIndex: 0,
|
|
232
|
+
agentName: chain.links[0].agentName,
|
|
233
|
+
reason: "Chain is signed but SECURITY_AUDIT_HMAC_KEY is not set — cannot verify integrity."
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
166
237
|
// Verify genesis parent hash
|
|
167
238
|
if (chain.links[0].parentHash !== GENESIS_PARENT_HASH) {
|
|
168
239
|
return {
|
|
@@ -179,10 +250,16 @@ export async function verifyChain(agentRunId) {
|
|
|
179
250
|
}
|
|
180
251
|
for (let i = 0; i < chain.links.length; i++) {
|
|
181
252
|
const link = chain.links[i];
|
|
182
|
-
// Recompute chain hash
|
|
183
|
-
const { chainHash: _stored, ...rest } = link;
|
|
184
|
-
const
|
|
185
|
-
|
|
253
|
+
// Recompute chain hash (HMAC if key present, SHA-256 otherwise)
|
|
254
|
+
const { chainHash: _stored, hmacSha256: _mac, ...rest } = link;
|
|
255
|
+
const payload = buildChainPayload(rest);
|
|
256
|
+
const recomputed = hmacKey ? hmacSha256(hmacKey, payload) : sha256(payload);
|
|
257
|
+
// CWE-208: use constant-time comparison to prevent timing oracle on HMAC values
|
|
258
|
+
const recomputedBuf = Buffer.from(recomputed, "hex");
|
|
259
|
+
const storedBuf = Buffer.from(link.chainHash, "hex");
|
|
260
|
+
const hashMismatch = recomputedBuf.length !== storedBuf.length ||
|
|
261
|
+
!timingSafeEqual(recomputedBuf, storedBuf);
|
|
262
|
+
if (hashMismatch) {
|
|
186
263
|
return {
|
|
187
264
|
agentRunId,
|
|
188
265
|
valid: false,
|
|
@@ -195,21 +272,38 @@ export async function verifyChain(agentRunId) {
|
|
|
195
272
|
}
|
|
196
273
|
};
|
|
197
274
|
}
|
|
198
|
-
// Verify parent linkage
|
|
199
|
-
if (i > 0
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
275
|
+
// Verify parent linkage (CWE-208: constant-time comparison)
|
|
276
|
+
if (i > 0) {
|
|
277
|
+
const parentBuf = Buffer.from(link.parentHash, "hex");
|
|
278
|
+
const prevChainBuf = Buffer.from(chain.links[i - 1].chainHash, "hex");
|
|
279
|
+
const parentMismatch = parentBuf.length !== prevChainBuf.length ||
|
|
280
|
+
!timingSafeEqual(parentBuf, prevChainBuf);
|
|
281
|
+
if (parentMismatch) {
|
|
282
|
+
return {
|
|
283
|
+
agentRunId,
|
|
284
|
+
valid: false,
|
|
285
|
+
linkCount: chain.links.length,
|
|
286
|
+
verifiedAt,
|
|
287
|
+
broken: {
|
|
288
|
+
linkIndex: i,
|
|
289
|
+
agentName: link.agentName,
|
|
290
|
+
reason: `Parent hash at link ${i} does not match chain hash of link ${i - 1} — chain is broken.`
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
211
294
|
}
|
|
212
295
|
}
|
|
296
|
+
// Key absent and chain unsigned — warn but pass
|
|
297
|
+
if (!hmacKey && !chainIsSigned) {
|
|
298
|
+
return {
|
|
299
|
+
agentRunId,
|
|
300
|
+
valid: true,
|
|
301
|
+
linkCount: chain.links.length,
|
|
302
|
+
verifiedAt,
|
|
303
|
+
warning: "Chain integrity is hash-only, not cryptographically signed. Set SECURITY_AUDIT_HMAC_KEY for tamper protection.",
|
|
304
|
+
broken: null
|
|
305
|
+
};
|
|
306
|
+
}
|
|
213
307
|
return {
|
|
214
308
|
agentRunId,
|
|
215
309
|
valid: true,
|
package/dist/mcp/auth.js
ADDED
|
@@ -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
|
+
}
|
package/dist/mcp/learning.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|