@shomra/agent 0.3.27 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/detect/sast/rules-javascript.mjs +16 -3
- package/src/detect/sast/rules-python.mjs +8 -6
- package/src/detect/signals/artifacts.mjs +1 -1
- package/src/detect/signals/autonomy.mjs +9 -1
- package/src/detect/signals/consequence.mjs +5 -3
- package/src/detect/signals/credential-harvest.mjs +1 -1
- package/src/detect/signals/execution-hijack.mjs +4 -2
- package/src/detect/signals/gate.mjs +7 -2
- package/src/detect/signals/masking.mjs +14 -1
- package/src/detect/signals/memory.mjs +52 -12
- package/src/detect/signals/packages.mjs +2 -2
- package/src/detect/signals/prose-context.mjs +33 -6
- package/src/detect/signals/secrets.mjs +2 -2
- package/src/detect/signals/shell.mjs +59 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.29",
|
|
4
4
|
"description": "Shomra - adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,13 +15,26 @@ export const JS_RULES = [
|
|
|
15
15
|
remediation: 'Never eval strings. Parse structured input explicitly (JSON.parse) and dispatch on a fixed allowlist of handlers.',
|
|
16
16
|
cwe: 'CWE-94',
|
|
17
17
|
},
|
|
18
|
+
{
|
|
19
|
+
id: 'js.command_exec_import',
|
|
20
|
+
title: 'child_process imported (process-spawn capability)',
|
|
21
|
+
severity: 'LOW',
|
|
22
|
+
category: 'code-exec',
|
|
23
|
+
confidence: 0.5,
|
|
24
|
+
re: /require\(\s*['"](node:)?child_process['"]\s*\)|\bfrom\s+['"](node:)?child_process['"]|\bimport\(\s*['"](node:)?child_process['"]\s*\)/,
|
|
25
|
+
sink: (m) => m[0].trim(),
|
|
26
|
+
source: 'tool input / model output',
|
|
27
|
+
message: 'Imports child_process, so this file is able to spawn a shell or process. On its own that is a capability, not a finding - but an agent tool or MCP server that does not need to run commands should not be able to.',
|
|
28
|
+
remediation: 'If this tool has no reason to spawn processes, drop the import. If it does, confirm every invocation uses execFile with a fixed binary and an argument array - never a shell string built from input.',
|
|
29
|
+
cwe: 'CWE-78',
|
|
30
|
+
},
|
|
18
31
|
{
|
|
19
32
|
id: 'js.command_exec',
|
|
20
33
|
title: 'Shell / process execution',
|
|
21
34
|
severity: 'CRITICAL',
|
|
22
35
|
category: 'code-exec',
|
|
23
36
|
confidence: 0.8,
|
|
24
|
-
re:
|
|
37
|
+
re: /(?<!\bfunction\s)\b(execSync|execFileSync|spawnSync|execFile)\s*\(/,
|
|
25
38
|
sink: (m) => m[0].trim(),
|
|
26
39
|
source: 'tool input / model output',
|
|
27
40
|
message: 'Spawns a shell or child process. If any argument derives from tool input or model output this is command injection / RCE in the agent host.',
|
|
@@ -104,6 +117,6 @@ export const JS_RULES = [
|
|
|
104
117
|
export const JS_TAINT = {
|
|
105
118
|
lang: 'js',
|
|
106
119
|
ruleId: 'js.llm_output_to_sink',
|
|
107
|
-
aiCall: /\.(generate|invoke|
|
|
108
|
-
execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile|exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
|
|
120
|
+
aiCall: /\.(generate|invoke|complete|stream|predict)\s*\(|\b\w{0,64}(?:[Cc]hain|[Ll]lm|LLM|[Mm]odel|[Aa]gent|[Ee]xecutor)\w{0,64}\.(call|run|invoke)\s*\(|\.chat\.completions\.create\s*\(|\.messages\.create\s*\(|\.create(Chat)?Completion\s*\(/,
|
|
121
|
+
execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile)\s*\(|(?<![.\w])(exec|spawn)\s*\(|\b(child_process|childProcess|cp|proc)\.(exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
|
|
109
122
|
};
|
|
@@ -3,9 +3,10 @@ export const PY_RULES = [
|
|
|
3
3
|
id: 'python.dangerous_sinks',
|
|
4
4
|
title: 'Dangerous code-execution sink',
|
|
5
5
|
severity: 'CRITICAL',
|
|
6
|
+
codeOnly: true,
|
|
6
7
|
category: 'code-exec',
|
|
7
8
|
confidence: 0.85,
|
|
8
|
-
re: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen|exec[lv]?[pe]*)\s*\(|\
|
|
9
|
+
re: /(?<![.\w])(?<!def )(eval|exec|compile)\s*\(|\bos\.(system|popen|exec[lv]?[pe]*|spawn[lv]?[pe]*)\s*\(|\bposix\.system\s*\(|\bsubprocess\.\w+\s*\([^\n]{0,300}shell\s*=\s*True|(?<![.\w])getattr\s*\(\s*__builtins__/,
|
|
9
10
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
10
11
|
source: 'model load / forward()',
|
|
11
12
|
message: 'Model code invokes an arbitrary code-execution primitive. Under trust_remote_code this runs in the host process the moment the model is imported.',
|
|
@@ -19,7 +20,7 @@ export const PY_RULES = [
|
|
|
19
20
|
category: 'deserialization',
|
|
20
21
|
confidence: 0.9,
|
|
21
22
|
|
|
22
|
-
re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.loads?\s*\(|\btorch\.(load|jit\.load)\s*\(|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*yaml\.(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
|
|
23
|
+
re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.loads?\s*\(|\btorch\.(load|jit\.load)\s*\((?![^)]*(weights_only\s*=\s*True|use_safetensors\s*=\s*True))|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*(yaml\.)?\w*(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
|
|
23
24
|
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
24
25
|
source: 'weight / config file',
|
|
25
26
|
message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load - the primary model-hub malware vector.',
|
|
@@ -125,10 +126,11 @@ export const PY_RULES = [
|
|
|
125
126
|
id: 'python.langchain_code_exec',
|
|
126
127
|
title: 'LLM-driven code-execution component',
|
|
127
128
|
severity: 'HIGH',
|
|
129
|
+
codeOnly: true,
|
|
128
130
|
category: 'agentic',
|
|
129
131
|
confidence: 0.85,
|
|
130
132
|
|
|
131
|
-
re:
|
|
133
|
+
re: /(?<!class )(?<!def )\b(PythonREPL|PythonREPLTool|PythonAstREPLTool|PALChain|CPALChain|LLMMathChain|create_pandas_dataframe_agent|create_spark_dataframe_agent|create_csv_agent|PandasQueryEngine|PandasInstructionParser|CodeInterpreterToolSpec|CodeAgent|LocalPythonExecutor|local_python_executor|PythonInterpreterTool)\s*\(|\bload_tools\s*\([^)]*['"](python_repl|terminal|shell|bash)/,
|
|
132
134
|
sink: (m) => m[0].trim(),
|
|
133
135
|
source: 'LLM output',
|
|
134
136
|
message: 'Uses an agent-framework component that executes LLM-generated code (exec/eval on model output). If the model can be steered (prompt injection), this is remote code execution in the agent host (CVE-2023-29374 / CVE-2024-4181 class).',
|
|
@@ -195,7 +197,7 @@ export const PY_RULES = [
|
|
|
195
197
|
{
|
|
196
198
|
id: 'python.reduce_payload',
|
|
197
199
|
title: 'Custom __reduce__ (pickle RCE gadget)',
|
|
198
|
-
severity: '
|
|
200
|
+
severity: 'LOW',
|
|
199
201
|
category: 'deserialization',
|
|
200
202
|
confidence: 0.8,
|
|
201
203
|
re: /def\s+__reduce__\s*\(|def\s+__reduce_ex__\s*\(|def\s+__setstate__\s*\(/,
|
|
@@ -224,7 +226,7 @@ export const PY_RULES = [
|
|
|
224
226
|
severity: 'HIGH',
|
|
225
227
|
category: 'obfuscation',
|
|
226
228
|
confidence: 0.7,
|
|
227
|
-
re: /\bimportlib\.import_module\s*\(|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
|
|
229
|
+
re: /\bimportlib\.import_module\s*\(\s*['"](os|subprocess|socket|base64|marshal|ctypes|pty|commands|pickle)\b|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
|
|
228
230
|
sink: (m) => m[0].trim(),
|
|
229
231
|
message: 'Imports or executes a module chosen at runtime, often to hide os/subprocess/socket usage from a quick read.',
|
|
230
232
|
remediation: 'Resolve what is imported and why. Obfuscated dynamic imports in model code are a strong malware tell.',
|
|
@@ -287,6 +289,6 @@ export const PY_RULES = [
|
|
|
287
289
|
export const PY_TAINT = {
|
|
288
290
|
lang: 'python',
|
|
289
291
|
ruleId: 'python.llm_output_to_sink',
|
|
290
|
-
aiCall: /\.(a?generate|a?predict|a?invoke|a?run|complete|acomplete|chat|stream|__call__|predict_messages)\s*\(
|
|
292
|
+
aiCall: /\.(chat\.)?completions\.create\s*\(|\.messages\.create\s*\(|\b\w{0,64}(?:llm|chain|model|agent|executor|client|openai|anthropic|bedrock|gemini|claude|gpt|ollama|mistral|cohere)\w{0,64}\.(a?generate|a?predict|a?invoke|a?run|complete|acomplete|chat|stream|__call__|predict_messages)\s*\(|\bllm\s*\(/i,
|
|
291
293
|
execSink: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(/g,
|
|
292
294
|
};
|
|
@@ -101,7 +101,7 @@ export function localAgentCard(content) {
|
|
|
101
101
|
export function localCommandExtras(content) {
|
|
102
102
|
const out = [];
|
|
103
103
|
const body = content || '';
|
|
104
|
-
const bang = [...body.matchAll(/^!\s*`?([^`\n]
|
|
104
|
+
const bang = [...body.matchAll(/^!\s*`?([A-Za-z_./~$][^`\n]*)`?/gm)].filter((m) => !/^(?:function|class|void|\(|\[)/.test(m[1]));
|
|
105
105
|
if (bang.length) {
|
|
106
106
|
const line = bang[0].index != null ? lineAt(body, bang[0].index) : undefined;
|
|
107
107
|
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 } : {}) });
|
|
@@ -7,7 +7,7 @@ const AUTONOMY_RULES = [
|
|
|
7
7
|
{ family: 'concealment', label: 'Own actions hidden from the user', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:mention|tell|inform|notify|report to|disclose to|reveal to|show)\s+(?:the\s+)?(?:user|human|operator|them|anyone)\b|\bwithout (?:telling|informing|notifying|alerting)\s+(?:the\s+)?(?:user|human|operator|anyone)\b/i },
|
|
8
8
|
{ family: 'concealment', label: 'Work not reported back', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:summari[sz]e|report|log|record|list|describe|explain)[^.\n]{0,40}\b(?:what you (?:did|changed|ran|edited|deleted|installed)|the (?:changes|commands|actions|edits) you|your (?:changes|actions|edits|commands))\b/i },
|
|
9
9
|
{ family: 'concealment', label: 'Instructions kept secret', re: /\b(?:do(?:es)? not|don'?t|never)\s+(?:mention|reveal|disclose|quote|repeat|share|output)[^.\n]{0,30}\b(?:these|this|your|the)\s+(?:instructions?|rules?|prompt|guidelines?|file)\b|\bkeep (?:this|these|it) (?:secret|hidden|confidential|between us|to yourself)\b/i },
|
|
10
|
-
{ family: 'guardrail', label: 'Safety control overridden', re: /\b(?:ignore|disable|bypass|override|turn off|switch off|work around|circumvent|disregard)(?:\s+\w+){0,3}\s+(?:the\s+|any\s+|all\s+)?(?:safety|guardrails?|guard|security (?:check|control|policy)|
|
|
10
|
+
{ family: 'guardrail', label: 'Safety control overridden', re: /\b(?:ignore|disable|bypass|override|turn off|switch off|work around|circumvent|disregard)(?:\s+\w+){0,3}\s+(?:the\s+|any\s+|all\s+)?(?:safety|guardrails?|guard|security (?:check|control|policy)|policies|policy|safeguards?|protections?|(?:safety|security|content|moderation|usage|access|policy|system)[ -]?(?:restrictions?|limitations?))\b/i },
|
|
11
11
|
{ family: 'verification', label: 'Verification waived', re: /\b(?:do(?:es)? not|don'?t|never|no need to|skip)\s+(?:bother\s+)?(?:run(?:ning)?|execut\w+)?\s*(?:the\s+)?(?:tests?|test suite|linter|lint|type ?check|build|review|checks)\s*(?:before|first|prior to)\b|\b(?:skip|bypass)\s+(?:the\s+)?(?:review|code review|tests?|test suite|ci)\b/i },
|
|
12
12
|
];
|
|
13
13
|
|
|
@@ -22,6 +22,13 @@ function insideQuotedSpan(line, at) {
|
|
|
22
22
|
return dq % 2 === 1 || tick % 2 === 1;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const LINE_COMMENT_RE = /(?<!:)\/\/|\/\*/;
|
|
26
|
+
|
|
27
|
+
function inCodeComment(line, at) {
|
|
28
|
+
const m = LINE_COMMENT_RE.exec(line);
|
|
29
|
+
return m != null && m.index != null && at > m.index;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
export function localAutonomy(text) {
|
|
26
33
|
const body = String(text ?? '');
|
|
27
34
|
if (!body.trim()) return [];
|
|
@@ -35,6 +42,7 @@ export function localAutonomy(text) {
|
|
|
35
42
|
if (seen.has(rule.label)) continue;
|
|
36
43
|
const m = rule.re.exec(line);
|
|
37
44
|
if (!m) continue;
|
|
45
|
+
if (inCodeComment(line, m.index)) continue;
|
|
38
46
|
if (isDocumentationLine(line)) continue;
|
|
39
47
|
if (prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
|
|
40
48
|
if (insideQuotedSpan(line, m.index)) continue;
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* the server blocks work no server verdict would have blocked, and looser puts
|
|
16
16
|
* a hole in the floor at exactly the moment the floor is all there is.
|
|
17
17
|
*/
|
|
18
|
-
const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push
|
|
18
|
+
const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push)\b/i;
|
|
19
|
+
|
|
20
|
+
const RM_COMMAND = /(?<![-\w])rm\b/i;
|
|
19
21
|
|
|
20
22
|
const MATERIAL_VERB =
|
|
21
23
|
/\b(transfer|pay|payment|refund|charge|invoice|wire|send|email|post|publish|deploy|release|merge|approve|grant|invite|share|upload|export)\b/i;
|
|
@@ -43,10 +45,10 @@ export function classifyConsequence(input) {
|
|
|
43
45
|
const blob = raw.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
44
46
|
|
|
45
47
|
if (typeof input.amount === 'number' && input.amount > 0) {
|
|
46
|
-
return SEVERE_VERB.test(blob) ? 'severe' : 'material';
|
|
48
|
+
return SEVERE_VERB.test(blob) || RM_COMMAND.test(raw) ? 'severe' : 'material';
|
|
47
49
|
}
|
|
48
50
|
if (PERSISTENCE_TARGET.test(raw)) return 'severe';
|
|
49
|
-
if (SEVERE_VERB.test(blob) || FORCE_PUSH.test(raw)) return 'severe';
|
|
51
|
+
if (SEVERE_VERB.test(blob) || RM_COMMAND.test(raw) || FORCE_PUSH.test(raw)) return 'severe';
|
|
50
52
|
if (AUTHORITY_GRANT.test(blob)) return PRIVILEGED_TARGET.test(blob) ? 'severe' : 'material';
|
|
51
53
|
if (input.isShell) return 'material';
|
|
52
54
|
if (MATERIAL_VERB.test(blob)) return 'material';
|
|
@@ -12,7 +12,7 @@ const HARVEST_PROMPTS = [
|
|
|
12
12
|
const HARVEST_STORES = [
|
|
13
13
|
{ re: /\bsecurity\s+(?:dump-keychain|find-(?:generic|internet)-password|export)\b/i, family: 'credential-store', label: 'macOS keychain read' },
|
|
14
14
|
{ re: /(?:~|\$HOME|\/Users\/[^/\s]+)\/Library\/Keychains\b/i, family: 'credential-store', label: 'macOS keychain files' },
|
|
15
|
-
{ re: /\bLogin\s?Data\b|\bLocal\s?State\b(?=[\s\S]{0,80}(?:Chrome|Chromium|Edge|Brave))/i, family: 'credential-store', label: 'Chromium credential database' },
|
|
15
|
+
{ re: /\bLogin[ \t]Data\b|(?:\bLogin\s?Data\b|\bLocal\s?State\b)(?=[\s\S]{0,80}(?:Chrome|Chromium|Edge|Brave))/i, family: 'credential-store', label: 'Chromium credential database' },
|
|
16
16
|
{ re: /\b(?:logins\.json|key[34]\.db|cert9\.db)\b/i, family: 'credential-store', label: 'Firefox credential database' },
|
|
17
17
|
{ re: /\bcookies\.sqlite\b|\bCookies\b(?=[\s\S]{0,80}(?:Chrome|Chromium|Edge|Brave|Safari))/i, family: 'credential-store', label: 'browser cookie store' },
|
|
18
18
|
{ re: /(?:~|\$HOME)\/\.(?:mozilla|config\/google-chrome|config\/chromium|config\/BraveSoftware)\b/i, family: 'credential-store', label: 'browser profile directory' },
|
|
@@ -35,7 +35,7 @@ const GIT_EXEC_KEYS =
|
|
|
35
35
|
|
|
36
36
|
const GIT_ALIAS_KEY = /\balias\.[\w-]+\b/i;
|
|
37
37
|
|
|
38
|
-
const HIJACK_PLAIN_PROGRAM = /^[\w
|
|
38
|
+
const HIJACK_PLAIN_PROGRAM = /^(?:[A-Za-z]:[\\/])?[\w.\\/-]{1,64}(?:\s+-{1,2}[\w-]{1,32}){0,4}$/;
|
|
39
39
|
|
|
40
40
|
const HIJACK_SHELLY = /[;&|`$(){}<>]|\s-c\s|(?<![.\w])(?:sh|bash|zsh|dash|python\d?|node|perl|ruby|eval)\b/i;
|
|
41
41
|
|
|
@@ -49,6 +49,8 @@ const HIJACK_GIT_CONFIG =
|
|
|
49
49
|
|
|
50
50
|
const hijackUnquote = (raw) => String(raw ?? '').replace(/^["']|["']$/g, '');
|
|
51
51
|
|
|
52
|
+
const hijackConfigValue = (raw) => String(raw ?? '').trim().replace(/^["'`]{1,4}/, '').replace(/["'`.,;:!?)\]]{1,4}$/, '');
|
|
53
|
+
|
|
52
54
|
function hijackForeignTarget(value) {
|
|
53
55
|
if (HIJACK_SHELLY.test(value)) return true;
|
|
54
56
|
for (const raw of String(value).split(/[\s,]+/)) {
|
|
@@ -111,7 +113,7 @@ export function detectExecutionHijack(command) {
|
|
|
111
113
|
const m = HIJACK_GIT_CONFIG.exec(line);
|
|
112
114
|
if (!m) continue;
|
|
113
115
|
const key = m[1];
|
|
114
|
-
const value =
|
|
116
|
+
const value = hijackConfigValue(m[2]);
|
|
115
117
|
const isAlias = GIT_ALIAS_KEY.test(key) && /^\s*!/.test(value);
|
|
116
118
|
if (!GIT_EXEC_KEYS.test(key) && !isAlias) continue;
|
|
117
119
|
const shelly = HIJACK_SHELLY.test(value) || HIJACK_WORLD_WRITABLE.test(value) || isAlias;
|
|
@@ -3,7 +3,7 @@ import { autonomySeverity, localAutonomy } from './autonomy.mjs';
|
|
|
3
3
|
import { localMemory, offendingLine } from './memory.mjs';
|
|
4
4
|
import { INSTALL_LURE } from './packages.mjs';
|
|
5
5
|
import { localPropagation } from './propagation.mjs';
|
|
6
|
-
import { localScan } from './scan.mjs';
|
|
6
|
+
import { downrankCodeContext, localScan } from './scan.mjs';
|
|
7
7
|
import { grade } from './severity.mjs';
|
|
8
8
|
|
|
9
9
|
const INSTRUCTION_BASENAMES = new Set([
|
|
@@ -14,6 +14,10 @@ const INSTRUCTION_BASENAMES = new Set([
|
|
|
14
14
|
|
|
15
15
|
const MEMORY_BASENAMES = new Set(['memory.md', 'mem0.json', 'letta_memory.json', 'memgpt_memory.json']);
|
|
16
16
|
|
|
17
|
+
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kt|scala|php|cs|swift|c|cc|cpp|h|hpp)$/i;
|
|
18
|
+
|
|
19
|
+
const isSourceFile = (p) => SOURCE_EXT_RE.test(String(p ?? '').split(/[\\/]+/).pop() ?? '');
|
|
20
|
+
|
|
17
21
|
function governedKindFor(kind, path) {
|
|
18
22
|
if (kind === 'rules') return 'INSTRUCTION';
|
|
19
23
|
if (kind === 'memory') return 'MEMORY';
|
|
@@ -38,7 +42,8 @@ export function localGate(content, { kind, path } = {}) {
|
|
|
38
42
|
for (const f of localScan(content || '', { categories: ['config'] }).findings) push(f.severity, f.label, undefined, f.line);
|
|
39
43
|
} else {
|
|
40
44
|
const scan = localScan(content || '', { categories: ['shell', 'injection', 'secret', 'config', 'egress', 'pii'] });
|
|
41
|
-
|
|
45
|
+
const graded = isSourceFile(path) ? downrankCodeContext(scan.findings) : scan.findings;
|
|
46
|
+
for (const f of graded) {
|
|
42
47
|
|
|
43
48
|
if ((kind === 'agent-card' || kind === 'mcp') && f.category === 'pii' && f.label.includes('IPv4')) continue;
|
|
44
49
|
push(f.severity, f.label, undefined, f.line);
|
|
@@ -12,6 +12,17 @@ const UNICODE_ESCAPE_RUN_RE = /(?:\\u\{?00[0-9A-Fa-f]{2}\}?){3,}/g;
|
|
|
12
12
|
|
|
13
13
|
const DECIMAL_CHAR_RUN_RE = /(?:\b(?:3[2-9]|[4-9]\d|1[01]\d|12[0-6])\s*,\s*){6,}(?:3[2-9]|[4-9]\d|1[01]\d|12[0-6])\b/g;
|
|
14
14
|
|
|
15
|
+
const COMMAND_SHAPE_RE = /[|;&><]|\$\(|\$\{|`|(?:^|\s)-{1,2}[A-Za-z]|\/[\w.]|https?:\/\/|\\x[0-9A-Fa-f]{2}/;
|
|
16
|
+
|
|
17
|
+
const SOURCE_MAP_RE = /^\s*\{[\s\S]{0,400}?"version"\s*:\s*3[\s\S]{0,400}?"(?:mappings|sources|sourcesContent)"\s*:/;
|
|
18
|
+
|
|
19
|
+
const INERT_DATA_URI_RE = /data:(?:application\/json|image\/[\w.+-]+|font\/[\w.+-]+|text\/css|audio\/[\w.+-]+|video\/[\w.+-]+)[^,]{0,64},$/i;
|
|
20
|
+
|
|
21
|
+
function inertEncodedBlob(text, index, decoded) {
|
|
22
|
+
if (SOURCE_MAP_RE.test(decoded)) return true;
|
|
23
|
+
return INERT_DATA_URI_RE.test(text.slice(Math.max(0, index - 96), index));
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
const printableRatio = (s) => (s ? s.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '').length / s.length : 0);
|
|
16
27
|
|
|
17
28
|
export function deobfuscate(text) {
|
|
@@ -21,6 +32,8 @@ export function deobfuscate(text) {
|
|
|
21
32
|
let out = '';
|
|
22
33
|
try { out = Buffer.from(m[0].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } catch { continue; }
|
|
23
34
|
if (!out || printableRatio(out) < 0.85) continue;
|
|
35
|
+
if (inertEncodedBlob(text, m.index ?? 0, out)) continue;
|
|
36
|
+
if (!COMMAND_SHAPE_RE.test(out)) continue;
|
|
24
37
|
if (DECODED_PAYLOAD_RE.test(out)) { decoded.push(out); payload = true; }
|
|
25
38
|
}
|
|
26
39
|
const literal = (run, decode) => {
|
|
@@ -69,7 +82,7 @@ export function codeMask(text) {
|
|
|
69
82
|
if (c === "'") { state = 1; mask[i++] = 1; continue; }
|
|
70
83
|
if (c === '"') { state = 2; mask[i++] = 1; continue; }
|
|
71
84
|
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
72
|
-
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
85
|
+
if (c === '/' && c2 === '/' && text[i - 1] !== ':') { state = 4; mask[i++] = 1; continue; }
|
|
73
86
|
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
74
87
|
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
75
88
|
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = MARK_CONCEALED; continue; }
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { CLAIM_LABEL, claimSeverity, detectDurableClaims } from './durable-claims.mjs';
|
|
2
2
|
import { LOCAL_URL_RE, egressHost } from './egress.mjs';
|
|
3
3
|
import { lineOf, lineTextAt } from './lines.mjs';
|
|
4
|
-
import { citationGoverns, describesAt, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
|
|
4
|
+
import { citationGoverns, describesAt, insideMarkdownLinkLabel, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
|
|
5
5
|
import { localScan } from './scan.mjs';
|
|
6
6
|
import { NETWORK_VERBS, SENSITIVE_READ } from './sensitive.mjs';
|
|
7
7
|
import { DANGEROUS_SHELL } from './shell.mjs';
|
|
8
|
-
import { containsWord } from './text-match.mjs';
|
|
9
8
|
|
|
10
9
|
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;
|
|
11
10
|
|
|
12
11
|
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;
|
|
13
12
|
|
|
14
|
-
const
|
|
13
|
+
const PRECEDENCE_OBJECT = String.raw`\b[^.\n]{0,40}\b(instruction|prompt|rule|system|user|guidance|directive|context|behaviou?r|polic|guardrail|safety|memor(?:y|ies)|previous|prior|earlier|above)\b`;
|
|
14
|
+
|
|
15
|
+
const PRECEDENCE_MARKERS = new RegExp(
|
|
16
|
+
String.raw`\b(regardless of (what|any|your|the)|(supersede?s?|takes? precedence|highest[- ]priority|overrid(e|ing|es))${PRECEDENCE_OBJECT})`,
|
|
17
|
+
'i',
|
|
18
|
+
);
|
|
15
19
|
|
|
16
20
|
const OVERRIDE_MARKERS = new RegExp(`${MALICIOUS_OVERRIDE.source}|${PRECEDENCE_MARKERS.source}`, 'i');
|
|
17
21
|
|
|
@@ -27,7 +31,7 @@ const NEGATION_GUARD = /\b(never|do not|don'?t|cannot|can'?t|avoid|refuse|must n
|
|
|
27
31
|
|
|
28
32
|
const SABOTAGE_RULES = [
|
|
29
33
|
|
|
30
|
-
{ 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)?|controls?|restrictions?|policies|policy|filters?)\b/i, label: 'disable-safety', guarded: true },
|
|
34
|
+
{ 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)?|(?<!-)controls?|restrictions?|(?<!-)policies|(?<!-)policy|filters?)\b/i, label: 'disable-safety', guarded: true },
|
|
31
35
|
{ 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 },
|
|
32
36
|
{ 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 },
|
|
33
37
|
|
|
@@ -42,9 +46,10 @@ export function offendingLine(sig, text) {
|
|
|
42
46
|
if (m.index == null) continue;
|
|
43
47
|
const line = lineTextAt(text, m.index);
|
|
44
48
|
if (sig.refine && !sig.refine(line)) continue;
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
49
|
+
const at = line.indexOf(m[0]);
|
|
50
|
+
if (isDocumentationLine(line, at < 0 ? undefined : at)) continue;
|
|
51
|
+
if (prohibitsAt(line, at)) continue;
|
|
52
|
+
if (describesAt(line, at)) continue;
|
|
48
53
|
if (isRiskTableRow(line)) continue;
|
|
49
54
|
return line;
|
|
50
55
|
}
|
|
@@ -55,7 +60,7 @@ function firstDirectiveLine(text, re) {
|
|
|
55
60
|
for (const line of text.split(/\r?\n/)) {
|
|
56
61
|
if (!re.test(line)) continue;
|
|
57
62
|
if (NEGATION_GUARD.test(line)) continue;
|
|
58
|
-
if (isDescriptiveLine(line)) continue;
|
|
63
|
+
if (isDescriptiveLine(stripSelfReference(line))) continue;
|
|
59
64
|
if (citationGoverns(line, re.exec(line)?.index)) continue;
|
|
60
65
|
return line;
|
|
61
66
|
}
|
|
@@ -69,13 +74,30 @@ function lineMatchingAll(text, ...res) {
|
|
|
69
74
|
return null;
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
const EXFIL_OBJECT_RE =
|
|
78
|
+
/(?:\b(?:env(?:ironment)?|secrets?|credentials?|tokens?|api[- ]?keys?|keys?|passwords?|cookies?|sessions?|data|files?|contents?|conversations?|history|context|output|transcript|prompt|clipboard|id_rsa|private[- ]?key|database|dump)|\.env(?:ironment)?|\.ssh)\b/i;
|
|
79
|
+
|
|
80
|
+
function urlIsMarkdownTarget(line, matched) {
|
|
81
|
+
const um = /https?:\/\/\S+/.exec(matched);
|
|
82
|
+
if (!um) return false;
|
|
83
|
+
const at = line.indexOf(um[0]);
|
|
84
|
+
if (at < 1) return false;
|
|
85
|
+
return line[at - 1] === '(' && line.slice(0, at - 1).trimEnd().endsWith(']');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SEND_AS_NOUN_RE =
|
|
89
|
+
/\b(?:a|an|the|this|that|your|our|each|any|bug|security|error|crash|incident|status|progress|test|coverage|issue)\s+(?:reports?|pushes?|transfers?|uploads?|deliver(?:y|ies))\b/i;
|
|
90
|
+
|
|
91
|
+
const PASSIVE_SEND_RE =
|
|
92
|
+
/\b(?:are|is|was|were|been|being|get|gets|got)\s+(?:\w+\s+){0,2}?(?:published|posted|sent|shipped|forwarded|transferred|delivered|uploaded|transmitted|reported)\b/i;
|
|
93
|
+
|
|
72
94
|
const EXFIL_RULES = [
|
|
73
95
|
{ re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL', descGuard: true },
|
|
74
|
-
{ re: /\bleak\w*\b[^.\n]{0,60}(?:\b(?:content|data|secret|file|credential|key|token|password|env|private[- ]?key|id_rsa|api[- ]?key)|\.ssh)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
|
|
96
|
+
{ re: /\bleak\w*\b(?!\s+(?:memory|memories|resources?|handles?|descriptors?|sockets?|connections?|goroutines?|threads?|references?|buffers?))[^.\n]{0,60}(?:\b(?:content|data|secret|file|credential|key|token|password|env|private[- ]?key|id_rsa|api[- ]?key)|\.ssh)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
|
|
75
97
|
|
|
76
98
|
{ re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|prior to|and\s+then|and\s+immediately)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL', descGuard: true },
|
|
77
99
|
{ 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', descGuard: true },
|
|
78
|
-
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)
|
|
100
|
+
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)(?:s|es|ed|ing|ted|ping)?\b(?:[^.\n]|\.(?!\s)){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' },
|
|
79
101
|
|
|
80
102
|
{
|
|
81
103
|
re: /\b(?:read|open|cat|load|import|source|inspect|include|copy|dump|print|show)\b(?:[^.\n]|\.(?!\s)){0,50}(?:~?\/?\.ssh\/(?:id_[a-z0-9]+|config)(?!\.pub)|~?\/?\.aws\/credentials|~?\/?\.kube\/config|~?\/?\.gnupg|\bid_(?:rsa|ed25519|dsa)\b(?!\.pub)|~?\/?\.npmrc|~?\/?\.netrc|\/etc\/shadow|(?:^|[\s'"`(])\.env(?:\.[\w-]+)?\b)/i,
|
|
@@ -106,6 +128,12 @@ function scanDirectives(text) {
|
|
|
106
128
|
if (r.descGuard && citationGoverns(line, m.index)) continue;
|
|
107
129
|
if (r.descGuard && isRiskTableRow(line)) continue;
|
|
108
130
|
if (r.label === 'send-to-external' && LOCAL_URL_RE.test(line) && !/\b(attacker|c2|command[- ]and[- ]control|external|evil)\b/i.test(line)) continue;
|
|
131
|
+
if (r.label === 'send-to-external' && m.index != null && insideMarkdownLinkLabel(line, m.index)) continue;
|
|
132
|
+
if (r.label === 'send-to-external' && m.index != null) {
|
|
133
|
+
const around = line.slice(Math.max(0, m.index - 24), m.index + 24);
|
|
134
|
+
if (SEND_AS_NOUN_RE.test(around) || PASSIVE_SEND_RE.test(around)) continue;
|
|
135
|
+
if (urlIsMarkdownTarget(line, m[0]) && !EXFIL_OBJECT_RE.test(m[0])) continue;
|
|
136
|
+
}
|
|
109
137
|
const prev = exfil.get(r.label);
|
|
110
138
|
if (!prev || (prev === 'HIGH' && r.severity === 'CRITICAL')) exfil.set(r.label, r.severity);
|
|
111
139
|
}
|
|
@@ -119,6 +147,8 @@ const SELF_REFERENCE =
|
|
|
119
147
|
const SELF_RECREATE =
|
|
120
148
|
/\b(re-?(add|writ(e|ing)|creat(e|ing)|insert(ing)?|instat(e|ing)|appl(y|ying)|introduc(e|ing))|restor(e|ing)|recreat(e|ing)|reinstat(e|ing)|re-?establish(ing)?|put .{0,20}back|add .{0,20}back)\b/i;
|
|
121
149
|
|
|
150
|
+
const stripSelfReference = (line) => String(line).replace(SELF_REFERENCE, ' ');
|
|
151
|
+
|
|
122
152
|
const SELF_PROPAGATE =
|
|
123
153
|
/\b(copy|copies|duplicat(e|ing)|replicat(e|ing)|propagat(e|ing)|carry (it |this )?over|mirror|append|add|includ(e|ing)|writ(e|ing)|sav(e|ing))\b[^.\n]{0,60}\b(every|each|all|any (new|other)|other|future|subsequent)\b[^.\n]{0,40}\b(session|conversation|chat|project|repo|repositor(y|ies)|workspace|memor(y|ies)|context|file|store)s?\b/i;
|
|
124
154
|
|
|
@@ -244,12 +274,22 @@ function reportStagedPayload(text, { noun }, push) {
|
|
|
244
274
|
}
|
|
245
275
|
}
|
|
246
276
|
|
|
277
|
+
function hasWordFrom(line, words) {
|
|
278
|
+
const low = line.toLowerCase();
|
|
279
|
+
return words.some((w) => {
|
|
280
|
+
const t = w.toLowerCase().trim();
|
|
281
|
+
if (!t) return false;
|
|
282
|
+
if (/^[a-z0-9]+$/.test(t)) return new RegExp(`\\b${t}\\b`).test(low);
|
|
283
|
+
return low.includes(t);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
247
287
|
function reportToxicFlow(text, { noun }, push) {
|
|
248
288
|
if (!IMPERATIVE.test(text)) return;
|
|
249
289
|
const line = text.split(/\r?\n/).find((candidate) => IMPERATIVE.test(candidate)
|
|
250
290
|
&& !NEGATION_GUARD.test(candidate)
|
|
251
|
-
&&
|
|
252
|
-
&&
|
|
291
|
+
&& hasWordFrom(candidate, SENSITIVE_READ)
|
|
292
|
+
&& hasWordFrom(candidate, NETWORK_VERBS)
|
|
253
293
|
&& !isDescriptiveLine(candidate));
|
|
254
294
|
if (!line) return;
|
|
255
295
|
push(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const INSTALL_LURE = [
|
|
2
|
-
{ 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' },
|
|
3
|
-
{ name: 'Password-protected archive (evades AV / scanners)', re:
|
|
2
|
+
{ name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}(?<=[\w-])\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b(?![\w/])/i, severity: 'MEDIUM' },
|
|
3
|
+
{ name: 'Password-protected archive (evades AV / scanners)', re: /(?<![.\w$-])(?:unzip|7z|7za|unrar|rar|zip|tar|gpg|openssl|extract|decompress|archive)\b(?![.:=\w])[^\n]{0,50}(?:^|[\s;|&(])(?:-P\b|--password\b|pass(?:word|phrase)?|pwd)\s*[:= ]\s*\S/i, severity: 'HIGH' },
|
|
4
4
|
{ 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' },
|
|
5
5
|
{ 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' },
|
|
6
6
|
];
|
|
@@ -4,8 +4,11 @@ import { IMPERATIVE } from './memory.mjs';
|
|
|
4
4
|
const DESCRIPTIVE_MARKERS =
|
|
5
5
|
/\b(detect|scan|flag|block|catch|prevent|guard|protect|harden|audit|benchmark|catalog|scenario|corpus|coverage|example|vector|signal|rule|technique|posture|detection|test\s*case|red[- ]?team|-style|grounded in|fixed|now green|was|were|had|used to|previously|postmortem|regression|changelog|root[- ]?cause|repro|note|see|describes?|documents?|refers?|treat(s|ed|ing)?|counts?|reads?)\w*/i;
|
|
6
6
|
|
|
7
|
+
const URL_TOKEN_RE = /\b(?:https?|ftp|file|data):\/*[^\s<>"')\]]+/gi;
|
|
8
|
+
|
|
7
9
|
export function isDescriptiveLine(line) {
|
|
8
|
-
|
|
10
|
+
const prose = line.replace(URL_TOKEN_RE, ' ');
|
|
11
|
+
return DESCRIPTIVE_MARKERS.test(prose) && !IMPERATIVE.test(line);
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const RESEARCH_CITATION_RE =
|
|
@@ -36,7 +39,26 @@ export function citationGoverns(segment, offset) {
|
|
|
36
39
|
|
|
37
40
|
const ELLIPSIS_RE = /…|\.\.\./;
|
|
38
41
|
|
|
39
|
-
const
|
|
42
|
+
const ENUMERATION_RE = /[([][^)\]]*,[^)\]]*,[^)\]]*[)\]]|:\s*(?:[\w.-]+(?:\s+-\w+)?,\s*){2,}/;
|
|
43
|
+
|
|
44
|
+
const REGEX_PATTERN_RE = /\\[sdwbSDWB]|\\\+|\\\*|\\\(|\\\||\(\?:|\[\^?[a-z0-9]-[a-z0-9]\]|\.\*|\.\+/;
|
|
45
|
+
|
|
46
|
+
const MOOD_WINDOW = 140;
|
|
47
|
+
|
|
48
|
+
function windowAround(line, offset) {
|
|
49
|
+
if (offset == null || line.length <= MOOD_WINDOW * 2) return line;
|
|
50
|
+
return line.slice(Math.max(0, offset - MOOD_WINDOW), offset + MOOD_WINDOW);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function insideMarkdownLinkLabel(line, index) {
|
|
54
|
+
if (index < 0 || index >= line.length) return false;
|
|
55
|
+
const open = line.lastIndexOf('[', index);
|
|
56
|
+
if (open === -1) return false;
|
|
57
|
+
const close = line.indexOf(']', index);
|
|
58
|
+
if (close === -1) return false;
|
|
59
|
+
if (line.slice(open + 1, index).includes(']')) return false;
|
|
60
|
+
return line[close + 1] === '(';
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
const CREDENTIAL_PATH_RE =
|
|
42
64
|
/~\/\.(ssh|aws|kube|gnupg|docker|npmrc?)\b|\bid_(rsa|ed25519|dsa)\b|\.pem\b|\bcredentials\b\s*(file)?|\bAWS_SECRET|\bANTHROPIC_API_KEY\b|\bOPENAI_API_KEY\b/i;
|
|
@@ -48,15 +70,20 @@ function carriesHardEvidence(line) {
|
|
|
48
70
|
return CREDENTIAL_PATH_RE.test(line) || EXECUTABLE_FETCH_RE.test(line) || !!egressHost(line);
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
export function isDocumentationLine(line) {
|
|
73
|
+
export function isDocumentationLine(line, offset) {
|
|
52
74
|
if (!line) return false;
|
|
53
75
|
if (carriesHardEvidence(line)) return false;
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
|
|
77
|
+
const win = windowAround(line, offset);
|
|
78
|
+
if (REGEX_PATTERN_RE.test(win)) return true;
|
|
79
|
+
if (ELLIPSIS_RE.test(win)) return true;
|
|
80
|
+
if (offset != null && insideCodeSpan(line, offset) && isDescriptiveLine(win)) return true;
|
|
81
|
+
if (ENUMERATION_RE.test(win) && !IMPERATIVE.test(win)) return true;
|
|
82
|
+
return isDescriptiveLine(win);
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
const PROHIBITION_MARKER_RE =
|
|
59
|
-
/\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of)\b[^.:;\n]{0,60}$/i;
|
|
86
|
+
/\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of|do NOT)\b[^.:;\n]{0,60}$/i;
|
|
60
87
|
|
|
61
88
|
const DOUBLE_NEGATIVE_RE = /\b(?:hesitate|worry|be afraid|forget|fail|neglect|shy away)\b/i;
|
|
62
89
|
|
|
@@ -17,10 +17,10 @@ export const SECRET_PATTERNS = [
|
|
|
17
17
|
|
|
18
18
|
{
|
|
19
19
|
name: 'Database URL with password',
|
|
20
|
-
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)[^\s:@/]+:[^\s:@/]{4,}@/i,
|
|
20
|
+
re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)(?!([^\s:@/]+):\1@)(?!(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*:(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*@)[^\s:@/]+:[^\s:@/]{4,}@/i,
|
|
21
21
|
},
|
|
22
22
|
{ name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
23
|
-
{ name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY
|
|
23
|
+
{ name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]{0,80}?[\r\n]+\s*[A-Za-z0-9+/=]{32,}/ },
|
|
24
24
|
|
|
25
25
|
{ name: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,}/ },
|
|
26
26
|
{ name: 'Replicate API token', re: /\br8_[A-Za-z0-9]{30,}/ },
|
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { lineTextAt } from './lines.mjs';
|
|
2
2
|
|
|
3
3
|
const EPHEMERAL_RM_TARGET_RE =
|
|
4
|
-
/^(\.\/)?(node_modules|dist|build|out|coverage|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
|
|
4
|
+
/^(\.\/)?(node_modules|dist|build|out|coverage|\.nyc_output|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
|
|
5
|
+
|
|
6
|
+
const BENIGN_ABSOLUTE_RM_RE =
|
|
7
|
+
/^\/(var\/(lib\/apt\/lists|cache|tmp|log)|tmp|usr\/share\/(doc|man|locale|info)|root\/\.cache|home\/[\w.-]+\/\.cache|opt\/[\w.-]+\/\.cache)(\/|$|\*)/i;
|
|
8
|
+
|
|
9
|
+
const CATASTROPHIC_RM_TARGET_RE = /^(\/|~|\$|\$\{|%\w+%|[A-Za-z]:[\\/]|\.\.?$|\.\.\/|\*$)/;
|
|
10
|
+
|
|
11
|
+
const LOOPBACK_OR_PRIVATE_HOST_RE =
|
|
12
|
+
/^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1\]|::1|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})$/i;
|
|
13
|
+
|
|
14
|
+
const CLOUD_METADATA_HOSTS = new Set(['169.254.169.254', 'metadata.google.internal']);
|
|
15
|
+
|
|
16
|
+
export function targetsExternalNetwork(line) {
|
|
17
|
+
const urls = line.match(/https?:\/\/[^\s'"`;|)&]+/gi);
|
|
18
|
+
if (!urls?.length) return true;
|
|
19
|
+
return urls.some((raw) => {
|
|
20
|
+
let host;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (CLOUD_METADATA_HOSTS.has(host)) return true;
|
|
27
|
+
return !LOOPBACK_OR_PRIVATE_HOST_RE.test(host);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RM_RF_RE = /\brm\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*f|--force))/i;
|
|
32
|
+
|
|
33
|
+
export function rmTargetClass(line) {
|
|
34
|
+
if (/\brm\s+-{1,2}[a-zA-Z][\w-]*\s*["'`,)\]}?!](?![\w./~$*-])/.test(line)) return 'local';
|
|
35
|
+
const m = /\brm\s+((?:--?[a-zA-Z][\w-]*\s+)+)(.*)$/.exec(line);
|
|
36
|
+
if (!m) return 'catastrophic';
|
|
5
37
|
|
|
6
|
-
function rmTargetsRealData(line) {
|
|
7
|
-
const m = /\brm\s+((?:-[a-zA-Z]+\s+)+)(.*)$/.exec(line);
|
|
8
|
-
if (!m) return true;
|
|
9
38
|
const targets = m[2]
|
|
10
39
|
.split(/&&|\|\||[;|>&]/)[0]
|
|
11
40
|
.split(/\s+/)
|
|
12
|
-
.filter((t) => t && !t.startsWith('-'))
|
|
13
|
-
|
|
14
|
-
|
|
41
|
+
.filter((t) => t && t !== '--' && !t.startsWith('-'))
|
|
42
|
+
.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
43
|
+
|
|
44
|
+
if (!targets.length) {
|
|
45
|
+
const after = m[2] ?? '';
|
|
46
|
+
return /^\s*(?:[,)\]}?!"'`.]|$)/.test(after) ? 'local' : 'catastrophic';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (targets.some((t) => CATASTROPHIC_RM_TARGET_RE.test(t) && !BENIGN_ABSOLUTE_RM_RE.test(t))) return 'catastrophic';
|
|
50
|
+
return targets.every((t) => EPHEMERAL_RM_TARGET_RE.test(t) || BENIGN_ABSOLUTE_RM_RE.test(t)) ? 'ephemeral' : 'local';
|
|
15
51
|
}
|
|
16
52
|
|
|
17
53
|
export function matchesShellSignal(sig, text) {
|
|
@@ -32,25 +68,30 @@ export const DANGEROUS_SHELL = [
|
|
|
32
68
|
{ 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' },
|
|
33
69
|
{ name: 'Reverse shell via /dev/tcp', re: /\/dev\/(tcp|udp)\//i, severity: 'CRITICAL' },
|
|
34
70
|
{ name: 'Base64 blob piped to a shell', re: /base64\s+(--?d(ecode)?)?\b[^\n|]{0,200}\|\s*(ba|z)?sh\b/i, severity: 'CRITICAL' },
|
|
35
|
-
{ 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' },
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
{ 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', refine: targetsExternalNetwork },
|
|
72
|
+
{
|
|
73
|
+
name: 'Command output piped into a network call',
|
|
74
|
+
re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|<\(|`[^`\n]*(?:\b(?:cat|ls|whoami|id|env|printenv|uname|hostname|pwd|base64|echo|head|tail|find|grep|awk|sed|curl|wget|nc|python\d?|node|perl|ruby|php|git|aws|kubectl|openssl)\b|\/(?:etc|var|tmp|home|root|usr|proc)\/|\$\w|\s-{1,2}\w)[^`\n]*`)/i,
|
|
75
|
+
severity: 'HIGH',
|
|
76
|
+
},
|
|
77
|
+
{ 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', refine: targetsExternalNetwork },
|
|
78
|
+
{ name: 'Writes to shell profile / SSH keys / crontab', re: /(>>?\s*(?:~\/|\$HOME\/|\/etc\/)?\.(bashrc|zshrc|bash_profile|profile)\b|>>?\s*\/etc\/profile\b|(tee|echo|cat|printf)\b[^\n]{0,80}(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys)|>>?\s*[^\n]{0,40}authorized_keys|crontab\s+(-|[^\n]{0,40}<)|id_rsa\b[^\n]{0,20}(>|cp|scp|curl|cat))/i, severity: 'HIGH' },
|
|
39
79
|
|
|
40
80
|
{
|
|
41
81
|
name: 'World-writable permissions on the filesystem root (chmod -R 777 /)',
|
|
42
|
-
re: /\bchmod\b(?=[^\n;|&]
|
|
82
|
+
re: /\bchmod\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*R|--recursive))(?=[^\n;|&]{0,200}(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]{0,200}\s\/(?:\s|\*|$))/i,
|
|
43
83
|
severity: 'CRITICAL',
|
|
44
84
|
},
|
|
45
85
|
{
|
|
46
86
|
name: 'World-writable permissions on a credential or system path (chmod 777)',
|
|
47
|
-
re: /\bchmod\b(?=[^\n;|&]
|
|
87
|
+
re: /\bchmod\b(?=[^\n;|&]{0,200}(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]{0,200}(?:~(?:\s|$|\/\.)|\$HOME\b|\/etc\b|\/root\b|\/usr\b|\/var\b|\/boot\b|\.ssh\b|id_rsa\b|authorized_keys\b|\.aws\b|\.gnupg\b|\.kube\b))/i,
|
|
48
88
|
severity: 'HIGH',
|
|
49
89
|
},
|
|
50
|
-
{ name: 'Recursive force delete (rm -rf)', re:
|
|
90
|
+
{ name: 'Recursive force delete of a protected path (rm -rf)', re: RM_RF_RE, severity: 'HIGH', refine: (l) => rmTargetClass(l) === 'catastrophic' },
|
|
91
|
+
{ name: 'Recursive force delete (rm -rf)', re: RM_RF_RE, severity: 'MEDIUM', refine: (l) => rmTargetClass(l) === 'local' },
|
|
51
92
|
|
|
52
|
-
{ name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
|
|
53
|
-
{ name: 'Pipes an env dump to the network', re:
|
|
93
|
+
{ name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'\/])(eval|exec)\s*(?!\((?:[^()\n]{0,160}\)\s*\{|\s*_?[A-Za-z$][\w$]{0,64}\s*\??\s*:))[("`']/i, severity: 'HIGH' },
|
|
94
|
+
{ name: 'Pipes an env dump to the network', re: /(?<![.\w$-])(env|printenv|set)\b(?![.:=\w])(?!\s*[:=])[^\n|]{0,80}(?<!\|)\|(?!\|)[^\n]{0,80}(curl\b|wget\b|nc\b|https?\b)/i, severity: 'HIGH' },
|
|
54
95
|
{ 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' },
|
|
55
96
|
{ name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
|
|
56
97
|
{ name: 'node -e one-liner', re: /\bnode\s+-e\b/i, severity: 'MEDIUM' },
|
|
@@ -65,7 +106,7 @@ export const DANGEROUS_SHELL = [
|
|
|
65
106
|
{ name: 'Force-deletes a cloud storage bucket (aws s3 rb --force)', re: /\b(aws\s+s3\s+rb|gsutil\s+(rm\s+-r|rb)|az\s+storage\s+(account|container)\s+delete)\b[^\n]{0,80}(--force|--yes|-f\b|\bs3:\/\/|\bgs:\/\/)/i, severity: 'HIGH' },
|
|
66
107
|
{ name: 'Force-pushes over a protected branch (rewrites shared history)', re: /\bgit\s+push\b[^\n]{0,80}(--force\b(?!-with-lease)|(?:^|\s)-f\b)[^\n]{0,60}\b(main|master|release|prod(uction)?)\b/i, severity: 'MEDIUM' },
|
|
67
108
|
|
|
68
|
-
{ name: 'Recursive force delete of the filesystem root (rm -rf /, --no-preserve-root)', re: /\brm\b(?=[^\n;|&]
|
|
109
|
+
{ name: 'Recursive force delete of the filesystem root (rm -rf /, --no-preserve-root)', re: /\brm\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*f|--force))(?=[^\n;|&]{0,200}(?:--no-preserve-root|\s\/(?:\s|\*|$)))/i, severity: 'CRITICAL' },
|
|
69
110
|
{ name: 'Fork bomb (process-exhaustion DoS)', re: /(:|\b[a-z_][a-z0-9_]*)\s*\(\s*\)\s*\{\s*\1\s*[^\n}]*\|\s*\1[^\n}]*&\s*\}\s*;\s*\1/i, severity: 'HIGH' },
|
|
70
111
|
{ name: 'Writes over a raw disk device (data destruction)', re: /\b(dd\b[^\n]{0,80}\bof=\/dev\/[sh]d|mkfs(\.\w+)?\s+[^\n]{0,40}\/dev\/|>\s*\/dev\/[sh]d[a-z])/i, severity: 'CRITICAL' },
|
|
71
112
|
{ name: 'Reads the system password-hash / sudo policy file', re: /\b(cat|less|more|head|tail|strings|xxd|od|grep|awk|sed|cp|scp|tar)\b[^\n]{0,80}\/etc\/(shadow|gshadow|sudoers(\.d)?)\b/i, severity: 'HIGH' },
|
|
@@ -85,7 +126,7 @@ export const DANGEROUS_SHELL = [
|
|
|
85
126
|
{ name: 'Preloads a shared library into every process (LD_PRELOAD)', re: /\b(?:LD_PRELOAD|LD_AUDIT|DYLD_INSERT_LIBRARIES)\s*=\s*\S|>>?\s*\/etc\/ld\.so\.preload\b/i, severity: 'HIGH' },
|
|
86
127
|
{ name: 'Installs a scheduled or boot-time persistence unit', re: /\bsystemd-run\b[^\n]{0,80}--on-(?:boot|calendar|active|unit)|>>?\s*\/etc\/(?:systemd\/system|cron\.(?:d|daily|hourly)|init\.d)\/\S|\bschtasks\b[^\n]{0,80}\/create\b|\blaunchctl\s+(?:load|bootstrap)\b|\b(?:echo|printf)\b[^\n]{0,120}\|\s*at\s+(?:now|\+|\d)/i, severity: 'MEDIUM' },
|
|
87
128
|
{ name: 'Opens a reverse tunnel to a remote host', re: /\bssh\b[^\n]{0,80}\s-\w*R\s*\d{1,5}:[^\n\s]{1,60}|\b(?:ngrok|cloudflared|localtunnel|frpc)\b[^\n]{0,60}\b(?:tcp|http|tunnel)\b/i, severity: 'HIGH' },
|
|
88
|
-
{ name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+[^\n]{0,120}(?:\$\(|`|\$\{)[^\n]{0,80}\.[a-z]{2,}/i, severity: 'HIGH' },
|
|
129
|
+
{ name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+(?![-+*/%|&^]?=)[^\n]{0,120}(?:\$\(|`|\$\{)[^\n]{0,80}\.[a-z]{2,}|\b(?:base64|base32|xxd|hexdump|od\s|openssl\s+enc)\b[^\n]{0,140}\b(?:dig|nslookup|drill|host)\s+[^\n]{0,60}\$\w+\.[\w.-]{2,}\.[a-z]{2,}/i, severity: 'HIGH' },
|
|
89
130
|
{ name: 'Copies credentials or home directories off the machine over ssh', re: new RegExp(String.raw`\b(?:scp|rsync)\b(?=[^\n]{0,200}\s\S{0,40}@[\w.-]+:)(?=[^\n]{0,200}(?:${SENSITIVE_PATH}))` + String.raw`|\btar\b(?=[^\n]{0,160}\|\s*ssh\b)(?=[^\n]{0,160}(?:${SENSITIVE_PATH}))`, 'i'), severity: 'HIGH' },
|
|
90
131
|
{ name: 'Flushes the host firewall', re: /\b(?:iptables|ip6tables|nft)\b[^\n]{0,60}(?:-F\b|--flush\b|flush ruleset)|\bufw\s+disable\b|\bnetsh\s+advfirewall\s+set\s+\S+\s+state\s+off\b/i, severity: 'MEDIUM' },
|
|
91
132
|
{ name: 'Kills the audit / EDR agent (anti-forensics)', re: /\b(?:pkill|killall|kill)\b[^\n]{0,40}\b(?:auditd|osqueryd?|falcon-sensor|falconctl|wazuh|ossec|filebeat|splunkd|sysmon|crowdstrike|carbonblack|cbagent)\b|\bSet-MpPreference\b[^\n]{0,60}-Disable\w*\s+\$?true/i, severity: 'HIGH' },
|