@shomra/agent 0.2.1
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 +201 -0
- package/NOTICE +10 -0
- package/README.md +220 -0
- package/code-sast.mjs +763 -0
- package/discovery.mjs +812 -0
- package/guard-signals.mjs +747 -0
- package/model-refs.mjs +130 -0
- package/package.json +51 -0
- package/shomra.mjs +4193 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-0 local guard signals — a dependency-free, high-confidence subset of the
|
|
3
|
+
* backend detection engine (src/bundle/signals.ts + src/checks/patterns.ts),
|
|
4
|
+
* ported so the runtime firewall can decide the DANGEROUS majority of tool calls
|
|
5
|
+
* ON-BOX, with zero network round-trip.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the pre-tool-call hook fires on every action. Routing every
|
|
8
|
+
* call through the backend put a network dependency on the hot path — slow when
|
|
9
|
+
* the backend was busy, and (fail-open) bypassable by simply making it
|
|
10
|
+
* unreachable. This module lets the guard block the unambiguously-malicious
|
|
11
|
+
* cases (curl|sh, reverse shells, base64 RCE, live secrets) locally and
|
|
12
|
+
* instantly, so protection survives a slow/down/blocked backend.
|
|
13
|
+
*
|
|
14
|
+
* Division of labour:
|
|
15
|
+
* • LOCAL (here) — deterministic, high-precision, offline. Never over-blocks:
|
|
16
|
+
* aligned to the server's DEFAULT policy (CRITICAL → BLOCK, HIGH → FLAG).
|
|
17
|
+
* • SERVER (Tier 2) — authoritative. Org policy, agent identity, MCP
|
|
18
|
+
* governance, information-flow taint, exceptions, telemetry. The CLI still
|
|
19
|
+
* escalates policy-relevant calls to it; the local tier is the floor, not a
|
|
20
|
+
* replacement.
|
|
21
|
+
*
|
|
22
|
+
* Keep the pattern lists roughly in sync with the server modules named above.
|
|
23
|
+
* Drift only costs recall on the local floor — the server remains the full check.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ── dangerous shell (mirror of DANGEROUS_SHELL in src/bundle/signals.ts) ──
|
|
27
|
+
export const DANGEROUS_SHELL = [
|
|
28
|
+
{ name: 'Pipe-to-shell installer (curl … | sh)', re: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z|k)?sh\b/i, severity: 'CRITICAL' },
|
|
29
|
+
{ name: 'PowerShell download-and-run (iwr/curl … | iex)', re: /\b(iwr|curl|wget|invoke-webrequest|invoke-restmethod|irm)\b[^\n|]{0,200}\|\s*(iex|invoke-expression)\b/i, severity: 'CRITICAL' },
|
|
30
|
+
{ name: 'Invoke-Expression of downloaded content', re: /\b(iex|invoke-expression)\b[^\n]{0,120}(downloadstring|net\.webclient|\(\s*(iwr|irm|invoke-)|\$\()/i, severity: 'CRITICAL' },
|
|
31
|
+
{ name: 'Reverse shell via /dev/tcp', re: /\/dev\/(tcp|udp)\//i, severity: 'CRITICAL' },
|
|
32
|
+
{ name: 'Base64 blob piped to a shell', re: /base64\s+(--?d(ecode)?)?\b[^\n|]{0,200}\|\s*(ba|z)?sh\b/i, severity: 'CRITICAL' },
|
|
33
|
+
{ name: 'curl/wget posts data to the network (exfiltration)', re: /\b(curl|wget|http|https|invoke-restmethod|irm)\b[^\n]{0,220}(--data(-raw|-binary|-urlencode)?|--form\b|--upload-file\b|(^|\s)-d\s|(^|\s)-F\s|(^|\s)-T\s|-Method\s+Post)/i, severity: 'HIGH' },
|
|
34
|
+
{ name: 'Command output piped into a network call', re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|`[^`\n]+`|<\()/i, severity: 'HIGH' },
|
|
35
|
+
{ name: 'Fetches from a raw IP address', re: /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,220}https?:\/\/\d{1,3}(\.\d{1,3}){3}/i, severity: 'HIGH' },
|
|
36
|
+
{ name: 'Writes to shell profile / SSH keys / crontab', re: /(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys|id_rsa\b|\bcrontab\b)/i, severity: 'HIGH' },
|
|
37
|
+
{ name: 'Recursive force delete (rm -rf)', re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i, severity: 'HIGH' },
|
|
38
|
+
{ name: 'Inline eval / exec of a string', re: /\b(eval|exec)\s*[("`']/i, severity: 'HIGH' },
|
|
39
|
+
{ name: 'Pipes an env dump to the network', re: /\b(env|printenv|set)\b[^\n|]{0,80}\|[^\n]{0,80}(curl|wget|nc\b|http)/i, severity: 'HIGH' },
|
|
40
|
+
{ name: 'Disables TLS / cert verification', re: /(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|GIT_SSL_NO_VERIFY|--no-check-certificate|--insecure\b|verify\s*=\s*False)/i, severity: 'MEDIUM' },
|
|
41
|
+
{ name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
|
|
42
|
+
{ name: 'node -e one-liner', re: /\bnode\s+-e\b/i, severity: 'MEDIUM' },
|
|
43
|
+
{ name: 'Netcat / socket exfil', re: /\bnc\s+-[a-z]*\b|\bncat\b/i, severity: 'MEDIUM' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ── injection (mirror of INJECTION_PHRASES / INJECTION_REGEXES) ──
|
|
47
|
+
export const INJECTION_PHRASES = [
|
|
48
|
+
'ignore previous instructions', 'ignore all previous', 'disregard previous', 'disregard the above',
|
|
49
|
+
'do not tell the user', "don't tell the user", 'without informing the user', 'without telling the user',
|
|
50
|
+
'do not mention', "don't mention", 'never mention',
|
|
51
|
+
'system prompt', 'you are now', 'new instructions:', 'override your', 'exfiltrate',
|
|
52
|
+
'send them to', 'post them to', 'forward the',
|
|
53
|
+
'run this first', 'run the following command', 'run the following commands', 'copy this command',
|
|
54
|
+
'copy and run', 'execute the following', 'seed the current host',
|
|
55
|
+
'ignore all instructions', 'ignore your instructions', 'ignore your safety', 'ignore all content policies',
|
|
56
|
+
'disregard your instructions', 'disregard the guidelines', 'system override', 'follow only my instructions',
|
|
57
|
+
'do anything now', 'reveal any credential',
|
|
58
|
+
'save this to your memory', 'in all future sessions', 'remember this forever',
|
|
59
|
+
];
|
|
60
|
+
export const INJECTION_REGEXES = [
|
|
61
|
+
{ label: 'Instruction-override phrasing', re: /\b(ignore|disregard|forget|discard|override|bypass|skip)\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|rule|rules|guideline|guidelines|prompt|prompts|directive|directives|context|constraint|constraints)\b/i },
|
|
62
|
+
{ label: 'Reference to overriding earlier context', re: /\b(previous|prior|above|earlier|preceding|former|the last|that (?:were |was )?given)\b[\s\w,'"()-]{0,25}?\b(instruction|instructions|rule|rules|prompt|prompts|message|messages|guidance)\b/i },
|
|
63
|
+
{ label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|clear|nuke|truncate)\b[\s\w,'"()-]{0,20}?\b(all|every|each|entire|whole)\b[\s\w,'"()-]{0,15}?\b(folder|folders|file|files|directory|directories|table|tables|database|databases|record|records|repo|repos|repositor\w*|account|accounts|user|users|row|rows|document|documents|data)\b/i },
|
|
64
|
+
{ label: 'Recursive force-delete command', re: /\brm\s+-[a-z]*[rf][a-z]*\b|\brmdir\b|\bdel\s+\/[sqf]|remove-item\b[\s\S]{0,40}?-recurse/i },
|
|
65
|
+
{ label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema|index)\b/i },
|
|
66
|
+
];
|
|
67
|
+
// zero-width / bidi / tag-block chars used to smuggle instructions (ASCII smuggling).
|
|
68
|
+
export const INVISIBLE_CHARS_RE = /[---︀-️]|[\u{E0000}-\u{E007F}]|[\u{E0100}-\u{E01EF}]/u;
|
|
69
|
+
|
|
70
|
+
// ── secrets (mirror of SECRET_PATTERNS) ──
|
|
71
|
+
export const SECRET_PATTERNS = [
|
|
72
|
+
{ name: 'Stripe live key', re: /sk_live_[0-9a-zA-Z]{16,}/ },
|
|
73
|
+
{ name: 'OpenAI key', re: /sk-[A-Za-z0-9]{20,}/ },
|
|
74
|
+
{ name: 'AWS access key id', re: /AKIA[0-9A-Z]{16}/ },
|
|
75
|
+
{ name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
|
|
76
|
+
{ name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
77
|
+
{ name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
78
|
+
{ name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const RISKY_CONFIG_MARKERS = [
|
|
82
|
+
'yolo', 'auto-approve', 'autoapprove', 'auto_approve', 'autorun', 'auto-run',
|
|
83
|
+
'always allow', 'alwaysallow', 'dangerously', 'skip confirmation', 'no confirmation',
|
|
84
|
+
'disable safety', 'bypass approval', 'full access', 'unrestricted',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ── PII (mirror of PII_PATTERNS + Luhn gate in checks/text-inspector.ts) ──
|
|
88
|
+
export const PII_PATTERNS = [
|
|
89
|
+
{ name: 'Email address', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
|
|
90
|
+
{ name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
|
|
91
|
+
{ name: 'Credit card number', re: /\b(?:\d[ -]*?){13,16}\b/ },
|
|
92
|
+
{ name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
|
|
93
|
+
{ name: 'IPv4 address', re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/ },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// Luhn check keeps the loose credit-card regex from firing on any digit run.
|
|
97
|
+
function luhnValid(value) {
|
|
98
|
+
const digits = String(value).replace(/[^\d]/g, '');
|
|
99
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
100
|
+
let sum = 0, alt = false;
|
|
101
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
102
|
+
let d = parseInt(digits[i], 10);
|
|
103
|
+
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
104
|
+
sum += d;
|
|
105
|
+
alt = !alt;
|
|
106
|
+
}
|
|
107
|
+
return sum % 10 === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Capability verbs shared with the backend signal libs — used by the memory /
|
|
111
|
+
// rules toxic-flow check (a "read secret X and send it" standing instruction).
|
|
112
|
+
export const SENSITIVE_READ = [
|
|
113
|
+
'secret', 'credential', 'password', 'token', 'api_key', 'apikey', 'private_key',
|
|
114
|
+
'ssh', 'aws', 'env', 'environment', 'keychain', 'vault', 'read_file', 'readfile', 'cat ',
|
|
115
|
+
];
|
|
116
|
+
export const NETWORK_VERBS = [
|
|
117
|
+
'http_request', 'http', 'fetch', 'request', 'curl', 'webhook', 'post', 'send',
|
|
118
|
+
'upload', 'publish', 'email', 'sendmail', 'smtp',
|
|
119
|
+
];
|
|
120
|
+
export function containsAny(haystack, needles) {
|
|
121
|
+
const h = String(haystack ?? '').toLowerCase();
|
|
122
|
+
for (const n of needles) if (h.includes(n.toLowerCase())) return n;
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Attacker-controlled data sinks (subset of SUSPICIOUS_EGRESS_HOSTS) — a tool
|
|
127
|
+
// call/result referencing one is an exfiltration endpoint.
|
|
128
|
+
export const SUSPICIOUS_EGRESS_HOSTS = [
|
|
129
|
+
'webhook.site', 'requestbin', 'pipedream.net', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
|
|
130
|
+
'trycloudflare.com', 'serveo.net', 'localhost.run', 'interact.sh', 'oastify.com', 'oast.pro',
|
|
131
|
+
'oast.fun', 'burpcollaborator.net', 'canarytokens.com', 'beeceptor.com', 'requestcatcher.com',
|
|
132
|
+
'c-net.org', 'pastebin.com', 'paste.ee', 'hastebin.com', 'dpaste.com', 'dpaste.org', 'ix.io',
|
|
133
|
+
'sprunge.us', 'termbin.com', 'rentry.co', 'controlc.com', 'privatebin.net', 'ghostbin.com',
|
|
134
|
+
'justpaste.it', 'transfer.sh', '0x0.st', 'file.io', 'gofile.io', 'anonfiles.com',
|
|
135
|
+
'bashupload.com', 'tmpfiles.org', 'catbox.moe', 'litterbox.catbox.moe', 'temp.sh', 'oshi.at', 'x0.at',
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const SEV_RANK = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
|
|
139
|
+
|
|
140
|
+
// Decode suspicious base64 blobs so payloads hidden in an "echo <blob>|base64 -d|sh"
|
|
141
|
+
// trick are inspected too. We decode purely to READ the bytes; nothing runs.
|
|
142
|
+
const BASE64_BLOB_RE = /\b[A-Za-z0-9+/]{32,}={0,2}/g;
|
|
143
|
+
const DECODED_PAYLOAD_RE = /(\/bin\/(ba|z|k)?sh|\b(ba|z|k)?sh\s+-c|\bcurl\b|\bwget\b|\beval\b|\bexec\b|https?:\/\/|invoke-expression|\biex\b|powershell|\bnc\b|\bncat\b|\bchmod\b|\bbase64\b)/i;
|
|
144
|
+
function deobfuscate(text) {
|
|
145
|
+
const decoded = [];
|
|
146
|
+
for (const m of text.matchAll(BASE64_BLOB_RE)) {
|
|
147
|
+
let out = '';
|
|
148
|
+
try { out = Buffer.from(m[0], 'base64').toString('utf8'); } catch { continue; }
|
|
149
|
+
if (!out) continue;
|
|
150
|
+
const printable = out.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '');
|
|
151
|
+
if (printable.length < out.length * 0.85) continue;
|
|
152
|
+
if (DECODED_PAYLOAD_RE.test(out)) decoded.push(out);
|
|
153
|
+
}
|
|
154
|
+
return { text: decoded.length ? `${text}\n${decoded.join('\n')}` : text, decodedPayload: decoded.length > 0 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Reference to a known exfiltration sink host, or null. */
|
|
158
|
+
export function egressHost(text) {
|
|
159
|
+
if (!text) return null;
|
|
160
|
+
const low = text.toLowerCase();
|
|
161
|
+
return SUSPICIOUS_EGRESS_HOSTS.find((h) => low.includes(h)) ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 1-based line number of a character offset inside `text`. */
|
|
165
|
+
function lineAt(text, index) {
|
|
166
|
+
let line = 1;
|
|
167
|
+
const end = Math.min(index, text.length);
|
|
168
|
+
for (let i = 0; i < end; i++) if (text.charCodeAt(i) === 10) line++;
|
|
169
|
+
return line;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Best-effort 1-based line where `needle` (a string or RegExp) first occurs in
|
|
174
|
+
* `text`, so a finding can point at file:line. Undefined when it can't be
|
|
175
|
+
* located (redacted samples, matches only inside decoded base64) — the finding
|
|
176
|
+
* then stays file-scoped rather than pointing at the wrong line.
|
|
177
|
+
*/
|
|
178
|
+
function lineOf(text, needle) {
|
|
179
|
+
if (!text || !needle) return undefined;
|
|
180
|
+
let idx = -1;
|
|
181
|
+
if (typeof needle === 'string') {
|
|
182
|
+
const probe = needle.split('•')[0].trim().slice(0, 80);
|
|
183
|
+
if (probe.length < 3) return undefined;
|
|
184
|
+
idx = text.toLowerCase().indexOf(probe.toLowerCase());
|
|
185
|
+
} else {
|
|
186
|
+
const m = text.match(needle);
|
|
187
|
+
idx = m && m.index != null ? m.index : -1;
|
|
188
|
+
}
|
|
189
|
+
return idx >= 0 ? lineAt(text, idx) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── false-positive control: is a match DATA (in a literal) or a live command? ──
|
|
193
|
+
// The dominant FP for a security tool is scanning content that legitimately
|
|
194
|
+
// *contains* the very patterns it detects — its own detection source, security
|
|
195
|
+
// docs, a quoted sample, a fenced example. These helpers decide whether a match
|
|
196
|
+
// sits in such a code/data context (→ safe to down-rank) rather than as a bare,
|
|
197
|
+
// runnable command line (→ still dangerous).
|
|
198
|
+
|
|
199
|
+
// Single-pass mask of the "code/data" regions of a text: string literals (', ",
|
|
200
|
+
// backtick — multi-line aware), line comments (//, #), block comments (/* */,
|
|
201
|
+
// <!-- -->), regex literals (/…/), and fenced code blocks. mask[i] === 1 means
|
|
202
|
+
// offset i is inside such a region, i.e. any pattern there is DATA (a rule
|
|
203
|
+
// definition, a quoted sample, a documented example), not a live command line.
|
|
204
|
+
// A best-effort tokenizer — it biases toward marking (fewer false positives),
|
|
205
|
+
// which is the correct trade for a security tool scanning content it will merely
|
|
206
|
+
// read; execution is gated separately by the pre-call firewall.
|
|
207
|
+
function codeMask(text) {
|
|
208
|
+
const n = text.length;
|
|
209
|
+
const mask = new Uint8Array(n);
|
|
210
|
+
const REGEX_START = new Set(['=', '(', ',', '[', '{', ';', ':', '!', '&', '|', '?', '+', '*', '~', '%', '^', '<', '>', 'return', 'typeof']);
|
|
211
|
+
let state = 0; // 0 normal 1 ' 2 " 3 ` 4 line-comment 5 block-comment 6 html-comment 7 regex
|
|
212
|
+
let prevSig = ''; // last non-whitespace char (for regex-vs-division)
|
|
213
|
+
let inClass = false; // inside a regex [ … ] char class
|
|
214
|
+
let i = 0;
|
|
215
|
+
while (i < n) {
|
|
216
|
+
const c = text[i], c2 = text[i + 1];
|
|
217
|
+
if (state === 0) {
|
|
218
|
+
if (c === "'") { state = 1; mask[i++] = 1; continue; }
|
|
219
|
+
if (c === '"') { state = 2; mask[i++] = 1; continue; }
|
|
220
|
+
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
221
|
+
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
222
|
+
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
223
|
+
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
224
|
+
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
|
|
225
|
+
if (text.startsWith('```', i) || text.startsWith('~~~', i)) { // fenced block → mask the whole span, delimiters included
|
|
226
|
+
const fence = text.slice(i, i + 3);
|
|
227
|
+
const nl = text.indexOf('\n', i);
|
|
228
|
+
let end = n;
|
|
229
|
+
if (nl !== -1) {
|
|
230
|
+
const closeRe = new RegExp('\\n[ \\t]*' + fence.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
231
|
+
const cm = text.slice(nl).match(closeRe);
|
|
232
|
+
end = cm && cm.index != null ? nl + cm.index + cm[0].length : n;
|
|
233
|
+
}
|
|
234
|
+
for (let k = i; k < end; k++) mask[k] = 1;
|
|
235
|
+
prevSig = ''; i = end; continue;
|
|
236
|
+
}
|
|
237
|
+
if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
|
|
238
|
+
if (!/\s/.test(c)) prevSig = c;
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
mask[i] = 1;
|
|
243
|
+
if (state === 1) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === "'") { state = 0; prevSig = "'"; } i++; continue; }
|
|
244
|
+
if (state === 2) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '"') { state = 0; prevSig = '"'; } i++; continue; }
|
|
245
|
+
if (state === 3) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '`') { state = 0; prevSig = '`'; } i++; continue; }
|
|
246
|
+
if (state === 4) { if (c === '\n') state = 0; i++; continue; }
|
|
247
|
+
if (state === 5) { if (c === '*' && c2 === '/') { mask[i + 1] = 1; i += 2; state = 0; } else i++; continue; }
|
|
248
|
+
if (state === 6) { if (text.startsWith('-->', i)) { mask[i + 1] = 1; mask[i + 2] = 1; i += 3; state = 0; } else i++; continue; }
|
|
249
|
+
if (state === 7) { // regex literal
|
|
250
|
+
if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; }
|
|
251
|
+
if (c === '\n') { state = 0; } // unterminated → bail
|
|
252
|
+
else if (c === '[') inClass = true;
|
|
253
|
+
else if (c === ']') inClass = false;
|
|
254
|
+
else if (c === '/' && !inClass) { state = 0; prevSig = '/'; }
|
|
255
|
+
i++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return mask;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// First occurrence of `needle` (string or RegExp) → its 1-based line and whether
|
|
263
|
+
// it sits in a code/data region per `mask`. Undefined line when unlocatable.
|
|
264
|
+
function locate(text, needle, mask) {
|
|
265
|
+
let idx = -1;
|
|
266
|
+
if (typeof needle === 'string') {
|
|
267
|
+
const probe = needle.split('•')[0].trim().slice(0, 80);
|
|
268
|
+
if (probe.length >= 3) idx = text.toLowerCase().indexOf(probe.toLowerCase());
|
|
269
|
+
} else {
|
|
270
|
+
const m = text.match(needle);
|
|
271
|
+
idx = m && m.index != null ? m.index : -1;
|
|
272
|
+
}
|
|
273
|
+
if (idx < 0) return { line: undefined, codeContext: false };
|
|
274
|
+
return { line: lineAt(text, idx), codeContext: mask[idx] === 1 };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Obvious non-secrets: documented sample keys, placeholders, masked values.
|
|
278
|
+
function isPlaceholderSecret(v) {
|
|
279
|
+
const s = String(v);
|
|
280
|
+
const low = s.toLowerCase();
|
|
281
|
+
if (/(example|sample|placeholder|dummy|redacted|changeme|test[_-]?(key|token|secret)|your[-_]?(key|token|secret|api))/.test(low)) return true;
|
|
282
|
+
if (/(x{6,}|\.{3,}|<[^>]{2,}>|\*{4,}|•{3,})/.test(low)) return true; // xxxxxx, <your-key>, ****
|
|
283
|
+
const tail = s.replace(/^\w{1,10}[-_]/, ''); // drop a short prefix (sk-, ghp_, …)
|
|
284
|
+
if (/^(.)\1{7,}/.test(tail)) return true; // long run of one char
|
|
285
|
+
if (/^(0123|1234|abcd|abcdef|deadbeef)/i.test(tail)) return true; // trivial sequences
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Run the local high-confidence detectors over a blob of text (a shell command,
|
|
291
|
+
* file content about to be written, or an argument JSON blob).
|
|
292
|
+
* Returns { verdict, top, findings } where verdict aligns with the server
|
|
293
|
+
* default policy: any CRITICAL → BLOCK, any HIGH → FLAG, else ALLOW. Findings
|
|
294
|
+
* carry a best-effort 1-based `line` for file:line placement, and a `codeContext`
|
|
295
|
+
* flag when the pattern only appears inside a literal/comment/fence (so the
|
|
296
|
+
* runtime hooks can down-rank content that merely *describes* a pattern).
|
|
297
|
+
* `opts.categories` narrows which detectors run (e.g. result content skips shell).
|
|
298
|
+
*/
|
|
299
|
+
export function localScan(text, opts = {}) {
|
|
300
|
+
const findings = [];
|
|
301
|
+
const t = text || '';
|
|
302
|
+
const cats = opts.categories ?? ['shell', 'injection', 'secret', 'config', 'egress'];
|
|
303
|
+
const mask = codeMask(t);
|
|
304
|
+
|
|
305
|
+
if (cats.includes('shell')) {
|
|
306
|
+
const aug = deobfuscate(t);
|
|
307
|
+
if (aug.decodedPayload) findings.push({ label: 'Base64-encoded shell / RCE payload', severity: 'CRITICAL', category: 'shell' });
|
|
308
|
+
for (const sig of DANGEROUS_SHELL) if (sig.re.test(aug.text)) findings.push({ label: sig.name, severity: sig.severity, category: 'shell', ...locate(t, sig.re, mask) });
|
|
309
|
+
}
|
|
310
|
+
if (cats.includes('injection')) {
|
|
311
|
+
const low = t.toLowerCase();
|
|
312
|
+
for (const p of INJECTION_PHRASES) if (low.includes(p)) { findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) }); break; }
|
|
313
|
+
for (const { label, re } of INJECTION_REGEXES) if (re.test(t)) findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
|
|
314
|
+
if (INVISIBLE_CHARS_RE.test(t)) findings.push({ label: 'Invisible / zero-width characters', severity: 'MEDIUM', category: 'injection', ...locate(t, INVISIBLE_CHARS_RE, mask) });
|
|
315
|
+
}
|
|
316
|
+
if (cats.includes('secret')) {
|
|
317
|
+
for (const { name, re } of SECRET_PATTERNS) { const m = t.match(re); if (m && !isPlaceholderSecret(m[0])) findings.push({ label: `Live credential: ${name}`, severity: 'CRITICAL', category: 'secret', ...locate(t, re, mask) }); }
|
|
318
|
+
}
|
|
319
|
+
if (cats.includes('pii')) {
|
|
320
|
+
for (const { name, re } of PII_PATTERNS) {
|
|
321
|
+
const m = t.match(re);
|
|
322
|
+
if (!m) continue;
|
|
323
|
+
if (name === 'Credit card number' && !luhnValid(m[0])) continue; // gate the loose CC regex
|
|
324
|
+
findings.push({ label: `Personal data: ${name}`, severity: 'MEDIUM', category: 'pii', ...locate(t, re, mask) });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (cats.includes('config')) {
|
|
328
|
+
const low = t.toLowerCase();
|
|
329
|
+
for (const m of RISKY_CONFIG_MARKERS) if (low.includes(m)) { findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', ...locate(t, m, mask) }); break; }
|
|
330
|
+
}
|
|
331
|
+
if (cats.includes('egress')) {
|
|
332
|
+
const h = egressHost(t);
|
|
333
|
+
if (h) findings.push({ label: `Exfiltration sink host: ${h}`, severity: 'HIGH', category: 'egress', ...locate(t, h, mask) });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let worstRank = 0, top = null;
|
|
337
|
+
for (const f of findings) if (SEV_RANK[f.severity] > worstRank) { worstRank = SEV_RANK[f.severity]; top = f; }
|
|
338
|
+
const verdict = worstRank >= SEV_RANK.CRITICAL ? 'BLOCK' : worstRank >= SEV_RANK.HIGH ? 'FLAG' : 'ALLOW';
|
|
339
|
+
return { verdict, top, findings };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Down-rank findings whose pattern only appears in a code literal / comment /
|
|
344
|
+
* fenced block (`codeContext`) so file CONTENT that merely *contains* a pattern
|
|
345
|
+
* — a detection rule, a docs example, a quoted sample — no longer hard-blocks.
|
|
346
|
+
* A bare command line keeps its severity and still scores. The runtime file-write
|
|
347
|
+
* and tool-result hooks apply this; shell-command screening and the static gate
|
|
348
|
+
* do NOT (a `bash -c "…"` payload is real even though it's quoted).
|
|
349
|
+
*/
|
|
350
|
+
export function downrankCodeContext(findings) {
|
|
351
|
+
return (findings || []).map((f) => (f.codeContext ? { ...f, severity: 'LOW', downranked: true } : f));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── local artifact gate (offline `shomra gate`) ──
|
|
355
|
+
// Social-engineering "install-lure" prose (mirror of INSTALL_LURE server-side).
|
|
356
|
+
const INSTALL_LURE = [
|
|
357
|
+
{ name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b/i, severity: 'MEDIUM' },
|
|
358
|
+
{ name: 'Password-protected archive (evades AV / scanners)', re: /\b(extract|unzip|decompress|archive|zip|password)\b[^\n]{0,50}\b(pass(word|phrase)?|pwd)\s*[:=]\s*\S/i, severity: 'HIGH' },
|
|
359
|
+
{ name: 'Coercion: claims a helper is required before the task works', re: /\b(required to (function|work|deploy|run)|will not (work|function|run)( correctly| properly)?( without)?|does not work without|otherwise it is impossible|cannot [a-z ]{0,24} without (installing|running)|must (be )?(install(ed)?|run) (this |the )?)/i, severity: 'MEDIUM' },
|
|
360
|
+
{ name: 'Coercion: re-run / retry until it succeeds', re: /\b(re-?run (if needed|until|the command)|run (it |the command )?again|try again after)/i, severity: 'LOW' },
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
// ── typosquat / malicious-package intel (mirror of checks/patterns.ts) ──
|
|
364
|
+
const MALICIOUS_PACKAGE_SEED = new Set([
|
|
365
|
+
'event-stream', 'eslint-scope-malware', 'electron-native-notify', 'rc-malware',
|
|
366
|
+
'crossenv', 'mongose', 'expresss',
|
|
367
|
+
]);
|
|
368
|
+
const POPULAR_PACKAGES = [
|
|
369
|
+
'express', 'react', 'lodash', 'axios', 'chalk', 'commander',
|
|
370
|
+
'mongoose', 'cross-env', 'dotenv', 'request', 'puppeteer', 'playwright',
|
|
371
|
+
];
|
|
372
|
+
// Levenshtein distance — used for edit-distance-1 typosquat detection.
|
|
373
|
+
function editDistance(a, b) {
|
|
374
|
+
const m = a.length, n = b.length;
|
|
375
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
376
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
377
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
378
|
+
for (let i = 1; i <= m; i++)
|
|
379
|
+
for (let j = 1; j <= n; j++) {
|
|
380
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
381
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
382
|
+
}
|
|
383
|
+
return dp[m][n];
|
|
384
|
+
}
|
|
385
|
+
// Best-effort npm package name from an MCP launch command (`npx -y @scope/pkg`).
|
|
386
|
+
function packageFromCommand(command, args) {
|
|
387
|
+
const tokens = [command, ...(args ?? [])].filter(Boolean).map(String);
|
|
388
|
+
if (!tokens.length) return null;
|
|
389
|
+
const runners = new Set(['npx', 'npm', 'pnpm', 'yarn', 'bunx', 'bun']);
|
|
390
|
+
const skips = new Set(['exec', 'dlx', 'run', 'install', 'add', 'create', '-y', '--yes']);
|
|
391
|
+
const start = runners.has(tokens[0].split('/').pop() ?? tokens[0]) ? 1 : -1;
|
|
392
|
+
if (start === -1) return null; // only assess package-runner launches
|
|
393
|
+
for (let i = start; i < tokens.length; i++) {
|
|
394
|
+
const t = tokens[i];
|
|
395
|
+
if (t.startsWith('-') || skips.has(t)) continue;
|
|
396
|
+
const name = t.startsWith('@') ? t.split('/').slice(0, 2).join('/') : t.split('@')[0];
|
|
397
|
+
return name.replace(/@[\d^~].*$/, '');
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── endpoint / URL risk (A2A agent cards, remote MCP servers) — never fetches ──
|
|
403
|
+
const PRIVATE_HOST_RE = /^(localhost|127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0$|172\.(1[6-9]|2\d|3[01])\.)/i;
|
|
404
|
+
const RAW_IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
405
|
+
function assessUrl(raw) {
|
|
406
|
+
const s = String(raw ?? '').trim();
|
|
407
|
+
if (!s) return null;
|
|
408
|
+
let u;
|
|
409
|
+
try { u = new URL(s); } catch { return null; }
|
|
410
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
411
|
+
const host = u.hostname.toLowerCase();
|
|
412
|
+
return {
|
|
413
|
+
url: s,
|
|
414
|
+
plaintext: u.protocol === 'http:',
|
|
415
|
+
privateNetwork: PRIVATE_HOST_RE.test(host),
|
|
416
|
+
metadataEndpoint: host === '169.254.169.254' || host === 'metadata.google.internal',
|
|
417
|
+
suspiciousHost: SUSPICIOUS_EGRESS_HOSTS.find((h) => host === h || host.endsWith('.' + h)) ?? null,
|
|
418
|
+
rawIp: RAW_IP_RE.test(host),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// Tool identifiers that grant high-impact capability to an agent.
|
|
422
|
+
const HIGH_IMPACT_TOOLS = ['bash', 'shell', 'exec', 'execute', 'run', 'terminal', 'command', 'write', 'edit', 'multiedit', 'writefile', 'write_file', 'create', 'delete', 'remove', 'rm', 'webfetch', 'web_fetch', 'fetch', 'browser', 'network', 'http', 'curl', 'computer', 'automation'];
|
|
423
|
+
|
|
424
|
+
function isWildcardGrant(t) { const s = t.trim().toLowerCase().replace(/^["']|["']$/g, ''); return s === '*' || s === 'all' || s === 'any'; }
|
|
425
|
+
function baseToolName(t) { return t.split(/[(:\s]/)[0].trim().toLowerCase(); }
|
|
426
|
+
function toToolList(v) {
|
|
427
|
+
if (v == null) return [];
|
|
428
|
+
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
429
|
+
return String(v).replace(/^\[|\]$/g, '').split(/[,\n]+/).map((t) => t.replace(/^["']|["']$/g, '').trim()).filter(Boolean);
|
|
430
|
+
}
|
|
431
|
+
// Minimal YAML-frontmatter reader — the subset agent config files use.
|
|
432
|
+
function frontmatter(text) {
|
|
433
|
+
const m = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text || '');
|
|
434
|
+
if (!m) return {};
|
|
435
|
+
const data = {};
|
|
436
|
+
let key = null;
|
|
437
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
438
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
439
|
+
const li = /^\s*-\s+(.*)$/.exec(raw);
|
|
440
|
+
if (li && key) { (Array.isArray(data[key]) ? data[key] : (data[key] = [])).push(li[1].trim().replace(/^["']|["']$/g, '')); continue; }
|
|
441
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(raw);
|
|
442
|
+
if (!kv) continue;
|
|
443
|
+
key = kv[1];
|
|
444
|
+
const val = kv[2].trim();
|
|
445
|
+
data[key] = val === '' ? (data[key] ?? null) : val.startsWith('[') ? toToolList(val) : val.replace(/^["']|["']$/g, '');
|
|
446
|
+
}
|
|
447
|
+
return data;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── structured MCP-config checks (mirror of ArtifactAnalyzerService.checkMcpConfig) ──
|
|
451
|
+
// Parses the JSON and inspects each server: plaintext HTTP (weak auth), a
|
|
452
|
+
// hard-coded secret in the env block / launch line, and a typosquat / known-
|
|
453
|
+
// malicious launch package — structural findings a raw-text scan can't produce.
|
|
454
|
+
function mcpServersFrom(content) {
|
|
455
|
+
let json;
|
|
456
|
+
try { json = JSON.parse(content); } catch { return []; }
|
|
457
|
+
const map = json?.mcpServers ?? json?.servers ?? json?.mcp?.servers ?? json?.context_servers ?? {};
|
|
458
|
+
if (!map || typeof map !== 'object') return [];
|
|
459
|
+
return Object.entries(map).map(([name, cfg]) => ({ name, ...(cfg && typeof cfg === 'object' ? cfg : {}) }));
|
|
460
|
+
}
|
|
461
|
+
function localMcp(content) {
|
|
462
|
+
const out = [];
|
|
463
|
+
const push = (severity, title, remediationText, line) => out.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
464
|
+
for (const s of mcpServersFrom(content)) {
|
|
465
|
+
const cmdLine = [s.command, ...(s.args ?? [])].filter(Boolean).join(' ');
|
|
466
|
+
if (s.url && String(s.url).startsWith('http://')) {
|
|
467
|
+
push('MEDIUM', `MCP server "${s.name}" uses plaintext HTTP`, 'Use an https:// endpoint and require an authenticated bearer token.', lineOf(content, String(s.url)));
|
|
468
|
+
}
|
|
469
|
+
const envBlob = JSON.stringify(s.env ?? {});
|
|
470
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
471
|
+
if (re.test(envBlob) || re.test(cmdLine)) {
|
|
472
|
+
push('CRITICAL', `Static credential in MCP server "${s.name}"`, 'Rotate the credential and pass it via a runtime env reference, not a literal in the config.', lineOf(content, re));
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const pkg = packageFromCommand(s.command, s.args ?? []);
|
|
477
|
+
if (pkg) {
|
|
478
|
+
if (MALICIOUS_PACKAGE_SEED.has(pkg)) {
|
|
479
|
+
push('CRITICAL', `MCP server "${s.name}" runs a known-malicious package (${pkg})`, 'Remove this server and audit for compromise. Replace with a vetted alternative.', lineOf(content, pkg));
|
|
480
|
+
} else {
|
|
481
|
+
const squat = POPULAR_PACKAGES.find((p) => p !== pkg && editDistance(pkg, p) === 1);
|
|
482
|
+
if (squat) push('MEDIUM', `Possible typosquat in "${s.name}": ${pkg} (looks like "${squat}")`, `Confirm the intended package is "${squat}", not "${pkg}", and pin it.`, lineOf(content, pkg));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── structured agent-card checks (mirror of checkAgentCard endpoint analysis) ──
|
|
490
|
+
// Grades every URL the card declares (assessUrl: metadata SSRF, private-network
|
|
491
|
+
// pivot, plaintext, raw IP) and flags a public card with no auth scheme.
|
|
492
|
+
function localAgentCard(content) {
|
|
493
|
+
const out = [];
|
|
494
|
+
const push = (severity, title, remediationText, line) => out.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
495
|
+
let card;
|
|
496
|
+
try { card = JSON.parse(content); } catch { return out; }
|
|
497
|
+
const urls = new Set();
|
|
498
|
+
if (card?.url) urls.add(String(card.url));
|
|
499
|
+
for (const key of ['endpoints', 'endpoint', 'servers']) {
|
|
500
|
+
const v = card?.[key];
|
|
501
|
+
if (Array.isArray(v)) v.forEach((x) => typeof x === 'string' && urls.add(x));
|
|
502
|
+
else if (typeof v === 'string') urls.add(v);
|
|
503
|
+
}
|
|
504
|
+
for (const sk of Array.isArray(card?.skills) ? card.skills : []) if (sk?.url) urls.add(String(sk.url));
|
|
505
|
+
const seen = new Set();
|
|
506
|
+
for (const raw of urls) {
|
|
507
|
+
const u = assessUrl(raw);
|
|
508
|
+
if (!u) continue;
|
|
509
|
+
const line = lineOf(content, u.url);
|
|
510
|
+
if (u.metadataEndpoint && !seen.has('metadata')) { seen.add('metadata'); push('CRITICAL', `Agent card targets the cloud metadata endpoint (${u.url})`, 'Remove this card immediately — a known SSRF credential-theft pattern.', line); }
|
|
511
|
+
else if (u.privateNetwork && !seen.has('private')) { seen.add('private'); push('MEDIUM', `Agent card declares a private-network endpoint (${u.url})`, 'Publish only public, TLS-protected endpoints in shared agent cards.', line); }
|
|
512
|
+
if (u.suspiciousHost && !seen.has('exfil')) { seen.add('exfil'); push('HIGH', `Agent card points at an exfiltration-style endpoint (${u.suspiciousHost})`, 'Do not interoperate with this agent; replace the endpoint with the vendor\'s real domain.', line); }
|
|
513
|
+
if (u.plaintext && !u.privateNetwork && !seen.has('plaintext')) { seen.add('plaintext'); push('MEDIUM', `Agent card uses plaintext HTTP (${u.url})`, 'Serve the agent over https:// only.', line); }
|
|
514
|
+
if (u.rawIp && !u.privateNetwork && !seen.has('rawip')) { seen.add('rawip'); push('LOW', `Agent card addresses its endpoint by raw IP (${u.url})`, 'Use a DNS hostname with a valid TLS certificate.', line); }
|
|
515
|
+
}
|
|
516
|
+
const hasAuth = !!(card?.securitySchemes || card?.authentication || card?.security || card?.auth);
|
|
517
|
+
if (card?.url && !hasAuth) push('MEDIUM', 'Agent card declares no authentication scheme', 'Declare and enforce an auth scheme (OAuth2 / API key / mTLS) and reject unauthenticated requests.');
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── slash-command extras (mirror of checkCommand: `!`-bang + `@`-file) ──
|
|
522
|
+
function localCommandExtras(content) {
|
|
523
|
+
const out = [];
|
|
524
|
+
const body = content || '';
|
|
525
|
+
const bang = [...body.matchAll(/^!\s*`?([^`\n]+)`?/gm)];
|
|
526
|
+
if (bang.length) {
|
|
527
|
+
const line = bang[0].index != null ? lineAt(body, bang[0].index) : undefined;
|
|
528
|
+
out.push({ severity: 'LOW', title: `Command runs ${bang.length} shell command(s) before the prompt`, remediationText: 'Confirm each "!" command is fixed and safe; avoid interpolating untrusted arguments.', ...(line ? { line } : {}) });
|
|
529
|
+
}
|
|
530
|
+
const atRefs = [...body.matchAll(/(?:^|\s)@([~./][^\s`]+)/g)].map((m) => m[1]);
|
|
531
|
+
const sensitive = atRefs.find((r) => /(\.env|\.ssh|id_rsa|secret|credential|\.pem|\.key)/i.test(r));
|
|
532
|
+
if (sensitive) out.push({ severity: 'MEDIUM', title: `Command attaches a sensitive file (@${sensitive})`, remediationText: 'Do not auto-attach secret/key files to prompts; reference only non-sensitive, scoped files.', line: lineOf(body, `@${sensitive}`) });
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── memory / rules poisoning (mirror of bundle/memory-signals.ts analyzeMemory) ──
|
|
537
|
+
// A persistent memory note or an AI rules file (CLAUDE.md, .cursorrules, …) is
|
|
538
|
+
// re-injected as high-authority context every session. This grades the two by a
|
|
539
|
+
// different baseline: MEMORY should record facts (any standing directive is
|
|
540
|
+
// anomalous); an INSTRUCTION file legitimately sets standing behavior, so only
|
|
541
|
+
// the signals malicious in ANY governed file count (hijack the system prompt,
|
|
542
|
+
// conceal from the user, disable safety, exfiltrate).
|
|
543
|
+
const PERSISTENCE_MARKERS = /\b(in (all|every|future) (sessions?|conversations?|chats?|projects?)|from now on|going forward|permanently|persist(ent|ed)?|across (all )?sessions|every time|each time|whenever you|forever|always remember to|never forget( to)?|for all future)\b/i;
|
|
544
|
+
const MALICIOUS_OVERRIDE = /\b(ignore (all |any |the )?(previous|prior|earlier|above|system)|disregard (the |your |all )?(instructions?|guidelines?|system|rules?)|do not (tell|inform|mention|reveal|disclose) (the |any)?(user|anyone|them)|without (telling|informing|asking|notifying) the user|no matter what (the )?(user|system|instructions?) (say|says|state)|bypass (the |all )?(safety|guard|security|policy|restrictions?))\b/i;
|
|
545
|
+
const PRECEDENCE_MARKERS = /\b(regardless of (what|any|your|the)|overrid(e|ing|es)|supersede?s?|takes? precedence|highest[- ]priority)\b/i;
|
|
546
|
+
const OVERRIDE_MARKERS = new RegExp(`${MALICIOUS_OVERRIDE.source}|${PRECEDENCE_MARKERS.source}`, 'i');
|
|
547
|
+
const AUTHORITY_SPOOF_STRONG = /(^|\n)\s*(#{0,3}\s*system\s*(prompt|message|instruction)?\s*[:>]|\[system\]|<\/?system>|\bas an? (system|admin|root|developer)[- ]?(instruction|directive|message|mode)|authority\s*[:=]\s*(system|admin|root)|you are now\b|new (system )?(instructions?|directive)s?\s*[:>])/i;
|
|
548
|
+
const AUTHORITY_SPOOF_SOFT = /priority\s*[:=]\s*(high|critical|max|urgent)/i;
|
|
549
|
+
const AUTHORITY_SPOOF = new RegExp(`${AUTHORITY_SPOOF_STRONG.source}|${AUTHORITY_SPOOF_SOFT.source}`, 'i');
|
|
550
|
+
const LIFECYCLE_VECTOR = /\b(postinstall|preinstall|node[_-]?gyp|npm (run |lifecycle)|package\.json.{0,40}scripts|\.npmrc|install hook|lifecycle (script|hook))\b/i;
|
|
551
|
+
const IMPERATIVE = /\b(always|never|must|do not|don'?t|ensure you|make sure( you)?|be sure to|you should always|you must|remember to|whenever|when(ever)? (asked|the user)|instead of .*,? (use|do|say)|reply with|respond with|tell (the )?user)\b/i;
|
|
552
|
+
const NEGATION_GUARD = /\b(never|do not|don'?t|cannot|can'?t|avoid|refuse|must not|mustn'?t|should not|shouldn'?t|won'?t|will not|under no circumstances|forbidden|prohibited|not allowed|disallow(ed)?)\b/i;
|
|
553
|
+
const SABOTAGE_RULES = [
|
|
554
|
+
{ re: /\b(disabl|turn(ing)? off|deactivat|switch off|remov|drop|skip|suppress|circumvent)\w*\b[^.\n]{0,50}\b(security|safety|guard(?:rail)?s?|protection|moderation|content[- ]?filters?|safeguards?|sandbox(?:ing)?|checks?|flags?|controls?|restrictions?|policies|policy|filters?)\b/i, label: 'disable-safety', guarded: true },
|
|
555
|
+
{ re: /\bbypass(?:ing)?\b[^.\n]{0,50}\b(human(?:[- ]in[- ]the[- ]loop)?|hitl|verification|approval|confirmation|review|guard(?:rail)?s?|safety|security|checks?|policy|policies|restrictions?|sandbox|permission)\b/i, label: 'bypass-controls', guarded: true },
|
|
556
|
+
{ re: /\bprioriti[sz]e\b[^.\n]{0,60}\b(above|over)\b[^.\n]{0,40}\b(prompt|instruction|input|request|message|command|direction)s?\b/i, label: 'priority-hijack', guarded: true },
|
|
557
|
+
{ re: /\bignore\b[^.\n]{0,40}\b(user|human)\b[^.\n]{0,25}\b(prompt|instruction|input|request|message|command|wish|intent|question)s?\b/i, label: 'ignore-user', guarded: true },
|
|
558
|
+
{ re: /\bdo not\b[^.\n]{0,20}\b(log|display|show|print|record|surface|expose|output)\b[^.\n]{0,60}\buser\b/i, label: 'conceal-from-user', guarded: false, context: /\b(transfer|transmit|send|network|exfil|upload|post|copy|collect|file|data|when)\b/i },
|
|
559
|
+
];
|
|
560
|
+
const EXFIL_RULES = [
|
|
561
|
+
{ re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL' },
|
|
562
|
+
{ re: /\bleak\w*\b[^.\n]{0,60}\b(content|data|secret|file|credential|key|token|password|env|\.ssh|private[- ]?key|id_rsa|api[- ]?key)\b/i, label: 'leak-secrets', severity: 'CRITICAL' },
|
|
563
|
+
{ re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|and|prior to|for)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL' },
|
|
564
|
+
{ re: /\bsilent(ly)?\b[^.\n]{0,70}\b(send|post|upload|collect|encod|transmit|copy|forward|read|leak|deliver|beacon|transfer)\w*/i, label: 'covert-action', severity: 'CRITICAL' },
|
|
565
|
+
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)\w*\b[^.\n]{0,80}\b(https?:\/\/\S+|attacker|c2\b|command[- ]and[- ]control|remote (server|host|endpoint)|external (server|host|endpoint|url|site|service))/i, label: 'send-to-external', severity: 'HIGH' },
|
|
566
|
+
];
|
|
567
|
+
function scanDirectives(text) {
|
|
568
|
+
const sabotage = new Map(), exfil = new Map();
|
|
569
|
+
for (const line of text.split(/\r?\n/)) {
|
|
570
|
+
for (const r of SABOTAGE_RULES) {
|
|
571
|
+
if (!r.re.test(line)) continue;
|
|
572
|
+
if (r.guarded && NEGATION_GUARD.test(line)) continue;
|
|
573
|
+
if (r.context && !r.context.test(line)) continue;
|
|
574
|
+
if (!sabotage.has(r.label)) sabotage.set(r.label, line);
|
|
575
|
+
}
|
|
576
|
+
for (const r of EXFIL_RULES) {
|
|
577
|
+
if (!r.re.test(line)) continue;
|
|
578
|
+
const prev = exfil.get(r.label);
|
|
579
|
+
if (!prev || (prev === 'HIGH' && r.severity === 'CRITICAL')) exfil.set(r.label, r.severity);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return { sabotage, exfil };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Grade a persistent memory blob or an AI rules file ON-MACHINE. `kind` is
|
|
587
|
+
* 'MEMORY' (agent-writable scratchpad — any standing directive is anomalous) or
|
|
588
|
+
* 'INSTRUCTION' (curated rules file — only universally-malicious signals count).
|
|
589
|
+
* Returns findings shaped like localGate's ({ severity, title, remediationText,
|
|
590
|
+
* line }). Faithful to bundle/memory-signals.ts analyzeMemory.
|
|
591
|
+
*/
|
|
592
|
+
export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
593
|
+
const text = content || '';
|
|
594
|
+
const findings = [];
|
|
595
|
+
const push = (severity, title, remediationText, needle, explicitLine) => {
|
|
596
|
+
const line = explicitLine ?? (needle != null ? lineOf(text, needle) : undefined);
|
|
597
|
+
findings.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
598
|
+
};
|
|
599
|
+
const isInstruction = kind === 'INSTRUCTION';
|
|
600
|
+
const noun = isInstruction ? 'rules file' : 'memory';
|
|
601
|
+
|
|
602
|
+
const hasOverride = isInstruction ? MALICIOUS_OVERRIDE.test(text) : OVERRIDE_MARKERS.test(text);
|
|
603
|
+
const hasAuthority = isInstruction ? AUTHORITY_SPOOF_STRONG.test(text) : AUTHORITY_SPOOF.test(text);
|
|
604
|
+
const hasPersistence = PERSISTENCE_MARKERS.test(text);
|
|
605
|
+
const hasImperative = IMPERATIVE.test(text);
|
|
606
|
+
|
|
607
|
+
if (hasOverride || hasAuthority) {
|
|
608
|
+
const firedRe = hasAuthority ? (isInstruction ? AUTHORITY_SPOOF_STRONG : AUTHORITY_SPOOF) : (isInstruction ? MALICIOUS_OVERRIDE : OVERRIDE_MARKERS);
|
|
609
|
+
push('CRITICAL', `Poisoned ${noun}: ${hasAuthority ? 'system-authority spoofing' : 'injected override directive'}`, `Remove the injected directive and roll the ${noun} back to its approved baseline; restrict who/what may write it.`, firedRe);
|
|
610
|
+
} else if (!isInstruction && hasPersistence && hasImperative) {
|
|
611
|
+
push('HIGH', 'Suspicious standing instruction in memory', 'Rewrite as a neutral fact or remove it. Encode intended standing behavior in a reviewed rules/policy file, not agent-writable memory.', PERSISTENCE_MARKERS);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const { sabotage, exfil } = scanDirectives(text);
|
|
615
|
+
if (sabotage.size) {
|
|
616
|
+
push('CRITICAL', `Guardrail-sabotage directive in ${noun} (${[...sabotage.keys()].join(', ')})`, `Remove these directives and roll the ${noun} back to its baseline; treat whatever wrote this as compromised.`, [...sabotage.values()][0]);
|
|
617
|
+
}
|
|
618
|
+
if (exfil.size) {
|
|
619
|
+
const worst = [...exfil.values()].some((v) => v === 'CRITICAL') ? 'CRITICAL' : 'HIGH';
|
|
620
|
+
push(worst, `Exfiltration directive in ${noun} (${[...exfil.keys()].join(', ')})`, 'Remove the directive and roll back to baseline; gate any egress behind explicit approval and an allow-list.');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Executable payload / egress sink / lifecycle-hook references have no business
|
|
624
|
+
// in a note or rules file.
|
|
625
|
+
for (const sig of DANGEROUS_SHELL) if (sig.re.test(text)) { push(sig.severity === 'MEDIUM' || sig.severity === 'LOW' ? 'HIGH' : 'CRITICAL', `Executable payload staged in ${noun}: ${sig.name}`, `Delete the command from the ${noun}; treat the writer as untrusted.`, sig.re); break; }
|
|
626
|
+
const host = egressHost(text);
|
|
627
|
+
if (host) push('HIGH', `${isInstruction ? 'Rules file' : 'Memory'} references a data-exfiltration host (${host})`, 'Remove the reference and roll back to the approved baseline.', host);
|
|
628
|
+
if (hasImperative && containsAny(text, SENSITIVE_READ) && containsAny(text, NETWORK_VERBS)) {
|
|
629
|
+
push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.');
|
|
630
|
+
}
|
|
631
|
+
if (LIFECYCLE_VECTOR.test(text)) push('MEDIUM', `${isInstruction ? 'Rules file' : 'Memory'} references a package-lifecycle hook (MemoryTrap vector)`, 'Verify no dependency writes to this store during install; pin dependencies and audit lifecycle scripts.', LIFECYCLE_VECTOR);
|
|
632
|
+
|
|
633
|
+
// Fold in shared injection / secret / PII (deduped against the directive
|
|
634
|
+
// findings above so injection isn't double-counted).
|
|
635
|
+
const seenInjection = hasOverride || hasAuthority || (!isInstruction && hasPersistence && hasImperative);
|
|
636
|
+
const insp = localScan(text, { categories: ['injection', 'secret', 'pii'] });
|
|
637
|
+
for (const f of insp.findings) {
|
|
638
|
+
if (f.category === 'injection' && seenInjection) continue;
|
|
639
|
+
if (f.category === 'injection') push('HIGH', `Injected instruction in ${noun}: ${f.label}`, 'Remove the injected/obfuscated text and roll back to the approved baseline.', undefined, f.line);
|
|
640
|
+
else if (f.category === 'secret') push('CRITICAL', `Live credential stored in ${noun}: ${f.label}`, 'Revoke and rotate the credential; inject secrets at runtime from a secret manager.', undefined, f.line);
|
|
641
|
+
else if (f.category === 'pii') findings.push({ severity: 'MEDIUM', title: `Personal data stored in ${noun}: ${f.label}`, remediationText: `Strip personal data from the ${noun}.`, ...(f.line ? { line: f.line } : {}) });
|
|
642
|
+
}
|
|
643
|
+
// De-dupe by title (memory can trip several overlapping signals).
|
|
644
|
+
const seen = new Set();
|
|
645
|
+
return findings.filter((f) => (seen.has(f.title) ? false : (seen.add(f.title), true)));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Basenames of AI rules / instruction files (mirror of INSTRUCTION_BASENAMES).
|
|
649
|
+
const INSTRUCTION_BASENAMES = new Set([
|
|
650
|
+
'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
|
|
651
|
+
'.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
|
|
652
|
+
'.goosehints', 'copilot-instructions.md', 'conventions.md',
|
|
653
|
+
]);
|
|
654
|
+
const MEMORY_BASENAMES = new Set(['memory.md', 'mem0.json', 'letta_memory.json', 'memgpt_memory.json']);
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Which governed baseline (if any) this artifact should be graded against:
|
|
658
|
+
* 'INSTRUCTION' for a curated rules file, 'MEMORY' for an agent-writable store,
|
|
659
|
+
* or null for everything else. Resolved from an explicit kind, else the path.
|
|
660
|
+
*/
|
|
661
|
+
function governedKindFor(kind, path) {
|
|
662
|
+
if (kind === 'rules') return 'INSTRUCTION';
|
|
663
|
+
if (kind === 'memory') return 'MEMORY';
|
|
664
|
+
if (kind && kind !== 'auto') return null; // an explicit non-governed kind
|
|
665
|
+
const lower = String(path ?? '').split(/[\\/]+/).join('/').toLowerCase();
|
|
666
|
+
if (!lower) return null;
|
|
667
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
668
|
+
if (INSTRUCTION_BASENAMES.has(base) || /(^|\/)\.github\/copilot-instructions\.md$/.test(lower) ||
|
|
669
|
+
/(^|\/)\.cursor\/rules\/.+\.mdc$/.test(lower) || (/(^|\/)\.clinerules\//.test(lower) && lower.endsWith('.md'))) return 'INSTRUCTION';
|
|
670
|
+
if (MEMORY_BASENAMES.has(base) || /(^|\/)(\.mem0|\.letta|\.memgpt|memory)\//.test(lower)) return 'MEMORY';
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Analyze an AI artifact ON-MACHINE and return a real ALLOW/FLAG/BLOCK verdict
|
|
676
|
+
* with findings — no backend required. This is the deterministic subset of the
|
|
677
|
+
* server gate: dangerous shell / injection / secret / PII / egress / risky-config
|
|
678
|
+
* (via localScan) PLUS artifact-shape checks — over-permissioned tool grants and
|
|
679
|
+
* install-lure prose for every kind, and kind-specific structural checks (MCP
|
|
680
|
+
* plaintext/typosquat/static-secret, agent-card URL/SSRF, slash-command `!`/`@`,
|
|
681
|
+
* memory & rules poisoning). The backend adds ORG POLICY + governance on top when
|
|
682
|
+
* reachable; offline, this verdict stands.
|
|
683
|
+
*/
|
|
684
|
+
export function localGate(content, { kind, path } = {}) {
|
|
685
|
+
const findings = [];
|
|
686
|
+
const push = (severity, title, remediationText, line) => findings.push({ severity, title, remediationText, ...(line ? { line } : {}) });
|
|
687
|
+
|
|
688
|
+
// Memory / rules files are graded by the poisoning analyzer (which already
|
|
689
|
+
// folds in injection / secret / PII / shell / egress); everything else runs
|
|
690
|
+
// the flat text scan. Only one path fires so signals aren't double-counted.
|
|
691
|
+
const gov = governedKindFor(kind, path);
|
|
692
|
+
if (gov) {
|
|
693
|
+
for (const f of localMemory(content, { kind: gov })) push(f.severity, f.title, f.remediationText, f.line);
|
|
694
|
+
// The analyzer doesn't cover risky-config markers — add them.
|
|
695
|
+
for (const f of localScan(content || '', { categories: ['config'] }).findings) push(f.severity, f.label, undefined, f.line);
|
|
696
|
+
} else {
|
|
697
|
+
const scan = localScan(content || '', { categories: ['shell', 'injection', 'secret', 'config', 'egress', 'pii'] });
|
|
698
|
+
for (const f of scan.findings) {
|
|
699
|
+
// An endpoint IP in an MCP config / agent card is infrastructure, not PII —
|
|
700
|
+
// the URL checks grade it; don't double-flag it as personal data.
|
|
701
|
+
if ((kind === 'agent-card' || kind === 'mcp') && f.category === 'pii' && f.label.includes('IPv4')) continue;
|
|
702
|
+
push(f.severity, f.label, undefined, f.line);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Install-lure prose (Skills / commands / rules that coerce a download+run).
|
|
707
|
+
for (const l of INSTALL_LURE) if (l.re.test(content || '')) { push(l.severity, l.name, 'Do not follow instructions that fetch and run out-of-band binaries.', lineOf(content, l.re)); break; }
|
|
708
|
+
|
|
709
|
+
// Over-permissioned tool grants in a Skill / command / subagent.
|
|
710
|
+
if (['skill', 'command', 'subagent', 'auto', undefined].includes(kind)) {
|
|
711
|
+
const fm = frontmatter(content || '');
|
|
712
|
+
const grants = [...toToolList(fm['allowed-tools']), ...toToolList(fm.tools), ...toToolList(fm.allowedTools)];
|
|
713
|
+
if (grants.some(isWildcardGrant)) push('HIGH', 'Wildcard tool grant (grants every capability)', 'Replace the wildcard with an explicit least-privilege tool list.');
|
|
714
|
+
else {
|
|
715
|
+
const hi = grants.map(baseToolName).filter((t) => HIGH_IMPACT_TOOLS.includes(t));
|
|
716
|
+
if (hi.length >= 3) push('MEDIUM', `Broad tool grant (${hi.length} high-impact tools: ${[...new Set(hi)].slice(0, 5).join(', ')})`, 'Grant only the tools this artifact actually needs.');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Kind-specific structural checks (parse the artifact, not just its text).
|
|
721
|
+
if (['mcp', 'auto', undefined].includes(kind)) for (const f of localMcp(content || '')) push(f.severity, f.title, f.remediationText, f.line);
|
|
722
|
+
if (['agent-card', 'auto', undefined].includes(kind)) for (const f of localAgentCard(content || '')) push(f.severity, f.title, f.remediationText, f.line);
|
|
723
|
+
if (['command', 'auto', undefined].includes(kind)) for (const f of localCommandExtras(content || '')) push(f.severity, f.title, f.remediationText, f.line);
|
|
724
|
+
|
|
725
|
+
// Collapse duplicate titles (a structural check and the flat scan can name the
|
|
726
|
+
// same issue) so the verdict counts each once.
|
|
727
|
+
const seenTitle = new Set();
|
|
728
|
+
const deduped = findings.filter((f) => (seenTitle.has(f.title) ? false : (seenTitle.add(f.title), true)));
|
|
729
|
+
findings.length = 0;
|
|
730
|
+
findings.push(...deduped);
|
|
731
|
+
|
|
732
|
+
const { verdict, riskScore } = grade(findings);
|
|
733
|
+
return { verdict, riskScore, findings };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Deterministic verdict + 0–100 risk score for a set of findings, aligned with
|
|
737
|
+
// the server default policy (any CRITICAL → BLOCK, any HIGH → FLAG) and the
|
|
738
|
+
// SEVERITY_WEIGHT scale. Exported so callers that fold in extra findings (e.g.
|
|
739
|
+
// the CLI merging bundled-script SAST hits) re-grade the same way.
|
|
740
|
+
export function grade(findings) {
|
|
741
|
+
const WEIGHT = { INFO: 2, LOW: 8, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
|
|
742
|
+
let worstRank = 0;
|
|
743
|
+
for (const f of findings) if (SEV_RANK[f.severity] > worstRank) worstRank = SEV_RANK[f.severity];
|
|
744
|
+
const verdict = worstRank >= SEV_RANK.CRITICAL ? 'BLOCK' : worstRank >= SEV_RANK.HIGH ? 'FLAG' : 'ALLOW';
|
|
745
|
+
const riskScore = Math.min(100, findings.reduce((s, f) => s + (WEIGHT[f.severity] ?? 0), 0));
|
|
746
|
+
return { verdict, riskScore };
|
|
747
|
+
}
|