residoo 0.21.0 → 0.23.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/package.json +1 -1
- package/src/cli.js +31 -3
- package/src/cve.js +278 -0
- package/src/injection.js +139 -0
- package/src/integrity.js +161 -1
- package/src/mcpTools.js +17 -13
- package/src/report.js +5 -1
- package/src/rotation.js +37 -0
- package/src/scan.js +44 -1
- package/src/watch.js +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CloudRoam (https://cloudroam.io)",
|
package/src/cli.js
CHANGED
|
@@ -165,6 +165,27 @@ Scan options:
|
|
|
165
165
|
excludes bare email/phone (too common in
|
|
166
166
|
ordinary text to meet this
|
|
167
167
|
project's own high-confidence bar even opt-in).
|
|
168
|
+
--include-injection also scan transcript content for prompt-
|
|
169
|
+
injection signatures: special/role-token
|
|
170
|
+
sequences (<|im_start|>, [INST], <<SYS>>, and
|
|
171
|
+
similar -- the control tokens an attacker can
|
|
172
|
+
smuggle into fetched content to make a model
|
|
173
|
+
treat it as a privileged turn instead of
|
|
174
|
+
untrusted data) and hidden instructions carried
|
|
175
|
+
by invisible Unicode. A third RISK CATEGORY,
|
|
176
|
+
neither a credential nor PII: this looks for
|
|
177
|
+
evidence an injection attempt already reached
|
|
178
|
+
the agent, in the same at-rest transcript
|
|
179
|
+
content every other pass scans -- not a
|
|
180
|
+
static-analysis check of an application's own
|
|
181
|
+
prompt-construction code (that's a different,
|
|
182
|
+
much bigger product; see docs/comparison.md).
|
|
183
|
+
Combine with --include-noisy for a small set of
|
|
184
|
+
canonical override phrases ("ignore previous
|
|
185
|
+
instructions" and close variants) -- disclosed
|
|
186
|
+
as genuinely heuristic and prone to matching a
|
|
187
|
+
security-research conversation about this exact
|
|
188
|
+
technique, not a solved detection problem.
|
|
168
189
|
|
|
169
190
|
Watch:
|
|
170
191
|
residoo watch continuous scanning instead of one snapshot:
|
|
@@ -182,7 +203,8 @@ Watch:
|
|
|
182
203
|
--verify same opt-in vendor check as scan --verify,
|
|
183
204
|
applied to each newly found credential once,
|
|
184
205
|
never to one already seen
|
|
185
|
-
--include-noisy, --include-suppressed, --include-pii,
|
|
206
|
+
--include-noisy, --include-suppressed, --include-pii,
|
|
207
|
+
--include-injection, --no-color
|
|
186
208
|
same meaning as scan
|
|
187
209
|
--no-notify skip the OS desktop notification watch fires for
|
|
188
210
|
each genuinely new finding (macOS via osascript,
|
|
@@ -774,6 +796,7 @@ async function runWatch(args) {
|
|
|
774
796
|
const verify = args.includes("--verify");
|
|
775
797
|
const noColor = args.includes("--no-color");
|
|
776
798
|
const includePii = args.includes("--include-pii");
|
|
799
|
+
const includeInjection = args.includes("--include-injection");
|
|
777
800
|
const noNotify = args.includes("--no-notify");
|
|
778
801
|
|
|
779
802
|
let intervalSeconds = 5;
|
|
@@ -800,7 +823,7 @@ async function runWatch(args) {
|
|
|
800
823
|
|
|
801
824
|
const { promise, stop } = startWatch({
|
|
802
825
|
sources,
|
|
803
|
-
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
826
|
+
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
804
827
|
});
|
|
805
828
|
|
|
806
829
|
const printFinalSummary = (stats) => {
|
|
@@ -1098,6 +1121,11 @@ async function main(argv) {
|
|
|
1098
1121
|
// card numbers, IBAN) rather than the shape-only, much noisier
|
|
1099
1122
|
// categories (bare email, phone) some competitors also ship.
|
|
1100
1123
|
const wantsPii = args.includes("--include-pii");
|
|
1124
|
+
// --include-injection: a third, separate risk category from either of the
|
|
1125
|
+
// above (see injection.js) -- detects a realized prompt-injection
|
|
1126
|
+
// signature already sitting in transcript content, not a credential or
|
|
1127
|
+
// personal data.
|
|
1128
|
+
const wantsInjection = args.includes("--include-injection");
|
|
1101
1129
|
|
|
1102
1130
|
// --project [dir]: the dir is optional (CI passes ".", a bare --project
|
|
1103
1131
|
// means the current directory). null means machine mode.
|
|
@@ -1209,7 +1237,7 @@ async function main(argv) {
|
|
|
1209
1237
|
|
|
1210
1238
|
const progress = makeProgressReporter(noColor);
|
|
1211
1239
|
const result = await scan({
|
|
1212
|
-
sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii,
|
|
1240
|
+
sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii, includeInjection: wantsInjection,
|
|
1213
1241
|
onProgress: progress.onProgress,
|
|
1214
1242
|
// Clears the spinner's last frame before --verify's own stderr lines
|
|
1215
1243
|
// print; without this the last spinner line sits uncleared on screen
|
package/src/cve.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small, hand-curated, dated table of published CVEs affecting the
|
|
5
|
+
* MCP/AI-agent ecosystem, plus a minimal version-range matcher -- the
|
|
6
|
+
* shared infrastructure behind `residoo scan`'s MCP-config CVE check
|
|
7
|
+
* (integrity.js's "MCP server configuration risks" section) and a future
|
|
8
|
+
* `--project` dependency-manifest check.
|
|
9
|
+
*
|
|
10
|
+
* SOURCE, stated precisely because it matters: every entry below was
|
|
11
|
+
* fetched directly from GitHub's own Security Advisory REST API
|
|
12
|
+
* (api.github.com/advisories?ecosystem=<npm|pip>&affects=<package>) on
|
|
13
|
+
* 2026-09-05 -- a primary, authoritative source (GHSA-reviewed, each
|
|
14
|
+
* entry carrying its own CVE id, GHSA id, and vulnerable/patched version
|
|
15
|
+
* range), not a secondary aggregator's summary. An earlier draft of this
|
|
16
|
+
* table was built from a dedicated MCP-CVE tracking site
|
|
17
|
+
* (vulnerablemcp.info) and cross-checked against this API before
|
|
18
|
+
* shipping; every version range below is the API's own
|
|
19
|
+
* `vulnerable_version_range` field, not a re-derived or estimated one.
|
|
20
|
+
* Package NAMES were independently confirmed to exist on the real npm/
|
|
21
|
+
* PyPI registries (`npm view <pkg>`, PyPI's own JSON API) before being
|
|
22
|
+
* added here -- a wrong package name would silently never match anything,
|
|
23
|
+
* the same "never a false all-clear" concern CONTRIBUTING.md states for
|
|
24
|
+
* source paths, applied to this table instead.
|
|
25
|
+
*
|
|
26
|
+
* DELIBERATELY SMALL, on purpose: this is ~24 entries across 10 packages,
|
|
27
|
+
* not a claim of exhaustive coverage the way Medusa's own "~200 CVEs" is.
|
|
28
|
+
* Every single entry here is individually traceable to a specific GHSA/
|
|
29
|
+
* CVE id and a real advisory -- the same "84 high-confidence rules beat a
|
|
30
|
+
* competitor's higher rule count" trade-off already proven on this
|
|
31
|
+
* project's own benchmark (see bench/RESULTS.md), applied to CVE data
|
|
32
|
+
* instead of secret patterns. A welcome follow-up PR is adding more
|
|
33
|
+
* packages through the same API query, cited the same way -- never by
|
|
34
|
+
* guessing a plausible-sounding range.
|
|
35
|
+
*
|
|
36
|
+
* VERSION COMPARISON: a minimal, hand-written major.minor.patch numeric
|
|
37
|
+
* comparator -- NOT a full semver-range grammar (no ^, ~, prerelease
|
|
38
|
+
* tags, build metadata, x-ranges). Sufficient for every range in this
|
|
39
|
+
* table, including the date-based versioning scheme
|
|
40
|
+
* `@modelcontextprotocol/server-filesystem` switched to mid-2025
|
|
41
|
+
* (`2025.1.14`, `2025.7.1`) -- verified directly that comparing
|
|
42
|
+
* `[2025,1,20]` against `[2025,7,1]` component-by-component gives the
|
|
43
|
+
* correct ordering, the same way it would for an ordinary semver triple.
|
|
44
|
+
* A version string this comparator can't parse (a prerelease suffix, a
|
|
45
|
+
* git-hash pseudo-version, anything non-numeric) is reported as
|
|
46
|
+
* "cannot determine" by the caller, never silently treated as either safe
|
|
47
|
+
* or vulnerable -- CONTRIBUTING.md's rule 5 applied to a version string
|
|
48
|
+
* instead of a file.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const CVE_DATABASE = [
|
|
52
|
+
// ---- npm ----
|
|
53
|
+
{
|
|
54
|
+
id: "CVE-2025-6514", ecosystem: "npm", package: "mcp-remote", severity: "critical",
|
|
55
|
+
ranges: [{ min: "0.0.5", maxExclusive: "0.1.16" }],
|
|
56
|
+
summary: "OS command injection via a crafted authorization_endpoint response URL when connecting to an untrusted MCP server.",
|
|
57
|
+
source: "GHSA-6xpm-ggf7-wc3p",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "CVE-2025-58444", ecosystem: "npm", package: "@modelcontextprotocol/inspector", severity: "high",
|
|
61
|
+
ranges: [{ maxExclusive: "0.16.6" }],
|
|
62
|
+
summary: "Potential command execution via XSS when Inspector connects to an untrusted MCP server.",
|
|
63
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "CVE-2025-49596", ecosystem: "npm", package: "@modelcontextprotocol/inspector", severity: "critical",
|
|
67
|
+
ranges: [{ maxExclusive: "0.14.1" }],
|
|
68
|
+
summary: "The Inspector proxy server lacks authentication between the Inspector client and the proxy, enabling RCE.",
|
|
69
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "CVE-2026-23744", ecosystem: "npm", package: "@mcpjam/inspector", severity: "critical",
|
|
73
|
+
ranges: [{ maxInclusive: "1.4.2" }],
|
|
74
|
+
summary: "Remote code execution via an exposed, unauthenticated HTTP endpoint.",
|
|
75
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "CVE-2025-65513", ecosystem: "npm", package: "mcp-fetch-server", severity: "medium",
|
|
79
|
+
ranges: [{ maxInclusive: "1.0.2" }],
|
|
80
|
+
summary: "Server-Side Request Forgery (SSRF) vulnerability.",
|
|
81
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "CVE-2025-53372", ecosystem: "npm", package: "node-code-sandbox-mcp", severity: "high",
|
|
85
|
+
ranges: [{ maxInclusive: "1.2.0" }],
|
|
86
|
+
summary: "Sandbox escape via command injection.",
|
|
87
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "CVE-2026-47250", ecosystem: "npm", package: "mcp-server-kubernetes", severity: "medium",
|
|
91
|
+
ranges: [{ maxInclusive: "3.6.2" }],
|
|
92
|
+
summary: "kubectl-generic flag injection enables Kubernetes bearer-token exfiltration.",
|
|
93
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "CVE-2026-46519", ecosystem: "npm", package: "mcp-server-kubernetes", severity: "high",
|
|
97
|
+
ranges: [{ maxExclusive: "3.6.0" }],
|
|
98
|
+
summary: "Tool access control bypass via presentation-layer filtering with no execution-layer enforcement.",
|
|
99
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "CVE-2026-39884", ecosystem: "npm", package: "mcp-server-kubernetes", severity: "high",
|
|
103
|
+
ranges: [{ maxInclusive: "3.4.0" }],
|
|
104
|
+
summary: "Argument injection in the port_forward tool via space-splitting.",
|
|
105
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "CVE-2025-66404", ecosystem: "npm", package: "mcp-server-kubernetes", severity: "medium",
|
|
109
|
+
ranges: [{ maxInclusive: "2.9.7" }],
|
|
110
|
+
summary: "Security issue in the exec_in_pod tool.",
|
|
111
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "CVE-2025-53355", ecosystem: "npm", package: "mcp-server-kubernetes", severity: "high",
|
|
115
|
+
ranges: [{ maxExclusive: "2.5.0" }],
|
|
116
|
+
summary: "Command injection in several tools.",
|
|
117
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "CVE-2026-25536", ecosystem: "npm", package: "@modelcontextprotocol/sdk", severity: "high",
|
|
121
|
+
ranges: [{ min: "1.10.0", maxInclusive: "1.25.3" }],
|
|
122
|
+
summary: "Cross-client data leak via shared server/transport instance reuse.",
|
|
123
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "CVE-2026-0621", ecosystem: "npm", package: "@modelcontextprotocol/sdk", severity: "high",
|
|
127
|
+
ranges: [{ min: "1.3.0", maxExclusive: "1.25.2" }],
|
|
128
|
+
summary: "ReDoS (regular expression denial of service) vulnerability.",
|
|
129
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "CVE-2025-66414", ecosystem: "npm", package: "@modelcontextprotocol/sdk", severity: "high",
|
|
133
|
+
ranges: [{ maxExclusive: "1.24.0" }],
|
|
134
|
+
summary: "DNS rebinding protection not enabled by default on localhost-bound SSE/StreamableHTTP servers.",
|
|
135
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "CVE-2025-53110", ecosystem: "npm", package: "@modelcontextprotocol/server-filesystem", severity: "high",
|
|
139
|
+
ranges: [{ maxInclusive: "0.6.2" }, { min: "2025.1.14", maxExclusive: "2025.7.1" }],
|
|
140
|
+
summary: "Path validation bypass via a colliding path prefix.",
|
|
141
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "CVE-2025-53109", ecosystem: "npm", package: "@modelcontextprotocol/server-filesystem", severity: "high",
|
|
145
|
+
ranges: [{ maxInclusive: "0.6.2" }, { min: "2025.1.14", maxExclusive: "2025.7.1" }],
|
|
146
|
+
summary: "Path validation bypass via prefix matching and symlink handling.",
|
|
147
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// ---- pip ----
|
|
151
|
+
{
|
|
152
|
+
id: "CVE-2026-59950", ecosystem: "pip", package: "mcp", severity: "high",
|
|
153
|
+
ranges: [{ maxExclusive: "1.28.1" }],
|
|
154
|
+
summary: "MCP Python SDK: WebSocket server transport does not support Host/Origin validation.",
|
|
155
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "CVE-2026-52869", ecosystem: "pip", package: "mcp", severity: "high",
|
|
159
|
+
ranges: [{ maxInclusive: "1.27.1" }],
|
|
160
|
+
summary: "MCP Python SDK: HTTP transports serve session requests without verifying the authenticated principal.",
|
|
161
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "CVE-2026-52870", ecosystem: "pip", package: "mcp", severity: "high",
|
|
165
|
+
ranges: [{ min: "1.23.0", maxInclusive: "1.27.1" }],
|
|
166
|
+
summary: "Experimental task handlers allow any client to access and cancel other clients' tasks.",
|
|
167
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "CVE-2025-66416", ecosystem: "pip", package: "mcp", severity: "high",
|
|
171
|
+
ranges: [{ maxExclusive: "1.23.0" }],
|
|
172
|
+
summary: "DNS rebinding protection not enabled by default on localhost-bound SSE/StreamableHTTP servers.",
|
|
173
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "CVE-2025-53366", ecosystem: "pip", package: "mcp", severity: "high",
|
|
177
|
+
ranges: [{ maxExclusive: "1.9.4" }],
|
|
178
|
+
summary: "FastMCP Server validation error leading to denial of service.",
|
|
179
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "CVE-2025-53365", ecosystem: "pip", package: "mcp", severity: "high",
|
|
183
|
+
ranges: [{ maxExclusive: "1.10.0" }],
|
|
184
|
+
summary: "Unhandled exception in the Streamable HTTP transport, leading to denial of service.",
|
|
185
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "CVE-2026-27735", ecosystem: "pip", package: "mcp-server-git", severity: "medium",
|
|
189
|
+
ranges: [{ maxExclusive: "2026.1.14" }],
|
|
190
|
+
summary: "Path traversal in git_add allows staging files outside the repository boundary.",
|
|
191
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "CVE-2025-68145", ecosystem: "pip", package: "mcp-server-git", severity: "medium",
|
|
195
|
+
ranges: [{ maxExclusive: "2025.12.18" }],
|
|
196
|
+
summary: "Missing path validation when using the --repository flag.",
|
|
197
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "CVE-2025-68144", ecosystem: "pip", package: "mcp-server-git", severity: "medium",
|
|
201
|
+
ranges: [{ maxExclusive: "2025.12.18" }],
|
|
202
|
+
summary: "Argument injection in git_diff and git_checkout allows overwriting local files.",
|
|
203
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: "CVE-2025-68143", ecosystem: "pip", package: "mcp-server-git", severity: "medium",
|
|
207
|
+
ranges: [{ maxExclusive: "2025.9.25" }],
|
|
208
|
+
summary: "Unrestricted git_init tool allows repository creation at arbitrary filesystem locations.",
|
|
209
|
+
source: "GHSA (fetched via api.github.com/advisories)",
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse a version string's leading major.minor.patch numeric triple.
|
|
215
|
+
* Returns null for anything this simple comparator can't handle (a
|
|
216
|
+
* prerelease/build-metadata suffix, a git-hash pseudo-version, a bare "1"
|
|
217
|
+
* or "1.2" with no patch component) -- the caller must treat null as
|
|
218
|
+
* "cannot determine," never as a match or a clean bill of health.
|
|
219
|
+
*/
|
|
220
|
+
function parseVersion(v) {
|
|
221
|
+
// `String(v)` itself can throw -- an object whose own `toString` property
|
|
222
|
+
// is present but not a function (e.g. `{ toString: "" }`, a real shape
|
|
223
|
+
// fast-check's property fuzzing found, not a hypothetical) fails the
|
|
224
|
+
// ToPrimitive coercion with "Cannot convert object to primitive value"
|
|
225
|
+
// before the regex ever runs. `v` here can be anything an attacker-
|
|
226
|
+
// controlled MCP config's `args` array contains, so this must degrade to
|
|
227
|
+
// "unparseable" the same as any other malformed input, never throw.
|
|
228
|
+
let s;
|
|
229
|
+
try { s = String(v).trim(); } catch { return null; }
|
|
230
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(s);
|
|
231
|
+
if (!m) return null;
|
|
232
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** -1 / 0 / 1, comparing two parseVersion() triples component-by-component. */
|
|
236
|
+
function compareVersions(a, b) {
|
|
237
|
+
for (let i = 0; i < 3; i++) {
|
|
238
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function inOneRange(parsed, range) {
|
|
244
|
+
if (range.min) {
|
|
245
|
+
const min = parseVersion(range.min);
|
|
246
|
+
if (min && compareVersions(parsed, min) < 0) return false;
|
|
247
|
+
}
|
|
248
|
+
if (range.maxInclusive) {
|
|
249
|
+
const max = parseVersion(range.maxInclusive);
|
|
250
|
+
if (max && compareVersions(parsed, max) > 0) return false;
|
|
251
|
+
}
|
|
252
|
+
if (range.maxExclusive) {
|
|
253
|
+
const max = parseVersion(range.maxExclusive);
|
|
254
|
+
if (max && compareVersions(parsed, max) >= 0) return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check one resolved `packageName`@`version` pair (ecosystem "npm" or
|
|
261
|
+
* "pip") against CVE_DATABASE. Returns `{ checkable, matches }` --
|
|
262
|
+
* `checkable: false` means the version string couldn't be parsed at all
|
|
263
|
+
* (report "cannot determine," never silently clean); `matches` is every
|
|
264
|
+
* CVE_DATABASE entry whose package matches and whose vulnerable range
|
|
265
|
+
* (any one of its, possibly several, disjoint ranges) contains this
|
|
266
|
+
* version.
|
|
267
|
+
*/
|
|
268
|
+
function checkVersion(ecosystem, packageName, version) {
|
|
269
|
+
const parsed = parseVersion(version);
|
|
270
|
+
if (!parsed) return { checkable: false, matches: [] };
|
|
271
|
+
const matches = CVE_DATABASE.filter((entry) =>
|
|
272
|
+
entry.ecosystem === ecosystem &&
|
|
273
|
+
entry.package === packageName &&
|
|
274
|
+
entry.ranges.some((r) => inOneRange(parsed, r)));
|
|
275
|
+
return { checkable: true, matches };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { CVE_DATABASE, parseVersion, compareVersions, checkVersion };
|
package/src/injection.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { scanZeroWidth } = require("./integrity");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prompt-injection signature detection, applied to the SAME transcript
|
|
7
|
+
* content every other pass already reads (tool_result blocks, fetched-page
|
|
8
|
+
* text, file contents an agent read, ordinary message text) -- no new
|
|
9
|
+
* source, no new file walk, just a second/third rule set matched against
|
|
10
|
+
* lines scan.js already has in memory. Opt-in via `--include-injection`,
|
|
11
|
+
* the same "different risk category, not a lower-confidence secret"
|
|
12
|
+
* reasoning pii.js's own header states for `--include-pii`.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT, stated up front because it is the single most important
|
|
15
|
+
* scope distinction here: this is NOT a static-analysis scanner for an LLM
|
|
16
|
+
* APPLICATION'S OWN SOURCE CODE (an f-string concatenating user input into a
|
|
17
|
+
* prompt, unsanitized external content reaching a prompt template). That is
|
|
18
|
+
* a real, different product -- it's what Medusa's own PI-SCAN does (checked
|
|
19
|
+
* directly against Medusa's own docs/AI_SECURITY.md, fetched 2026-09-05:
|
|
20
|
+
* "Direct Injection: f-string interpolation with user_input... Indirect
|
|
21
|
+
* Injection: External content fetched and embedded in prompts without
|
|
22
|
+
* sanitization" -- both examples are about auditing an application's PROMPT
|
|
23
|
+
* -CONSTRUCTION code for a latent vulnerability class). residoo has no
|
|
24
|
+
* access to that code and isn't built to read it; what residoo already has,
|
|
25
|
+
* uniquely, is the agent's own TRANSCRIPT -- a record of what actually got
|
|
26
|
+
* fed to a live agent. So this module detects INJECTION PAYLOADS THAT
|
|
27
|
+
* ALREADY REACHED AN AGENT, sitting in the same at-rest data every other
|
|
28
|
+
* residoo pass scans -- a genuinely different, arguably more valuable
|
|
29
|
+
* signal (a realized attempt, not a hypothetical vulnerable code path), not
|
|
30
|
+
* an attempt to clone Medusa's SAST feature with a worse implementation.
|
|
31
|
+
*
|
|
32
|
+
* SIGNAL SOURCES, verified 2026-09-05:
|
|
33
|
+
*
|
|
34
|
+
* - **Special/role-token injection**: `<|im_start|>`, `<|im_end|>`,
|
|
35
|
+
* `<|system|>`, `<|user|>`, `<|assistant|>`, `<|endoftext|>`,
|
|
36
|
+
* `<|endofprompt|>`, `[INST]`/`[/INST]`, `<<SYS>>`/`<</SYS>>` -- the
|
|
37
|
+
* control tokens chat-templated models use to delineate a message's
|
|
38
|
+
* ROLE. An attacker who gets one of these into content an agent reads
|
|
39
|
+
* (a fetched webpage, a file, a tool's output) can, on a vulnerable
|
|
40
|
+
* serving pipeline, make the model treat injected text as a new
|
|
41
|
+
* system/assistant turn rather than untrusted data. This is a named,
|
|
42
|
+
* real technique -- "Special Token Injection" (Sentry's own STI attack
|
|
43
|
+
* guide, blog.sentry.security/special-token-injection-sti-attack-guide,
|
|
44
|
+
* fetched directly: "the model expects certain token patterns to
|
|
45
|
+
* signify roles... if the... pipeline does not properly filter or
|
|
46
|
+
* escape these sequences, an attacker's input will reach the model...
|
|
47
|
+
* analogous to injecting a SQL query via an input field"), corroborated
|
|
48
|
+
* by OWASP's LLM01 Prompt Injection entry (genai.owasp.org) and a 2026
|
|
49
|
+
* arXiv paper specifically on chat-template abuse for indirect
|
|
50
|
+
* injection ("ChatInject: Abusing Chat Templates for Prompt Injection
|
|
51
|
+
* in LLM Agents," arxiv.org/abs/2509.22830) -- not one vendor's
|
|
52
|
+
* unverified claim. Medusa's own docs name this same technique family
|
|
53
|
+
* ("Code-Level Prompt Injection... ChatML tokens, role manipulation"),
|
|
54
|
+
* confirming it's a real, converged-upon signal, not something invented
|
|
55
|
+
* here. HIGH confidence: these exact token strings essentially never
|
|
56
|
+
* appear in ordinary prose or code by accident -- the honest, disclosed
|
|
57
|
+
* exception is a message that *discusses* these tokens by name (a
|
|
58
|
+
* tokenizer bug report, this very file's own docstring) rather than
|
|
59
|
+
* attempting to use them, the same "a real key a user pasted to ask
|
|
60
|
+
* about it" false-positive class patterns.js's private_key_block rule
|
|
61
|
+
* already carries.
|
|
62
|
+
* - **Hidden/invisible Unicode**: reuses `scanZeroWidth` from
|
|
63
|
+
* `integrity.js` verbatim (see that function's own docstring for the
|
|
64
|
+
* TrapDoor campaign citation and the always-suspicious/context-
|
|
65
|
+
* dependent tiering) -- extended here to every line of every
|
|
66
|
+
* transcript this project reads, not only the fixed CLAUDE.md/memory-
|
|
67
|
+
* file locations `checkIntegrity` already covers. This closes a real
|
|
68
|
+
* gap in the existing coverage: a hidden instruction delivered via a
|
|
69
|
+
* fetched web page or a tool's own output lands in ordinary transcript
|
|
70
|
+
* content, not in one of `checkIntegrity`'s known config paths, so the
|
|
71
|
+
* existing check cannot see it.
|
|
72
|
+
*
|
|
73
|
+
* NOISY_INJECTION_PATTERNS (opt-in ADDITIONALLY via `--include-noisy`,
|
|
74
|
+
* exactly mirroring patterns.js's own NOISY_PATTERNS contract -- "broader,
|
|
75
|
+
* shape-based patterns that catch more but false-positive more often"):
|
|
76
|
+
* a small set of the most-cited canonical instruction-override phrases
|
|
77
|
+
* ("ignore previous instructions" and its close variants). Disclosed
|
|
78
|
+
* plainly, not glossed over: phrase-based matching is genuinely prone to
|
|
79
|
+
* matching a security-research conversation, a GitHub issue about prompt
|
|
80
|
+
* injection, or this very codebase's own documentation discussing the
|
|
81
|
+
* technique -- OWASP's own LLM01 page and multiple practitioner write-ups
|
|
82
|
+
* (Simon Willison's "prompt injection" writing among them) describe
|
|
83
|
+
* reliable phrase-based detection as an open, unsolved problem, not
|
|
84
|
+
* something this rule set claims to have solved. LOW confidence, never
|
|
85
|
+
* part of the default report, for exactly that reason.
|
|
86
|
+
*
|
|
87
|
+
* WHAT THIS DOES NOT COVER, stated rather than silently gapped: tool-
|
|
88
|
+
* DESCRIPTION poisoning (a malicious MCP server changing a tool's
|
|
89
|
+
* description after approval, "rug-pull") is a real, named technique
|
|
90
|
+
* (Medusa's own "Tool Poisoning (MCP101)") that this module cannot check,
|
|
91
|
+
* because a tool's description is part of the MCP protocol payload sent to
|
|
92
|
+
* the model at request time, not something Claude Code's own transcript
|
|
93
|
+
* JSONL logs — verified directly against a real transcript on this
|
|
94
|
+
* project's own build machine: a `tool_use` record for an
|
|
95
|
+
* `mcp__`-namespaced tool carries only `{name, input}`, never the tool's
|
|
96
|
+
* description or input schema. Checking that would require a live MCP
|
|
97
|
+
* client connection to query `tools/list`, a fundamentally different
|
|
98
|
+
* architecture (an active protocol client, not a file scanner) that this
|
|
99
|
+
* project has not built and is not attempting to fake here.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
const CHATML_TOKEN_RE = /<\|(?:im_start|im_end|system|user|assistant|endoftext|endofprompt)\|>|\[\/?INST\]|<<\/?SYS>>/g;
|
|
103
|
+
|
|
104
|
+
const INJECTION_PATTERNS = [
|
|
105
|
+
{ id: "chatml_special_token", label: "Special/role-token injection (ChatML or similar)", confidence: "high" },
|
|
106
|
+
{ id: "zero_width_hidden_instruction", label: "Hidden instruction carried by invisible Unicode", confidence: "high" },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const NOISY_INJECTION_PATTERNS = [
|
|
110
|
+
{
|
|
111
|
+
id: "injection_override_phrase", label: "Instruction-override phrase (heuristic)", confidence: "low",
|
|
112
|
+
// Deliberately narrow: the small set of phrasings cited across OWASP's
|
|
113
|
+
// LLM01 page and independent practitioner write-ups as the canonical
|
|
114
|
+
// "ignore what came before" injection framing, not an attempt at
|
|
115
|
+
// exhaustive jailbreak-phrase coverage (see module docstring on why
|
|
116
|
+
// phrase-based detection stays opt-in and low-confidence).
|
|
117
|
+
re: /\b(?:ignore|disregard)\s+(?:all\s+|any\s+)?(?:the\s+|your\s+)?(?:previous|prior|above|earlier)\s+instructions\b|\bforget\s+(?:everything|all)\s+(?:above|before\s+this)\b/gi,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Minimal per-line invisible-character summary: codepoint name + count,
|
|
123
|
+
* no line-number list (unlike integrity.js's summarizeZeroWidth, which is
|
|
124
|
+
* built for a whole-file, many-line summary) -- the caller already has the
|
|
125
|
+
* real line number for this one call, so repeating it here would just be
|
|
126
|
+
* confusing "(line 1)" noise from scanZeroWidth's own internal, line-blind
|
|
127
|
+
* counting of a single line with no embedded newline.
|
|
128
|
+
*/
|
|
129
|
+
function summarizeInvisibles(hits) {
|
|
130
|
+
const byCp = new Map();
|
|
131
|
+
for (const h of hits) byCp.set(h.cp, (byCp.get(h.cp) || 0) + 1);
|
|
132
|
+
const parts = [];
|
|
133
|
+
for (const [cp, count] of byCp) {
|
|
134
|
+
parts.push("U+" + cp.toString(16).toUpperCase().padStart(4, "0") + " ×" + count);
|
|
135
|
+
}
|
|
136
|
+
return parts.join(", ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { INJECTION_PATTERNS, NOISY_INJECTION_PATTERNS, CHATML_TOKEN_RE, summarizeInvisibles };
|
package/src/integrity.js
CHANGED
|
@@ -10,6 +10,7 @@ const os = require("os");
|
|
|
10
10
|
// reference at import time and never see a later patch.
|
|
11
11
|
const cp = require("child_process");
|
|
12
12
|
const { PATTERNS, redact } = require("./patterns");
|
|
13
|
+
const { checkVersion } = require("./cve");
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Integrity checks for agent config directories.
|
|
@@ -386,6 +387,100 @@ function extractHooks(parsed) {
|
|
|
386
387
|
return { hooks, unrecognized, hadHooksKey: true, truncated, sawLeaf };
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
// ── MCP server configuration risk checks ────────────────────────────────
|
|
391
|
+
//
|
|
392
|
+
// Distinct from agent-configs.js (which scans these same files' raw TEXT
|
|
393
|
+
// for leaked secrets) and from injection.js (which scans transcript
|
|
394
|
+
// CONTENT for injection payloads): this parses the STRUCTURED
|
|
395
|
+
// `mcpServers` object every MCP client config converges on
|
|
396
|
+
// (`{"mcpServers": {"<name>": {"command", "args", "env", "url"}}}` --
|
|
397
|
+
// Claude Code, Claude Desktop, Cursor, and Visual Studio's own docs all
|
|
398
|
+
// document this identical shape, already cited in agent-configs.js's own
|
|
399
|
+
// header) and checks it for two named, real risk classes:
|
|
400
|
+
//
|
|
401
|
+
// 1. A known-vulnerable version of a specific MCP-ecosystem package
|
|
402
|
+
// pinned in the server's launch command (see cve.js).
|
|
403
|
+
// 2. A remote (non-loopback) server configured over plain HTTP instead
|
|
404
|
+
// of HTTPS -- MCP protocol traffic, including tool definitions,
|
|
405
|
+
// crossing a real network in cleartext.
|
|
406
|
+
//
|
|
407
|
+
// NOT checked, disclosed rather than silently gapped (see injection.js's
|
|
408
|
+
// own header for the fuller version of this point): a malicious server
|
|
409
|
+
// changing a TOOL'S OWN DESCRIPTION after approval ("tool poisoning") --
|
|
410
|
+
// a tool's description is protocol data returned by a live server at
|
|
411
|
+
// request time, never present in a static config file, so there is
|
|
412
|
+
// nothing here for a file-scanner to check it against.
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Extract the `mcpServers` object from a parsed config file. Every real
|
|
416
|
+
* MCP client this project has verified (Claude Code, Claude Desktop,
|
|
417
|
+
* Cursor, Visual Studio) uses this identical top-level shape. Anything
|
|
418
|
+
* else (the key absent, present but not an object) yields an empty
|
|
419
|
+
* object -- a config with no MCP servers configured is not an error.
|
|
420
|
+
*/
|
|
421
|
+
function extractMcpServers(parsed) {
|
|
422
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
423
|
+
const servers = parsed.mcpServers;
|
|
424
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) return {};
|
|
425
|
+
return servers;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// npm: optional @scope/, then a name, a literal @, then a version
|
|
429
|
+
// starting with a digit. pip: a name, a literal ==, then a version
|
|
430
|
+
// starting with a digit. Both anchored full-string (^...$) -- matched
|
|
431
|
+
// against ONE argv element at a time, never a substring of a longer
|
|
432
|
+
// string that happens to contain a coincidental match.
|
|
433
|
+
const NPM_PKG_VERSION_RE = /^(@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+|[a-zA-Z0-9._-]+)@(\d[\w.-]*)$/;
|
|
434
|
+
const PIP_PKG_VERSION_RE = /^([a-zA-Z0-9._-]+)==(\d[\w.-]*)$/;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Scan one MCP server's `args` array for a `package@version` (npm) or
|
|
438
|
+
* `package==version` (pip) argv element -- the documented invocation
|
|
439
|
+
* shape for `npx <pkg>@<version>` / `uvx <pkg>==<version>` / `pip install
|
|
440
|
+
* <pkg>==<version>`. An unpinned invocation (`npx mcp-remote` with no
|
|
441
|
+
* `@version`) yields nothing here on purpose: there's no version to
|
|
442
|
+
* check, and this deliberately does NOT treat "unpinned" itself as a
|
|
443
|
+
* finding -- that's a real, genuinely debatable trade-off (always
|
|
444
|
+
* getting the latest PATCHED release vs. exposure to a compromised
|
|
445
|
+
* "latest" publish), not a clear vulnerability the way a specific
|
|
446
|
+
* known-bad pinned version is.
|
|
447
|
+
*/
|
|
448
|
+
function extractPackageVersions(args) {
|
|
449
|
+
if (!Array.isArray(args)) return [];
|
|
450
|
+
const found = [];
|
|
451
|
+
for (const a of args) {
|
|
452
|
+
if (typeof a !== "string") continue;
|
|
453
|
+
const npm = NPM_PKG_VERSION_RE.exec(a);
|
|
454
|
+
if (npm) { found.push({ ecosystem: "npm", package: npm[1], version: npm[2] }); continue; }
|
|
455
|
+
const pip = PIP_PKG_VERSION_RE.exec(a);
|
|
456
|
+
if (pip) found.push({ ecosystem: "pip", package: pip[1], version: pip[2] });
|
|
457
|
+
}
|
|
458
|
+
return found;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A well-known, widely-flagged supply-chain red flag: an installer script
|
|
462
|
+
// fetched and piped directly into a shell, never saved or reviewed first.
|
|
463
|
+
// Checked against the whole command+args joined, not `command` alone,
|
|
464
|
+
// since the realistic shape is `command: "sh", args: ["-c", "curl ... |
|
|
465
|
+
// bash"]` -- the risky text lives in `args`, not `command`.
|
|
466
|
+
const CURL_PIPE_SHELL_RE = /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* True for a URL-transport MCP server (`url` field present) using plain
|
|
470
|
+
* HTTP to a non-loopback host. A loopback URL (localhost/127.0.0.1/::1)
|
|
471
|
+
* is excluded -- an ordinary local-dev MCP server, not the actual risk
|
|
472
|
+
* this names: MCP protocol traffic, including tool definitions, crossing
|
|
473
|
+
* a real network where it can be observed or altered in transit.
|
|
474
|
+
*/
|
|
475
|
+
function isInsecureRemoteUrl(url) {
|
|
476
|
+
if (typeof url !== "string") return false;
|
|
477
|
+
let parsed;
|
|
478
|
+
try { parsed = new URL(url); } catch { return false; }
|
|
479
|
+
if (parsed.protocol !== "http:") return false;
|
|
480
|
+
const host = parsed.hostname;
|
|
481
|
+
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "[::1]";
|
|
482
|
+
}
|
|
483
|
+
|
|
389
484
|
// ── the checker ───────────────────────────────────────────────────────────
|
|
390
485
|
|
|
391
486
|
/**
|
|
@@ -827,6 +922,66 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
827
922
|
}
|
|
828
923
|
}
|
|
829
924
|
|
|
925
|
+
// ---- 6. MCP server configuration risks ---------------------------------
|
|
926
|
+
// Home/machine-level configs (none of these are ever project-scoped by
|
|
927
|
+
// any vendor's own design, the same reasoning section 5 states) plus
|
|
928
|
+
// the two genuinely project-scoped MCP config locations, which use `cwd`
|
|
929
|
+
// unconditionally -- `cwd` already equals the project root in project
|
|
930
|
+
// mode (see this function's own docstring), so no separate branch is
|
|
931
|
+
// needed for them.
|
|
932
|
+
const mcpConfigFiles = [];
|
|
933
|
+
if (!projectMode) {
|
|
934
|
+
mcpConfigFiles.push(path.join(home, ".claude.json"));
|
|
935
|
+
const desktopConfig =
|
|
936
|
+
process.platform === "darwin" ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") :
|
|
937
|
+
process.platform === "win32" ? path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json") :
|
|
938
|
+
null; // Linux: no official build, unofficial ports disagree -- see agent-configs.js's own header
|
|
939
|
+
if (desktopConfig) mcpConfigFiles.push(desktopConfig);
|
|
940
|
+
mcpConfigFiles.push(path.join(home, ".cursor", "mcp.json"));
|
|
941
|
+
mcpConfigFiles.push(path.join(home, ".kiro", "settings", "mcp.json"));
|
|
942
|
+
mcpConfigFiles.push(path.join(home, ".mcp.json"));
|
|
943
|
+
}
|
|
944
|
+
mcpConfigFiles.push(path.join(cwd, ".mcp.json"));
|
|
945
|
+
mcpConfigFiles.push(path.join(cwd, ".vs", "mcp.json"));
|
|
946
|
+
|
|
947
|
+
for (const file of mcpConfigFiles) {
|
|
948
|
+
const text = readOrReport(file);
|
|
949
|
+
if (text === null) continue;
|
|
950
|
+
|
|
951
|
+
let parsed;
|
|
952
|
+
try { parsed = JSON.parse(text); } catch {
|
|
953
|
+
add("warn", "unparseable-config", file, "exists in a known MCP config location but is not valid JSON; the client's own loader would also choke on it, so corruption or tampering is worth a look");
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const servers = extractMcpServers(parsed);
|
|
958
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
959
|
+
if (!def || typeof def !== "object") continue;
|
|
960
|
+
const label = safePreview(name, 60);
|
|
961
|
+
|
|
962
|
+
for (const { ecosystem, package: pkg, version } of extractPackageVersions(def.args)) {
|
|
963
|
+
const { checkable, matches } = checkVersion(ecosystem, pkg, version);
|
|
964
|
+
if (!checkable) continue; // a version string this comparator can't parse: unverified, not silently clean, but nothing more to say than that
|
|
965
|
+
for (const m of matches) {
|
|
966
|
+
add("warn", "mcp-known-cve", file,
|
|
967
|
+
`MCP server "${label}" pins ${pkg}@${version}, matching ${m.id} (${m.severity}): ${m.summary} Upgrade past the vulnerable range.`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (isInsecureRemoteUrl(def.url)) {
|
|
972
|
+
add("warn", "mcp-insecure-transport", file,
|
|
973
|
+
`MCP server "${label}" connects to ${safePreview(def.url, 80)} over plain HTTP -- protocol traffic, including tool definitions, can be observed or altered in transit. Use an HTTPS URL if the server supports one.`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const commandLine = [def.command, ...(Array.isArray(def.args) ? def.args : [])]
|
|
977
|
+
.filter((x) => typeof x === "string").join(" ");
|
|
978
|
+
if (commandLine && CURL_PIPE_SHELL_RE.test(commandLine)) {
|
|
979
|
+
add("warn", "mcp-curl-pipe-shell", file,
|
|
980
|
+
`MCP server "${label}" launch command fetches a script and pipes it directly into a shell: "${safePreview(commandLine)}". Review and pin to a specific, reviewed version instead of trusting whatever the URL currently serves.`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
830
985
|
return {
|
|
831
986
|
findings,
|
|
832
987
|
filesChecked,
|
|
@@ -836,4 +991,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
836
991
|
};
|
|
837
992
|
}
|
|
838
993
|
|
|
839
|
-
|
|
994
|
+
// scanZeroWidth is also reused by injection.js, applying the same
|
|
995
|
+
// TrapDoor-sourced invisible-character classification (see its own
|
|
996
|
+
// docstring above) to general transcript content, not just this file's
|
|
997
|
+
// own fixed config-location list -- additive export, this module's own
|
|
998
|
+
// behavior is unchanged.
|
|
999
|
+
module.exports = { checkIntegrity, scanZeroWidth };
|
package/src/mcpTools.js
CHANGED
|
@@ -52,18 +52,20 @@ function rejectUnknownKeys(args, allowed) {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Shared arg shape for residoo_scan/residoo_check: includeNoisy,
|
|
55
|
-
* includeSuppressed, includePii, maxEntries. includePii
|
|
56
|
-
* (unlike ocr or verify, see this
|
|
57
|
-
*
|
|
58
|
-
* local-only, no network
|
|
59
|
-
*
|
|
60
|
-
*
|
|
55
|
+
* includeSuppressed, includePii, includeInjection, maxEntries. includePii
|
|
56
|
+
* and includeInjection are exposed here (unlike ocr or verify, see this
|
|
57
|
+
* file's own header comment on verify's exclusion) because they are
|
|
58
|
+
* architecturally identical to includeNoisy -- local-only, no network
|
|
59
|
+
* call, no external process, just a different detection category (see
|
|
60
|
+
* pii.js and injection.js respectively) -- not the network/live-secret
|
|
61
|
+
* trust boundary verify's own exclusion is specifically about.
|
|
61
62
|
*/
|
|
62
63
|
function validateSweepArgs(args, allowedKeys) {
|
|
63
64
|
const errs = rejectUnknownKeys(args, allowedKeys);
|
|
64
65
|
if (args.includeNoisy !== undefined && typeof args.includeNoisy !== "boolean") errs.push("includeNoisy must be a boolean");
|
|
65
66
|
if (args.includeSuppressed !== undefined && typeof args.includeSuppressed !== "boolean") errs.push("includeSuppressed must be a boolean");
|
|
66
67
|
if (args.includePii !== undefined && typeof args.includePii !== "boolean") errs.push("includePii must be a boolean");
|
|
68
|
+
if (args.includeInjection !== undefined && typeof args.includeInjection !== "boolean") errs.push("includeInjection must be a boolean");
|
|
67
69
|
let maxEntries = 25;
|
|
68
70
|
if (args.maxEntries !== undefined) {
|
|
69
71
|
if (typeof args.maxEntries !== "number" || !Number.isInteger(args.maxEntries) || args.maxEntries < 1 || args.maxEntries > 200) {
|
|
@@ -74,7 +76,7 @@ function validateSweepArgs(args, allowedKeys) {
|
|
|
74
76
|
}
|
|
75
77
|
return {
|
|
76
78
|
errs, includeNoisy: args.includeNoisy === true, includeSuppressed: args.includeSuppressed === true,
|
|
77
|
-
includePii: args.includePii === true, maxEntries,
|
|
79
|
+
includePii: args.includePii === true, includeInjection: args.includeInjection === true, maxEntries,
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -134,8 +136,8 @@ function buildTools({ sources }) {
|
|
|
134
136
|
let checkStarted = false;
|
|
135
137
|
|
|
136
138
|
async function handleScan(args) {
|
|
137
|
-
const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
|
|
138
|
-
const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
|
|
139
|
+
const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "includePii", "includeInjection", "maxEntries"]);
|
|
140
|
+
const { errs, includeNoisy, includeSuppressed, includePii, includeInjection, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
|
|
139
141
|
if (args.projectDir !== undefined && typeof args.projectDir !== "string") errs.push("projectDir must be a string");
|
|
140
142
|
if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
|
|
141
143
|
|
|
@@ -152,7 +154,7 @@ function buildTools({ sources }) {
|
|
|
152
154
|
scanSources = sources;
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, includePii, verify: false, noColor: true });
|
|
157
|
+
const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, includePii, includeInjection, verify: false, noColor: true });
|
|
156
158
|
const acks = loadAcks();
|
|
157
159
|
const dismissed = loadDismissed();
|
|
158
160
|
const rotation = renderRotation(result.findings, acks, dismissed);
|
|
@@ -179,8 +181,8 @@ function buildTools({ sources }) {
|
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
async function handleCheck(args) {
|
|
182
|
-
const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
|
|
183
|
-
const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
|
|
184
|
+
const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "includePii", "includeInjection", "maxEntries"]);
|
|
185
|
+
const { errs, includeNoisy, includeSuppressed, includePii, includeInjection, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
|
|
184
186
|
if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
|
|
185
187
|
|
|
186
188
|
const firstCheckThisSession = !checkStarted;
|
|
@@ -191,7 +193,7 @@ function buildTools({ sources }) {
|
|
|
191
193
|
const emit = (e) => events.push(e);
|
|
192
194
|
const stats = await sweepOnce({
|
|
193
195
|
sources, tracked: checkTracked, seen: checkSeen, ledger,
|
|
194
|
-
options: { includeNoisy, includeSuppressed, includePii, verify: false, noColor: true }, emit,
|
|
196
|
+
options: { includeNoisy, includeSuppressed, includePii, includeInjection, verify: false, noColor: true }, emit,
|
|
195
197
|
});
|
|
196
198
|
|
|
197
199
|
const allNew = events.filter((e) => e.type === "finding");
|
|
@@ -366,6 +368,7 @@ function buildTools({ sources }) {
|
|
|
366
368
|
includeNoisy: { type: "boolean", default: false, description: "Also run residoo's two low-confidence heuristic rules (generic password/secret assignments) -- catches more, false-positives more. Off by default." },
|
|
367
369
|
includeSuppressed: { type: "boolean", default: false, description: "Include matches normally hidden because they look like vendor-documented example values or placeholder text. Off by default." },
|
|
368
370
|
includePii: { type: "boolean", default: false, description: "Also scan for PII and adjacent secrets (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs, BIP-39 checksum-validated crypto wallet seed phrases) -- a different risk category from a vendor credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
|
|
371
|
+
includeInjection: { type: "boolean", default: false, description: "Also scan transcript content for prompt-injection signatures (special/role-token sequences like <|im_start|> or [INST], and hidden instructions carried by invisible Unicode) -- evidence an injection attempt already reached the agent, not a static-analysis check of application code. A third risk category, off by default." },
|
|
369
372
|
maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on distinct findings returned in full detail, pending-first. Counts in the response are always exact even when the entry list is truncated." },
|
|
370
373
|
},
|
|
371
374
|
required: [],
|
|
@@ -382,6 +385,7 @@ function buildTools({ sources }) {
|
|
|
382
385
|
includeNoisy: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
383
386
|
includeSuppressed: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
384
387
|
includePii: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
388
|
+
includeInjection: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
385
389
|
maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on new findings / re-exposures returned in full detail. Counts are always exact even when truncated." },
|
|
386
390
|
},
|
|
387
391
|
required: [],
|
package/src/report.js
CHANGED
|
@@ -541,11 +541,15 @@ function renderJson(result, integrity = null, rotation = null) {
|
|
|
541
541
|
// value was never plain text at all -- it was read out of a
|
|
542
542
|
// pasted or tool-returned image (see ocr.js); `pii` means this is
|
|
543
543
|
// a --include-pii finding, a different risk category from a
|
|
544
|
-
// credential, not a rule from the default set (see pii.js)
|
|
544
|
+
// credential, not a rule from the default set (see pii.js);
|
|
545
|
+
// `injection` means this is a --include-injection finding -- a
|
|
546
|
+
// prompt-injection signature, not a credential or PII at all (see
|
|
547
|
+
// injection.js).
|
|
545
548
|
...(f.encoding ? { encoding: f.encoding } : {}),
|
|
546
549
|
...(f.spanLines ? { spanLines: f.spanLines } : {}),
|
|
547
550
|
...(f.ocr ? { ocr: true } : {}),
|
|
548
551
|
...(f.pii ? { pii: true } : {}),
|
|
552
|
+
...(f.injection ? { injection: true } : {}),
|
|
549
553
|
fingerprint: fingerprintFinding(f),
|
|
550
554
|
// Only present on an --include-suppressed run: says WHY this finding
|
|
551
555
|
// is low-confidence, so a JSON consumer doesn't have to guess.
|
package/src/rotation.js
CHANGED
|
@@ -1222,6 +1222,43 @@ const ROTATION_GUIDANCE = {
|
|
|
1222
1222
|
],
|
|
1223
1223
|
revokeNote: "Low-confidence match: verify before rotating anything.",
|
|
1224
1224
|
},
|
|
1225
|
+
|
|
1226
|
+
// ── INJECTION_PATTERNS (--include-injection; see injection.js) ─────────
|
|
1227
|
+
// Framed like the PII entries above, not like a credential: there is no
|
|
1228
|
+
// issuer, no console, nothing to rotate. The real action is investigating
|
|
1229
|
+
// HOW this reached the transcript -- a fetched page, a file the agent
|
|
1230
|
+
// read, a tool's own output -- since that's the actual attack surface,
|
|
1231
|
+
// not the token/character itself.
|
|
1232
|
+
chatml_special_token: {
|
|
1233
|
+
label: "Special/role-token injection (ChatML or similar)",
|
|
1234
|
+
consolePath: "No vendor console -- this is a structural signature in content, not a credential.",
|
|
1235
|
+
steps: [
|
|
1236
|
+
"Find which tool call or fetched source produced the line this was found in -- that's the actual entry point, not this file",
|
|
1237
|
+
"If it came from external content (a web page, an API response, a file the agent read), treat that source as untrusted going forward and review what the agent did in the turns immediately after seeing it",
|
|
1238
|
+
"If this is a false positive -- code or documentation that legitimately discusses these tokens by name (a tokenizer bug report, this project's own docs) -- no action needed",
|
|
1239
|
+
],
|
|
1240
|
+
revokeNote: "High confidence structurally (these exact token sequences are rare in ordinary prose/code), but confidence in the MATCH is not the same as confidence an attack succeeded -- whether it actually altered the agent's behavior depends on the specific model/serving pipeline, which this check cannot see.",
|
|
1241
|
+
},
|
|
1242
|
+
zero_width_hidden_instruction: {
|
|
1243
|
+
label: "Hidden instruction carried by invisible Unicode",
|
|
1244
|
+
consolePath: "No vendor console -- this is a structural signature in content, not a credential.",
|
|
1245
|
+
steps: [
|
|
1246
|
+
"Inspect the source file in a hex viewer or an editor that reveals invisible characters -- never trust how it renders in a normal terminal, that's the whole point of this technique",
|
|
1247
|
+
"Find which tool call or fetched source produced this line, the same way as the special-token rule above",
|
|
1248
|
+
"This is the same technique named in the TrapDoor campaign (see integrity.js's own citation) -- if this pattern shows up in a fixed config location (CLAUDE.md, a hook script) rather than ordinary transcript content, `residoo scan`'s own integrity check (not this rule) is what already covers that case with campaign-specific detail",
|
|
1249
|
+
],
|
|
1250
|
+
revokeNote: "The always-suspicious codepoint tier (not the context-dependent emoji-joiner tier) is what reaches this rule -- see integrity.js's scanZeroWidth for exactly which codepoints qualify and why.",
|
|
1251
|
+
},
|
|
1252
|
+
injection_override_phrase: {
|
|
1253
|
+
label: "Instruction-override phrase (noisy rule)",
|
|
1254
|
+
generic: true,
|
|
1255
|
+
consolePath: "No vendor console -- this is a phrase match in content, not a credential.",
|
|
1256
|
+
steps: [
|
|
1257
|
+
"Read the surrounding context before treating this as a real attempt -- this exact phrase is also what a security-research conversation, a GitHub issue, or a prompt-engineering discussion about this technique looks like, and this rule cannot tell the difference",
|
|
1258
|
+
"If it's a real attempt, find which tool call or fetched source it came from",
|
|
1259
|
+
],
|
|
1260
|
+
revokeNote: "Low-confidence, phrase-based match: OWASP's own LLM01 guidance and independent practitioner writing both describe reliable phrase-based injection detection as unsolved, not something this rule claims to have done.",
|
|
1261
|
+
},
|
|
1225
1262
|
};
|
|
1226
1263
|
Object.freeze(ROTATION_GUIDANCE);
|
|
1227
1264
|
|
package/src/scan.js
CHANGED
|
@@ -5,6 +5,8 @@ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
|
|
|
5
5
|
const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
|
|
6
6
|
const { isTesseractAvailable, extractImageBlocks, ocrImageBase64 } = require("./ocr");
|
|
7
7
|
const { PII_PATTERNS } = require("./pii");
|
|
8
|
+
const { INJECTION_PATTERNS, NOISY_INJECTION_PATTERNS, CHATML_TOKEN_RE, summarizeInvisibles } = require("./injection");
|
|
9
|
+
const { scanZeroWidth } = require("./integrity");
|
|
8
10
|
const { findPairedSecret, findNearbyCandidate } = require("./pairing");
|
|
9
11
|
const { looksRandom } = require("./rarity");
|
|
10
12
|
const { decodeJwtExpiryMs } = require("./jwtExpiry");
|
|
@@ -273,7 +275,7 @@ function localTimestamp(d) {
|
|
|
273
275
|
* absolute path can itself carry a username or a project name the rest of
|
|
274
276
|
* this report is careful never to print.
|
|
275
277
|
*/
|
|
276
|
-
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false, includePii = false } = {}) {
|
|
278
|
+
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false, includePii = false, includeInjection = false } = {}) {
|
|
277
279
|
const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
|
|
278
280
|
// --ocr: checked once, not per line/image -- isTesseractAvailable shells
|
|
279
281
|
// out, and this scan can touch thousands of lines. ocrRequestedButMissing
|
|
@@ -639,6 +641,40 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
639
641
|
}
|
|
640
642
|
};
|
|
641
643
|
|
|
644
|
+
// --include-injection: a third, separate risk category (see injection.js's
|
|
645
|
+
// own header for why this is neither a secret nor PII). No suppression
|
|
646
|
+
// heuristics apply here -- there is no "vendor-documented example" or
|
|
647
|
+
// "placeholder-like context" equivalent for a special-token sequence or a
|
|
648
|
+
// hidden Unicode character, unlike a value-shaped secret. NOISY_INJECTION_
|
|
649
|
+
// PATTERNS additionally require --include-noisy, mirroring exactly how
|
|
650
|
+
// patterns.js's own NOISY_PATTERNS require it for secrets.
|
|
651
|
+
const injectionLine = (line, file, relFile, lineNo, mtimeMs) => {
|
|
652
|
+
if (!includeInjection) return;
|
|
653
|
+
for (const rule of INJECTION_PATTERNS) {
|
|
654
|
+
if (rule.id === "chatml_special_token") {
|
|
655
|
+
CHATML_TOKEN_RE.lastIndex = 0;
|
|
656
|
+
let m;
|
|
657
|
+
while ((m = CHATML_TOKEN_RE.exec(line)) !== null) {
|
|
658
|
+
record(rule, m[0], relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
|
|
659
|
+
}
|
|
660
|
+
} else if (rule.id === "zero_width_hidden_instruction") {
|
|
661
|
+
const hits = scanZeroWidth(line).filter((h) => h.suspicious);
|
|
662
|
+
if (hits.length > 0) {
|
|
663
|
+
record(rule, summarizeInvisibles(hits), relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (includeNoisy) {
|
|
668
|
+
for (const rule of NOISY_INJECTION_PATTERNS) {
|
|
669
|
+
rule.re.lastIndex = 0;
|
|
670
|
+
let m;
|
|
671
|
+
while ((m = rule.re.exec(line)) !== null) {
|
|
672
|
+
record(rule, m[0], relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
642
678
|
// Feature 2: split-line boundary join. A finding here means one credential
|
|
643
679
|
// was split across this line and the next and is contiguous on neither. It
|
|
644
680
|
// is recorded against BOTH contributing lines (each holds a fragment of the
|
|
@@ -763,6 +799,13 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
763
799
|
flagFailed();
|
|
764
800
|
}
|
|
765
801
|
}
|
|
802
|
+
if (includeInjection) {
|
|
803
|
+
try {
|
|
804
|
+
injectionLine(line, file, relFile, i + 1, mtimeMs);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
flagFailed();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
766
809
|
try {
|
|
767
810
|
const content = contentProjection(line);
|
|
768
811
|
// Boundary join with the previous line (2-way splits only; see
|
package/src/watch.js
CHANGED
|
@@ -267,14 +267,14 @@ function makeSyntheticSource(realId, batchesByFile) {
|
|
|
267
267
|
* `verify` is always forced off here: seeding a dedup cache must never be
|
|
268
268
|
* the reason a live vendor API gets hit.
|
|
269
269
|
*/
|
|
270
|
-
async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii) {
|
|
270
|
+
async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii, includeInjection) {
|
|
271
271
|
const batch = await readWholeFile(source, file, sizeBytes, mtimeMs);
|
|
272
272
|
if (!batch) return;
|
|
273
273
|
let result;
|
|
274
274
|
try {
|
|
275
275
|
result = await scan({
|
|
276
276
|
sources: [makeSyntheticSource(sourceId, new Map([[file, batch]]))],
|
|
277
|
-
includeNoisy, includeSuppressed, verify: false, noColor, includePii,
|
|
277
|
+
includeNoisy, includeSuppressed, verify: false, noColor, includePii, includeInjection,
|
|
278
278
|
});
|
|
279
279
|
} catch {
|
|
280
280
|
return; // best-effort: a failure here just leaves this file's dedup
|
|
@@ -300,7 +300,7 @@ async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, in
|
|
|
300
300
|
* `dismiss` takes effect without a restart.
|
|
301
301
|
*/
|
|
302
302
|
async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
303
|
-
const { includeNoisy, includeSuppressed, verify, noColor, includePii } = options || {};
|
|
303
|
+
const { includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection } = options || {};
|
|
304
304
|
let loud = 0;
|
|
305
305
|
let quiet = 0;
|
|
306
306
|
let suppressedByLedger = 0;
|
|
@@ -364,7 +364,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
|
364
364
|
contentHash: tailable ? null : wholeFileHash(file),
|
|
365
365
|
});
|
|
366
366
|
if (!tailable) {
|
|
367
|
-
await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii);
|
|
367
|
+
await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii, includeInjection);
|
|
368
368
|
}
|
|
369
369
|
continue;
|
|
370
370
|
}
|
|
@@ -433,7 +433,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
|
433
433
|
try {
|
|
434
434
|
result = await scan({
|
|
435
435
|
sources: [makeSyntheticSource(sourceId, batchesByFile)],
|
|
436
|
-
includeNoisy, includeSuppressed, verify, noColor, includePii,
|
|
436
|
+
includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection,
|
|
437
437
|
});
|
|
438
438
|
} catch (err) {
|
|
439
439
|
emit({ type: "watch-error", at: new Date(), source: sourceId, detail: "scan failed: " + (err && err.message) });
|