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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.21.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, --no-color
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 };
@@ -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
- module.exports = { checkIntegrity };
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 is exposed here
56
- * (unlike ocr or verify, see this file's own header comment on verify's
57
- * exclusion) because it is architecturally identical to includeNoisy --
58
- * local-only, no network call, no external process, just a different
59
- * detection category (see pii.js) -- not the network/live-secret trust
60
- * boundary verify's own exclusion is specifically about.
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) });