toolmoat 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
import { redactSecrets } from '../patterns/secrets.js';
|
|
2
|
+
import { FILE_KINDS, SEVERITY_RANK } from '../types.js';
|
|
3
|
+
import { findAll, isInvisible, printable, truncate } from '../util/text.js';
|
|
4
|
+
/**
|
|
5
|
+
* Prompt-injection (TM-PI-*) and hidden-content (TM-HID-*) rules for the text an
|
|
6
|
+
* agent reads: SKILL.md, CLAUDE.md and friends, live MCP tool definitions and
|
|
7
|
+
* tool output, and tool descriptions embedded in MCP server source.
|
|
8
|
+
*
|
|
9
|
+
* All of it is attacker-controlled and the same rules run inside the runtime
|
|
10
|
+
* proxy, so regexes start at a literal anchor and only use bounded gaps,
|
|
11
|
+
* proximity checks look at short same-line windows instead of spanning regexes,
|
|
12
|
+
* character scans are single linear passes, and every rule caps both the regex
|
|
13
|
+
* hits it examines and the matches it returns.
|
|
14
|
+
*/
|
|
15
|
+
/** Matches returned per rule per file. */
|
|
16
|
+
const MAX_MATCHES = 20;
|
|
17
|
+
/** Regex hits examined per pattern per file. */
|
|
18
|
+
const MAX_HITS = 2000;
|
|
19
|
+
const AGENT_TEXT = ['skill', 'instructions', 'tool-definition'];
|
|
20
|
+
const AGENT_TEXT_AND_CODE = ['skill', 'instructions', 'tool-definition', 'script'];
|
|
21
|
+
const MARKUP = ['skill', 'instructions', 'docs', 'tool-definition'];
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Severity
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const RAISE = { info: 'low', low: 'medium', medium: 'high', high: 'critical', critical: 'critical' };
|
|
26
|
+
const LOWER = { critical: 'high', high: 'medium', medium: 'low', low: 'info', info: 'info' };
|
|
27
|
+
/**
|
|
28
|
+
* Final severity of a prompt-injection hit: one level lower when the phrase is
|
|
29
|
+
* only quoted or discussed, one level higher inside a tool definition, which has
|
|
30
|
+
* no business addressing the model at all.
|
|
31
|
+
*/
|
|
32
|
+
function adjust(base, kind, discussed, escalate = true) {
|
|
33
|
+
const severity = discussed ? LOWER[base] : base;
|
|
34
|
+
return escalate && kind === 'tool-definition' ? RAISE[severity] : severity;
|
|
35
|
+
}
|
|
36
|
+
/** True for the kinds where tool descriptions live: live definitions and MCP server source. */
|
|
37
|
+
function isToolSurface(kind) {
|
|
38
|
+
return kind === 'tool-definition' || kind === 'script';
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Text helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/** Collapses whitespace, redacts secrets and escapes invisible characters so a fragment is safe to quote in a message. */
|
|
44
|
+
function quote(text, max = 100) {
|
|
45
|
+
const head = text.length > 1000 ? text.slice(0, 1000) : text;
|
|
46
|
+
return truncate(printable(redactSecrets(head.replace(/\s+/g, ' ').trim())), max);
|
|
47
|
+
}
|
|
48
|
+
/** Visible escape for one code point, in the same style as util/text printable(). */
|
|
49
|
+
function escapeCodePoint(cp) {
|
|
50
|
+
return cp > 0xffff ? `\\u{${cp.toString(16)}}` : `\\u${cp.toString(16).padStart(4, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
/** The text before `index` on the same line, at most `reach` characters. */
|
|
53
|
+
function linePrefix(content, index, reach) {
|
|
54
|
+
const raw = content.slice(Math.max(0, index - reach), index);
|
|
55
|
+
const nl = raw.lastIndexOf('\n');
|
|
56
|
+
return nl === -1 ? raw : raw.slice(nl + 1);
|
|
57
|
+
}
|
|
58
|
+
/** The text after `index` on the same line, at most `reach` characters. */
|
|
59
|
+
function lineSuffix(content, index, reach) {
|
|
60
|
+
const raw = content.slice(index, index + reach);
|
|
61
|
+
const nl = raw.indexOf('\n');
|
|
62
|
+
return nl === -1 ? raw : raw.slice(0, nl);
|
|
63
|
+
}
|
|
64
|
+
/** The same-line text around [start, end), at most `reach` characters on each side. */
|
|
65
|
+
function lineWindow(content, start, end, reach) {
|
|
66
|
+
return linePrefix(content, start, reach) + content.slice(start, end) + lineSuffix(content, end, reach);
|
|
67
|
+
}
|
|
68
|
+
/** Maps offsets to 1-based line numbers with a binary search over line starts. */
|
|
69
|
+
class LineIndex {
|
|
70
|
+
starts = [0];
|
|
71
|
+
constructor(content) {
|
|
72
|
+
for (let i = content.indexOf('\n'); i !== -1; i = content.indexOf('\n', i + 1))
|
|
73
|
+
this.starts.push(i + 1);
|
|
74
|
+
}
|
|
75
|
+
lineOf(index) {
|
|
76
|
+
let lo = 0;
|
|
77
|
+
let hi = this.starts.length - 1;
|
|
78
|
+
while (lo < hi) {
|
|
79
|
+
const mid = (lo + hi + 1) >> 1;
|
|
80
|
+
if (this.starts[mid] <= index)
|
|
81
|
+
lo = mid;
|
|
82
|
+
else
|
|
83
|
+
hi = mid - 1;
|
|
84
|
+
}
|
|
85
|
+
return lo + 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Collects at most one match per line, keeping the most severe, and at most MAX_MATCHES lines. */
|
|
89
|
+
class Matches {
|
|
90
|
+
content;
|
|
91
|
+
byLine = new Map();
|
|
92
|
+
lines;
|
|
93
|
+
constructor(content) {
|
|
94
|
+
this.content = content;
|
|
95
|
+
}
|
|
96
|
+
get full() {
|
|
97
|
+
return this.byLine.size >= MAX_MATCHES;
|
|
98
|
+
}
|
|
99
|
+
/** True when another match at `index` could not change the result, so callers can skip expensive checks. */
|
|
100
|
+
covers(index, severity) {
|
|
101
|
+
if (this.byLine.size === 0)
|
|
102
|
+
return false;
|
|
103
|
+
this.lines ??= new LineIndex(this.content);
|
|
104
|
+
const previous = this.byLine.get(this.lines.lineOf(index));
|
|
105
|
+
return previous ? SEVERITY_RANK[previous.severity ?? 'info'] >= SEVERITY_RANK[severity] : this.full;
|
|
106
|
+
}
|
|
107
|
+
add(index, severity, message, snippet) {
|
|
108
|
+
this.lines ??= new LineIndex(this.content);
|
|
109
|
+
const line = this.lines.lineOf(index);
|
|
110
|
+
const previous = this.byLine.get(line);
|
|
111
|
+
if (previous ? SEVERITY_RANK[severity] <= SEVERITY_RANK[previous.severity ?? 'info'] : this.full)
|
|
112
|
+
return;
|
|
113
|
+
const match = { index, severity, message };
|
|
114
|
+
if (snippet !== undefined)
|
|
115
|
+
match.snippet = snippet;
|
|
116
|
+
this.byLine.set(line, match);
|
|
117
|
+
}
|
|
118
|
+
list() {
|
|
119
|
+
return [...this.byLine.values()].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.{0,200})/;
|
|
123
|
+
/** Offsets covered by fenced code blocks, whose content renders visibly as code instead of hiding. */
|
|
124
|
+
class FencedCode {
|
|
125
|
+
ranges = [];
|
|
126
|
+
constructor(content) {
|
|
127
|
+
let open;
|
|
128
|
+
let pos = 0;
|
|
129
|
+
while (pos < content.length) {
|
|
130
|
+
const nl = content.indexOf('\n', pos);
|
|
131
|
+
const end = nl === -1 ? content.length : nl;
|
|
132
|
+
const fence = FENCE.exec(content.slice(pos, Math.min(end, pos + 210)));
|
|
133
|
+
if (fence) {
|
|
134
|
+
const marker = fence[1];
|
|
135
|
+
if (!open)
|
|
136
|
+
open = { start: pos, marker };
|
|
137
|
+
else if (marker[0] === open.marker[0] && marker.length >= open.marker.length && !/\S/.test(fence[2] ?? '')) {
|
|
138
|
+
this.ranges.push([open.start, end]);
|
|
139
|
+
open = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (nl === -1)
|
|
143
|
+
break;
|
|
144
|
+
pos = nl + 1;
|
|
145
|
+
}
|
|
146
|
+
if (open)
|
|
147
|
+
this.ranges.push([open.start, content.length]);
|
|
148
|
+
}
|
|
149
|
+
contains(index) {
|
|
150
|
+
return this.startOf(index) !== undefined;
|
|
151
|
+
}
|
|
152
|
+
/** Start offset of the fenced block containing `index`, if any. */
|
|
153
|
+
startOf(index) {
|
|
154
|
+
let lo = 0;
|
|
155
|
+
let hi = this.ranges.length - 1;
|
|
156
|
+
while (lo <= hi) {
|
|
157
|
+
const mid = (lo + hi) >> 1;
|
|
158
|
+
const [start, end] = this.ranges[mid];
|
|
159
|
+
if (index < start)
|
|
160
|
+
hi = mid - 1;
|
|
161
|
+
else if (index > end)
|
|
162
|
+
lo = mid + 1;
|
|
163
|
+
else
|
|
164
|
+
return start;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** True when an odd number of backticks precede the offset on its line, i.e. it sits inside inline code. */
|
|
170
|
+
function inInlineCode(content, index) {
|
|
171
|
+
const prefix = linePrefix(content, index, 400);
|
|
172
|
+
let ticks = 0;
|
|
173
|
+
for (let i = 0; i < prefix.length; i++)
|
|
174
|
+
if (prefix.charCodeAt(i) === 0x60)
|
|
175
|
+
ticks++;
|
|
176
|
+
return ticks % 2 === 1;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Quoted or discussed (mention, not use)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
const DISCUSSION_CUE = new RegExp([
|
|
182
|
+
'\\b(?:attackers?|attacks?|adversar(?:y|ies|ial)|malicious|malware|injections?|injected|phishing|jailbreaks?|poison(?:ed|ing)?|scams?)\\b',
|
|
183
|
+
'\\b(?:such\\s+as|for\\s+(?:example|instance)|examples?\\s+(?:of|like|include)|e\\.g\\.|i\\.e\\.)',
|
|
184
|
+
'\\b(?:phrases?|things|text|strings?|patterns?|wording|requests?|prompts?|content|messages?|instructions?)\\s+(?:like|such\\s+as|that\\s+say)\\b',
|
|
185
|
+
'\\b(?:look(?:ing|s)?|watch(?:ing|es)?)\\s+(?:out\\s+)?for\\b',
|
|
186
|
+
'\\b(?:beware|be\\s+wary|be\\s+careful|detects?|detecting|refuse|reject|warns?\\s+(?:about|against|of))\\b',
|
|
187
|
+
"\\b(?:never|do\\s+not|don['’]t)\\s+(?:obey|follow|comply|trust|act\\s+on)\\b",
|
|
188
|
+
'\\b(?:tells?|telling|asks?|asking|instructs?|instructing|tricks?|tricking)\\s+(?:you|the\\s+(?:model|agent|assistant|ai|llm))\\s+(?:in)?to\\b',
|
|
189
|
+
'\\bclaims?\\s+to\\b',
|
|
190
|
+
].join('|'), 'i');
|
|
191
|
+
/** A negation shortly before a phrase: "never run ... with --flag", "do not ignore ...". */
|
|
192
|
+
const NEGATED = /(?:\bnever|\bnot|n['’]t|\bavoid|\brefus(?:e|es|ed|ing)\s+to|\bno\s+longer)\s+(?:[\w-]+\s+){0,4}$/i;
|
|
193
|
+
/** A markdown table row: reference material such as a severity guide, not an instruction. */
|
|
194
|
+
const TABLE_ROW = /^[ \t]{0,3}\|[^\n]{0,2000}\|/;
|
|
195
|
+
/** Opening quote characters and the character that closes each. */
|
|
196
|
+
const QUOTE_PAIRS = { '"': '"', "'": "'", '`': '`', '“': '”', '‘': '’', '«': '»' };
|
|
197
|
+
/** A source or config comment line: //, #, *, /* or <!--. */
|
|
198
|
+
const COMMENT_LINE = /^\s{0,40}(?:\/\/|#|\*|\/\*|<!--)/;
|
|
199
|
+
/** URLs, host names and dotted paths, removed before looking for cue words ("attacker.example" is not a discussion). */
|
|
200
|
+
const URL_OR_HOST = /https?:\/\/\S{1,300}|[\w~-]{0,80}(?:[./\\][a-z_][\w-]{1,80}){1,12}/gi;
|
|
201
|
+
/** Text right before a fenced block that marks its content as an example. */
|
|
202
|
+
const EXAMPLE_INTRO = /\b(?:examples?|samples?|quoted|poisoned|malicious|injection|attack\w*|do\s+not\s+(?:run|follow|obey))\b/i;
|
|
203
|
+
let fenceCache;
|
|
204
|
+
/** Fenced-code ranges of the most recently inspected file, computed once per file. */
|
|
205
|
+
function fencesOf(content) {
|
|
206
|
+
if (fenceCache?.content !== content)
|
|
207
|
+
fenceCache = { content, fences: new FencedCode(content) };
|
|
208
|
+
return fenceCache.fences;
|
|
209
|
+
}
|
|
210
|
+
/** True when quote marks (and, optionally, an inline code span) are open at the end of the prefix. */
|
|
211
|
+
function insideQuotes(prefix, backticks) {
|
|
212
|
+
let straight = 0;
|
|
213
|
+
let ticks = 0;
|
|
214
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
215
|
+
const c = prefix.charCodeAt(i);
|
|
216
|
+
if (c === 0x22)
|
|
217
|
+
straight++;
|
|
218
|
+
else if (c === 0x60)
|
|
219
|
+
ticks++;
|
|
220
|
+
}
|
|
221
|
+
return (straight % 2 === 1 ||
|
|
222
|
+
(backticks && ticks % 2 === 1) ||
|
|
223
|
+
prefix.lastIndexOf('“') > prefix.lastIndexOf('”') ||
|
|
224
|
+
prefix.lastIndexOf('«') > prefix.lastIndexOf('»'));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* True when a phrase at `index` is quoted, negated or discussed ("attackers
|
|
228
|
+
* often say ...", or a fenced block introduced as an example) rather than
|
|
229
|
+
* given as an instruction. Source code keeps its tool descriptions in string
|
|
230
|
+
* literals, so quotes do not count there; file paths routinely sit in code
|
|
231
|
+
* spans, so callers matching paths pass `backticks: false`.
|
|
232
|
+
*/
|
|
233
|
+
function isDiscussed(file, index, backticks = true) {
|
|
234
|
+
const prefix = linePrefix(file.content, index, 160);
|
|
235
|
+
if (DISCUSSION_CUE.test(prefix.replace(URL_OR_HOST, ' ')) || NEGATED.test(prefix))
|
|
236
|
+
return true;
|
|
237
|
+
if (file.kind === 'script')
|
|
238
|
+
return false;
|
|
239
|
+
if (insideQuotes(prefix, backticks))
|
|
240
|
+
return true;
|
|
241
|
+
if (TABLE_ROW.test(file.content.slice(file.content.lastIndexOf('\n', index - 1) + 1, index + 2000)))
|
|
242
|
+
return true;
|
|
243
|
+
const fenceStart = fencesOf(file.content).startOf(index);
|
|
244
|
+
return fenceStart !== undefined && EXAMPLE_INTRO.test(file.content.slice(Math.max(0, fenceStart - 200), fenceStart));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* True when a phrase is the whole of a quoted string given as an example:
|
|
248
|
+
* one item of a list such as ("SYSTEM:", "ignore previous instructions", ...)
|
|
249
|
+
* in a prompt that warns about injection, or a quoted phrase in a code
|
|
250
|
+
* comment. Such a mention is reported two levels lower.
|
|
251
|
+
*/
|
|
252
|
+
function isQuotedExample(content, start, end) {
|
|
253
|
+
const close = QUOTE_PAIRS[content[start - 1] ?? ''];
|
|
254
|
+
if (close === undefined)
|
|
255
|
+
return false;
|
|
256
|
+
let after = end;
|
|
257
|
+
while (after < end + 3 && /[.!?,;:]/.test(content[after] ?? ''))
|
|
258
|
+
after++;
|
|
259
|
+
if (content[after] !== close)
|
|
260
|
+
return false;
|
|
261
|
+
const before = linePrefix(content, start - 1, 400);
|
|
262
|
+
if (COMMENT_LINE.test(before))
|
|
263
|
+
return true;
|
|
264
|
+
const next = content.slice(after + 1, after + 4).trimStart();
|
|
265
|
+
return /[([,]\s*$/.test(before) || /^[,)\]]/.test(next);
|
|
266
|
+
}
|
|
267
|
+
/** Suffix explaining a lowered severity. */
|
|
268
|
+
function discussedNote(discussed) {
|
|
269
|
+
return discussed ? ' (looks quoted or discussed as an example, so severity is lowered)' : '';
|
|
270
|
+
}
|
|
271
|
+
const has = (re) => (text) => re.test(text);
|
|
272
|
+
const HIGH_VALUE_TARGET = /\.ssh\b|\bid_(?:rsa|ed25519|ecdsa|dsa)\b|\.aws[\\/]credentials\b|\bwallet\.dat\b|\b(?:seed|recovery|mnemonic)\s+phrases?\b|\bkeychains?\b|\bLogin\s+Data\b|\bmcp\.json\b|\bclaude_desktop_config\b|\.claude\.json\b|(?<![\w$])\.env\b|\bsaved\s+passwords\b|\bprivate\s+keys?\b/i;
|
|
273
|
+
const ANY_ACCESS_VERB = /\b(?:read|cat|open|send|upload|post|copy|include|attach|exfiltrate|print|output|collect|extract|dump|pass|provide|leak|forward|paste|share|steal|grab|harvest|fetch|access|retrieve)\b/i;
|
|
274
|
+
const GENERIC_SECRET = /\b(?:passwords?|secrets?|credentials?|api[_ -]?keys?|(?:access|auth|bearer|session|oauth)\s+tokens?|tokens)\b/i;
|
|
275
|
+
const LEAK_VERB = /\b(?:send|upload|post|exfiltrate|leak|forward|paste|share|steal|harvest|dump|transmit|print|output|reveal|collect|extract)\b/i;
|
|
276
|
+
const INSTRUCTION_SIGNALS = [
|
|
277
|
+
{
|
|
278
|
+
label: 'an instruction override',
|
|
279
|
+
strong: true,
|
|
280
|
+
test: has(/\b(?:ignore|disregard|forget|override)\s+(?:all\s+|any\s+)?(?:of\s+)?(?:the\s+|your\s+|my\s+)?(?:previous|prior|above|earlier|preceding|original|initial|system)\b/i),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
label: 'a concealment instruction',
|
|
284
|
+
strong: true,
|
|
285
|
+
test: has(/\b(?:do\s+not|don['’]t|never|must\s+not)\s+(?:tell|inform|notify|alert|mention|reveal|disclose)\b|\bwithout\s+(?:telling|informing|notifying|alerting|letting)\b|\b(?:silently|secretly|covertly|stealthily|quietly)\s+(?:run|execute|send|upload|read|call|invoke|delete|fetch|post|copy|forward|install|download|collect|exfiltrate)\b/i),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
label: 'instructions addressed to the AI',
|
|
289
|
+
strong: true,
|
|
290
|
+
test: has(/\bif\s+you\s+are\s+an?\s+(?:ai|llm|assistant|language\s+model|agent|bot|chatbot|automated)\b|\b(?:dear|attention|note\s+(?:to|for)|hey|hello)\s+(?:the\s+)?(?:ai|assistant|agent|llm|model|claude|chatgpt|gpt|copilot)s?\b|\b(?:assistant|ai|llm|agent|claude|chatgpt|gpt|copilot|model)s?\s*[,:]\s*(?:you\s+|please\s+)?(?:must|should|need|always|never|do\s+not|don['’]t|ignore|now|first|before|read|run|send|execute)\b|\b(?:ai|llm|assistants?|agents?|claude|models?)\s+(?:must|should|need\s+to|has\s+to|have\s+to|will)\s+(?:always\s+|also\s+|now\s+|first\s+)?(?:run|execute|read|send|ignore|fetch|open|upload|install|download)\b/i),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
label: 'a command to execute',
|
|
294
|
+
strong: true,
|
|
295
|
+
test: has(/\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,160}\|\s{0,4}(?:sudo\s+)?(?:ba|z|da|k|fi)?sh\b|\|\s{0,4}iex\b|\b(?:run|execute|paste)\s+(?:this|the\s+following|these|that)\s+(?:command|script|code|snippet|one-liner)s?\b|\bbase64\s+(?:-d|--decode|-D)\b|\bxattr\s+-[a-z]{0,3}d\b/i),
|
|
296
|
+
},
|
|
297
|
+
{ label: 'an instruction to access secrets', strong: true, test: (text) => HIGH_VALUE_TARGET.test(text) && ANY_ACCESS_VERB.test(text) },
|
|
298
|
+
{ label: 'an exfiltration instruction', strong: true, test: has(/\bside[_ -]?note\b|\bbcc\b[^\n]{0,40}@[\w-]/i) },
|
|
299
|
+
{
|
|
300
|
+
label: 'a cross-tool instruction',
|
|
301
|
+
strong: true,
|
|
302
|
+
test: has(/\b(?:before|instead\s+of)\s+(?:using|calling|invoking)\s+(?:any|another|other|every|each)\b|\bwhen(?:ever)?\s+this\s+tool\s+is\s+(?:available|present|installed|enabled|loaded)\b|\b[\w.-]{1,60}\s+tool\s+(?:must|should|shall)\s+(?:always\s+)?(?:send|include|add|bcc|cc|forward|redirect)\b/i),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
label: 'a safety-bypass instruction',
|
|
306
|
+
strong: true,
|
|
307
|
+
test: has(/--dangerously-skip-permissions\b|\bbypassPermissions\b|\bdangerouslyDisableSandbox\b|\bauto[- ]?approve\s+(?:all|every|everything)\b|\byolo\s+mode\b/i),
|
|
308
|
+
},
|
|
309
|
+
{ label: 'an instruction to share secrets', strong: false, test: (text) => GENERIC_SECRET.test(text) && LEAK_VERB.test(text) },
|
|
310
|
+
{
|
|
311
|
+
label: 'an instruction to send data out',
|
|
312
|
+
strong: false,
|
|
313
|
+
test: has(/\b(?:send|post|upload|forward|transmit|exfiltrate|leak)\b[^\n]{0,100}?(?:https?:\/\/|\b[\w.+-]{1,64}@[\w-]{1,63}\.[\w.-]{2,}|\bwebhook)/i),
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
label: 'an imperative aimed at the reader',
|
|
317
|
+
strong: false,
|
|
318
|
+
test: has(/\byou\s+(?:must|should|need\s+to|have\s+to|are\s+(?:required|instructed|expected)\s+to)\s+(?:always\s+|also\s+|first\s+|now\s+|then\s+)?(?:run|execute|call|invoke|use|send|read|open|fetch|download|install|reply|respond|answer|say|include|add|ignore|follow|tell|output|print|write|delete|remove|visit|navigate|click)\b|\b(?:always|never|make\s+sure\s+(?:to|you))\s+(?:first\s+|also\s+)?(?:run|execute|call|invoke|send|read|fetch|download|install|reply|respond|include|delete|upload|visit)\b/i),
|
|
319
|
+
},
|
|
320
|
+
{ label: 'a note addressed to the AI', strong: false, test: has(/\b(?:assistant|ai|llm|agent|claude|chatgpt|copilot|gpt)s?\s*[,:]\s+\w/i) },
|
|
321
|
+
];
|
|
322
|
+
/** The strongest instruction-like signal in a hidden region, or undefined when it reads as an ordinary note. */
|
|
323
|
+
function instructionSignal(text) {
|
|
324
|
+
let moderate;
|
|
325
|
+
for (const signal of INSTRUCTION_SIGNALS) {
|
|
326
|
+
if (!signal.test(text))
|
|
327
|
+
continue;
|
|
328
|
+
if (signal.strong)
|
|
329
|
+
return signal;
|
|
330
|
+
moderate ??= signal;
|
|
331
|
+
}
|
|
332
|
+
return moderate;
|
|
333
|
+
}
|
|
334
|
+
function baseSeverity(phrase, kind) {
|
|
335
|
+
if (typeof phrase.severity === 'string')
|
|
336
|
+
return phrase.severity;
|
|
337
|
+
return isToolSurface(kind) ? phrase.severity[0] : phrase.severity[1];
|
|
338
|
+
}
|
|
339
|
+
function phraseMessage(label, text, discussed) {
|
|
340
|
+
return `${label}: "${quote(text)}"${discussedNote(discussed)}`;
|
|
341
|
+
}
|
|
342
|
+
/** Runs phrase patterns over a file, one match per line, with the discussed and tool-definition adjustments. */
|
|
343
|
+
function matchPhrases(file, phrases, escalate = true) {
|
|
344
|
+
const out = new Matches(file.content);
|
|
345
|
+
for (const phrase of phrases) {
|
|
346
|
+
const ceiling = adjust(phrase.refine ? 'high' : baseSeverity(phrase, file.kind), file.kind, false, escalate);
|
|
347
|
+
for (const hit of findAll(phrase.re, file.content, MAX_HITS)) {
|
|
348
|
+
if (out.covers(hit.index, ceiling))
|
|
349
|
+
continue;
|
|
350
|
+
if (phrase.accept && !phrase.accept(hit, file))
|
|
351
|
+
continue;
|
|
352
|
+
let base = baseSeverity(phrase, file.kind);
|
|
353
|
+
if (phrase.refine) {
|
|
354
|
+
const refined = phrase.refine(hit, file.content);
|
|
355
|
+
if (refined === null)
|
|
356
|
+
continue;
|
|
357
|
+
base = refined;
|
|
358
|
+
}
|
|
359
|
+
const example = isQuotedExample(file.content, hit.index, hit.index + hit.text.length);
|
|
360
|
+
const discussed = example || isDiscussed(file, hit.index);
|
|
361
|
+
const severity = adjust(base, file.kind, discussed, escalate);
|
|
362
|
+
out.add(hit.index, example ? LOWER[severity] : severity, phraseMessage(phrase.label, hit.text, discussed));
|
|
363
|
+
if (out.full)
|
|
364
|
+
return out.list();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out.list();
|
|
368
|
+
}
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// TM-HID-001 Unicode tag characters
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
const TAG_RUN = /[\u{E0000}-\u{E007F}]+/gu;
|
|
373
|
+
const SUBDIVISION_TAGS = /^[\u{E0030}-\u{E0039}\u{E0061}-\u{E007A}]{2,7}\u{E007F}$/u;
|
|
374
|
+
const WAVING_BLACK_FLAG = 0x1f3f4;
|
|
375
|
+
/** True for an emoji subdivision flag such as England: U+1F3F4, tag letters, CANCEL TAG. */
|
|
376
|
+
function isSubdivisionFlag(content, index, run) {
|
|
377
|
+
return index >= 2 && content.codePointAt(index - 2) === WAVING_BLACK_FLAG && SUBDIVISION_TAGS.test(run);
|
|
378
|
+
}
|
|
379
|
+
/** Decodes a run of tag characters back to the ASCII it conceals. */
|
|
380
|
+
function decodeTags(run, max) {
|
|
381
|
+
let out = '';
|
|
382
|
+
for (let i = 0; i < run.length && out.length < max; i += 2) {
|
|
383
|
+
const ascii = run.codePointAt(i) - 0xe0000;
|
|
384
|
+
if (ascii >= 0x20 && ascii <= 0x7e)
|
|
385
|
+
out += String.fromCharCode(ascii);
|
|
386
|
+
else if (ascii !== 0x01 && ascii !== 0x7f)
|
|
387
|
+
out += '?';
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
const AM_HID_001 = {
|
|
392
|
+
id: 'TM-HID-001',
|
|
393
|
+
title: 'Unicode tag characters hiding text',
|
|
394
|
+
description: 'Unicode tag characters (U+E0000-U+E007F) render as nothing but map one-to-one onto ASCII, so they smuggle instructions to the model past human review (ASCII smuggling). Emoji subdivision flags are the only legitimate use and are not reported.',
|
|
395
|
+
category: 'hidden-content',
|
|
396
|
+
severity: 'critical',
|
|
397
|
+
kinds: FILE_KINDS,
|
|
398
|
+
remediation: 'Delete the invisible tag characters. Treat the whole artifact as hostile: legitimate text never needs them.',
|
|
399
|
+
check(file) {
|
|
400
|
+
const content = file.content;
|
|
401
|
+
const lines = new LineIndex(content);
|
|
402
|
+
const byLine = new Map();
|
|
403
|
+
for (const hit of findAll(TAG_RUN, content, MAX_HITS)) {
|
|
404
|
+
if (isSubdivisionFlag(content, hit.index, hit.text))
|
|
405
|
+
continue;
|
|
406
|
+
const line = lines.lineOf(hit.index);
|
|
407
|
+
const entry = byLine.get(line);
|
|
408
|
+
if (!entry && byLine.size >= MAX_MATCHES)
|
|
409
|
+
break;
|
|
410
|
+
const found = entry ?? { index: hit.index, count: 0, decoded: '' };
|
|
411
|
+
found.count += hit.text.length / 2;
|
|
412
|
+
if (found.decoded.length < 200)
|
|
413
|
+
found.decoded += decodeTags(hit.text, 200 - found.decoded.length);
|
|
414
|
+
byLine.set(line, found);
|
|
415
|
+
}
|
|
416
|
+
return [...byLine.values()].map(({ index, count, decoded }) => {
|
|
417
|
+
const readable = /[A-Za-z0-9]/.test(decoded);
|
|
418
|
+
return {
|
|
419
|
+
index,
|
|
420
|
+
severity: readable ? 'critical' : 'high',
|
|
421
|
+
message: readable
|
|
422
|
+
? `${count} invisible Unicode tag character(s) decode to hidden text: "${quote(decoded, 120)}"`
|
|
423
|
+
: `${count} invisible Unicode tag character(s) with no visible rendering`,
|
|
424
|
+
snippet: `hidden text: ${decoded}`,
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Per-line character tallies for TM-HID-002 and TM-HID-003
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
/** Counts characters of interest on one line and renders them as visible escapes. */
|
|
433
|
+
class CharTally {
|
|
434
|
+
first = -1;
|
|
435
|
+
total = 0;
|
|
436
|
+
more = false;
|
|
437
|
+
counts = new Map();
|
|
438
|
+
add(cp, index) {
|
|
439
|
+
if (this.first < 0)
|
|
440
|
+
this.first = index;
|
|
441
|
+
this.total++;
|
|
442
|
+
if (this.counts.has(cp) || this.counts.size < 8)
|
|
443
|
+
this.counts.set(cp, (this.counts.get(cp) ?? 0) + 1);
|
|
444
|
+
else
|
|
445
|
+
this.more = true;
|
|
446
|
+
}
|
|
447
|
+
describe() {
|
|
448
|
+
const parts = [...this.counts].map(([cp, n]) => (n > 1 ? `${escapeCodePoint(cp)} x${n}` : escapeCodePoint(cp)));
|
|
449
|
+
return parts.join(', ') + (this.more ? ', ...' : '');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** True for letters of right-to-left scripts, where directional marks and embeddings are legitimate. */
|
|
453
|
+
function isRtlLetter(cp) {
|
|
454
|
+
return (cp >= 0x0590 && cp <= 0x08ff) || (cp >= 0xfb1d && cp <= 0xfdff) || (cp >= 0xfe70 && cp <= 0xfefc);
|
|
455
|
+
}
|
|
456
|
+
function isHangul(cp) {
|
|
457
|
+
return (cp >= 0x1100 && cp <= 0x11ff) || (cp >= 0x3130 && cp <= 0x318f) || (cp >= 0xa960 && cp <= 0xa97f) || (cp >= 0xac00 && cp <= 0xd7ff);
|
|
458
|
+
}
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// TM-HID-002 Bidirectional control characters
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
function isBidiControl(cp) {
|
|
463
|
+
return (cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069);
|
|
464
|
+
}
|
|
465
|
+
function isDirectionalMark(cp) {
|
|
466
|
+
return cp === 0x200e || cp === 0x200f || cp === 0x061c;
|
|
467
|
+
}
|
|
468
|
+
const AM_HID_002 = {
|
|
469
|
+
id: 'TM-HID-002',
|
|
470
|
+
title: 'Bidirectional control characters',
|
|
471
|
+
description: 'Bidi embeddings, overrides and isolates (U+202A-U+202E, U+2066-U+2069) reorder how a line is displayed, so code or instructions read differently from how they execute (Trojan Source). Directional marks are reported only inside otherwise-ASCII text.',
|
|
472
|
+
category: 'hidden-content',
|
|
473
|
+
severity: 'high',
|
|
474
|
+
kinds: FILE_KINDS,
|
|
475
|
+
remediation: 'Remove the bidirectional control characters, or confine them to genuine right-to-left text.',
|
|
476
|
+
check(file) {
|
|
477
|
+
const content = file.content;
|
|
478
|
+
const out = [];
|
|
479
|
+
const base = file.kind === 'docs' ? 'medium' : 'high';
|
|
480
|
+
let controls = new CharTally();
|
|
481
|
+
let marks = new CharTally();
|
|
482
|
+
let rtl = false;
|
|
483
|
+
let otherNonAscii = false;
|
|
484
|
+
const endLine = () => {
|
|
485
|
+
if (controls.total > 0 && out.length < MAX_MATCHES) {
|
|
486
|
+
const severity = rtl && file.kind !== 'script' ? LOWER[base] : base;
|
|
487
|
+
const context = rtl ? ' inside right-to-left text' : '';
|
|
488
|
+
out.push({
|
|
489
|
+
index: controls.first,
|
|
490
|
+
severity,
|
|
491
|
+
message: `${controls.total} bidirectional control character(s) (${controls.describe()})${context} make this line display in a different order than it is read or executed`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else if (marks.total > 0 && !otherNonAscii && out.length < MAX_MATCHES) {
|
|
495
|
+
out.push({
|
|
496
|
+
index: marks.first,
|
|
497
|
+
severity: LOWER[base],
|
|
498
|
+
message: `${marks.total} directional mark(s) (${marks.describe()}) inside plain ASCII text`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
controls = new CharTally();
|
|
502
|
+
marks = new CharTally();
|
|
503
|
+
rtl = false;
|
|
504
|
+
otherNonAscii = false;
|
|
505
|
+
};
|
|
506
|
+
for (let i = 0; i < content.length;) {
|
|
507
|
+
const cp = content.codePointAt(i);
|
|
508
|
+
if (cp === 0x0a)
|
|
509
|
+
endLine();
|
|
510
|
+
else if (isBidiControl(cp))
|
|
511
|
+
controls.add(cp, i);
|
|
512
|
+
else if (isDirectionalMark(cp))
|
|
513
|
+
marks.add(cp, i);
|
|
514
|
+
else if (cp >= 0x80) {
|
|
515
|
+
if (isRtlLetter(cp))
|
|
516
|
+
rtl = true;
|
|
517
|
+
if (!isInvisible(cp))
|
|
518
|
+
otherNonAscii = true;
|
|
519
|
+
}
|
|
520
|
+
i += cp > 0xffff ? 2 : 1;
|
|
521
|
+
}
|
|
522
|
+
endLine();
|
|
523
|
+
return out;
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// TM-HID-003 Zero-width and invisible characters
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
/** A run of this many consecutive invisible characters is never accidental. */
|
|
530
|
+
const LONG_RUN = 5;
|
|
531
|
+
/** Consecutive variation selectors that signal data hidden after an emoji. */
|
|
532
|
+
const SMUGGLING_RUN = 4;
|
|
533
|
+
/** Byte value carried by a variation selector in "emoji smuggling", or undefined for other code points. */
|
|
534
|
+
function variationSelectorByte(cp) {
|
|
535
|
+
if (cp >= 0xfe00 && cp <= 0xfe0f)
|
|
536
|
+
return cp - 0xfe00;
|
|
537
|
+
if (cp >= 0xe0100 && cp <= 0xe01ef)
|
|
538
|
+
return cp - 0xe0100 + 16;
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
/** Characters that may join a zero-width run. */
|
|
542
|
+
function isZeroWidthLike(cp) {
|
|
543
|
+
return (cp >= 0x200b && cp <= 0x200d) || (cp >= 0x2060 && cp <= 0x2064) || cp === 0xfeff || cp === 0x034f || cp === 0x180e;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Whether the code point at `index` is an invisible character worth reporting.
|
|
547
|
+
* Joiners count only between ASCII characters (inside emoji sequences and
|
|
548
|
+
* Persian, Arabic or Indic words they are legitimate), hangul fillers only
|
|
549
|
+
* outside Hangul text, and a BOM only past offset 0.
|
|
550
|
+
*/
|
|
551
|
+
function isHiddenChar(content, index, cp, prev, prevHidden) {
|
|
552
|
+
if (cp === 0x200b || (cp >= 0x2060 && cp <= 0x2064) || cp === 0x180e)
|
|
553
|
+
return true;
|
|
554
|
+
if (cp === 0xfeff)
|
|
555
|
+
return index > 0;
|
|
556
|
+
if (cp === 0x200c || cp === 0x200d || cp === 0x034f) {
|
|
557
|
+
const next = content.codePointAt(index + 1) ?? 0x0a;
|
|
558
|
+
return (prevHidden || prev < 0x80) && (next < 0x80 || isZeroWidthLike(next));
|
|
559
|
+
}
|
|
560
|
+
if (cp === 0x115f || cp === 0x1160 || cp === 0x3164 || cp === 0xffa0) {
|
|
561
|
+
const next = content.codePointAt(index + 1) ?? 0x0a;
|
|
562
|
+
return !isHangul(prev) && !isHangul(next);
|
|
563
|
+
}
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
/** Decodes emoji-smuggling bytes as UTF-8. */
|
|
567
|
+
function decodeSmuggled(bytes) {
|
|
568
|
+
return Buffer.from(bytes).toString('utf8');
|
|
569
|
+
}
|
|
570
|
+
const AM_HID_003 = {
|
|
571
|
+
id: 'TM-HID-003',
|
|
572
|
+
title: 'Zero-width and invisible characters',
|
|
573
|
+
description: 'Zero-width spaces, word joiners, hangul fillers, stray BOMs and long runs of variation selectors render as nothing; they hide data (emoji smuggling) or split keywords so filters and reviewers miss them.',
|
|
574
|
+
category: 'hidden-content',
|
|
575
|
+
severity: 'medium',
|
|
576
|
+
kinds: FILE_KINDS,
|
|
577
|
+
remediation: 'Strip the invisible characters and re-review the text they were attached to.',
|
|
578
|
+
check(file) {
|
|
579
|
+
const content = file.content;
|
|
580
|
+
const out = [];
|
|
581
|
+
let tally = new CharTally();
|
|
582
|
+
let run = 0;
|
|
583
|
+
let longest = 0;
|
|
584
|
+
let vsStart = -1;
|
|
585
|
+
let vsCount = 0;
|
|
586
|
+
let vsBytes = [];
|
|
587
|
+
let smuggled;
|
|
588
|
+
const endSelectors = () => {
|
|
589
|
+
if (vsCount >= SMUGGLING_RUN) {
|
|
590
|
+
if (smuggled)
|
|
591
|
+
smuggled.count += vsCount;
|
|
592
|
+
else
|
|
593
|
+
smuggled = { index: vsStart, count: vsCount, bytes: vsBytes.length, text: decodeSmuggled(vsBytes) };
|
|
594
|
+
}
|
|
595
|
+
vsCount = 0;
|
|
596
|
+
vsBytes = [];
|
|
597
|
+
};
|
|
598
|
+
const endLine = () => {
|
|
599
|
+
endSelectors();
|
|
600
|
+
if ((tally.total > 0 || smuggled) && out.length < MAX_MATCHES) {
|
|
601
|
+
const parts = [];
|
|
602
|
+
if (tally.total > 0) {
|
|
603
|
+
const runNote = longest >= LONG_RUN ? `, including a run of ${longest}` : '';
|
|
604
|
+
parts.push(`${tally.total} zero-width or invisible character(s) on this line (${tally.describe()})${runNote}`);
|
|
605
|
+
}
|
|
606
|
+
if (smuggled) {
|
|
607
|
+
parts.push(`${smuggled.count} consecutive variation selectors hide ${smuggled.bytes} byte(s) of data (emoji smuggling): "${quote(smuggled.text, 80)}"`);
|
|
608
|
+
}
|
|
609
|
+
const high = file.kind === 'tool-definition' || longest >= LONG_RUN || smuggled !== undefined;
|
|
610
|
+
const index = tally.first < 0 ? smuggled.index : smuggled ? Math.min(tally.first, smuggled.index) : tally.first;
|
|
611
|
+
out.push({ index, severity: high ? 'high' : 'medium', message: parts.join('; ') });
|
|
612
|
+
}
|
|
613
|
+
tally = new CharTally();
|
|
614
|
+
run = 0;
|
|
615
|
+
longest = 0;
|
|
616
|
+
smuggled = undefined;
|
|
617
|
+
};
|
|
618
|
+
let prev = 0x0a;
|
|
619
|
+
let prevHidden = false;
|
|
620
|
+
for (let i = 0; i < content.length;) {
|
|
621
|
+
const cp = content.codePointAt(i);
|
|
622
|
+
const width = cp > 0xffff ? 2 : 1;
|
|
623
|
+
const vsByte = variationSelectorByte(cp);
|
|
624
|
+
if (vsByte === undefined && vsCount > 0)
|
|
625
|
+
endSelectors();
|
|
626
|
+
if (cp === 0x0a) {
|
|
627
|
+
endLine();
|
|
628
|
+
prevHidden = false;
|
|
629
|
+
}
|
|
630
|
+
else if (vsByte !== undefined) {
|
|
631
|
+
if (vsCount === 0)
|
|
632
|
+
vsStart = i;
|
|
633
|
+
vsCount++;
|
|
634
|
+
if (vsBytes.length < 512)
|
|
635
|
+
vsBytes.push(vsByte);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
const hidden = isHiddenChar(content, i, cp, prev, prevHidden);
|
|
639
|
+
if (hidden) {
|
|
640
|
+
tally.add(cp, i);
|
|
641
|
+
run++;
|
|
642
|
+
if (run > longest)
|
|
643
|
+
longest = run;
|
|
644
|
+
}
|
|
645
|
+
else
|
|
646
|
+
run = 0;
|
|
647
|
+
prevHidden = hidden;
|
|
648
|
+
}
|
|
649
|
+
prev = cp;
|
|
650
|
+
i += width;
|
|
651
|
+
}
|
|
652
|
+
endLine();
|
|
653
|
+
return out;
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// TM-HID-004 Instructions hidden in comments
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
const COMMENT_SPAN = 4000;
|
|
660
|
+
const REF_COMMENT = /^ {0,3}\[[^\]\n]{0,40}\]:[ \t]{0,8}(?:#|<>)[ \t]{0,8}(?:\(([^\n]{0,500}?)\)[ \t]*$|"([^"\n]{0,500})"|'([^'\n]{0,500})')/gm;
|
|
661
|
+
/** HTML comments in order, bounded per comment; an unterminated comment hides the rest of the file. */
|
|
662
|
+
function htmlComments(content) {
|
|
663
|
+
const comments = [];
|
|
664
|
+
let from = 0;
|
|
665
|
+
while (comments.length < MAX_HITS) {
|
|
666
|
+
const open = content.indexOf('<!--', from);
|
|
667
|
+
if (open === -1)
|
|
668
|
+
break;
|
|
669
|
+
const start = open + 4;
|
|
670
|
+
const close = content.indexOf('-->', start);
|
|
671
|
+
const end = close === -1 ? content.length : close;
|
|
672
|
+
comments.push({ index: open, inner: content.slice(start, Math.min(end, start + COMMENT_SPAN)) });
|
|
673
|
+
if (close === -1)
|
|
674
|
+
break;
|
|
675
|
+
from = close + 3;
|
|
676
|
+
}
|
|
677
|
+
return comments;
|
|
678
|
+
}
|
|
679
|
+
const AM_HID_004 = {
|
|
680
|
+
id: 'TM-HID-004',
|
|
681
|
+
title: 'Instructions hidden in comments',
|
|
682
|
+
description: 'HTML comments and markdown reference comments ([//]: # (...)) disappear when markdown is rendered but are still loaded into the agent context; instruction-like text inside them targets the model while hiding from a human reviewer.',
|
|
683
|
+
category: 'hidden-content',
|
|
684
|
+
severity: 'high',
|
|
685
|
+
kinds: MARKUP,
|
|
686
|
+
remediation: 'Remove the comment, or move any legitimate guidance into visible text where reviewers can see it.',
|
|
687
|
+
check(file) {
|
|
688
|
+
const content = file.content;
|
|
689
|
+
const docs = file.kind === 'docs';
|
|
690
|
+
const out = new Matches(content);
|
|
691
|
+
const fences = fencesOf(content);
|
|
692
|
+
const report = (index, inner, what) => {
|
|
693
|
+
const signal = instructionSignal(inner);
|
|
694
|
+
if (!signal || (docs && !signal.strong))
|
|
695
|
+
return;
|
|
696
|
+
const severity = signal.strong ? (docs ? 'low' : 'high') : 'medium';
|
|
697
|
+
out.add(index, severity, `${what} hides ${signal.label} from readers of the rendered page: "${quote(inner)}"`, quote(inner, 160));
|
|
698
|
+
};
|
|
699
|
+
const ceiling = docs ? 'low' : 'high';
|
|
700
|
+
for (const comment of htmlComments(content)) {
|
|
701
|
+
if (out.covers(comment.index, ceiling))
|
|
702
|
+
continue;
|
|
703
|
+
if (fences.contains(comment.index) || inInlineCode(content, comment.index))
|
|
704
|
+
continue;
|
|
705
|
+
report(comment.index, comment.inner, 'HTML comment');
|
|
706
|
+
}
|
|
707
|
+
for (const hit of findAll(REF_COMMENT, content, MAX_HITS)) {
|
|
708
|
+
if (out.covers(hit.index, ceiling))
|
|
709
|
+
continue;
|
|
710
|
+
if (fences.contains(hit.index))
|
|
711
|
+
continue;
|
|
712
|
+
report(hit.index, hit.groups[0] ?? hit.groups[1] ?? hit.groups[2] ?? '', 'Markdown reference comment');
|
|
713
|
+
}
|
|
714
|
+
return out.list();
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
// TM-HID-005 Visually hidden markup and whitespace padding
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
const OPEN_TAG = /<([a-z][a-z0-9-]{0,20})(\s[^<>]{0,500})>/gi;
|
|
721
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
722
|
+
const HAS_WORDS = /[\p{L}\p{N}]{2}/u;
|
|
723
|
+
const BLANK_LINES = 30;
|
|
724
|
+
const PAD_COLUMNS = 200;
|
|
725
|
+
const HIDING_STYLES = [
|
|
726
|
+
{ re: /display\s*:\s*none\b/i, label: 'display:none' },
|
|
727
|
+
{ re: /visibility\s*:\s*(?:hidden|collapse)\b/i, label: 'visibility:hidden' },
|
|
728
|
+
{ re: /font-size\s*:\s*(?:0(?:\.0+)?|\.0+)(?:px|pt|em|rem|%|vw|vh)?(?![\w.%])|font-size\s*:\s*[01]px\b/i, label: 'font-size:0' },
|
|
729
|
+
{ re: /opacity\s*:\s*(?:0(?:\.0+)?|\.0+)%?(?![\w.%])/i, label: 'opacity:0' },
|
|
730
|
+
{ re: /(?:^|[;\s"'{])color\s*:\s*(?:transparent|rgba\([^)]{0,40},\s*0(?:\.0+)?\s*\))/i, label: 'transparent text' },
|
|
731
|
+
{ re: /(?:left|top|right|text-indent|margin-left|margin-top)\s*:\s*-\d{4,}/i, label: 'an off-screen position' },
|
|
732
|
+
{ re: /(?:^|\s)hidden(?=[\s=/]|$)/i, label: 'the hidden attribute' },
|
|
733
|
+
];
|
|
734
|
+
const WHITE_TEXT = /(?:^|[;\s"'{])color\s*:\s*(?:white|#fff(?:fff)?\b|rgba?\(\s*255\s*,\s*255\s*,\s*255\b)|(?:^|\s)color\s*=\s*["']?(?:white|#fff(?:fff)?)\b/i;
|
|
735
|
+
/** A background other than white, which makes white text a deliberate, visible design choice. */
|
|
736
|
+
const DARK_BACKGROUND = /background(?:-color)?\s*:\s*(?!\s*(?:white|#fff(?:fff)?\b|transparent|none|inherit))[#\w(]/i;
|
|
737
|
+
const ZERO_HEIGHT = /(?:^|[;\s"'])(?:max-)?height\s*:\s*0(?:px|em|rem|%)?(?![\w.%])/i;
|
|
738
|
+
const ZERO_WIDTH = /(?:^|[;\s"'])(?:max-)?width\s*:\s*0(?:px|em|rem|%)?(?![\w.%])/i;
|
|
739
|
+
const OVERFLOW_HIDDEN = /overflow\s*:\s*hidden/i;
|
|
740
|
+
/** How an element's attributes hide it, or undefined when it is visible. */
|
|
741
|
+
function hidingStyle(attrs) {
|
|
742
|
+
for (const style of HIDING_STYLES)
|
|
743
|
+
if (style.re.test(attrs))
|
|
744
|
+
return style.label;
|
|
745
|
+
if (WHITE_TEXT.test(attrs) && !DARK_BACKGROUND.test(attrs))
|
|
746
|
+
return 'white text';
|
|
747
|
+
const zeroHeight = ZERO_HEIGHT.test(attrs);
|
|
748
|
+
const zeroWidth = ZERO_WIDTH.test(attrs);
|
|
749
|
+
if ((zeroHeight && zeroWidth) || ((zeroHeight || zeroWidth) && OVERFLOW_HIDDEN.test(attrs)))
|
|
750
|
+
return 'zero size';
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
/** Text inside an element up to its closing tag (bounded), with nested tags removed. */
|
|
754
|
+
function elementText(content, tag, from) {
|
|
755
|
+
if (VOID_ELEMENTS.has(tag))
|
|
756
|
+
return { text: '', length: 0 };
|
|
757
|
+
const slice = content.slice(from, from + 3000);
|
|
758
|
+
const close = slice.search(new RegExp(`</${tag}\\b`, 'i'));
|
|
759
|
+
const body = close === -1 ? slice.slice(0, 600) : slice.slice(0, close);
|
|
760
|
+
return { text: body.replace(/<[^<>]{0,300}>/g, ' '), length: body.length };
|
|
761
|
+
}
|
|
762
|
+
function isPadding(c) {
|
|
763
|
+
return c === 0x20 || c === 0x09 || c === 0x0d || c === 0xa0 || c === 0x3000 || c === 0x200b || c === 0xfeff;
|
|
764
|
+
}
|
|
765
|
+
/** Reports text placed after 30+ blank lines, or after 200+ spaces on one line. */
|
|
766
|
+
function findPadding(content, out) {
|
|
767
|
+
let blankLines = 0;
|
|
768
|
+
let lineHasText = false;
|
|
769
|
+
let spaces = 0;
|
|
770
|
+
for (let i = 0; i < content.length && !out.full; i++) {
|
|
771
|
+
const c = content.charCodeAt(i);
|
|
772
|
+
if (c === 0x0a) {
|
|
773
|
+
blankLines = lineHasText ? 0 : blankLines + 1;
|
|
774
|
+
lineHasText = false;
|
|
775
|
+
spaces = 0;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (isPadding(c)) {
|
|
779
|
+
spaces++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (!lineHasText && blankLines >= BLANK_LINES) {
|
|
783
|
+
out.add(i, 'medium', `Text follows ${blankLines} blank lines of padding that push it out of view: "${quote(lineSuffix(content, i, 120))}"`);
|
|
784
|
+
}
|
|
785
|
+
else if (spaces >= PAD_COLUMNS && c !== 0x7c) {
|
|
786
|
+
out.add(i, 'medium', `Text follows ${spaces} spaces of padding on one line, pushing it out of view: "${quote(lineSuffix(content, i, 120))}"`);
|
|
787
|
+
}
|
|
788
|
+
lineHasText = true;
|
|
789
|
+
spaces = 0;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const AM_HID_005 = {
|
|
793
|
+
id: 'TM-HID-005',
|
|
794
|
+
title: 'Visually hidden markup or padding',
|
|
795
|
+
description: 'Text inside elements styled display:none, visibility:hidden, font-size:0, opacity:0, white-on-white or zero size, or pushed out of view by long runs of blank lines or spaces, is invisible to a reader but still loaded into the agent context.',
|
|
796
|
+
category: 'hidden-content',
|
|
797
|
+
severity: 'medium',
|
|
798
|
+
kinds: MARKUP,
|
|
799
|
+
remediation: 'Remove the hidden element or the whitespace padding so every instruction is visible to reviewers.',
|
|
800
|
+
check(file) {
|
|
801
|
+
const content = file.content;
|
|
802
|
+
const out = new Matches(content);
|
|
803
|
+
const fences = fencesOf(content);
|
|
804
|
+
let resumeAt = 0;
|
|
805
|
+
for (const hit of findAll(OPEN_TAG, content, MAX_HITS)) {
|
|
806
|
+
if (out.full)
|
|
807
|
+
break;
|
|
808
|
+
if (hit.index < resumeAt)
|
|
809
|
+
continue; // nested inside an element already examined
|
|
810
|
+
const style = hidingStyle(hit.groups[1] ?? '');
|
|
811
|
+
if (!style || fences.contains(hit.index) || inInlineCode(content, hit.index))
|
|
812
|
+
continue;
|
|
813
|
+
const element = elementText(content, (hit.groups[0] ?? '').toLowerCase(), hit.index + hit.text.length);
|
|
814
|
+
resumeAt = hit.index + hit.text.length + element.length;
|
|
815
|
+
const text = element.text;
|
|
816
|
+
if (!HAS_WORDS.test(text))
|
|
817
|
+
continue;
|
|
818
|
+
const signal = instructionSignal(text);
|
|
819
|
+
const severity = signal?.strong && file.kind !== 'docs' ? 'high' : 'medium';
|
|
820
|
+
const carries = signal ? ` contains ${signal.label}` : ' carries text';
|
|
821
|
+
out.add(hit.index, severity, `Element hidden with ${style}${carries}: "${quote(text)}"`);
|
|
822
|
+
}
|
|
823
|
+
findPadding(content, out);
|
|
824
|
+
return out.list();
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// TM-PI-001 Instruction override
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
const IGNORE = '(?:ignore|disregard|forget)';
|
|
831
|
+
const EARLIER = '(?:previous|prior|above|earlier|preceding|foregoing|former|original|initial|system|developer)';
|
|
832
|
+
const DIRECTIVES = '(?:instructions?|prompts?|rules|directions|directives|guidelines|guidance|constraints|restrictions|programming|orders)';
|
|
833
|
+
const PI001 = [
|
|
834
|
+
{
|
|
835
|
+
re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+|any\\s+|each\\s+|every\\s+)?(?:of\\s+)?(?:the\\s+|your\\s+|my\\s+|these\\s+|those\\s+)?${EARLIER}\\s+${DIRECTIVES}\\b`, 'gi'),
|
|
836
|
+
severity: 'high',
|
|
837
|
+
label: 'Tells the model to ignore its earlier instructions',
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
re: new RegExp(`\\b(?:override|bypass|discard|abandon)\\s+(?:all\\s+|any\\s+)?(?:of\\s+)?(?:the\\s+|your\\s+|my\\s+)?${EARLIER}\\s+(?:instructions?|prompts?|directives|guidelines|programming)\\b`, 'gi'),
|
|
841
|
+
severity: 'high',
|
|
842
|
+
label: 'Tells the model to override its earlier instructions',
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+)?(?:of\\s+)?your\\s+(?:(?:previous|prior|original|current|existing|safety|ethical|core|initial|system)\\s+)?(?:instructions|prompts?|rules|directives|guidelines|programming|training|restrictions|constraints)\\b`, 'gi'),
|
|
846
|
+
severity: 'high',
|
|
847
|
+
label: 'Tells the model to ignore its own instructions',
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+)?(?:of\\s+)?(?:the|your|any|these|those)\\s+(?:instructions|prompts?|directives|guidelines|rules)\\s+(?:you\\s+(?:were|have\\s+been)\\s+given|(?:given|provided|written)\\s+(?:to\\s+you\\s+)?(?:before|earlier|above|previously)|from\\s+(?:before|earlier|the\\s+(?:system|developer)))\\b`, 'gi'),
|
|
851
|
+
severity: 'high',
|
|
852
|
+
label: 'Tells the model to ignore the instructions it was given',
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+)?(?:of\\s+)?(?:the|these|those)\\s+(?:instructions|prompts?|directives|guidelines|rules)\\s+(?:above|before\\s+this)\\b`, 'gi'),
|
|
856
|
+
severity: 'medium',
|
|
857
|
+
label: 'Tells the model to ignore the instructions above',
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+|any\\s+)?(?:of\\s+)?(?:everything|anything|all)\\s+(?:(?:you(?:['’]ve|\\s+have|\\s+were)?\\s+)?(?:been\\s+)?(?:said|written|stated|told|given|mentioned|learned|read)\\s+)?(?:above|before|previously|earlier|so\\s+far|until\\s+now|up\\s+to\\s+(?:now|this\\s+point))\\b`, 'gi'),
|
|
861
|
+
severity: 'medium',
|
|
862
|
+
label: 'Tells the model to disregard everything before this point',
|
|
863
|
+
},
|
|
864
|
+
{ re: new RegExp(`\\b${IGNORE}\\s+(?:all\\s+)?(?:of\\s+)?the\\s+above\\b`, 'gi'), severity: 'medium', label: 'Tells the model to disregard the text above' },
|
|
865
|
+
{
|
|
866
|
+
re: /\b(?:override|overwrite|bypass|ignore|disregard|forget)\s+(?:the\s+|your\s+|its\s+|any\s+|all\s+)?(?:original\s+|current\s+|previous\s+|existing\s+)?system\s+(?:prompt|instructions|message|rules)\b/gi,
|
|
867
|
+
severity: 'high',
|
|
868
|
+
label: 'Tells the model to override its system prompt',
|
|
869
|
+
},
|
|
870
|
+
{ re: /\bnew\s+system\s+(?:instructions|directives|rules)\b/gi, severity: 'high', label: 'Claims to supply new system instructions' },
|
|
871
|
+
{ re: /\byour\s+new\s+(?:instructions|directives|rules|task|objective|goal|role)\s+(?:is|are)\b/gi, severity: 'high', label: 'Hands the model a replacement task' },
|
|
872
|
+
{
|
|
873
|
+
re: /\byou\s+are\s+now\s+(?:in\s+)?(?:the\s+)?(?:developer|dan|jailbreak|jailbroken|god|unrestricted|unfiltered|admin|sudo)\s+mode\b/gi,
|
|
874
|
+
severity: 'high',
|
|
875
|
+
label: 'Claims to switch the model into an unrestricted mode',
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
re: /\b(?:dan|jailbreak|god|unrestricted|unfiltered)\s+mode\s+(?:is\s+)?(?:now\s+)?(?:enabled|activated|unlocked|engaged|on)\b/gi,
|
|
879
|
+
severity: 'high',
|
|
880
|
+
label: 'Claims an unrestricted mode is enabled',
|
|
881
|
+
},
|
|
882
|
+
{ re: /\byou\s+are\s+(?:now\s+)?DAN\b|\bDo\s+Anything\s+Now\b/g, severity: 'high', label: 'Uses the DAN jailbreak persona' },
|
|
883
|
+
{
|
|
884
|
+
re: /\byou\s+are\s+no\s+longer\s+(?:bound|restricted|limited|constrained|governed)\s+by\b|\bpretend\s+(?:that\s+)?you\s+(?:have|are\s+under)\s+no\s+(?:restrictions|rules|guidelines|limits|filters|content\s+polic(?:y|ies))\b/gi,
|
|
885
|
+
severity: 'high',
|
|
886
|
+
label: 'Tells the model it is free of its rules',
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
re: /\b(?:act|behave|respond|answer)\s+(?:as|like)\s+(?:an?\s+)?(?:unrestricted|unfiltered|uncensored|jailbroken|amoral|evil)\s+(?:ai|assistant|model|chatbot|llm|version)\b/gi,
|
|
890
|
+
severity: 'high',
|
|
891
|
+
label: 'Tells the model to act as an unrestricted AI',
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
re: /\bforget\s+(?:everything|all)\s+(?:that\s+)?you(?:\s+were|\s+have\s+been|['’]ve\s+been)\s+(?:told|given|taught|instructed)\b/gi,
|
|
895
|
+
severity: 'medium',
|
|
896
|
+
label: 'Tells the model to forget what it was told',
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
re: /(?:про)?игнорир(?:уй|уйте|овать)\s+(?:все\s+|всё\s+)?(?:предыдущие|прежние|прошлые|вышеуказанные|вышеприведённые|вышеприведенные|предшествующие|изначальные|системные)\s+(?:инструкции|указания|правила|команды|промпты|установки)/gi,
|
|
900
|
+
severity: 'high',
|
|
901
|
+
label: 'Tells the model to ignore its earlier instructions (Russian)',
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
re: /(?:забудь|забудьте)\s+(?:все\s+|всё\s+|про\s+)?(?:предыдущие|прежние|прошлые|вышеуказанные|свои|твои|ваши|все)\s+(?:инструкции|указания|правила|промпты|установки)|не\s+обращай(?:те)?\s+внимания\s+на\s+(?:все\s+)?(?:предыдущие|прежние)\s+(?:инструкции|указания|правила)/gi,
|
|
905
|
+
severity: 'high',
|
|
906
|
+
label: 'Tells the model to forget its earlier instructions (Russian)',
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
re: /(?:忽略|无视|忽视|忘记|忘掉|忘記|無視|不要理会|不要理會)(?:你|您)?(?:之前|以上|上述|前面|先前|此前|所有|全部|上面|以前)(?:的)?(?:所有|全部)?(?:的)?(?:指令|说明|說明|指示|命令|提示|规则|規則|设定|設定)/g,
|
|
910
|
+
severity: 'high',
|
|
911
|
+
label: 'Tells the model to ignore its earlier instructions (Chinese)',
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
re: /\b(?:ignora|ignorar|olvida|olvide|olvidar)\s+(?:todas\s+|todos\s+)?(?:las\s+|los\s+|tus\s+|sus\s+)?(?:instrucciones|indicaciones|reglas|órdenes)\s+(?:anteriores|previas|de\s+arriba)/gi,
|
|
915
|
+
severity: 'high',
|
|
916
|
+
label: 'Tells the model to ignore its earlier instructions (Spanish)',
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
re: /\b(?:ignore[rz]?|oublie[rz]?)\s+(?:toutes\s+|tous\s+)?(?:les\s+|tes\s+|vos\s+)?(?:instructions|consignes|règles|directives)\s+(?:précédentes|precedentes|antérieures|anterieures|ci-dessus)/gi,
|
|
920
|
+
severity: 'high',
|
|
921
|
+
label: 'Tells the model to ignore its earlier instructions (French)',
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
re: /\b(?:ignoriere|ignorier|ignorieren\s+sie|vergiss|vergessen\s+sie)\s+(?:alle\s+)?(?:deine\s+|ihre\s+|die\s+)?(?:vorherigen|bisherigen|vorigen|obigen|früheren|vorangegangenen)\s+(?:Anweisungen|Instruktionen|Regeln|Befehle|Vorgaben)/gi,
|
|
925
|
+
severity: 'high',
|
|
926
|
+
label: 'Tells the model to ignore its earlier instructions (German)',
|
|
927
|
+
},
|
|
928
|
+
];
|
|
929
|
+
const AM_PI_001 = {
|
|
930
|
+
id: 'TM-PI-001',
|
|
931
|
+
title: 'Instruction override',
|
|
932
|
+
description: 'Text that tells the model to ignore, forget or override its previous instructions or system prompt, or claims to switch it into a jailbreak mode, is the classic prompt-injection opener (English, Russian, Chinese, Spanish, French and German variants).',
|
|
933
|
+
category: 'prompt-injection',
|
|
934
|
+
severity: 'high',
|
|
935
|
+
kinds: AGENT_TEXT_AND_CODE,
|
|
936
|
+
remediation: 'Remove the override text. If the artifact is meant to discuss prompt injection, quote the example in a code block.',
|
|
937
|
+
check: (file) => matchPhrases(file, PI001),
|
|
938
|
+
};
|
|
939
|
+
// ---------------------------------------------------------------------------
|
|
940
|
+
// TM-PI-002 Concealment from the user
|
|
941
|
+
// ---------------------------------------------------------------------------
|
|
942
|
+
/** Object guard so "the user doesn't need to know the internal IDs" does not read as concealment. */
|
|
943
|
+
const UNAWARE = "(?:know|learn|notice|find\\s+out|suspect|see|realize|realise|be\\s+(?:told|aware|informed|notified|alerted))(?:\\s+(?:about\\s+|of\\s+)?(?:this|it|that|these|anything|any\\s+of\\s+(?:this|it)|what\\s+(?:you|happened|is\\s+happening))\\b|(?=\\s*(?:[.!,;)]|$)))";
|
|
944
|
+
/** A relative clause about code ("retry logic that exhausts attempts ..."): the phrase describes behaviour under review. */
|
|
945
|
+
const DESCRIBES_CODE = /\b(?:logic|code|functions?|methods?|handlers?|catch|blocks?|errors?|exceptions?|retries|retry|fallbacks?|loops?|endpoints?|operations?|jobs?|services?|apps?|programs?|tests?|checks?|validation|failures?)\s+(?:that|which)\s+(?:\w+\s+){0,2}?\w+(?:s|ed)\b/i;
|
|
946
|
+
/** "the run would silently ...": a determiner makes the verb a noun. */
|
|
947
|
+
const NOUN_DETERMINER = /\b(?:the|a|an|this|that|each|every|one|first|last|next|dry|test)\s+$/i;
|
|
948
|
+
/** A negation of the adverb inside the match: "refusing to silently fall back", "instead of quietly ...". */
|
|
949
|
+
const ADVERB_NEGATED = /(?:\brefus(?:e|es|ed|ing)\s+to|\bnot|n['’]t|\bnever|\bavoid(?:s|ed|ing)?|\binstead\s+of|\brather\s+than)\s+(?:to\s+)?$/i;
|
|
950
|
+
const TRAILING_ADVERB = /(?:silently|quietly|secretly|covertly|surreptitiously|stealthily)$/i;
|
|
951
|
+
/** Keeps a "without telling the user" hit unless it only describes what reviewed code does. */
|
|
952
|
+
function concealmentOrCodeReview(severity) {
|
|
953
|
+
return (hit, content) => (DESCRIBES_CODE.test(linePrefix(content, hit.index, 120)) ? 'low' : severity);
|
|
954
|
+
}
|
|
955
|
+
/** Drops "<verb> ... silently" hits where the verb is a noun or the adverb is negated. */
|
|
956
|
+
function verbThenAdverb(severity) {
|
|
957
|
+
return (hit, content) => {
|
|
958
|
+
if (NOUN_DETERMINER.test(linePrefix(content, hit.index, 20)))
|
|
959
|
+
return null;
|
|
960
|
+
const adverb = TRAILING_ADVERB.exec(hit.text);
|
|
961
|
+
return adverb && ADVERB_NEGATED.test(hit.text.slice(0, adverb.index)) ? null : severity;
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
const PI002 = [
|
|
965
|
+
{
|
|
966
|
+
re: /\b(?:do\s+not|don['’]t|never|must\s+not|should\s+not)\s+(?:ever\s+)?(?:tell|inform|notify|alert|warn)\s+(?:the\s+)?user\b/gi,
|
|
967
|
+
severity: 'high',
|
|
968
|
+
label: 'Tells the agent to keep the user uninformed',
|
|
969
|
+
},
|
|
970
|
+
{ re: /\b(?:do\s+not|don['’]t|never)\s+let\s+(?:the\s+)?user\s+(?:know|see|notice|find\s+out)\b/gi, severity: 'high', label: 'Tells the agent to keep the user unaware' },
|
|
971
|
+
{
|
|
972
|
+
re: /\b(?:do\s+not|don['’]t|never|must\s+not)\s+(?:mention|reveal|disclose|show|display|expose|explain|describe)\s+(?:this|these|that|it|anything|any\s+of\s+(?:this|it|these)|the\s+(?:above|following))(?:\s+(?:steps?|tools?|instructions?|notes?|requests?|actions?|sidenote|files?|details?|parts?|changes?))?\s+to\s+(?:the\s+)?(?:user|human|anyone|them)\b/gi,
|
|
973
|
+
severity: 'high',
|
|
974
|
+
label: 'Tells the agent to hide this from the user',
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
re: /\b(?:do\s+not|don['’]t|never|must\s+not)\s+(?:mention|reveal|disclose|admit|say|tell\s+(?:the\s+user|anyone))\s+that\s+you\b/gi,
|
|
978
|
+
severity: 'high',
|
|
979
|
+
label: 'Tells the agent to hide what it is doing',
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
re: /\b(?:do\s+not|don['’]t|never)\s+(?:mention|reveal|disclose|acknowledge|discuss|refer\s+to)\s+(?:this|these|the)\s+(?:hidden\s+|secret\s+|special\s+|additional\s+|extra\s+)?(?:tool|instructions?|steps?|notes?|requirements?|sidenote|directives?|prompt)\b/gi,
|
|
983
|
+
severity: 'high',
|
|
984
|
+
label: 'Tells the agent never to mention these instructions',
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
re: /\bwithout\s+(?:telling|informing|notifying|alerting|warning|letting)\s+(?:the\s+)?user\b/gi,
|
|
988
|
+
severity: 'high',
|
|
989
|
+
label: 'Tells the agent to act without informing the user',
|
|
990
|
+
refine: concealmentOrCodeReview('high'),
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
re: /\bwithout\s+(?:the\s+user|them|anyone)\s+(?:knowing|noticing|seeing|realizing|realising|being\s+aware)\b/gi,
|
|
994
|
+
severity: 'high',
|
|
995
|
+
label: 'Tells the agent to act without the user noticing',
|
|
996
|
+
},
|
|
997
|
+
{ re: /\bwithout\s+(?:asking|consulting)\s+(?:the\s+)?user\b/gi, severity: 'medium', label: 'Tells the agent to act without asking the user' },
|
|
998
|
+
{
|
|
999
|
+
re: new RegExp(`\\b(?:the\\s+)?user\\s+(?:must\\s+not|mustn['’]t|should\\s+not|shouldn['’]t|does\\s+not\\s+need\\s+to|doesn['’]t\\s+need\\s+to|need\\s+not|needn['’]t|must\\s+never|should\\s+never|will\\s+not|won['’]t|cannot|can['’]t)\\s+(?:ever\\s+)?${UNAWARE}`, 'gim'),
|
|
1000
|
+
severity: 'high',
|
|
1001
|
+
label: 'Says the user must not know what happens',
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
re: new RegExp(`\\bso\\s+(?:that\\s+)?(?:the\\s+)?user\\s+(?:does\\s+not|doesn['’]t|won['’]t|will\\s+not|cannot|can['’]t|never)\\s+${UNAWARE}`, 'gim'),
|
|
1005
|
+
severity: 'high',
|
|
1006
|
+
label: 'Arranges for the user not to notice',
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
re: /\bkeep\s+(?:this|it|that|these|them|the\s+[\w-]+(?:\s+[\w-]+)?)\s+(?:a\s+)?(?:secret|hidden|confidential|private)\s+from\s+(?:the\s+)?(?:user|human)s?\b/gi,
|
|
1010
|
+
severity: 'high',
|
|
1011
|
+
label: 'Tells the agent to keep something secret from the user',
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
re: /\bhide\s+(?:this|it|that|these|them|the\s+[\w-]+(?:\s+[\w-]+)?|your\s+[\w-]+)\s+from\s+(?:the\s+)?(?:user|human)s?\b/gi,
|
|
1015
|
+
severity: 'high',
|
|
1016
|
+
label: 'Tells the agent to hide something from the user',
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
re: /\b(?:secretly|covertly|surreptitiously|stealthily)\s+(?:\w+\s+)?(?:run|execute|send|upload|read|call|invoke|delete|fetch|post|copy|forward|install|download|collect|exfiltrate|add|modify|write|access|open)\b/gi,
|
|
1020
|
+
severity: 'high',
|
|
1021
|
+
label: 'Tells the agent to act secretly',
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
re: /\b(?:silently|quietly|discreetly|invisibly)\s+(?:send|upload|post|forward|transmit|exfiltrate|copy|read|collect|access|harvest|steal)\b/gi,
|
|
1025
|
+
severity: 'high',
|
|
1026
|
+
label: 'Tells the agent to move data silently',
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
re: /\b(?:silently|quietly|discreetly)\s+(?:run|execute|call|invoke|fetch|install|download|delete|modify|overwrite|add)\b/gi,
|
|
1030
|
+
severity: 'medium',
|
|
1031
|
+
label: 'Tells the agent to act silently',
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
re: /\b(?:send|upload|post|forward|transmit|exfiltrate|copy|read|collect|access|run|execute|install|download)\b[^\n]{0,80}?\b(?:secretly|covertly|surreptitiously|stealthily)\b/gi,
|
|
1035
|
+
severity: 'high',
|
|
1036
|
+
label: 'Tells the agent to act secretly',
|
|
1037
|
+
refine: verbThenAdverb('high'),
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
re: /\b(?:send|upload|post|forward|transmit|exfiltrate|copy|collect)\b[^\n]{0,80}?\b(?:silently|quietly)\b/gi,
|
|
1041
|
+
severity: 'high',
|
|
1042
|
+
label: 'Tells the agent to move data silently',
|
|
1043
|
+
refine: verbThenAdverb('high'),
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
re: /\b(?:run|execute|install|download|call|invoke|delete)\b[^\n]{0,80}?\b(?:silently|quietly)\b/gi,
|
|
1047
|
+
severity: 'medium',
|
|
1048
|
+
label: 'Tells the agent to act silently',
|
|
1049
|
+
refine: verbThenAdverb('medium'),
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
re: /\b(?:do\s+not|don['’]t|never)\s+(?:mention|reveal|disclose)\s+(?:this|it|that|these)\s+(?:step\s+|action\s+|call\s+)?(?:in|to)\s+(?:your\s+|the\s+)?(?:reply|replies|response|responses|answer|answers|output|summary|messages?|chat)\b/gi,
|
|
1053
|
+
severity: 'medium',
|
|
1054
|
+
label: 'Tells the agent to leave a step out of what it reports',
|
|
1055
|
+
},
|
|
1056
|
+
{ re: /\bbehind\s+the\s+user['’]s\s+back\b/gi, severity: 'high', label: "Tells the agent to act behind the user's back" },
|
|
1057
|
+
{
|
|
1058
|
+
re: /\b(?:this\s+(?:could|might|would)|it\s+(?:could|might|would))\s+(?:even\s+)?(?:upset|scare|worry|alarm)\s+the\s+user\b/gi,
|
|
1059
|
+
severity: 'medium',
|
|
1060
|
+
label: 'Gives the model a reason to keep the user unaware',
|
|
1061
|
+
},
|
|
1062
|
+
];
|
|
1063
|
+
const AM_PI_002 = {
|
|
1064
|
+
id: 'TM-PI-002',
|
|
1065
|
+
title: 'Concealment from the user',
|
|
1066
|
+
description: 'Instructions to act silently, to keep the user uninformed or to never mention a step are how injected text hides its own actions; legitimate skills have no reason to work against the user they serve.',
|
|
1067
|
+
category: 'prompt-injection',
|
|
1068
|
+
severity: 'high',
|
|
1069
|
+
kinds: AGENT_TEXT_AND_CODE,
|
|
1070
|
+
remediation: 'Remove the concealment instruction and review what the hidden step was meant to do.',
|
|
1071
|
+
check: (file) => matchPhrases(file, PI002),
|
|
1072
|
+
};
|
|
1073
|
+
const TAG_MARKERS = [
|
|
1074
|
+
{ re: /<\s*important\s*>/gi, label: '<IMPORTANT>', role: false, closing: /<\/\s*important\s*>/i },
|
|
1075
|
+
{
|
|
1076
|
+
re: /<\s*system(?:[-_ ](?:prompt|message|reminder|instructions?|override|note|directive))?\s*>/gi,
|
|
1077
|
+
label: '<system>',
|
|
1078
|
+
role: true,
|
|
1079
|
+
closing: /<\/\s*system/i,
|
|
1080
|
+
},
|
|
1081
|
+
{ re: /<\|im_start\|>\s*system\b/gi, label: '<|im_start|>system', role: true, closing: /<\|im_end\|>/i },
|
|
1082
|
+
{ re: /<\|system\|>/gi, label: '<|system|>', role: true },
|
|
1083
|
+
{ re: /<\|start_header_id\|>\s*system\b/gi, label: '<|start_header_id|>system', role: true, closing: /<\|eot_id\|>/i },
|
|
1084
|
+
{ re: /<<SYS>>/g, label: '<<SYS>>', role: true, closing: /<<\/SYS>>/ },
|
|
1085
|
+
{ re: /\[INST\]/g, label: '[INST]', role: true, closing: /\[\/INST\]/ },
|
|
1086
|
+
{ re: /<\s*(?:admin|administrator|sudo)(?:[-_ ](?:instructions?|message|note|override))?\s*>/gi, label: '<admin>', role: true },
|
|
1087
|
+
];
|
|
1088
|
+
const POINT_MARKERS = [
|
|
1089
|
+
{ re: /\[(?:SYSTEM|ADMIN|DEVELOPER)(?:[ _](?:MESSAGE|NOTE|NOTICE|OVERRIDE|INSTRUCTIONS?|PROMPT))?\]/g, label: '[SYSTEM]', role: true },
|
|
1090
|
+
{ re: /^[ \t]{0,3}#{1,6}[ \t]*SYSTEM(?:[ _](?:MESSAGE|PROMPT|NOTE|OVERRIDE|INSTRUCTIONS?))?[ \t]*:/gm, label: '### SYSTEM:', role: true },
|
|
1091
|
+
{ re: /^[ \t]{0,3}SYSTEM(?:[ _](?:MESSAGE|PROMPT|NOTE|OVERRIDE|INSTRUCTIONS?))?[ \t]*:/gm, label: 'SYSTEM:', role: true },
|
|
1092
|
+
{ re: /\bSYSTEM[ \t]+(?:OVERRIDE|UPDATE|NOTICE|ALERT|INSTRUCTIONS?|DIRECTIVE)\b/g, label: 'SYSTEM OVERRIDE', role: true },
|
|
1093
|
+
{
|
|
1094
|
+
re: /\b(?:message|note|notice|instructions?|directive)\s+from\s+(?:the\s+|your\s+)?(?:developers?|administrators?|admins?|sysadmins?|system\s+administrators?|dev\s+team|security\s+team|anthropic|openai|operator)\b/gi,
|
|
1095
|
+
label: 'message from the developer/administrator',
|
|
1096
|
+
role: false,
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
re: /\b(?:anthropic|openai|the\s+developers?|the\s+administrators?|your\s+(?:developers?|creators?|administrators?|operators?))\s+(?:has|have)\s+(?:explicitly\s+|already\s+)?(?:authorized|authorised|approved|instructed|permitted|allowed|granted|sanctioned|requested)\b/gi,
|
|
1100
|
+
label: 'claimed vendor or developer authorization',
|
|
1101
|
+
role: false,
|
|
1102
|
+
},
|
|
1103
|
+
{ re: /\b(?:authorized|authorised|approved|sanctioned)\s+by\s+(?:anthropic|openai)\b/gi, label: 'claimed vendor authorization', role: false },
|
|
1104
|
+
];
|
|
1105
|
+
const IMPERATIVE = /\b(?:you\s+(?:must|should|need\s+to|have\s+to|are\s+(?:required|instructed)\s+to|will\s+now)|always|never|do\s+not|don['’]t|ignore|disregard|from\s+now\s+on|make\s+sure|before\s+(?:using|calling|responding|answering)|first\s+(?:call|read|run|use|send))\b/i;
|
|
1106
|
+
/** A lowercase tag name, the usual spelling of a placeholder such as <system> or <admin>. */
|
|
1107
|
+
const LOWERCASE_TAG = /^<[a-z_ -]{1,40}>$/;
|
|
1108
|
+
/** A path or name around a tag: legacy/<system>/x, <system>-uplifted, <system>{,-old}. */
|
|
1109
|
+
const PATH_AROUND = /[/\\]$|^(?:[/\\{]|-\w)/;
|
|
1110
|
+
/** Command-line usage after a tag: "<system> [module]", "<system> --flag", "<system> <path>". */
|
|
1111
|
+
const USAGE_AFTER = /^[ \t]{1,4}(?:\[|--?[A-Za-z]|<[\w-]{1,40}>)/;
|
|
1112
|
+
/**
|
|
1113
|
+
* True for a lowercase tag used as a placeholder rather than a marker: part
|
|
1114
|
+
* of a path or name, command-line usage syntax, or a command's argument-hint.
|
|
1115
|
+
* A tag that is closed later (<system>...</system>) is always a marker.
|
|
1116
|
+
*/
|
|
1117
|
+
function isPlaceholderTag(content, index, text, closing) {
|
|
1118
|
+
if (!LOWERCASE_TAG.test(text))
|
|
1119
|
+
return false;
|
|
1120
|
+
const end = index + text.length;
|
|
1121
|
+
if (closing?.test(content.slice(end, end + 600)))
|
|
1122
|
+
return false;
|
|
1123
|
+
if (PATH_AROUND.test(content.slice(Math.max(0, index - 1), index)) || PATH_AROUND.test(content.slice(end, end + 2)))
|
|
1124
|
+
return true;
|
|
1125
|
+
return USAGE_AFTER.test(content.slice(end, end + 60)) || /\bargument-hint\s*:/i.test(linePrefix(content, index, 200));
|
|
1126
|
+
}
|
|
1127
|
+
/** The text a marker wraps: up to its closing tag when there is one, else the rest of the paragraph (bounded). */
|
|
1128
|
+
function markedRegion(content, from, closing) {
|
|
1129
|
+
const slice = content.slice(from, from + 2000);
|
|
1130
|
+
if (closing) {
|
|
1131
|
+
const end = closing.exec(slice);
|
|
1132
|
+
if (end)
|
|
1133
|
+
return slice.slice(0, end.index);
|
|
1134
|
+
}
|
|
1135
|
+
const paragraph = slice.indexOf('\n\n');
|
|
1136
|
+
return slice.slice(0, Math.min(paragraph === -1 ? 600 : paragraph, 600));
|
|
1137
|
+
}
|
|
1138
|
+
const AM_PI_003 = {
|
|
1139
|
+
id: 'TM-PI-003',
|
|
1140
|
+
title: 'Fake authority or system markers',
|
|
1141
|
+
description: 'Markers such as <IMPORTANT>, <system>, <|im_start|>system, [SYSTEM], "SYSTEM OVERRIDE" or "Anthropic has authorized" try to pass injected text off as a privileged instruction (the MCPTox tool-poisoning pattern). Plain markdown such as **Important:** is not reported.',
|
|
1142
|
+
category: 'prompt-injection',
|
|
1143
|
+
severity: 'medium',
|
|
1144
|
+
kinds: AGENT_TEXT_AND_CODE,
|
|
1145
|
+
remediation: 'Remove the fake marker and review the instructions it wraps.',
|
|
1146
|
+
check(file) {
|
|
1147
|
+
const content = file.content;
|
|
1148
|
+
const out = new Matches(content);
|
|
1149
|
+
const markers = file.kind === 'script' ? TAG_MARKERS : [...TAG_MARKERS, ...POINT_MARKERS];
|
|
1150
|
+
for (const marker of markers) {
|
|
1151
|
+
let resumeAt = 0;
|
|
1152
|
+
for (const hit of findAll(marker.re, content, MAX_HITS)) {
|
|
1153
|
+
if (hit.index < resumeAt || out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1154
|
+
continue;
|
|
1155
|
+
if (isPlaceholderTag(content, hit.index, hit.text, marker.closing))
|
|
1156
|
+
continue;
|
|
1157
|
+
const region = markedRegion(content, hit.index + hit.text.length, marker.closing);
|
|
1158
|
+
resumeAt = hit.index + hit.text.length + region.length;
|
|
1159
|
+
const signal = instructionSignal(region);
|
|
1160
|
+
let base = 'medium';
|
|
1161
|
+
let wraps = '';
|
|
1162
|
+
if (signal?.strong) {
|
|
1163
|
+
base = 'high';
|
|
1164
|
+
wraps = ` wrapping ${signal.label}`;
|
|
1165
|
+
}
|
|
1166
|
+
else if (marker.role && IMPERATIVE.test(region)) {
|
|
1167
|
+
base = 'high';
|
|
1168
|
+
wraps = ' wrapping an imperative instruction';
|
|
1169
|
+
}
|
|
1170
|
+
else if (signal || IMPERATIVE.test(region)) {
|
|
1171
|
+
wraps = ' wrapping an instruction';
|
|
1172
|
+
}
|
|
1173
|
+
const discussed = isDiscussed(file, hit.index);
|
|
1174
|
+
const note = discussedNote(discussed);
|
|
1175
|
+
out.add(hit.index, adjust(base, file.kind, discussed), `Fake authority marker ${marker.label}${wraps}: "${quote(region, 90)}"${note}`);
|
|
1176
|
+
if (out.full)
|
|
1177
|
+
return out.list();
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return out.list();
|
|
1181
|
+
},
|
|
1182
|
+
};
|
|
1183
|
+
const EXFIL_VERB = /\b(?:send|sends|sending|upload|uploads|uploading|post|posts|transmit|transmits|exfiltrate|exfiltrates|leak|leaks|forward|forwards|share|shares|paste|pastes|copy|copies|include|includes|including|attach|attaches|embed|embeds|append|appends|pass|passes|provide|provides|print|prints|output|outputs|dump|dumps|display|displays|reveal|reveals|return|returns|extract|extracts|collect|collects|harvest|harvests|steal|steals|grab|grabs|encode|encodes|email|zip|archive)\b/gi;
|
|
1184
|
+
const READ_VERB = /\b(?:read|reads|reading|cat|open|opens|access|accesses|retrieve|retrieves|fetch|fetches|view|views|inspect|inspects|parse|parses|scan|scans|look\s+(?:at|in|inside|into)|contents?\s+of)\b/gi;
|
|
1185
|
+
const GENERIC_EXFIL_VERB = /\b(?:send|sends|upload|uploads|post|posts|transmit|exfiltrate|leak|leaks|forward|forwards|share|shares|paste|print|prints|output|outputs|dump|dumps|display|displays|reveal|reveals|collect|collects|harvest|steal|extract|extracts|grab|email)\b/gi;
|
|
1186
|
+
const STRICT_READ_VERB = /\b(?:read|reads|reading|cat|access|accesses|retrieve|retrieves|inspect|inspects|parse|parses|contents?\s+of)\b/gi;
|
|
1187
|
+
const NEGATION_TAIL = /(?:\bnever|\bnot|n['’]t|\bavoid|\bwithout|\bno|\brefuse\s+to|\bforbidden\s+to)\s+(?:[\w-]+,?\s+){0,3}$/i;
|
|
1188
|
+
/** A target introduced as a destination ("... in claude_desktop_config.json", "paste it into .env"), not as the thing taken. */
|
|
1189
|
+
const DESTINATION_BEFORE = /\b(?:in|into|to|under|inside|within|onto|through)\s+(?:the\s+|your\s+|its\s+|their\s+|a\s+)?\S{0,20}$/i;
|
|
1190
|
+
const SENTENCE_END = /[.!?;](?=\s|$)/g;
|
|
1191
|
+
const SSH_NOT_SECRET = /(?:\.pub|[\\/](?:config|known_hosts(?:\.old)?|authorized_keys2?|environment|rc))$/i;
|
|
1192
|
+
const ENV_TEMPLATE = /\.env[.-](?:example|sample|template|dist)\b/i;
|
|
1193
|
+
const API_USAGE = /\b(?:header|authorization|bearer|environment\s+variables?|env\s+vars?|placeholder|redact\w*|mask\w*|\.env\.example|hash\w*)\b/i;
|
|
1194
|
+
const SENSITIVE_TARGETS = [
|
|
1195
|
+
{ re: /\.ssh\b(?:[\\/][\w.*-]{0,40})?/gi, what: 'SSH keys', exfil: 'high', read: 'high', skip: (text) => SSH_NOT_SECRET.test(text) },
|
|
1196
|
+
{ re: /\bid_(?:rsa|ed25519|ecdsa|dsa)(?:_sk)?\b(?!\.pub)/gi, what: 'an SSH private key', exfil: 'high', read: 'high' },
|
|
1197
|
+
{ re: /\bssh\s+private\s+keys?\b|\bprivate\s+ssh\s+keys?\b/gi, what: 'SSH private keys', exfil: 'high', read: 'high' },
|
|
1198
|
+
{
|
|
1199
|
+
re: /\.aws[\\/]credentials\b|\bapplication_default_credentials\.json\b|\bgcloud[\\/](?:credentials|access_tokens)\.db\b|\.azure[\\/](?:accessTokens\.json|msal_token_cache[\w.]{0,12}|azureProfile\.json)|\.git-credentials\b|\.docker[\\/]config\.json\b/gi,
|
|
1200
|
+
what: 'cloud credential files',
|
|
1201
|
+
exfil: 'high',
|
|
1202
|
+
read: 'high',
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
re: /\.aws[\\/]config\b|\.kube[\\/]config\b|(?<![\w$])\.npmrc\b|(?<![\w$])\.pypirc\b|(?<![\w$])\.netrc\b|\.gnupg\b|\bterraform\.tfstate\b/gi,
|
|
1206
|
+
what: 'developer credential files',
|
|
1207
|
+
exfil: 'medium',
|
|
1208
|
+
},
|
|
1209
|
+
{
|
|
1210
|
+
re: /\.claude[\\/]\.credentials\.json\b|\.codex[\\/]auth\.json\b|\bgh[\\/]hosts\.ya?ml\b|\.gemini[\\/]oauth_creds\.json\b|\.config[\\/]openai\b/gi,
|
|
1211
|
+
what: 'AI agent or developer tool tokens',
|
|
1212
|
+
exfil: 'high',
|
|
1213
|
+
read: 'high',
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
re: /\bmcp\.json\b|\bclaude_desktop_config(?:\.json)?\b|(?<![\w$])\.claude\.json\b|\bmcp_config\.json\b|\b(?:cline_)?mcp_settings\.json\b/gi,
|
|
1217
|
+
what: 'the MCP client config, which holds server credentials',
|
|
1218
|
+
exfil: 'high',
|
|
1219
|
+
read: 'medium',
|
|
1220
|
+
readVerbs: STRICT_READ_VERB,
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
re: /\bwallet\.dat\b|\b(?:seed|recovery|mnemonic|secret)\s+(?:phrases?|words)\b|\b(?:crypto(?:currency)?|bitcoin|ethereum|metamask|phantom|exodus|electrum|solana|atomic|trust)\s+wallets?\b|\bwallet\s+(?:files?|data|vaults?|private\s+keys?|keystores?|seeds?)\b|\bnkbihfbeogaeaoehlefnkodbefgpgknn\b/gi,
|
|
1224
|
+
what: 'crypto wallet data',
|
|
1225
|
+
exfil: 'high',
|
|
1226
|
+
read: 'high',
|
|
1227
|
+
},
|
|
1228
|
+
{ re: /\bLocal\s+Extension\s+Settings\b/g, what: 'browser extension storage (wallet vaults)', exfil: 'high', read: 'high' },
|
|
1229
|
+
{
|
|
1230
|
+
re: /\blogin\.keychain(?:-db)?\b|[\\/]Keychains\b|\bkeychain\s+(?:passwords?|items?|entries|dump|contents?|database|secrets?)\b|\bdump-keychain\b|\bfind-(?:generic|internet)-password\b/gi,
|
|
1231
|
+
what: 'keychain secrets',
|
|
1232
|
+
exfil: 'high',
|
|
1233
|
+
read: 'high',
|
|
1234
|
+
},
|
|
1235
|
+
{ re: /\b(?:macos|mac|os\s*x|apple|system|login|gnome|kde)\s+(?:keychain|keyring)\b|\bkeychain\b/gi, what: 'the OS keychain', exfil: 'high', read: 'medium' },
|
|
1236
|
+
{ re: /\bLogin\s+Data\b/g, what: 'the browser password store', exfil: 'high', read: 'high' },
|
|
1237
|
+
{
|
|
1238
|
+
re: /\blogins\.json\b|\bkey[34]\.db\b|\bcookies\.sqlite\b|\b(?:browser|chrome|chromium|firefox|edge|brave|safari|opera|vivaldi)(?:['’]s)?\s+(?:saved\s+|stored\s+)?(?:passwords?|cookies|credentials|login\s+data|autofill\s+data|password\s+(?:store|database|vault))\b|\bsaved\s+(?:browser\s+)?passwords\b/gi,
|
|
1239
|
+
what: 'browser password stores',
|
|
1240
|
+
exfil: 'high',
|
|
1241
|
+
read: 'high',
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
re: /(?<![\w$])\.env\b(?\b)(?:\.[\w-]{1,20})?/gi,
|
|
1245
|
+
what: 'a .env file',
|
|
1246
|
+
exfil: 'medium',
|
|
1247
|
+
read: 'low',
|
|
1248
|
+
skip: (_text, window) => ENV_TEMPLATE.test(window),
|
|
1249
|
+
},
|
|
1250
|
+
{
|
|
1251
|
+
re: /\bapi[_ -]?keys?\b|\bpasswords?\b|\bcredentials?\b|\bsecret\s+keys?\b|\b(?:access|auth|bearer|session|refresh|oauth)\s+tokens?\b|\bprivate\s+keys?\b|\bsecrets\b/gi,
|
|
1252
|
+
what: 'API keys or passwords',
|
|
1253
|
+
exfil: 'medium',
|
|
1254
|
+
verbs: GENERIC_EXFIL_VERB,
|
|
1255
|
+
skip: (_text, window) => API_USAGE.test(window),
|
|
1256
|
+
},
|
|
1257
|
+
];
|
|
1258
|
+
/** The sentence around [start, end) on its line, at most `reach` characters either side, and where it starts. */
|
|
1259
|
+
function sentenceWindow(content, start, end, reach) {
|
|
1260
|
+
let prefix = linePrefix(content, start, reach);
|
|
1261
|
+
let lastEnd = -1;
|
|
1262
|
+
for (const hit of findAll(SENTENCE_END, prefix, 200))
|
|
1263
|
+
lastEnd = hit.index + 1;
|
|
1264
|
+
if (lastEnd > 0)
|
|
1265
|
+
prefix = prefix.slice(lastEnd);
|
|
1266
|
+
let suffix = lineSuffix(content, end, reach);
|
|
1267
|
+
const stop = suffix.search(SENTENCE_END);
|
|
1268
|
+
if (stop !== -1)
|
|
1269
|
+
suffix = suffix.slice(0, stop);
|
|
1270
|
+
const target = content.slice(start, end);
|
|
1271
|
+
return { text: prefix + target + suffix, offset: start - prefix.length, targetStart: prefix.length, targetEnd: prefix.length + target.length };
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* The first non-negated access verb in the target's sentence, its offset there,
|
|
1275
|
+
* and the severity it implies. Exfiltration verbs do not count when the target
|
|
1276
|
+
* is only a destination ("pass it through the env block in mcp.json").
|
|
1277
|
+
*/
|
|
1278
|
+
function accessVerb(target, window) {
|
|
1279
|
+
const text = window.text;
|
|
1280
|
+
const negated = (index) => NEGATION_TAIL.test(text.slice(Math.max(0, index - 40), index));
|
|
1281
|
+
const isDestination = DESTINATION_BEFORE.test(text.slice(Math.max(0, window.targetStart - 30), window.targetStart));
|
|
1282
|
+
if (!isDestination) {
|
|
1283
|
+
for (const hit of findAll(target.verbs ?? EXFIL_VERB, text, 20)) {
|
|
1284
|
+
if (target.verbs && hit.index > window.targetStart)
|
|
1285
|
+
break;
|
|
1286
|
+
if (hit.index >= window.targetStart && hit.index < window.targetEnd)
|
|
1287
|
+
continue;
|
|
1288
|
+
if (!negated(hit.index))
|
|
1289
|
+
return { verb: hit.text.toLowerCase(), index: hit.index, severity: target.exfil };
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (target.read) {
|
|
1293
|
+
for (const hit of findAll(target.readVerbs ?? READ_VERB, text, 20)) {
|
|
1294
|
+
if (!negated(hit.index))
|
|
1295
|
+
return { verb: hit.text.toLowerCase(), index: hit.index, severity: target.read };
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return undefined;
|
|
1299
|
+
}
|
|
1300
|
+
const AM_PI_004 = {
|
|
1301
|
+
id: 'TM-PI-004',
|
|
1302
|
+
title: 'Instruction to access sensitive files or secrets',
|
|
1303
|
+
description: 'An access verb (read, cat, send, pass, include ...) within about 100 characters of a sensitive target steers the agent into reading secrets: SSH keys, cloud credential files, crypto wallets, keychains, browser password stores and MCP client configs (high), or .env files and generic API keys or passwords (medium).',
|
|
1304
|
+
category: 'prompt-injection',
|
|
1305
|
+
severity: 'high',
|
|
1306
|
+
kinds: AGENT_TEXT,
|
|
1307
|
+
remediation: 'Remove the instruction. A skill should never need the contents of credential stores; ask the user for the specific value instead.',
|
|
1308
|
+
check(file) {
|
|
1309
|
+
const content = file.content;
|
|
1310
|
+
const out = new Matches(content);
|
|
1311
|
+
for (const target of SENSITIVE_TARGETS) {
|
|
1312
|
+
const best = target.read && SEVERITY_RANK[target.read] > SEVERITY_RANK[target.exfil] ? target.read : target.exfil;
|
|
1313
|
+
const ceiling = adjust(best, file.kind, false);
|
|
1314
|
+
for (const hit of findAll(target.re, content, MAX_HITS)) {
|
|
1315
|
+
if (out.covers(hit.index, ceiling))
|
|
1316
|
+
continue;
|
|
1317
|
+
const window = sentenceWindow(content, hit.index, hit.index + hit.text.length, 100);
|
|
1318
|
+
if (target.skip?.(hit.text, window.text))
|
|
1319
|
+
continue;
|
|
1320
|
+
const access = accessVerb(target, window);
|
|
1321
|
+
if (!access)
|
|
1322
|
+
continue;
|
|
1323
|
+
const discussed = isDiscussed(file, Math.min(hit.index, window.offset + access.index), false);
|
|
1324
|
+
const note = discussedNote(discussed);
|
|
1325
|
+
const message = `Asks the agent to access ${target.what}: "${access.verb}" next to "${quote(hit.text, 60)}"${note}`;
|
|
1326
|
+
out.add(hit.index, adjust(access.severity, file.kind, discussed), message);
|
|
1327
|
+
if (out.full)
|
|
1328
|
+
return out.list();
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return out.list();
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
// TM-PI-005 Data exfiltration instruction
|
|
1336
|
+
// ---------------------------------------------------------------------------
|
|
1337
|
+
const SEND_VERB = /\b(?:send|sends|sending|post|posts|posting|upload|uploads|uploading|forward|forwards|forwarding|transmit|transmits|exfiltrate|exfiltrates|leak|leaks|beacon|relay|relays|deliver|delivers|report|reports|email|e-mail|mail)\b/gi;
|
|
1338
|
+
const DESTINATION = /https?:\/\/[^\s)"'<>\]]{2,200}|\b[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,4}\b|\bwebhooks?\b|\b(?:remote|external|attacker['’]?s?|our|my|third[- ]party|collection|logging|exfil(?:tration)?)\s+(?:server|endpoint|host|url|api|collector|address|bucket)\b|\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{2,5})?\b/i;
|
|
1339
|
+
const CONNECTOR_TAIL = /\b(?:to|at|into|via|towards?|onto|through|using)\b[^\n]{0,40}$/i;
|
|
1340
|
+
const EMAIL = /^[\w.+-]{1,64}@/;
|
|
1341
|
+
const STOLEN_DATA = /\b(?:secrets?|credentials?|passwords?|passphrases?|(?:api|access|auth|session|refresh|bearer|oauth)[_ -]?(?:keys?|tokens?)|tokens|private\s+keys?|ssh\s+keys?|id_rsa|id_ed25519|env(?:ironment)?\s+var(?:iable)?s|cookies|conversation(?:\s+history)?|chat\s+(?:history|logs?|transcripts?)|(?:message|conversation)\s+history|(?:previous|prior|all|earlier|whole)\s+(?:messages|conversation)|system\s+prompt|contents?\s+of|file\s+contents?|(?:its|their|the)\s+contents?|keychain|wallets?|seed\s+phrases?|personal\s+(?:data|information|details)|pii|(?:user|customer)['’]?s?\s+(?:data|files|emails|messages|information|details|documents))\b|\.ssh\b|\.aws\b|(?<![\w$])\.env\b|\bmcp\.json\b|\bclaude_desktop_config\b/i;
|
|
1342
|
+
const GENERIC_DATA = /\b(?:data|results?|outputs?|files?|logs?|everything|it|them|this|these|those|information|info|details|documents?|summary|summaries|report|transcripts?|answers?|responses?|history|findings|notes|screenshots?|attachments?)\b/i;
|
|
1343
|
+
const PRONOUN_OBJECT = /^\s*(?:it|them|this|these|those|that|everything|all\s+of\s+(?:it|them|this))\b/i;
|
|
1344
|
+
const MASS_REDIRECT = /\b(?:all|every|each|any)\s+(?:\w+\s+)?(?:emails?|messages?|mails?|correspondence)\b/i;
|
|
1345
|
+
const EXFIL_SINK = /webhook\.site|requestbin|pipedream\.net|ngrok|trycloudflare\.com|interact\.sh|\.oast\.|burpcollaborator|discord(?:app)?\.com\/api\/webhooks|api\.telegram\.org|pastebin\.com|transfer\.sh|0x0\.st|^(?:https?:\/\/)?(?:\d{1,3}\.){3}\d{1,3}\b/i;
|
|
1346
|
+
const EMBED_VERB = /\b(?:append|appends|add|adds|include|includes|put|puts|pass|passes|place|places|embed|embeds|encode|encodes|insert|inserts|stuff|attach|attaches|smuggle|hide|hides|store|stores|write|writes|set|sets|provide|provides|send|sends)\b/gi;
|
|
1347
|
+
const CARRIER = /\b(?:as|in|into|inside|within|via|to|through)\s+(?:the\s+|a\s+|an\s+|its\s+)?(?:value\s+of\s+(?:the\s+)?)?(?:['"`]?(?:sidenote|side_note|side-note)['"`]?|['"`](?:notes?|metadata|context|comment|extra|debug|annotations?|feedback|reasoning|memo|remarks?)['"`]|(?:notes?|metadata|context|comment|extra|debug|annotations?|feedback|reasoning|memo|remarks?)\s+(?:parameter|param|field|argument|arg|property)|(?:url|query\s*string|querystring|query\s+param(?:eter)?s?|request\s+url|link|image\s+(?:url|link|src)|url\s+param(?:eter)?s?|get\s+param(?:eter)?s?|subdomain|dns\s+(?:query|lookup|request)))/i;
|
|
1348
|
+
const URL_CARRIER = /(?:url|query|link|param|subdomain|dns)/i;
|
|
1349
|
+
const OWN_API_KEY = /^\s*(?:the\s+|your\s+)?(?:api|access|auth)[_ -]?(?:key|token)\s*$/i;
|
|
1350
|
+
const SIDENOTE = /\bside[_-]?note\b/gi;
|
|
1351
|
+
const SIDENOTE_DATA = /\b(?:contents?|file|key|secret|token|password|credential|ssh|env|config|json|data|conversation|history)\b/i;
|
|
1352
|
+
const MD_IMAGE = /!\[[^\]\n]{0,200}\]\([ \t]{0,3}<?(https?:\/\/[^\s)>]{1,600})/g;
|
|
1353
|
+
const HTML_IMG = /<img\b[^<>]{0,300}?\bsrc\s*=\s*["']?(https?:\/\/[^"'\s>]{1,600})/gi;
|
|
1354
|
+
const TEMPLATE_MARK = /\{[^}\s]{0,80}\}|\{\{|\$\{|\$[A-Za-z_][A-Za-z0-9_]{1,40}|<[^>\s]{1,60}|%7[bBdD]|\[[A-Z_]{3,40}\]|\b(?:INSERT|PLACEHOLDER|DATA_HERE|YOUR_DATA)\b|=(?:[A-Z0-9]{1,20}_){1,4}[A-Z0-9]{1,20}\b|=(?:SUMMARY|SECRETS?|TOKENS?|DATA|CONTENTS?|HISTORY|CONVERSATION|MESSAGES?|PASSWORDS?|KEYS?|ENV|CHAT|CONTEXT|MEMORY)\b/;
|
|
1355
|
+
const SENSITIVE_NAME = /secret|token|key|pass|cred|content|data|history|conversation|chat|message|env|ssh|summary|context|memory|user|prompt/i;
|
|
1356
|
+
const BCC_PATTERNS = [
|
|
1357
|
+
/\b(?:always|automatically|silently|secretly|quietly|also|additionally|be\s+sure\s+to|make\s+sure\s+to)\s+(?:also\s+)?(?:add\s+)?(bcc|cc)\b[^\n]{0,60}?([\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,4})/gi,
|
|
1358
|
+
/\b(bcc|cc)\s+(?:all|every|each|any)\b[^\n]{0,60}?([\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,4})/gi,
|
|
1359
|
+
/\badd\s+([\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,4})\s+(?:as\s+(?:a\s+|the\s+)?|to\s+(?:the\s+)?)(bcc|cc)\b/gi,
|
|
1360
|
+
];
|
|
1361
|
+
/** Where an image URL carries templated data, and how sure that is exfiltration. */
|
|
1362
|
+
function imageExfil(url) {
|
|
1363
|
+
const mark = TEMPLATE_MARK.exec(url);
|
|
1364
|
+
if (!mark)
|
|
1365
|
+
return undefined;
|
|
1366
|
+
const query = url.search(/[?#&=]/);
|
|
1367
|
+
if (query !== -1 && mark.index > query)
|
|
1368
|
+
return 'high';
|
|
1369
|
+
return SENSITIVE_NAME.test(mark[0]) ? 'high' : 'medium';
|
|
1370
|
+
}
|
|
1371
|
+
/** "send X to <destination>": the object decides how sure this is exfiltration. */
|
|
1372
|
+
function findSends(file, out) {
|
|
1373
|
+
const content = file.content;
|
|
1374
|
+
for (const hit of findAll(SEND_VERB, content, MAX_HITS)) {
|
|
1375
|
+
if (out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1376
|
+
continue;
|
|
1377
|
+
const after = lineSuffix(content, hit.index + hit.text.length, 200);
|
|
1378
|
+
const dest = DESTINATION.exec(after);
|
|
1379
|
+
if (!dest)
|
|
1380
|
+
continue;
|
|
1381
|
+
const object = after.slice(0, dest.index);
|
|
1382
|
+
if (!CONNECTOR_TAIL.test(object))
|
|
1383
|
+
continue;
|
|
1384
|
+
const destination = dest[0];
|
|
1385
|
+
let severity;
|
|
1386
|
+
if (STOLEN_DATA.test(object) || (MASS_REDIRECT.test(object) && EMAIL.test(destination)))
|
|
1387
|
+
severity = 'high';
|
|
1388
|
+
else if (GENERIC_DATA.test(object)) {
|
|
1389
|
+
const refersBack = PRONOUN_OBJECT.test(object) && STOLEN_DATA.test(linePrefix(content, hit.index, 150));
|
|
1390
|
+
severity = refersBack || EXFIL_SINK.test(destination) ? 'high' : 'medium';
|
|
1391
|
+
}
|
|
1392
|
+
if (!severity || (file.kind === 'script' && severity !== 'high'))
|
|
1393
|
+
continue;
|
|
1394
|
+
const what = quote(object.replace(CONNECTOR_TAIL, ''), 60) || 'data';
|
|
1395
|
+
const discussed = isDiscussed(file, hit.index);
|
|
1396
|
+
const note = discussedNote(discussed);
|
|
1397
|
+
out.add(hit.index, adjust(severity, file.kind, discussed), `Tells the agent to ${hit.text.toLowerCase()} ${what} to ${quote(destination.replace(/[.,;:!?]+$/, ''), 80)}${note}`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/** "pass the file contents as 'sidenote'" / "put the secrets in the URL": data hidden in an innocuous carrier. */
|
|
1401
|
+
function findSmuggling(file, out) {
|
|
1402
|
+
const content = file.content;
|
|
1403
|
+
for (const hit of findAll(EMBED_VERB, content, MAX_HITS)) {
|
|
1404
|
+
if (out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1405
|
+
continue;
|
|
1406
|
+
const after = lineSuffix(content, hit.index + hit.text.length, 160);
|
|
1407
|
+
const carrier = CARRIER.exec(after);
|
|
1408
|
+
if (!carrier)
|
|
1409
|
+
continue;
|
|
1410
|
+
const object = after.slice(0, carrier.index);
|
|
1411
|
+
if (!STOLEN_DATA.test(object))
|
|
1412
|
+
continue;
|
|
1413
|
+
if (URL_CARRIER.test(carrier[0]) && OWN_API_KEY.test(object))
|
|
1414
|
+
continue;
|
|
1415
|
+
const discussed = isDiscussed(file, hit.index);
|
|
1416
|
+
out.add(hit.index, adjust('high', file.kind, discussed), `Tells the agent to hide ${quote(object, 60)} ${quote(carrier[0], 50)}, where the data leaves unnoticed${discussedNote(discussed)}`);
|
|
1417
|
+
}
|
|
1418
|
+
for (const hit of findAll(SIDENOTE, content, MAX_HITS)) {
|
|
1419
|
+
if (out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1420
|
+
continue;
|
|
1421
|
+
if (!SIDENOTE_DATA.test(lineWindow(content, hit.index, hit.index + hit.text.length, 150)))
|
|
1422
|
+
continue;
|
|
1423
|
+
const discussed = isDiscussed(file, hit.index);
|
|
1424
|
+
out.add(hit.index, adjust('high', file.kind, discussed), `Routes data through a "${hit.text}" parameter, the MCPTox exfiltration channel: "${quote(lineWindow(content, hit.index, hit.index, 80))}"${discussedNote(discussed)}`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/** Markdown or HTML images whose URL carries templated data: rendering them sends that data to the host. */
|
|
1428
|
+
function findImages(file, out) {
|
|
1429
|
+
for (const re of [MD_IMAGE, HTML_IMG]) {
|
|
1430
|
+
for (const hit of findAll(re, file.content, MAX_HITS)) {
|
|
1431
|
+
if (out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1432
|
+
continue;
|
|
1433
|
+
const url = hit.groups[0] ?? '';
|
|
1434
|
+
const severity = imageExfil(url);
|
|
1435
|
+
if (!severity || (file.kind === 'script' && severity !== 'high'))
|
|
1436
|
+
continue;
|
|
1437
|
+
out.add(hit.index, adjust(severity, file.kind, false), `Image URL carries templated data, so rendering it leaks that data to the host: "${quote(url, 90)}"`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/** "always BCC x@y": hidden redirection of the user's mail. */
|
|
1442
|
+
function findBcc(file, out) {
|
|
1443
|
+
for (const re of BCC_PATTERNS) {
|
|
1444
|
+
for (const hit of findAll(re, file.content, MAX_HITS)) {
|
|
1445
|
+
if (out.covers(hit.index, adjust('high', file.kind, false)))
|
|
1446
|
+
continue;
|
|
1447
|
+
const [first = '', second = ''] = hit.groups;
|
|
1448
|
+
const kind = (first.includes('@') ? second : first).toLowerCase();
|
|
1449
|
+
const address = first.includes('@') ? first : second;
|
|
1450
|
+
const discussed = isDiscussed(file, hit.index);
|
|
1451
|
+
const base = kind === 'bcc' ? 'high' : 'medium';
|
|
1452
|
+
out.add(hit.index, adjust(base, file.kind, discussed), `Tells the agent to ${kind.toUpperCase()} ${quote(address, 80)} on messages it sends${discussedNote(discussed)}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
const AM_PI_005 = {
|
|
1457
|
+
id: 'TM-PI-005',
|
|
1458
|
+
title: 'Data exfiltration instruction',
|
|
1459
|
+
description: 'Instructions to send secrets or conversation data to a URL, webhook or address, to hide file contents in an innocuous parameter such as "sidenote" or in a URL, to always BCC an outside address, or markdown images whose URL carries templated data, all move data out of the session.',
|
|
1460
|
+
category: 'prompt-injection',
|
|
1461
|
+
severity: 'high',
|
|
1462
|
+
kinds: AGENT_TEXT_AND_CODE,
|
|
1463
|
+
remediation: 'Remove the instruction. Data should only leave the session through tools the user invoked for that purpose.',
|
|
1464
|
+
check(file) {
|
|
1465
|
+
const out = new Matches(file.content);
|
|
1466
|
+
findSmuggling(file, out);
|
|
1467
|
+
findBcc(file, out);
|
|
1468
|
+
findImages(file, out);
|
|
1469
|
+
findSends(file, out);
|
|
1470
|
+
return out.list();
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
// ---------------------------------------------------------------------------
|
|
1474
|
+
// TM-PI-006 Tool shadowing
|
|
1475
|
+
// ---------------------------------------------------------------------------
|
|
1476
|
+
const NOT_A_TOOL_NAME = /^(?:this|that|the|a|an|any|same|each|every|its|your|my)$/i;
|
|
1477
|
+
/** A live tool definition's own name, from its synthetic path "mcp://server/tool" (or ".../tool#output"). */
|
|
1478
|
+
function ownToolName(file) {
|
|
1479
|
+
if (!file.path.startsWith('mcp://'))
|
|
1480
|
+
return undefined;
|
|
1481
|
+
const name = file.path.slice(file.path.lastIndexOf('/') + 1).replace(/#.*$/, '');
|
|
1482
|
+
return name ? name.toLowerCase() : undefined;
|
|
1483
|
+
}
|
|
1484
|
+
/** Accepts a hit only when capture group 1 names some other tool, not "this" one or the tool itself. */
|
|
1485
|
+
function namesOtherTool(hit, file) {
|
|
1486
|
+
const name = hit.groups[0] ?? '';
|
|
1487
|
+
return !NOT_A_TOOL_NAME.test(name) && name.toLowerCase() !== ownToolName(file);
|
|
1488
|
+
}
|
|
1489
|
+
const PI006 = [
|
|
1490
|
+
{
|
|
1491
|
+
re: /\bbefore\s+(?:using|calling|invoking|running|executing)\s+(?:any|every|each|all|another|other)\s+(?:other\s+)?(?:tools?|functions?|mcp\s+tools?)\b|\bbefore\s+any\s+other\s+(?:tool|function)\b/gi,
|
|
1492
|
+
severity: ['high', 'medium'],
|
|
1493
|
+
label: 'Puts itself in front of every other tool call',
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
re: /\balways\s+(?:call|use|invoke|run)\s+this\s+tool\s+(?:first|before)\b|\b(?:call|use|invoke|run)\s+this\s+tool\s+(?:first\s+)?before\s+(?:any|every|each|all|using|calling|invoking)\b/gi,
|
|
1497
|
+
severity: ['high', 'medium'],
|
|
1498
|
+
label: 'Demands to be called before other tools',
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
re: /\bthis\s+tool\s+(?:must|should|has\s+to|shall)\s+(?:always\s+)?be\s+(?:used|called|invoked|preferred|chosen)\s+(?:instead\s+of|before|in\s+place\s+of|over|rather\s+than)\b|\bthis\s+tool\s+(?:replaces|overrides|supersedes)\s+(?:the\s+)?[\w.-]{1,60}/gi,
|
|
1502
|
+
severity: ['high', 'medium'],
|
|
1503
|
+
label: 'Claims to replace another tool',
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
re: /\b(?:override|overrides|replace|replaces|change|changes|modify|modifies|intercept|intercepts|alter|alters|redefine|redefines|hijack|hijacks)\s+(?:the\s+)?(?:behaviou?r|output|outputs|results?|responses?|functionality|arguments?|parameters?|recipients?|inputs?)\s+of\s+(?:the\s+|any\s+|all\s+|every\s+)?(?:other\s+)?([\w.-]{1,60})\s+tools?\b/gi,
|
|
1507
|
+
severity: ['high', 'medium'],
|
|
1508
|
+
label: 'Changes how another tool behaves',
|
|
1509
|
+
accept: namesOtherTool,
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
re: /\b(?:side[- ]effects?|effects?|impact|influence)\s+on\s+(?:the\s+)?(?:also\s+present\s+|other\s+|existing\s+)?(?!(?:next|first|last|following|previous|each|every|any)\s)([\w.-]{1,60})\s+tools?\b/gi,
|
|
1513
|
+
severity: ['high', 'medium'],
|
|
1514
|
+
label: 'Describes a side effect on another tool (tool shadowing)',
|
|
1515
|
+
accept: namesOtherTool,
|
|
1516
|
+
},
|
|
1517
|
+
{ re: /\bwhen(?:ever)?\s+this\s+tool\s+is\s+(?:available|present|installed|enabled|loaded|connected|active)\b/gi, severity: ['high', 'medium'], label: 'Changes behaviour merely by being installed (tool shadowing)' },
|
|
1518
|
+
{
|
|
1519
|
+
re: /\b([\w.-]{1,60})\s+tool\s+(?:must|should|shall|has\s+to|needs\s+to)\s+(?:always\s+|now\s+|also\s+|only\s+)?(?:send|use|include|add|bcc|cc|forward|redirect|route|point|pass|be\s+called\s+with|ignore|skip)\b/gi,
|
|
1520
|
+
severity: ['high', 'medium'],
|
|
1521
|
+
label: 'Dictates what another tool must do',
|
|
1522
|
+
accept: namesOtherTool,
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
re: /\binstead\s+of\s+(?:using|calling|invoking)\s+(?:the\s+|any\s+)?(?:([\w.-]{1,60})\s+)?(?:tool|function|mcp\s+server)s?\b|\binstead\s+of\s+(?:using|calling|invoking)\s+(?:the\s+)?`?([a-z][a-z0-9]{0,30}_[a-z0-9_]{1,60})`?/gi,
|
|
1526
|
+
severity: ['high', 'low'],
|
|
1527
|
+
label: 'Redirects calls away from another tool',
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
re: /\b(?:use|call|invoke|prefer)\s+this\s+(?:tool|function)\s+(?:instead\s+of|in\s+place\s+of|rather\s+than|over)\b/gi,
|
|
1531
|
+
severity: ['high', 'medium'],
|
|
1532
|
+
label: 'Claims to replace another tool',
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
re: /\b(?:never|do\s+not|don['’]t)\s+(?:use|call|invoke)\s+(?:the\s+)?([\w.-]{1,60})\s+tool\b/gi,
|
|
1536
|
+
severity: ['high', 'medium'],
|
|
1537
|
+
label: 'Tells the agent never to use another tool',
|
|
1538
|
+
accept: namesOtherTool,
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
re: /\b(?:when(?:ever)?|if)\s+(?:the\s+)?user\s+(?:asks|requests|wants|says|tries|types)\b[^\n]{0,80}?\binstead\b/gi,
|
|
1542
|
+
severity: ['high', 'medium'],
|
|
1543
|
+
label: 'Redirects what the user asked for',
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
re: /\bwhen(?:ever)?\s+(?:you\s+)?(?:use|call|invoke|using|calling|invoking)\s+(?:the\s+)?([\w.-]{1,60})\s+tool\s*,?\s+(?:always|also|you\s+must|make\s+sure\s+to|first|never|be\s+sure\s+to)\b/gi,
|
|
1547
|
+
severity: ['high', 'low'],
|
|
1548
|
+
label: 'Adds conditions to every use of another tool',
|
|
1549
|
+
accept: namesOtherTool,
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
re: /\bbefore\s+(?:using|calling|invoking)\s+(?:the\s+)?([\w.-]{1,60})\s+tool\b/gi,
|
|
1553
|
+
severity: ['medium', 'low'],
|
|
1554
|
+
label: 'Puts a step in front of every use of another tool',
|
|
1555
|
+
accept: namesOtherTool,
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
re: /\bafter\s+(?:using|calling|invoking)\s+(?:the\s+)?([\w.-]{1,60})\s+tool\b/gi,
|
|
1559
|
+
severity: 'low',
|
|
1560
|
+
label: 'Sequences itself after another tool',
|
|
1561
|
+
accept: namesOtherTool,
|
|
1562
|
+
},
|
|
1563
|
+
];
|
|
1564
|
+
const AM_PI_006 = {
|
|
1565
|
+
id: 'TM-PI-006',
|
|
1566
|
+
title: 'Tool shadowing or cross-tool manipulation',
|
|
1567
|
+
description: 'A tool description that changes how other tools are used (call this first, use instead of, always BCC through send_email, never use the X tool) lets one poisoned MCP server hijack trusted tools without ever being called itself.',
|
|
1568
|
+
category: 'prompt-injection',
|
|
1569
|
+
severity: 'medium',
|
|
1570
|
+
kinds: AGENT_TEXT_AND_CODE,
|
|
1571
|
+
remediation: 'Remove references to other tools from the description; a tool should only describe itself.',
|
|
1572
|
+
check: (file) => matchPhrases(file, PI006, false),
|
|
1573
|
+
};
|
|
1574
|
+
// ---------------------------------------------------------------------------
|
|
1575
|
+
// TM-PI-007 Safety bypass or permission escalation
|
|
1576
|
+
// ---------------------------------------------------------------------------
|
|
1577
|
+
const RISKY_ACTION = /\b(?:delete|deletes|remove|removes|rm|wipe|erase|drop|overwrite|force|deploy|publish|install|execute|run|send|upload|transfer|pay|purchase|buy|sudo|curl|wget|download)\b/i;
|
|
1578
|
+
const CONFIG_EDIT = /\b(?:add|append|insert|put|write|set|update|edit|modify|change)\b/i;
|
|
1579
|
+
const PERMISSION_WORD = /\b(?:permissions?|allowedTools|allow\s*(?:list|rules?)|bypass\w*|auto-?approve|defaultMode|trust(?:ed)?\s+(?:all|tools))\b|"allow"/i;
|
|
1580
|
+
const riskyOrMedium = (hit, content) => RISKY_ACTION.test(lineWindow(content, hit.index, hit.index + hit.text.length, 120)) ? 'high' : 'medium';
|
|
1581
|
+
const PI007 = [
|
|
1582
|
+
{ re: /--dangerously-skip-permissions\b/gi, severity: 'high', label: 'Tells the agent to skip every permission prompt' },
|
|
1583
|
+
{ re: /\bbypassPermissions\b/g, severity: 'high', label: 'Uses the bypassPermissions mode, which approves every tool call' },
|
|
1584
|
+
{ re: /\bdangerouslyDisableSandbox\b/g, severity: 'high', label: 'Asks for commands to run outside the sandbox' },
|
|
1585
|
+
{
|
|
1586
|
+
re: /--dangerously-bypass-approvals-and-sandbox\b|--yolo\b|--trust-all-tools\b|--allow-dangerously-skip-permissions\b/gi,
|
|
1587
|
+
severity: 'high',
|
|
1588
|
+
label: 'Uses a flag that turns off approvals',
|
|
1589
|
+
},
|
|
1590
|
+
{ re: /\byolo\s+mode\b/gi, severity: 'high', label: 'Tells the agent to run in YOLO mode' },
|
|
1591
|
+
{
|
|
1592
|
+
re: /\b(?:disable|disabling|turn\s+off|turning\s+off|switch\s+off|bypass|bypassing|circumvent|deactivate|skip|get\s+around|evade|remove)\s+(?:the\s+|your\s+|all\s+|any\s+)?(?:sandbox(?:ing)?|safety\s+(?:checks?|filters?|guardrails?|features?|measures?|restrictions?|guidelines|rules|settings)|guardrails|permission\s+(?:prompts?|checks?|system|dialogs?|requests?|model)|security\s+(?:checks?|warnings?|prompts?|controls?|restrictions?|policies|settings)|approval\s+(?:prompts?|steps?|checks?|requirements?)|content\s+(?:filters?|filtering|polic(?:y|ies)))\b/gi,
|
|
1593
|
+
severity: 'high',
|
|
1594
|
+
label: 'Tells the agent to switch off a safeguard',
|
|
1595
|
+
},
|
|
1596
|
+
{ re: /\bauto[- ]?approve\s+(?:all|every|any|everything)\b/gi, severity: 'high', label: 'Tells the agent to auto-approve everything' },
|
|
1597
|
+
{
|
|
1598
|
+
re: /\b(?:approve|accept|allow)\s+(?:all|every|any)\s+(?:tool\s+calls?|tools?|actions?|commands?|permissions?|permission\s+requests?|operations?)\s+(?:automatically|without)\b/gi,
|
|
1599
|
+
severity: 'high',
|
|
1600
|
+
label: 'Tells the agent to approve every action automatically',
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
re: /\bwithout\s+(?:asking|requesting|prompting|seeking|needing|waiting|checking)\s+(?:for\s+)?(?:the\s+user['’]?s?\s+|user\s+|any\s+|their\s+|for\s+)?(?:permission|confirmation|approval|consent)\b/gi,
|
|
1604
|
+
severity: 'high',
|
|
1605
|
+
label: 'Tells the agent to act without asking for permission',
|
|
1606
|
+
refine: riskyOrMedium,
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
re: /\b(?:do\s+not|don['’]t|never)\s+ask\s+(?:the\s+user\s+)?(?:for\s+)?(?:permission|confirmation|approval|consent)\b/gi,
|
|
1610
|
+
severity: 'high',
|
|
1611
|
+
label: 'Tells the agent not to ask for permission',
|
|
1612
|
+
refine: riskyOrMedium,
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
re: /\b(?:add|append|insert|put|write|set|update|edit|modify)\b[^\n]{0,80}?(?:\ballowedTools\b|\ballowed_tools\b|\bpermissions\.allow\b|\bdefaultMode\b|\bautoApprove\b|\balwaysAllow\b|\btrustedTools\b|--allowedTools\b)/gi,
|
|
1616
|
+
severity: 'high',
|
|
1617
|
+
label: 'Tells the agent to widen its own tool permissions',
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
re: /\bsettings(?:\.local)?\.json\b/gi,
|
|
1621
|
+
severity: 'high',
|
|
1622
|
+
label: 'Tells the agent to grant itself permissions in settings.json',
|
|
1623
|
+
refine: (hit, content) => {
|
|
1624
|
+
const window = lineWindow(content, hit.index, hit.index + hit.text.length, 120);
|
|
1625
|
+
return CONFIG_EDIT.test(window) && PERMISSION_WORD.test(window) ? 'high' : null;
|
|
1626
|
+
},
|
|
1627
|
+
},
|
|
1628
|
+
];
|
|
1629
|
+
const AM_PI_007 = {
|
|
1630
|
+
id: 'TM-PI-007',
|
|
1631
|
+
title: 'Safety bypass or permission escalation',
|
|
1632
|
+
description: 'Instructions to skip permission prompts (--dangerously-skip-permissions, bypassPermissions, YOLO mode), disable the sandbox or guardrails, auto-approve everything, act without asking, or add rules to settings.json / allowedTools try to remove the safeguards around the agent.',
|
|
1633
|
+
category: 'prompt-injection',
|
|
1634
|
+
severity: 'high',
|
|
1635
|
+
kinds: AGENT_TEXT,
|
|
1636
|
+
remediation: 'Remove the instruction. Permissions should be granted by the user, never requested by the content the agent is reading.',
|
|
1637
|
+
check: (file) => matchPhrases(file, PI007),
|
|
1638
|
+
};
|
|
1639
|
+
// ---------------------------------------------------------------------------
|
|
1640
|
+
// TM-PI-008 Remote instruction loading
|
|
1641
|
+
// ---------------------------------------------------------------------------
|
|
1642
|
+
const PI008 = [
|
|
1643
|
+
{
|
|
1644
|
+
re: /\b(?:fetch|download|load|read|retrieve|pull|get|import|sync|grab|obtain)\b[^\n]{0,40}?\b(?:latest|updated|new|newest|current|real|actual|full|complete|remote|your|additional|further|fresh)\s+(?:set\s+of\s+)?(?:instructions|rules|prompts?|directives|guidelines|steps|commands|tasks|orders|system\s+prompt|skill\s+(?:content|instructions|definition))\b[^\n]{0,40}?\b(?:from|at|on|via)\s+<?https?:\/\//gi,
|
|
1645
|
+
severity: 'medium',
|
|
1646
|
+
label: 'Loads its instructions from a remote URL',
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
re: /\b(?:follow|obey|execute|apply|adopt|carry\s+out)\s+(?:the\s+|all\s+)?(?:instructions|rules|steps|directions|guidance|directives|prompt|commands)\s+(?:found\s+|located\s+|hosted\s+|published\s+|listed\s+|given\s+|provided\s+)?(?:at|from|on|in)\s+<?https?:\/\//gi,
|
|
1650
|
+
severity: 'medium',
|
|
1651
|
+
label: 'Tells the agent to follow instructions hosted at a URL',
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
re: /\b(?:follow|complete)\s+(?:the\s+)?(?:setup|installation|install|prerequisite|onboarding)\s+(?:instructions|steps|guide)\s+(?:at|from|on|in)\s+<?https?:\/\//gi,
|
|
1655
|
+
severity: 'low',
|
|
1656
|
+
label: 'Points the agent at setup instructions hosted at a URL',
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
re: /\b(?:fetch|download|load|read|retrieve|curl|wget|get)\s+<?https?:\/\/[^\s)>"']{3,200}\s+(?:and|then)\s+(?:follow|obey|execute|apply|do\s+what)\b/gi,
|
|
1660
|
+
severity: 'medium',
|
|
1661
|
+
label: 'Fetches a URL and follows what it says',
|
|
1662
|
+
},
|
|
1663
|
+
{
|
|
1664
|
+
re: /\b(?:real|actual|full|complete|latest|updated|current|true|remaining)\s+(?:instructions|rules|prompt|skill(?:\s+content)?)\s+(?:are|is|live|lives|can\s+be\s+found|reside|resides)\s+(?:at|on|in|hosted\s+at)\s+<?https?:\/\//gi,
|
|
1665
|
+
severity: 'medium',
|
|
1666
|
+
label: 'Says the real instructions live at a URL',
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
re: /\b(?:check|poll|visit|consult|query)\s+<?https?:\/\/[^\s)>"']{3,200}\s+(?:regularly\s+|periodically\s+)?(?:for|to\s+get|to\s+receive)\s+(?:new|updated|the\s+latest|further|additional|more|your)\s+(?:instructions|rules|commands|tasks|directives|orders)\b/gi,
|
|
1670
|
+
severity: 'medium',
|
|
1671
|
+
label: 'Polls a URL for new instructions',
|
|
1672
|
+
},
|
|
1673
|
+
];
|
|
1674
|
+
const AM_PI_008 = {
|
|
1675
|
+
id: 'TM-PI-008',
|
|
1676
|
+
title: 'Remote instruction loading',
|
|
1677
|
+
description: 'A skill or tool whose real instructions are fetched from a URL can change after review (a skill-level rug pull), so what was scanned and approved is not what the agent ends up following.',
|
|
1678
|
+
category: 'prompt-injection',
|
|
1679
|
+
severity: 'medium',
|
|
1680
|
+
kinds: AGENT_TEXT,
|
|
1681
|
+
remediation: 'Vendor the instructions into the skill itself so they are reviewed, pinned and versioned with it.',
|
|
1682
|
+
check: (file) => matchPhrases(file, PI008),
|
|
1683
|
+
};
|
|
1684
|
+
export const textRules = [
|
|
1685
|
+
AM_HID_001,
|
|
1686
|
+
AM_HID_002,
|
|
1687
|
+
AM_HID_003,
|
|
1688
|
+
AM_HID_004,
|
|
1689
|
+
AM_HID_005,
|
|
1690
|
+
AM_PI_001,
|
|
1691
|
+
AM_PI_002,
|
|
1692
|
+
AM_PI_003,
|
|
1693
|
+
AM_PI_004,
|
|
1694
|
+
AM_PI_005,
|
|
1695
|
+
AM_PI_006,
|
|
1696
|
+
AM_PI_007,
|
|
1697
|
+
AM_PI_008,
|
|
1698
|
+
];
|