@clear-capabilities/agentic-security-scanner 0.75.0 → 0.77.0
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/CHANGELOG.md +57 -0
- package/bin/agentic-security.js +2 -2
- package/dist/838.index.js +152 -0
- package/dist/{634.index.js → 985.index.js} +21 -144
- package/dist/agentic-security.mjs +8 -8
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -6
- package/src/mcp/tools.js +17 -2
- package/src/sca/base-images.json +1 -1
- package/bin/.agentic-security/findings.json +0 -1596
- package/bin/.agentic-security/last-scan.json +0 -1596
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -470
- package/bin/.agentic-security/streak.json +0 -25
- package/dist/218.index.js +0 -793
- package/dist/601.index.js +0 -1038
- package/src/.agentic-security/findings.json +0 -80844
- package/src/.agentic-security/last-scan.json +0 -80844
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -8408
- package/src/.agentic-security/streak.json +0 -26
- package/src/dataflow/.agentic-security/findings.json +0 -3487
- package/src/dataflow/.agentic-security/last-scan.json +0 -3487
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -735
- package/src/dataflow/.agentic-security/streak.json +0 -24
- package/src/integrations/.agentic-security/findings.json +0 -1504
- package/src/integrations/.agentic-security/last-scan.json +0 -1504
- package/src/integrations/.agentic-security/scan-history.json +0 -40
- package/src/integrations/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -3036
- package/src/ir/.agentic-security/last-scan.json +0 -3036
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -364
- package/src/ir/.agentic-security/streak.json +0 -23
- package/src/llm-validator/.agentic-security/findings.json +0 -1891
- package/src/llm-validator/.agentic-security/last-scan.json +0 -1891
- package/src/llm-validator/.agentic-security/last-scan.json.sig +0 -1
- package/src/llm-validator/.agentic-security/scan-history.json +0 -168
- package/src/llm-validator/.agentic-security/streak.json +0 -20
- package/src/lsp/.agentic-security/findings.json +0 -28
- package/src/lsp/.agentic-security/last-scan.json +0 -28
- package/src/lsp/.agentic-security/scan-history.json +0 -79
- package/src/lsp/.agentic-security/streak.json +0 -22
- package/src/mcp/.agentic-security/findings.json +0 -8358
- package/src/mcp/.agentic-security/last-scan.json +0 -8358
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -1125
- package/src/mcp/.agentic-security/streak.json +0 -22
- package/src/posture/.agentic-security/findings.json +0 -51239
- package/src/posture/.agentic-security/last-scan.json +0 -51239
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/scan-history.json +0 -5557
- package/src/posture/.agentic-security/streak.json +0 -24
- package/src/report/.agentic-security/findings.json +0 -79
- package/src/report/.agentic-security/last-scan.json +0 -79
- package/src/report/.agentic-security/last-scan.json.sig +0 -1
- package/src/report/.agentic-security/scan-history.json +0 -332
- package/src/report/.agentic-security/streak.json +0 -23
- package/src/sast/.agentic-security/findings.json +0 -5051
- package/src/sast/.agentic-security/last-scan.json +0 -5051
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -788
- package/src/sast/.agentic-security/streak.json +0 -23
- package/src/sast/bench-shape/.agentic-security/findings.json +0 -28
- package/src/sast/bench-shape/.agentic-security/last-scan.json +0 -28
- package/src/sast/bench-shape/.agentic-security/scan-history.json +0 -24
- package/src/sast/bench-shape/.agentic-security/streak.json +0 -22
package/dist/601.index.js
DELETED
|
@@ -1,1038 +0,0 @@
|
|
|
1
|
-
export const id = 601;
|
|
2
|
-
export const ids = [601];
|
|
3
|
-
export const modules = {
|
|
4
|
-
|
|
5
|
-
/***/ 3601:
|
|
6
|
-
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// EXPORTS
|
|
10
|
-
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
-
runStdio: () => (/* binding */ runStdio)
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// EXTERNAL MODULE: external "node:fs"
|
|
15
|
-
var external_node_fs_ = __webpack_require__(3024);
|
|
16
|
-
// EXTERNAL MODULE: external "node:crypto"
|
|
17
|
-
var external_node_crypto_ = __webpack_require__(7598);
|
|
18
|
-
// EXTERNAL MODULE: external "node:path"
|
|
19
|
-
var external_node_path_ = __webpack_require__(6760);
|
|
20
|
-
// EXTERNAL MODULE: external "node:url"
|
|
21
|
-
var external_node_url_ = __webpack_require__(3136);
|
|
22
|
-
// EXTERNAL MODULE: external "node:fs/promises"
|
|
23
|
-
var promises_ = __webpack_require__(1455);
|
|
24
|
-
// EXTERNAL MODULE: ./src/runScan.js + 2 modules
|
|
25
|
-
var runScan = __webpack_require__(9099);
|
|
26
|
-
// EXTERNAL MODULE: ./src/posture/fix-history.js
|
|
27
|
-
var fix_history = __webpack_require__(4407);
|
|
28
|
-
// EXTERNAL MODULE: ./src/posture/integrity.js
|
|
29
|
-
var integrity = __webpack_require__(1130);
|
|
30
|
-
;// CONCATENATED MODULE: ./src/mcp/redact.js
|
|
31
|
-
// Secret redactor for MCP tool outputs and audit log argument summaries.
|
|
32
|
-
//
|
|
33
|
-
// OWASP MCP01 + MCP10: the scanner reads source code, and findings carry
|
|
34
|
-
// `snippet` / `description` / `trace` strings that may contain hardcoded
|
|
35
|
-
// credentials, API keys, JWTs, private keys, etc. When those flow back to
|
|
36
|
-
// the agent through tools/call responses they land in the agent's context
|
|
37
|
-
// — exposing the secret to model logs, transcripts, and any downstream tool
|
|
38
|
-
// the agent passes them to.
|
|
39
|
-
//
|
|
40
|
-
// We replace high-confidence secret shapes with [REDACTED:<kind>] before
|
|
41
|
-
// emitting them. The original full content is still on disk (scanner
|
|
42
|
-
// findings); the MCP surface is the bottleneck we control.
|
|
43
|
-
//
|
|
44
|
-
// Patterns deliberately stay narrow: high-precision so we don't garble
|
|
45
|
-
// non-secret long strings (UUIDs, SHAs, base64-encoded scan IDs).
|
|
46
|
-
|
|
47
|
-
const PATTERNS = [
|
|
48
|
-
// Provider-specific high-entropy keys (anchored prefixes give very low FP)
|
|
49
|
-
[/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
|
|
50
|
-
[/ASIA[0-9A-Z]{16}/g, 'aws-temp-key'],
|
|
51
|
-
[/gh[pousr]_[A-Za-z0-9]{36,255}/g, 'github-token'],
|
|
52
|
-
[/xox[abprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
|
|
53
|
-
[/sk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
|
|
54
|
-
[/sk-proj-[A-Za-z0-9_-]{20,}/g, 'openai-project-key'],
|
|
55
|
-
[/sk-[A-Za-z0-9]{32,}/g, 'openai-or-stripe-key'],
|
|
56
|
-
[/sk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-key'],
|
|
57
|
-
[/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
|
|
58
|
-
[/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
|
|
59
|
-
[/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
|
|
60
|
-
// JWT — three dot-separated b64url segments starting with eyJ
|
|
61
|
-
[/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
|
|
62
|
-
// PEM-encoded private keys
|
|
63
|
-
[/-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g, 'private-key-block'],
|
|
64
|
-
// Authorization headers — common copy-paste shape
|
|
65
|
-
[/(?:Authorization|authorization)\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g, 'bearer-token'],
|
|
66
|
-
// Hardcoded password literals — assignment shape with quoted value
|
|
67
|
-
[/(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\n]{6,}["']/gi, 'hardcoded-credential'],
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
const SNIPPET_MAX = 2000;
|
|
71
|
-
// OWASP A03 — cap input before running 14 regex patterns over it. A forged
|
|
72
|
-
// last-scan.json could plant a 50MB description string; without this cap a
|
|
73
|
-
// single explain_finding/query_taint call would peg CPU. After truncation
|
|
74
|
-
// the snippet still gets the final SNIPPET_MAX trim downstream.
|
|
75
|
-
const INPUT_MAX = 100_000;
|
|
76
|
-
|
|
77
|
-
function redactString(s) {
|
|
78
|
-
if (typeof s !== 'string') return s;
|
|
79
|
-
let out = s;
|
|
80
|
-
if (out.length > INPUT_MAX) out = out.slice(0, INPUT_MAX) + `…(+${out.length - INPUT_MAX})`;
|
|
81
|
-
for (const [re, kind] of PATTERNS) {
|
|
82
|
-
out = out.replace(re, `[REDACTED:${kind}]`);
|
|
83
|
-
}
|
|
84
|
-
if (out.length > SNIPPET_MAX) out = out.slice(0, SNIPPET_MAX) + `…(+${out.length - SNIPPET_MAX})`;
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Deep-redact every string in a finding-like object (mutates returned copy).
|
|
89
|
-
function redactFinding(f) {
|
|
90
|
-
if (!f || typeof f !== 'object') return f;
|
|
91
|
-
const out = { ...f };
|
|
92
|
-
for (const k of ['snippet', 'description', 'remediation', 'title', 'vuln', 'message']) {
|
|
93
|
-
if (typeof out[k] === 'string') out[k] = redactString(out[k]);
|
|
94
|
-
}
|
|
95
|
-
if (out.trace) {
|
|
96
|
-
try { out.trace = JSON.parse(redactString(JSON.stringify(out.trace))); }
|
|
97
|
-
catch { /* keep as-is if not round-trippable */ }
|
|
98
|
-
}
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Redact a freeform JSON-stringified argument blob (used by audit log).
|
|
103
|
-
function redactArgsBlob(s) {
|
|
104
|
-
return redactString(s);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// EXTERNAL MODULE: external "node:child_process"
|
|
108
|
-
var external_node_child_process_ = __webpack_require__(1421);
|
|
109
|
-
// EXTERNAL MODULE: ./src/engine.js + 422 modules
|
|
110
|
-
var engine = __webpack_require__(3209);
|
|
111
|
-
;// CONCATENATED MODULE: ./src/posture/fix-verify.js
|
|
112
|
-
// Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
|
|
113
|
-
//
|
|
114
|
-
// Given a candidate patch (the new file content + the finding stableId being
|
|
115
|
-
// fixed), verify it:
|
|
116
|
-
//
|
|
117
|
-
// 1. The original finding's stableId no longer fires on the patched file.
|
|
118
|
-
// 2. No new findings at severity ≥ medium were introduced by the patch.
|
|
119
|
-
// 3. The project's existing linter (when present) passes on the patched file.
|
|
120
|
-
//
|
|
121
|
-
// If any of those fail, the caller is expected to NOT apply the patch and
|
|
122
|
-
// instead surface a "fix plan" — a numbered list of steps the engineer can
|
|
123
|
-
// follow — rather than dump a broken patch on the user.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
131
|
-
|
|
132
|
-
// Run a focused re-scan over just the patched file(s) using the in-memory
|
|
133
|
-
// engine. No filesystem write needed — we hand the new content in via the
|
|
134
|
-
// fileContents map.
|
|
135
|
-
async function verifyPatch({
|
|
136
|
-
scanRoot,
|
|
137
|
-
originalFindingStableId,
|
|
138
|
-
files, // { [relPath]: newContent }
|
|
139
|
-
depFileContents = {},
|
|
140
|
-
} = {}) {
|
|
141
|
-
if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
|
|
142
|
-
const fileContents = { ...files };
|
|
143
|
-
let scan;
|
|
144
|
-
try {
|
|
145
|
-
scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot }, () => {});
|
|
146
|
-
} catch (e) {
|
|
147
|
-
return { ok: false, reason: 'rescan-failed', error: e.message };
|
|
148
|
-
}
|
|
149
|
-
const findings = (scan && scan.findings) || [];
|
|
150
|
-
const stillHasOriginal = !!originalFindingStableId &&
|
|
151
|
-
findings.some(f => f.stableId === originalFindingStableId);
|
|
152
|
-
if (stillHasOriginal) {
|
|
153
|
-
return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
|
|
154
|
-
}
|
|
155
|
-
const introducedHighOrAbove = findings.filter(f =>
|
|
156
|
-
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
|
|
157
|
-
// Don't count findings on lines outside the patched files — but our
|
|
158
|
-
// fileContents map IS the patched files, so every finding is in-scope.
|
|
159
|
-
return {
|
|
160
|
-
ok: introducedHighOrAbove.length === 0,
|
|
161
|
-
reason: introducedHighOrAbove.length === 0 ? 'verified' : 'introduced-new-findings',
|
|
162
|
-
introduced: introducedHighOrAbove.map(f => ({
|
|
163
|
-
vuln: f.vuln, file: f.file, line: f.line, severity: f.severity,
|
|
164
|
-
stableId: f.stableId,
|
|
165
|
-
})),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Detect which linter the project uses and run it on the patched files.
|
|
170
|
-
// Returns { ok, runner, output } or { ok: true, runner: 'none' } when no
|
|
171
|
-
// linter is configured (silent pass).
|
|
172
|
-
function runProjectLinter(scanRoot, filePaths) {
|
|
173
|
-
if (!scanRoot || !Array.isArray(filePaths) || filePaths.length === 0) {
|
|
174
|
-
return { ok: true, runner: 'none' };
|
|
175
|
-
}
|
|
176
|
-
const has = (p) => { try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, p)); } catch { return false; } };
|
|
177
|
-
// Pick the linter by config file present in the repo root.
|
|
178
|
-
const jsFiles = filePaths.filter(f => /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(f));
|
|
179
|
-
const pyFiles = filePaths.filter(f => /\.py$/i.test(f));
|
|
180
|
-
const goFiles = filePaths.filter(f => /\.go$/i.test(f));
|
|
181
|
-
const javaFiles = filePaths.filter(f => /\.java$/i.test(f));
|
|
182
|
-
|
|
183
|
-
if (jsFiles.length && (has('.eslintrc') || has('.eslintrc.json') || has('.eslintrc.js') || has('eslint.config.js') || has('eslint.config.mjs'))) {
|
|
184
|
-
return runLinter(scanRoot, 'eslint', ['--no-error-on-unmatched-pattern', ...jsFiles]);
|
|
185
|
-
}
|
|
186
|
-
if (pyFiles.length && (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml'))) {
|
|
187
|
-
return runLinter(scanRoot, 'ruff', ['check', ...pyFiles]);
|
|
188
|
-
}
|
|
189
|
-
if (pyFiles.length && has('.flake8')) {
|
|
190
|
-
return runLinter(scanRoot, 'flake8', pyFiles);
|
|
191
|
-
}
|
|
192
|
-
if (goFiles.length && (has('.golangci.yml') || has('.golangci.yaml'))) {
|
|
193
|
-
return runLinter(scanRoot, 'golangci-lint', ['run', ...goFiles]);
|
|
194
|
-
}
|
|
195
|
-
if (javaFiles.length && has('checkstyle.xml')) {
|
|
196
|
-
return runLinter(scanRoot, 'checkstyle', ['-c', 'checkstyle.xml', ...javaFiles]);
|
|
197
|
-
}
|
|
198
|
-
return { ok: true, runner: 'none' };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function runLinter(cwd, cmd, args) {
|
|
202
|
-
let r;
|
|
203
|
-
try {
|
|
204
|
-
r = (0,external_node_child_process_.spawnSync)(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
205
|
-
} catch (e) {
|
|
206
|
-
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing', error: e.message };
|
|
207
|
-
}
|
|
208
|
-
if (r.error && r.error.code === 'ENOENT') {
|
|
209
|
-
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing' };
|
|
210
|
-
}
|
|
211
|
-
if (r.status === null) {
|
|
212
|
-
return { ok: false, runner: cmd, reason: 'timed-out', output: (r.stderr || r.stdout || '').slice(-2000) };
|
|
213
|
-
}
|
|
214
|
-
return {
|
|
215
|
-
ok: r.status === 0,
|
|
216
|
-
runner: cmd,
|
|
217
|
-
exitCode: r.status,
|
|
218
|
-
output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Top-level verify: re-scan + lint. Returns the combined verdict + a
|
|
223
|
-
// human-readable summary string suitable for surfacing to the user.
|
|
224
|
-
async function verifyFix({
|
|
225
|
-
scanRoot,
|
|
226
|
-
originalFindingStableId,
|
|
227
|
-
files,
|
|
228
|
-
depFileContents,
|
|
229
|
-
} = {}) {
|
|
230
|
-
const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
231
|
-
const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
|
|
232
|
-
const ok = rescan.ok && (lint.ok || lint.skipped);
|
|
233
|
-
const summary = [
|
|
234
|
-
`re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
|
|
235
|
-
`linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
|
|
236
|
-
: lint.skipped ? `${lint.runner} not installed`
|
|
237
|
-
: lint.ok ? `${lint.runner} PASS`
|
|
238
|
-
: `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
|
|
239
|
-
].join('\n');
|
|
240
|
-
return { ok, rescan, lint, summary };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
;// CONCATENATED MODULE: ./src/mcp/tools.js
|
|
244
|
-
// MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
|
|
245
|
-
// Top 10 (see ./redact.js, ./audit.js, ./server.js for sibling controls).
|
|
246
|
-
//
|
|
247
|
-
// Trust model:
|
|
248
|
-
// - Session root fixed at server boot. No per-call retargeting.
|
|
249
|
-
// - Path arguments lstat-checked (symlinks refused, OWASP MCP05) and
|
|
250
|
-
// realpath-confined to session root.
|
|
251
|
-
// - Tool outputs marked _meta.untrusted_excerpts:true (OWASP MCP03/MCP06)
|
|
252
|
-
// because they may contain text from scanned files, which is adversary-
|
|
253
|
-
// controlled in any context where the agent might read malicious code.
|
|
254
|
-
// - Secret-shaped strings redacted on the way out (OWASP MCP01/MCP10).
|
|
255
|
-
// - `apply_fix` requires confirm:true, valid HMAC signature on
|
|
256
|
-
// last-scan.json, non-shadow finding, and confined file path.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const MAX_FILES_PER_SCAN = 1024;
|
|
268
|
-
const MAX_FILE_BYTES = 500_000;
|
|
269
|
-
const MAX_TOTAL_SCAN_BYTES = 50_000_000;
|
|
270
|
-
const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
|
|
271
|
-
|
|
272
|
-
// OWASP A01 — refuse writes to paths that could subvert the security tool
|
|
273
|
-
// itself or the host's source-control / dependency state. A forged finding
|
|
274
|
-
// could otherwise tell apply_fix to overwrite our own rules.yml, our audit
|
|
275
|
-
// log, a .git/hooks/post-commit payload, or a node_modules package.
|
|
276
|
-
const RESERVED_WRITE_PREFIXES = [
|
|
277
|
-
'.git/',
|
|
278
|
-
'.agentic-security/',
|
|
279
|
-
'node_modules/',
|
|
280
|
-
];
|
|
281
|
-
function _isReservedWritePath(sessionRoot, absFile) {
|
|
282
|
-
// Resolve sessionRoot symlinks so the relative path is computed against
|
|
283
|
-
// the same canonical root as `absFile` (which _confine already realpath'd).
|
|
284
|
-
// On macOS /tmp → /private/tmp; without this normalization the relative
|
|
285
|
-
// would contain "../" and the prefix check would miss the reserved path.
|
|
286
|
-
const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
|
|
287
|
-
const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
|
|
288
|
-
return RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// ─── Path confinement ────────────────────────────────────────────────────────
|
|
292
|
-
// Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05.
|
|
293
|
-
//
|
|
294
|
-
// For non-existent paths (apply_fix to a new file is a possible legitimate
|
|
295
|
-
// case; in practice we re-check existence at the use-site) we walk up the
|
|
296
|
-
// deepest existing ancestor and realpath that, so a parent-symlink can't
|
|
297
|
-
// silently relocate writes.
|
|
298
|
-
function _confine(sessionRoot, candidate, label) {
|
|
299
|
-
if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
|
|
300
|
-
const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
|
|
301
|
-
const abs = external_node_path_.isAbsolute(candidate) ? candidate : external_node_path_.resolve(rootReal, candidate);
|
|
302
|
-
|
|
303
|
-
// Lexical pre-check: rejects "../../etc/passwd" before any fs call.
|
|
304
|
-
const relLex = external_node_path_.relative(rootReal, external_node_path_.resolve(abs));
|
|
305
|
-
if (relLex === '' || relLex.startsWith('..') || external_node_path_.isAbsolute(relLex)) {
|
|
306
|
-
throw new Error(`${label}: path "${candidate}" escapes session root`);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// If the path exists, the leaf must not be a symlink and its realpath
|
|
310
|
-
// must still be under rootReal.
|
|
311
|
-
if (external_node_fs_.existsSync(abs)) {
|
|
312
|
-
if (external_node_fs_.lstatSync(abs).isSymbolicLink()) {
|
|
313
|
-
throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
|
|
314
|
-
}
|
|
315
|
-
const real = external_node_fs_.realpathSync(abs);
|
|
316
|
-
if (external_node_path_.relative(rootReal, real).startsWith('..')) {
|
|
317
|
-
throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
|
|
318
|
-
}
|
|
319
|
-
return real;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Path doesn't exist — walk up to the deepest existing ancestor and
|
|
323
|
-
// realpath that. If a parent dir is a symlink pointing outside rootReal
|
|
324
|
-
// we catch it here.
|
|
325
|
-
let parent = external_node_path_.dirname(abs);
|
|
326
|
-
while (parent !== external_node_path_.dirname(parent) && !external_node_fs_.existsSync(parent)) {
|
|
327
|
-
parent = external_node_path_.dirname(parent);
|
|
328
|
-
}
|
|
329
|
-
const parentReal = external_node_fs_.realpathSync(parent);
|
|
330
|
-
if (external_node_path_.relative(rootReal, parentReal).startsWith('..')) {
|
|
331
|
-
throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
|
|
332
|
-
}
|
|
333
|
-
const suffix = external_node_path_.relative(parent, abs);
|
|
334
|
-
return external_node_path_.resolve(parentReal, suffix);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
|
|
338
|
-
const stateDir = external_node_path_.join(sessionRoot, '.agentic-security');
|
|
339
|
-
const scanFile = external_node_path_.join(stateDir, 'last-scan.json');
|
|
340
|
-
const sigFile = scanFile + '.sig';
|
|
341
|
-
if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
|
|
342
|
-
const body = external_node_fs_.readFileSync(scanFile, 'utf8');
|
|
343
|
-
const ok = (0,integrity/* verifyLastScan */.E)(body, sigFile);
|
|
344
|
-
if (ok === false) return { scan: null, status: 'tampered' };
|
|
345
|
-
if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
|
|
346
|
-
let parsed;
|
|
347
|
-
try { parsed = JSON.parse(body); }
|
|
348
|
-
catch { return { scan: null, status: 'unparseable' }; }
|
|
349
|
-
return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function _findById(scan, id) {
|
|
353
|
-
if (!scan) return null;
|
|
354
|
-
return (scan.findings || []).find(f => f.id === id)
|
|
355
|
-
|| (scan.secrets || []).find(f => f.id === id)
|
|
356
|
-
|| null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// ─── scan_diff ───────────────────────────────────────────────────────────────
|
|
360
|
-
const scan_diff = {
|
|
361
|
-
name: 'scan_diff',
|
|
362
|
-
description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',
|
|
363
|
-
inputSchema: {
|
|
364
|
-
type: 'object',
|
|
365
|
-
additionalProperties: false,
|
|
366
|
-
properties: {
|
|
367
|
-
files: {
|
|
368
|
-
type: 'array', minItems: 1, maxItems: MAX_FILES_PER_SCAN,
|
|
369
|
-
items: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
370
|
-
},
|
|
371
|
-
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
|
|
372
|
-
},
|
|
373
|
-
required: ['files'],
|
|
374
|
-
},
|
|
375
|
-
async handler({ files, severity }, ctx) {
|
|
376
|
-
const sessionRoot = ctx.sessionRoot;
|
|
377
|
-
const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
|
|
378
|
-
|
|
379
|
-
const fileContents = {};
|
|
380
|
-
let totalBytes = 0;
|
|
381
|
-
for (const a of abs) {
|
|
382
|
-
let stat;
|
|
383
|
-
try { stat = external_node_fs_.statSync(a); } catch { continue; }
|
|
384
|
-
if (!stat.isFile()) continue;
|
|
385
|
-
if (stat.size > MAX_FILE_BYTES) continue;
|
|
386
|
-
totalBytes += stat.size;
|
|
387
|
-
if (totalBytes > MAX_TOTAL_SCAN_BYTES) {
|
|
388
|
-
throw new Error(`scan_diff: total scan size exceeds ${MAX_TOTAL_SCAN_BYTES} bytes`);
|
|
389
|
-
}
|
|
390
|
-
let content;
|
|
391
|
-
try { content = external_node_fs_.readFileSync(a, 'utf8'); } catch { continue; }
|
|
392
|
-
const rel = external_node_path_.relative(sessionRoot, a).replace(/\\/g, '/');
|
|
393
|
-
fileContents[rel] = content;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const result = await (0,runScan.runScan)(sessionRoot, { network: false, fileContents });
|
|
397
|
-
const wantSet = new Set(Object.keys(fileContents));
|
|
398
|
-
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
399
|
-
const min = sevRank[severity] ?? 0;
|
|
400
|
-
const findings = (result.scan.findings || [])
|
|
401
|
-
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
402
|
-
.map(f => redactFinding({
|
|
403
|
-
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
404
|
-
title: f.title || f.vuln, cwe: f.cwe,
|
|
405
|
-
description: f.description, remediation: f.remediation,
|
|
406
|
-
}));
|
|
407
|
-
return {
|
|
408
|
-
_meta: META,
|
|
409
|
-
scannedFiles: Object.keys(fileContents).length,
|
|
410
|
-
findingCount: findings.length,
|
|
411
|
-
findings,
|
|
412
|
-
};
|
|
413
|
-
},
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
// ─── query_taint ─────────────────────────────────────────────────────────────
|
|
417
|
-
const query_taint = {
|
|
418
|
-
name: 'query_taint',
|
|
419
|
-
description: 'Query whether the last verified scan found a taint path involving a given source and sink.',
|
|
420
|
-
inputSchema: {
|
|
421
|
-
type: 'object',
|
|
422
|
-
additionalProperties: false,
|
|
423
|
-
properties: {
|
|
424
|
-
source: { type: 'string', minLength: 1, maxLength: 256 },
|
|
425
|
-
sink: { type: 'string', minLength: 1, maxLength: 256 },
|
|
426
|
-
},
|
|
427
|
-
required: ['source', 'sink'],
|
|
428
|
-
},
|
|
429
|
-
async handler({ source, sink }, ctx) {
|
|
430
|
-
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
431
|
-
if (!scan) {
|
|
432
|
-
return { _meta: META, hasResult: false, status, message: `No usable scan state (${status}).` };
|
|
433
|
-
}
|
|
434
|
-
const srcL = String(source).toLowerCase();
|
|
435
|
-
const sinkL = String(sink).toLowerCase();
|
|
436
|
-
const matches = (scan.findings || []).filter(f => {
|
|
437
|
-
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
|
|
438
|
-
return hay.includes(srcL) && hay.includes(sinkL);
|
|
439
|
-
}).map(f => redactFinding({
|
|
440
|
-
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
441
|
-
title: f.title || f.vuln, description: f.description,
|
|
442
|
-
trace: f.trace || null,
|
|
443
|
-
}));
|
|
444
|
-
return {
|
|
445
|
-
_meta: META,
|
|
446
|
-
hasResult: true,
|
|
447
|
-
integrity: status,
|
|
448
|
-
scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
|
|
449
|
-
matchCount: matches.length,
|
|
450
|
-
matches,
|
|
451
|
-
};
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
// ─── explain_finding ─────────────────────────────────────────────────────────
|
|
456
|
-
const explain_finding = {
|
|
457
|
-
name: 'explain_finding',
|
|
458
|
-
description: 'Return full details for a single finding from the last verified scan. Snippet/description redacted of secret patterns.',
|
|
459
|
-
inputSchema: {
|
|
460
|
-
type: 'object',
|
|
461
|
-
additionalProperties: false,
|
|
462
|
-
properties: {
|
|
463
|
-
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
464
|
-
},
|
|
465
|
-
required: ['finding_id'],
|
|
466
|
-
},
|
|
467
|
-
async handler({ finding_id }, ctx) {
|
|
468
|
-
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
469
|
-
if (!scan) throw new Error(`No usable scan state (${status}).`);
|
|
470
|
-
const f = _findById(scan, finding_id);
|
|
471
|
-
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
472
|
-
const redacted = redactFinding({
|
|
473
|
-
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
474
|
-
title: f.title || f.vuln, cwe: f.cwe,
|
|
475
|
-
description: f.description, remediation: f.remediation,
|
|
476
|
-
snippet: f.snippet || null,
|
|
477
|
-
trace: f.trace || null,
|
|
478
|
-
});
|
|
479
|
-
return {
|
|
480
|
-
_meta: META,
|
|
481
|
-
...redacted,
|
|
482
|
-
confidence: f.confidence ?? null,
|
|
483
|
-
hasReplacementFix: typeof f.fix?.replacement === 'string',
|
|
484
|
-
integrity: status,
|
|
485
|
-
};
|
|
486
|
-
},
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
// ─── apply_fix ───────────────────────────────────────────────────────────────
|
|
490
|
-
const apply_fix = {
|
|
491
|
-
name: 'apply_fix',
|
|
492
|
-
description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
|
|
493
|
-
inputSchema: {
|
|
494
|
-
type: 'object',
|
|
495
|
-
additionalProperties: false,
|
|
496
|
-
properties: {
|
|
497
|
-
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
498
|
-
confirm: { type: 'boolean' },
|
|
499
|
-
dry_run: { type: 'boolean' },
|
|
500
|
-
},
|
|
501
|
-
required: ['finding_id', 'confirm'],
|
|
502
|
-
},
|
|
503
|
-
async handler({ finding_id, confirm, dry_run = false }, ctx) {
|
|
504
|
-
if (confirm !== true) {
|
|
505
|
-
return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
506
|
-
}
|
|
507
|
-
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
508
|
-
if (!scan) {
|
|
509
|
-
return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
|
|
510
|
-
}
|
|
511
|
-
const f = _findById(scan, finding_id);
|
|
512
|
-
if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
|
|
513
|
-
if (f._shadow === true) {
|
|
514
|
-
return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
|
|
515
|
-
}
|
|
516
|
-
if (typeof f.fix?.replacement !== 'string') {
|
|
517
|
-
return {
|
|
518
|
-
_meta: META, applied: false,
|
|
519
|
-
reason: 'No full replacement available — only a template. Apply the template manually.',
|
|
520
|
-
template: redactString(f.fix?.code || ''),
|
|
521
|
-
file: f.file, line: f.line,
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
let absFile;
|
|
525
|
-
try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
|
|
526
|
-
catch (e) {
|
|
527
|
-
return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` };
|
|
528
|
-
}
|
|
529
|
-
if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
|
|
530
|
-
return { _meta: META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
|
|
531
|
-
}
|
|
532
|
-
if (!external_node_fs_.existsSync(absFile)) {
|
|
533
|
-
return { _meta: META, applied: false, reason: `File not found: ${absFile}` };
|
|
534
|
-
}
|
|
535
|
-
const originalContent = await promises_.readFile(absFile, 'utf8');
|
|
536
|
-
|
|
537
|
-
if (dry_run) {
|
|
538
|
-
return {
|
|
539
|
-
_meta: META,
|
|
540
|
-
applied: false, dryRun: true,
|
|
541
|
-
file: f.file,
|
|
542
|
-
originalSize: originalContent.length,
|
|
543
|
-
newSize: f.fix.replacement.length,
|
|
544
|
-
diffSummary: `${originalContent.length} → ${f.fix.replacement.length} bytes`,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const entry = await (0,fix_history/* applyFix */.oM)({
|
|
549
|
-
scanRoot: ctx.sessionRoot,
|
|
550
|
-
file: f.file,
|
|
551
|
-
originalContent,
|
|
552
|
-
newContent: f.fix.replacement,
|
|
553
|
-
findingId: f.id,
|
|
554
|
-
stableId: f.stableId || null, // premortem 4R-8
|
|
555
|
-
ruleId: f.rule || null,
|
|
556
|
-
vuln: f.vuln || f.title || null,
|
|
557
|
-
});
|
|
558
|
-
return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status };
|
|
559
|
-
},
|
|
560
|
-
};
|
|
561
|
-
|
|
562
|
-
// ─── verify_fix ──────────────────────────────────────────────────────────────
|
|
563
|
-
// Closed-loop verification of a proposed patch BEFORE the agent applies it.
|
|
564
|
-
// Re-scans the patched files in-memory (no disk write), confirms the original
|
|
565
|
-
// stableId is gone, and runs the project's existing linter on the patched
|
|
566
|
-
// files. Returns a structured verdict the agent can use to decide whether to
|
|
567
|
-
// proceed with apply_fix.
|
|
568
|
-
const verify_fix = {
|
|
569
|
-
name: 'verify_fix',
|
|
570
|
-
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory and runs the project linter. Returns { ok, rescan, lint, summary }. No filesystem writes.',
|
|
571
|
-
inputSchema: {
|
|
572
|
-
type: 'object',
|
|
573
|
-
additionalProperties: false,
|
|
574
|
-
properties: {
|
|
575
|
-
stable_id: { type: 'string', minLength: 8, maxLength: 64 },
|
|
576
|
-
files: {
|
|
577
|
-
type: 'object',
|
|
578
|
-
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
579
|
-
minProperties: 1,
|
|
580
|
-
maxProperties: 8,
|
|
581
|
-
},
|
|
582
|
-
},
|
|
583
|
-
required: ['stable_id', 'files'],
|
|
584
|
-
},
|
|
585
|
-
async handler({ stable_id, files }, ctx) {
|
|
586
|
-
// Confine every file path before passing to the verifier.
|
|
587
|
-
const confined = {};
|
|
588
|
-
for (const [relPath, content] of Object.entries(files || {})) {
|
|
589
|
-
try {
|
|
590
|
-
_confine(ctx.sessionRoot, relPath, 'files key');
|
|
591
|
-
} catch (e) {
|
|
592
|
-
return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` };
|
|
593
|
-
}
|
|
594
|
-
confined[relPath] = String(content);
|
|
595
|
-
}
|
|
596
|
-
try {
|
|
597
|
-
const r = await verifyFix({
|
|
598
|
-
scanRoot: ctx.sessionRoot,
|
|
599
|
-
originalFindingStableId: stable_id,
|
|
600
|
-
files: confined,
|
|
601
|
-
});
|
|
602
|
-
return {
|
|
603
|
-
_meta: META,
|
|
604
|
-
ok: r.ok,
|
|
605
|
-
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
606
|
-
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
|
|
607
|
-
summary: r.summary,
|
|
608
|
-
};
|
|
609
|
-
} catch (e) {
|
|
610
|
-
return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
|
|
611
|
-
}
|
|
612
|
-
},
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
// ─── synthesize_fix ──────────────────────────────────────────────────────────
|
|
616
|
-
// Return the stored fix replacement + regression-test scaffold for a finding,
|
|
617
|
-
// WITHOUT applying anything. The agent can call verify_fix → apply_fix in
|
|
618
|
-
// sequence with the returned blob.
|
|
619
|
-
const synthesize_fix = {
|
|
620
|
-
name: 'synthesize_fix',
|
|
621
|
-
description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
|
|
622
|
-
inputSchema: {
|
|
623
|
-
type: 'object',
|
|
624
|
-
additionalProperties: false,
|
|
625
|
-
properties: {
|
|
626
|
-
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
627
|
-
},
|
|
628
|
-
required: ['finding_id'],
|
|
629
|
-
},
|
|
630
|
-
async handler({ finding_id }, ctx) {
|
|
631
|
-
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
632
|
-
if (!scan) {
|
|
633
|
-
return { _meta: META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
|
|
634
|
-
}
|
|
635
|
-
const f = _findById(scan, finding_id);
|
|
636
|
-
if (!f) return { _meta: META, ok: false, reason: `Finding not found: ${finding_id}` };
|
|
637
|
-
if (f._shadow === true) return { _meta: META, ok: false, reason: 'shadow findings have no synthesized fix' };
|
|
638
|
-
const fix = f.fix || {};
|
|
639
|
-
const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
|
|
640
|
-
// Patch bounds: count files touched + LoC delta.
|
|
641
|
-
let touchedFiles = 1;
|
|
642
|
-
let locDelta = 0;
|
|
643
|
-
if (hasReplacement) {
|
|
644
|
-
let orig = '';
|
|
645
|
-
try {
|
|
646
|
-
const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
|
|
647
|
-
orig = external_node_fs_.readFileSync(abs, 'utf8');
|
|
648
|
-
} catch { /* ignore — counts will reflect new-only LoC */ }
|
|
649
|
-
locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
|
|
650
|
-
}
|
|
651
|
-
const oversized = touchedFiles > 3 || locDelta > 100;
|
|
652
|
-
return {
|
|
653
|
-
_meta: META,
|
|
654
|
-
ok: true,
|
|
655
|
-
stable_id: f.stableId || null,
|
|
656
|
-
file: f.file, line: f.line,
|
|
657
|
-
vuln: f.vuln,
|
|
658
|
-
severity: f.severity,
|
|
659
|
-
hasReplacement,
|
|
660
|
-
replacement: hasReplacement ? redactString(fix.replacement) : null,
|
|
661
|
-
template: fix.code ? redactString(fix.code) : null,
|
|
662
|
-
remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
|
|
663
|
-
patchBounds: { touchedFiles, locDelta, oversized },
|
|
664
|
-
recommendsFixPlan: oversized && !hasReplacement,
|
|
665
|
-
};
|
|
666
|
-
},
|
|
667
|
-
};
|
|
668
|
-
|
|
669
|
-
const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix];
|
|
670
|
-
|
|
671
|
-
;// CONCATENATED MODULE: ./src/mcp/validate.js
|
|
672
|
-
// Minimal JSON Schema validator — just the subset our tool schemas use.
|
|
673
|
-
// No deps. Throws on invalid input with a path-prefixed error message.
|
|
674
|
-
//
|
|
675
|
-
// Supported keywords: type (object/array/string/boolean/number),
|
|
676
|
-
// required, properties, items, enum, minItems, maxItems, maxLength,
|
|
677
|
-
// minLength, additionalProperties (only as `false` — strict).
|
|
678
|
-
|
|
679
|
-
const TYPE_OF = (v) => {
|
|
680
|
-
if (v === null) return 'null';
|
|
681
|
-
if (Array.isArray(v)) return 'array';
|
|
682
|
-
return typeof v;
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
function validate(schema, value, path = 'arguments') {
|
|
686
|
-
if (!schema) return;
|
|
687
|
-
const t = schema.type;
|
|
688
|
-
if (t === 'object') {
|
|
689
|
-
if (TYPE_OF(value) !== 'object') throw new Error(`${path}: expected object, got ${TYPE_OF(value)}`);
|
|
690
|
-
for (const req of schema.required || []) {
|
|
691
|
-
if (!(req in value)) throw new Error(`${path}: missing required property "${req}"`);
|
|
692
|
-
}
|
|
693
|
-
if (schema.additionalProperties === false) {
|
|
694
|
-
const allowed = new Set(Object.keys(schema.properties || {}));
|
|
695
|
-
for (const k of Object.keys(value)) {
|
|
696
|
-
if (!allowed.has(k)) throw new Error(`${path}: unexpected property "${k}"`);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
for (const [k, sub] of Object.entries(schema.properties || {})) {
|
|
700
|
-
if (k in value) validate(sub, value[k], `${path}.${k}`);
|
|
701
|
-
}
|
|
702
|
-
} else if (t === 'array') {
|
|
703
|
-
if (!Array.isArray(value)) throw new Error(`${path}: expected array, got ${TYPE_OF(value)}`);
|
|
704
|
-
if (schema.minItems != null && value.length < schema.minItems) throw new Error(`${path}: minItems=${schema.minItems}, got length=${value.length}`);
|
|
705
|
-
if (schema.maxItems != null && value.length > schema.maxItems) throw new Error(`${path}: maxItems=${schema.maxItems}, got length=${value.length}`);
|
|
706
|
-
if (schema.items) for (let i = 0; i < value.length; i++) validate(schema.items, value[i], `${path}[${i}]`);
|
|
707
|
-
} else if (t === 'string') {
|
|
708
|
-
if (typeof value !== 'string') throw new Error(`${path}: expected string, got ${TYPE_OF(value)}`);
|
|
709
|
-
if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: must be one of [${schema.enum.join(', ')}]`);
|
|
710
|
-
if (schema.maxLength != null && value.length > schema.maxLength) throw new Error(`${path}: maxLength=${schema.maxLength}, got length=${value.length}`);
|
|
711
|
-
if (schema.minLength != null && value.length < schema.minLength) throw new Error(`${path}: minLength=${schema.minLength}, got length=${value.length}`);
|
|
712
|
-
} else if (t === 'boolean') {
|
|
713
|
-
if (typeof value !== 'boolean') throw new Error(`${path}: expected boolean, got ${TYPE_OF(value)}`);
|
|
714
|
-
} else if (t === 'number' || t === 'integer') {
|
|
715
|
-
if (typeof value !== 'number') throw new Error(`${path}: expected number, got ${TYPE_OF(value)}`);
|
|
716
|
-
if (t === 'integer' && !Number.isInteger(value)) throw new Error(`${path}: expected integer`);
|
|
717
|
-
if (schema.minimum != null && value < schema.minimum) throw new Error(`${path}: < minimum (${schema.minimum})`);
|
|
718
|
-
if (schema.maximum != null && value > schema.maximum) throw new Error(`${path}: > maximum (${schema.maximum})`);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
;// CONCATENATED MODULE: ./src/mcp/audit.js
|
|
723
|
-
// Append-only audit log of MCP tool calls — OWASP MCP08.
|
|
724
|
-
//
|
|
725
|
-
// Format: one JSON object per line (NDJSON) at
|
|
726
|
-
// <sessionRoot>/.agentic-security/mcp-audit.log
|
|
727
|
-
//
|
|
728
|
-
// Each entry carries `prev` — the SHA-256 of the previous entry's serialized
|
|
729
|
-
// form. The first entry's prev is "GENESIS". Tampering with any line breaks
|
|
730
|
-
// the chain from that point forward; a reader can detect partial truncation
|
|
731
|
-
// or in-place edits. (Cannot prevent total deletion of the file — for that
|
|
732
|
-
// you need write-once storage or a remote sink, out of scope for v1.)
|
|
733
|
-
//
|
|
734
|
-
// Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
|
|
735
|
-
// arguments cannot leak via the audit trail.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const MAX_ARG_BYTES = 1024;
|
|
743
|
-
const GENESIS = 'GENESIS';
|
|
744
|
-
|
|
745
|
-
function _summarize(args) {
|
|
746
|
-
let s;
|
|
747
|
-
try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
|
|
748
|
-
s = redactArgsBlob(s);
|
|
749
|
-
if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
|
|
750
|
-
return s;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function _sha(s) { return external_node_crypto_.createHash('sha256').update(s).digest('hex'); }
|
|
754
|
-
|
|
755
|
-
function _readLastEntryHash(logFile) {
|
|
756
|
-
if (!external_node_fs_.existsSync(logFile)) return GENESIS;
|
|
757
|
-
try {
|
|
758
|
-
const all = external_node_fs_.readFileSync(logFile, 'utf8');
|
|
759
|
-
const lines = all.split('\n').filter(Boolean);
|
|
760
|
-
if (!lines.length) return GENESIS;
|
|
761
|
-
return _sha(lines[lines.length - 1]);
|
|
762
|
-
} catch { return GENESIS; }
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
766
|
-
if (!sessionRoot) return;
|
|
767
|
-
try {
|
|
768
|
-
const dir = external_node_path_.join(sessionRoot, '.agentic-security');
|
|
769
|
-
external_node_fs_.mkdirSync(dir, { recursive: true });
|
|
770
|
-
const logFile = external_node_path_.join(dir, 'mcp-audit.log');
|
|
771
|
-
const entry = {
|
|
772
|
-
ts: new Date().toISOString(),
|
|
773
|
-
tool,
|
|
774
|
-
outcome,
|
|
775
|
-
...(reason ? { reason } : {}),
|
|
776
|
-
args: _summarize(args),
|
|
777
|
-
prev: _readLastEntryHash(logFile),
|
|
778
|
-
};
|
|
779
|
-
external_node_fs_.appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
780
|
-
} catch { /* audit failure must never break a tool call */ }
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Verify the chain from start to end. Returns
|
|
784
|
-
// { ok: true, entries: N } if intact
|
|
785
|
-
// { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
|
|
786
|
-
// Reader/operator-facing tool.
|
|
787
|
-
function verifyAuditLog(logFile) {
|
|
788
|
-
if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
|
|
789
|
-
const text = fs.readFileSync(logFile, 'utf8');
|
|
790
|
-
const lines = text.split('\n').filter(Boolean);
|
|
791
|
-
let expectedPrev = GENESIS;
|
|
792
|
-
for (let i = 0; i < lines.length; i++) {
|
|
793
|
-
let entry;
|
|
794
|
-
try { entry = JSON.parse(lines[i]); }
|
|
795
|
-
catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
|
|
796
|
-
if (entry.prev !== expectedPrev) {
|
|
797
|
-
return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
|
|
798
|
-
}
|
|
799
|
-
expectedPrev = _sha(lines[i]);
|
|
800
|
-
}
|
|
801
|
-
return { ok: true, entries: lines.length };
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
;// CONCATENATED MODULE: ./src/mcp/server.js
|
|
805
|
-
// MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
|
|
806
|
-
//
|
|
807
|
-
// Hardening posture (mapped to OWASP MCP Top 10):
|
|
808
|
-
// - Session root chosen at server boot, no per-call retargeting (MCP02)
|
|
809
|
-
// - Every tools/call argument validated against the tool's inputSchema (MCP02/MCP05)
|
|
810
|
-
// - Every tools/call audited with a hash-chained log (MCP08)
|
|
811
|
-
// - serverInfo.codeFingerprint = SHA-256 of MCP source files (MCP04/MCP09)
|
|
812
|
-
// so a fleet can detect tampered or unauthorized server deployments
|
|
813
|
-
// - AGENTIC_SECURITY_MCP_DISABLED=1 hard-disables all tool calls (MCP09)
|
|
814
|
-
// - Stdio transport caps line/buffer size (./stdio.js) (MCP05 DoS)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
const PROTOCOL_VERSION = '2025-03-26';
|
|
825
|
-
const SERVER_NAME = 'agentic-security';
|
|
826
|
-
const SERVER_VERSION = '0.39.2';
|
|
827
|
-
|
|
828
|
-
const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
|
|
829
|
-
|
|
830
|
-
// Code fingerprint — SHA-256 of the MCP source files concatenated in a
|
|
831
|
-
// stable order. Embedded in `initialize` response so a fleet operator can
|
|
832
|
-
// detect when an unapproved build is running (OWASP MCP04/MCP09).
|
|
833
|
-
function _codeFingerprint() {
|
|
834
|
-
try {
|
|
835
|
-
const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
|
|
836
|
-
const files = ['server.js', 'tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
837
|
-
const h = external_node_crypto_.createHash('sha256');
|
|
838
|
-
for (const f of files) {
|
|
839
|
-
try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
|
|
840
|
-
}
|
|
841
|
-
return h.digest('hex');
|
|
842
|
-
} catch { return null; }
|
|
843
|
-
}
|
|
844
|
-
const CODE_FINGERPRINT = _codeFingerprint();
|
|
845
|
-
|
|
846
|
-
function _err(id, code, message, data) {
|
|
847
|
-
const out = { jsonrpc: '2.0', id, error: { code, message } };
|
|
848
|
-
if (data !== undefined) out.error.data = data;
|
|
849
|
-
return out;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function _ok(id, result) {
|
|
853
|
-
return { jsonrpc: '2.0', id, result };
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
857
|
-
const ctx = { sessionRoot };
|
|
858
|
-
|
|
859
|
-
async function handleRequest(msg) {
|
|
860
|
-
if (!msg || typeof msg !== 'object') return _err(null, -32600, 'Invalid Request');
|
|
861
|
-
if (msg.jsonrpc !== '2.0') return _err(msg.id ?? null, -32600, 'Invalid Request: jsonrpc must be "2.0"');
|
|
862
|
-
|
|
863
|
-
const isNotification = msg.id === undefined || msg.id === null;
|
|
864
|
-
const id = msg.id ?? null;
|
|
865
|
-
const disabled = process.env.AGENTIC_SECURITY_MCP_DISABLED === '1';
|
|
866
|
-
|
|
867
|
-
switch (msg.method) {
|
|
868
|
-
case 'initialize':
|
|
869
|
-
return _ok(id, {
|
|
870
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
871
|
-
capabilities: { tools: {} },
|
|
872
|
-
serverInfo: {
|
|
873
|
-
name: SERVER_NAME,
|
|
874
|
-
version: SERVER_VERSION,
|
|
875
|
-
codeFingerprint: CODE_FINGERPRINT,
|
|
876
|
-
disabled,
|
|
877
|
-
},
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
case 'notifications/initialized':
|
|
881
|
-
return null;
|
|
882
|
-
|
|
883
|
-
case 'ping':
|
|
884
|
-
return _ok(id, {});
|
|
885
|
-
|
|
886
|
-
case 'tools/list':
|
|
887
|
-
return _ok(id, {
|
|
888
|
-
tools: ALL_TOOLS.map(t => ({
|
|
889
|
-
name: t.name,
|
|
890
|
-
description: t.description,
|
|
891
|
-
inputSchema: t.inputSchema,
|
|
892
|
-
})),
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
case 'tools/call': {
|
|
896
|
-
const name = msg.params?.name;
|
|
897
|
-
const args = msg.params?.arguments ?? {};
|
|
898
|
-
if (disabled) {
|
|
899
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
|
|
900
|
-
return _ok(id, {
|
|
901
|
-
content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
|
|
902
|
-
isError: true,
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
const tool = TOOLS_BY_NAME[name];
|
|
906
|
-
if (!tool) {
|
|
907
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
|
|
908
|
-
return _err(id, -32602, `Unknown tool: ${name}`);
|
|
909
|
-
}
|
|
910
|
-
try { validate(tool.inputSchema, args); }
|
|
911
|
-
catch (e) {
|
|
912
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
|
|
913
|
-
return _ok(id, {
|
|
914
|
-
content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
|
|
915
|
-
isError: true,
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
const result = await tool.handler(args, ctx);
|
|
920
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'ok' });
|
|
921
|
-
return _ok(id, {
|
|
922
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
923
|
-
isError: false,
|
|
924
|
-
});
|
|
925
|
-
} catch (e) {
|
|
926
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
|
|
927
|
-
return _ok(id, {
|
|
928
|
-
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
929
|
-
isError: true,
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
default:
|
|
935
|
-
if (isNotification) return null;
|
|
936
|
-
return _err(id, -32601, `Method not found: ${msg.method}`);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
return { handleRequest, sessionRoot };
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// NOTE: no default-singleton export. Callers must use createServer({...})
|
|
944
|
-
// with an explicit sessionRoot. Removed because the prior default was bound
|
|
945
|
-
// to process.cwd() at module-load time — a footgun for any caller that
|
|
946
|
-
// imported `handleRequest` directly (OWASP A05).
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
;// CONCATENATED MODULE: ./src/mcp/stdio.js
|
|
951
|
-
// Stdio transport for the MCP server — newline-delimited JSON in/out.
|
|
952
|
-
//
|
|
953
|
-
// MCP's stdio transport is NDJSON: one JSON-RPC message per line on stdin,
|
|
954
|
-
// one response per line on stdout. stderr is reserved for logging.
|
|
955
|
-
//
|
|
956
|
-
// Hardening:
|
|
957
|
-
// - Per-message line cap (MAX_LINE_BYTES). A line over the cap is dropped
|
|
958
|
-
// and the buffer state is reset so a long oversize payload can't peg
|
|
959
|
-
// the parser via `buf += chunk` growth.
|
|
960
|
-
// - Buffer hard cap (MAX_BUFFER_BYTES). Reached if input arrives with no
|
|
961
|
-
// newlines (e.g., a peer streaming a 4GB stream of `a`). On overflow we
|
|
962
|
-
// emit a parse-error response and reset.
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const MAX_LINE_BYTES = 4 * 1024 * 1024; // 4 MB per JSON-RPC message
|
|
967
|
-
const MAX_BUFFER_BYTES = 8 * 1024 * 1024; // 8 MB sliding buffer
|
|
968
|
-
|
|
969
|
-
function runStdio({
|
|
970
|
-
stdin = process.stdin,
|
|
971
|
-
stdout = process.stdout,
|
|
972
|
-
stderr = process.stderr,
|
|
973
|
-
sessionRoot = process.cwd(),
|
|
974
|
-
} = {}) {
|
|
975
|
-
const server = createServer({ sessionRoot });
|
|
976
|
-
let buf = '';
|
|
977
|
-
let overflowSkip = false; // true while we are dropping bytes until the next newline
|
|
978
|
-
|
|
979
|
-
stdin.setEncoding('utf8');
|
|
980
|
-
|
|
981
|
-
stdin.on('data', async (chunk) => {
|
|
982
|
-
if (overflowSkip) {
|
|
983
|
-
const nl = chunk.indexOf('\n');
|
|
984
|
-
if (nl === -1) return;
|
|
985
|
-
// Resume after the next newline.
|
|
986
|
-
chunk = chunk.slice(nl + 1);
|
|
987
|
-
overflowSkip = false;
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
buf += chunk;
|
|
991
|
-
|
|
992
|
-
// Hard buffer cap — only triggers if a peer is streaming without newlines.
|
|
993
|
-
if (buf.length > MAX_BUFFER_BYTES) {
|
|
994
|
-
stderr.write(`mcp: input buffer exceeded ${MAX_BUFFER_BYTES} bytes — dropping until next newline\n`);
|
|
995
|
-
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: input too large' } };
|
|
996
|
-
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
997
|
-
buf = '';
|
|
998
|
-
overflowSkip = true;
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
let nl;
|
|
1003
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
1004
|
-
const line = buf.slice(0, nl).trim();
|
|
1005
|
-
buf = buf.slice(nl + 1);
|
|
1006
|
-
if (!line) continue;
|
|
1007
|
-
if (line.length > MAX_LINE_BYTES) {
|
|
1008
|
-
stderr.write(`mcp: dropped oversize line (${line.length} > ${MAX_LINE_BYTES} bytes)\n`);
|
|
1009
|
-
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: line too large' } };
|
|
1010
|
-
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
let msg;
|
|
1014
|
-
try { msg = JSON.parse(line); }
|
|
1015
|
-
catch (e) {
|
|
1016
|
-
stderr.write(`mcp: failed to parse line as JSON: ${e.message}\n`);
|
|
1017
|
-
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
|
|
1018
|
-
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
1019
|
-
continue;
|
|
1020
|
-
}
|
|
1021
|
-
try {
|
|
1022
|
-
const response = await server.handleRequest(msg);
|
|
1023
|
-
if (response !== null) stdout.write(JSON.stringify(response) + '\n');
|
|
1024
|
-
} catch (e) {
|
|
1025
|
-
stderr.write(`mcp: handler threw: ${e.message}\n`);
|
|
1026
|
-
const errResponse = { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: 'Internal error', data: e.message } };
|
|
1027
|
-
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
});
|
|
1031
|
-
|
|
1032
|
-
stdin.on('end', () => { process.exit(0); });
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
/***/ })
|
|
1037
|
-
|
|
1038
|
-
};
|