residoo 0.21.0 → 0.22.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/injection.js +139 -0
- package/src/integrity.js +6 -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.22.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/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
|
@@ -836,4 +836,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
836
836
|
};
|
|
837
837
|
}
|
|
838
838
|
|
|
839
|
-
|
|
839
|
+
// scanZeroWidth is also reused by injection.js, applying the same
|
|
840
|
+
// TrapDoor-sourced invisible-character classification (see its own
|
|
841
|
+
// docstring above) to general transcript content, not just this file's
|
|
842
|
+
// own fixed config-location list -- additive export, this module's own
|
|
843
|
+
// behavior is unchanged.
|
|
844
|
+
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) });
|