toolmoat 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { findAll, lineAt, lineColAt } from '../util/text.js';
|
|
2
|
+
import { AGENT_CONFIG_PATHS, EXFIL_HOSTS, SENSITIVE_CRED_PATHS } from '../patterns/indicators.js';
|
|
3
|
+
/** Kinds that carry runnable commands. Docs are intentionally excluded. */
|
|
4
|
+
const CODE_KINDS = ['script', 'skill', 'instructions', 'tool-definition'];
|
|
5
|
+
const MATCH_LIMIT = 40;
|
|
6
|
+
/** A download command, in shell or PowerShell form. */
|
|
7
|
+
const DOWNLOADER = String.raw `curl|wget|fetch|iwr|irm|Invoke-WebRequest|Invoke-RestMethod`;
|
|
8
|
+
/** An interpreter that will run whatever it is handed. */
|
|
9
|
+
const INTERP = String.raw `sh|bash|zsh|dash|ksh|fish|python[23]?|node|nodejs|perl|ruby|php|pwsh|powershell|iex|Invoke-Expression`;
|
|
10
|
+
/** Extracts the first http(s) URL from a slice, for use in messages. */
|
|
11
|
+
function firstUrl(text) {
|
|
12
|
+
const m = /https?:\/\/[^\s"'`)|<>]{1,200}/i.exec(text);
|
|
13
|
+
return m ? m[0] : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** True when `re` matches anywhere in a window around an offset. `re` must not be global. */
|
|
16
|
+
function near(content, index, matchLen, re, before = 0, after = 320) {
|
|
17
|
+
const start = Math.max(0, index - before);
|
|
18
|
+
const end = Math.min(content.length, index + matchLen + after);
|
|
19
|
+
return re.test(content.slice(start, end));
|
|
20
|
+
}
|
|
21
|
+
/** Runs one regex and maps each hit to a match with a message built from the hit text. */
|
|
22
|
+
function collect(content, re, toMessage, limit = MATCH_LIMIT) {
|
|
23
|
+
return findAll(re, content, limit).map((h) => ({ index: h.index, message: toMessage(h.text) }));
|
|
24
|
+
}
|
|
25
|
+
/** De-duplicates matches that land on the same line, keeping the first. */
|
|
26
|
+
function oncePerLine(content, matches) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const m of matches) {
|
|
30
|
+
const line = m.index === undefined ? (m.line ?? 0) : lineColAt(content, m.index).line;
|
|
31
|
+
if (seen.has(line))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(line);
|
|
34
|
+
out.push(m);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
// --- TM-EXEC-001 ---------------------------------------------------------
|
|
39
|
+
const PIPE_TO_INTERP = new RegExp(String.raw `(?:\b(?:${DOWNLOADER})\b)[^\n]{0,400}\|\s*(?:sudo\s+)?(?:${INTERP})\b`, 'i');
|
|
40
|
+
const PROC_SUBST = /\b(?:sh|bash|zsh|dash|ksh|fish)\s+<\(\s*(?:curl|wget|fetch|irm|iwr)\b/i;
|
|
41
|
+
const SH_C_SUBST = /\b(?:sh|bash|zsh|dash|ksh|eval)\b[^\n]{0,24}\$\(\s*(?:curl|wget|fetch|irm|iwr)\b/i;
|
|
42
|
+
const IEX_DOWNLOAD = /\b(?:iex|Invoke-Expression)\b[^\n]{0,200}(?:DownloadString|DownloadData|DownloadFile)\s*\(/i;
|
|
43
|
+
const DOWNLOAD_PIPE_IEX = /(?:DownloadString|DownloadData)\s*\([^\n]{0,250}\|\s*(?:iex|Invoke-Expression)\b/i;
|
|
44
|
+
const IEX_IRM = /\b(?:iex|Invoke-Expression)\s*\(?\s*\(?\s*(?:irm|iwr|curl|wget|Invoke-RestMethod|Invoke-WebRequest)\b/i;
|
|
45
|
+
const execDownloadPipe = {
|
|
46
|
+
id: 'TM-EXEC-001',
|
|
47
|
+
title: 'Download piped into an interpreter',
|
|
48
|
+
description: 'Fetches a remote script and executes it in place (curl | bash, iex (irm ...), bash <(curl ...)). The code that runs is whatever the server returns, so the author can change it at any time.',
|
|
49
|
+
category: 'dangerous-execution',
|
|
50
|
+
severity: 'critical',
|
|
51
|
+
kinds: CODE_KINDS,
|
|
52
|
+
remediation: 'Download to a file, review it, pin a checksum, then run the reviewed copy.',
|
|
53
|
+
check(file) {
|
|
54
|
+
const c = file.content;
|
|
55
|
+
const matches = [];
|
|
56
|
+
for (const re of [PIPE_TO_INTERP, PROC_SUBST, SH_C_SUBST, IEX_DOWNLOAD, DOWNLOAD_PIPE_IEX, IEX_IRM]) {
|
|
57
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
58
|
+
const url = firstUrl(h.text) ?? firstUrl(c.slice(h.index, h.index + 300));
|
|
59
|
+
const official = url ? OFFICIAL_INSTALLERS.find((i) => url.startsWith(i.prefix)) : undefined;
|
|
60
|
+
if (official) {
|
|
61
|
+
matches.push({
|
|
62
|
+
index: h.index,
|
|
63
|
+
severity: 'medium',
|
|
64
|
+
message: `Pipes the official ${official.name} installer (${url}) into an interpreter; well known, but it still runs unreviewed remote code`,
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
matches.push({
|
|
69
|
+
index: h.index,
|
|
70
|
+
message: url ? `Pipes a download from ${url} into an interpreter` : 'Pipes a remote download straight into an interpreter',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return oncePerLine(c, matches);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Vendor-documented curl | sh installers. They are still remote code, so they
|
|
79
|
+
* stay reported, but at medium so they do not drown out real droppers. Only
|
|
80
|
+
* exact https prefixes count: an attacker's repo on the same host does not match.
|
|
81
|
+
*/
|
|
82
|
+
const OFFICIAL_INSTALLERS = [
|
|
83
|
+
{ prefix: 'https://raw.githubusercontent.com/Homebrew/install/', name: 'Homebrew' },
|
|
84
|
+
{ prefix: 'https://sh.rustup.rs', name: 'rustup' },
|
|
85
|
+
{ prefix: 'https://raw.githubusercontent.com/nvm-sh/nvm/', name: 'nvm' },
|
|
86
|
+
{ prefix: 'https://bun.sh/install', name: 'Bun' },
|
|
87
|
+
{ prefix: 'https://deno.land/install.sh', name: 'Deno' },
|
|
88
|
+
{ prefix: 'https://astral.sh/uv/', name: 'uv' },
|
|
89
|
+
{ prefix: 'https://get.docker.com', name: 'Docker' },
|
|
90
|
+
{ prefix: 'https://ollama.com/install.sh', name: 'Ollama' },
|
|
91
|
+
{ prefix: 'https://claude.ai/install.sh', name: 'Claude Code' },
|
|
92
|
+
{ prefix: 'https://get.pnpm.io/install.sh', name: 'pnpm' },
|
|
93
|
+
{ prefix: 'https://install.python-poetry.org', name: 'Poetry' },
|
|
94
|
+
{ prefix: 'https://pyenv.run', name: 'pyenv' },
|
|
95
|
+
];
|
|
96
|
+
// --- TM-EXEC-002 ---------------------------------------------------------
|
|
97
|
+
const DOWNLOAD_OUT = new RegExp(String.raw `\b(?:${DOWNLOADER})\b[^\n]{0,200}(?:\s-o\b|\s-O\b|--output\b|-OutFile\b)`, 'i');
|
|
98
|
+
const EXEC_AFTER_DOWNLOAD = /chmod\s+[0-7]*[+]?x|chmod\s+\+x|Start-Process|(?:^|[\s;&(])\.\/[a-z0-9._-]+/im;
|
|
99
|
+
const LOLBIN_PATTERNS = [
|
|
100
|
+
{ re: /\bcertutil(?:\.exe)?\s+[^\n]{0,80}-urlcache/i, label: 'certutil -urlcache download' },
|
|
101
|
+
{ re: /\bbitsadmin(?:\.exe)?\s+[^\n]{0,80}\/transfer\b/i, label: 'bitsadmin /transfer download' },
|
|
102
|
+
{ re: /\bmshta(?:\.exe)?\s+(?:https?:|javascript:|vbscript:)/i, label: 'mshta remote/script execution' },
|
|
103
|
+
{ re: /\bregsvr32(?:\.exe)?\s+[^\n]{0,60}\/i:\s*https?:/i, label: 'regsvr32 scriptlet from URL' },
|
|
104
|
+
{ re: /\brundll32(?:\.exe)?\s+[^\n]{0,40}javascript:/i, label: 'rundll32 javascript: execution' },
|
|
105
|
+
];
|
|
106
|
+
const execDownloadRun = {
|
|
107
|
+
id: 'TM-EXEC-002',
|
|
108
|
+
title: 'Download then execute',
|
|
109
|
+
description: 'Downloads a file and runs it (download, chmod +x, execute) or uses a living-off-the-land binary such as certutil or bitsadmin to fetch and launch a payload.',
|
|
110
|
+
category: 'dangerous-execution',
|
|
111
|
+
severity: 'high',
|
|
112
|
+
kinds: CODE_KINDS,
|
|
113
|
+
remediation: 'Avoid fetch-and-run; ship the executable with the skill or verify it before running.',
|
|
114
|
+
check(file) {
|
|
115
|
+
const c = file.content;
|
|
116
|
+
const matches = [];
|
|
117
|
+
for (const h of findAll(DOWNLOAD_OUT, c, MATCH_LIMIT)) {
|
|
118
|
+
if (near(c, h.index, h.text.length, EXEC_AFTER_DOWNLOAD, 0, 320)) {
|
|
119
|
+
const url = firstUrl(h.text) ?? firstUrl(c.slice(h.index, h.index + 300));
|
|
120
|
+
matches.push({
|
|
121
|
+
index: h.index,
|
|
122
|
+
message: url ? `Downloads ${url} to a file and then executes it` : 'Downloads a file and then executes it',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const { re, label } of LOLBIN_PATTERNS) {
|
|
127
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
128
|
+
matches.push({ index: h.index, message: `Uses a living-off-the-land binary to fetch and run code (${label})` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return oncePerLine(c, matches);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
// --- TM-EXEC-003 ---------------------------------------------------------
|
|
135
|
+
const PW_ARCHIVE = [
|
|
136
|
+
/\bunzip\b[^\n]{0,80}\s-P\s*\S/i,
|
|
137
|
+
/\b7za?\b[^\n]{0,40}\sx\b[^\n]{0,20}-p\S/i,
|
|
138
|
+
/\b7za?\b[^\n]{0,40}-p\S[^\n]{0,20}\sx\b/i,
|
|
139
|
+
];
|
|
140
|
+
const DOWNLOAD_ANY = new RegExp(String.raw `\b(?:${DOWNLOADER})\b`, 'i');
|
|
141
|
+
const execPasswordArchive = {
|
|
142
|
+
id: 'TM-EXEC-003',
|
|
143
|
+
title: 'Password-protected archive extraction',
|
|
144
|
+
description: 'Extracts a password-protected archive. Malware ships payloads inside encrypted zips so antivirus and static scanners cannot read them (seen in the ClawHavoc campaign).',
|
|
145
|
+
category: 'dangerous-execution',
|
|
146
|
+
severity: 'high',
|
|
147
|
+
kinds: CODE_KINDS,
|
|
148
|
+
check(file) {
|
|
149
|
+
const c = file.content;
|
|
150
|
+
const matches = [];
|
|
151
|
+
const downloadNearby = DOWNLOAD_ANY.test(c);
|
|
152
|
+
for (const re of PW_ARCHIVE) {
|
|
153
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
154
|
+
matches.push({
|
|
155
|
+
index: h.index,
|
|
156
|
+
severity: downloadNearby ? 'high' : 'medium',
|
|
157
|
+
message: downloadNearby
|
|
158
|
+
? 'Extracts a password-protected archive near a download (payload hidden from scanners)'
|
|
159
|
+
: 'Extracts a password-protected archive',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return oncePerLine(c, matches);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
// --- TM-EXEC-004 ---------------------------------------------------------
|
|
167
|
+
const PROTECTION_OFF = [
|
|
168
|
+
{ re: /xattr\s+(?:-d\s+com\.apple\.quarantine|-cr?\b|-c\b)/i, label: 'strips the macOS quarantine attribute (xattr)' },
|
|
169
|
+
{ re: /spctl\s+--master-disable/i, label: 'disables Gatekeeper (spctl --master-disable)' },
|
|
170
|
+
{ re: /Set-MpPreference\s+[^\n]{0,120}-DisableRealtimeMonitoring/i, label: 'disables Defender real-time monitoring' },
|
|
171
|
+
{ re: /Add-MpPreference\s+[^\n]{0,120}-ExclusionPath/i, label: 'adds a Defender exclusion path' },
|
|
172
|
+
{ re: /netsh\s+advfirewall\s+set\s+[^\n]{0,60}\bstate\s+off/i, label: 'turns off the Windows firewall' },
|
|
173
|
+
{ re: /\bsetenforce\s+0\b/i, label: 'puts SELinux into permissive mode (setenforce 0)' },
|
|
174
|
+
{ re: /\bcsrutil\s+disable/i, label: 'disables System Integrity Protection (csrutil disable)' },
|
|
175
|
+
];
|
|
176
|
+
const execDisableProtection = {
|
|
177
|
+
id: 'TM-EXEC-004',
|
|
178
|
+
title: 'Disables OS malware protection or quarantine',
|
|
179
|
+
description: 'Turns off a security control: macOS quarantine/Gatekeeper/SIP, Windows Defender or firewall, or SELinux. Legitimate skills do not need to disable the machine defences.',
|
|
180
|
+
category: 'dangerous-execution',
|
|
181
|
+
severity: 'high',
|
|
182
|
+
kinds: CODE_KINDS,
|
|
183
|
+
check(file) {
|
|
184
|
+
const c = file.content;
|
|
185
|
+
const matches = [];
|
|
186
|
+
for (const { re, label } of PROTECTION_OFF) {
|
|
187
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
188
|
+
matches.push({ index: h.index, message: `Code ${label}` });
|
|
189
|
+
}
|
|
190
|
+
return oncePerLine(c, matches);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
// --- TM-EXEC-005 ---------------------------------------------------------
|
|
194
|
+
const REVERSE_SHELL = [
|
|
195
|
+
{ re: /\/dev\/tcp\/[0-9a-z.\-]{1,60}\/\d{1,5}/i, label: '/dev/tcp redirection' },
|
|
196
|
+
{ re: /\bn(?:c|cat)\b[^\n]{0,60}\s-e\b/i, label: 'netcat -e' },
|
|
197
|
+
{ re: /\bbash\s+-i\b[^\n]{0,40}>&/i, label: 'interactive bash redirected to a socket' },
|
|
198
|
+
{ re: /\bsocat\b[^\n]{0,100}\bexec:/i, label: 'socat exec:' },
|
|
199
|
+
{ re: /\bmkfifo\b[^\n]{0,120}\bn(?:c|cat)\b/i, label: 'mkfifo + netcat shell' },
|
|
200
|
+
{ re: /os\.dup2\s*\(\s*s\.fileno\s*\(/i, label: 'Python socket dup2 shell' },
|
|
201
|
+
{ re: /System\.Net\.Sockets\.TCPClient[^\n]{0,200}GetStream/i, label: 'PowerShell TCPClient shell' },
|
|
202
|
+
];
|
|
203
|
+
const execReverseShell = {
|
|
204
|
+
id: 'TM-EXEC-005',
|
|
205
|
+
title: 'Reverse shell',
|
|
206
|
+
description: 'Opens an interactive shell back to a remote host, giving an attacker a foothold on the machine. No benign skill needs a reverse shell.',
|
|
207
|
+
category: 'dangerous-execution',
|
|
208
|
+
severity: 'critical',
|
|
209
|
+
kinds: CODE_KINDS,
|
|
210
|
+
check(file) {
|
|
211
|
+
const c = file.content;
|
|
212
|
+
const matches = [];
|
|
213
|
+
for (const { re, label } of REVERSE_SHELL) {
|
|
214
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
215
|
+
matches.push({ index: h.index, message: `Reverse shell (${label})` });
|
|
216
|
+
}
|
|
217
|
+
return oncePerLine(c, matches);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
// --- TM-OBF-001 ----------------------------------------------------------
|
|
221
|
+
const DECODE_EXEC = [
|
|
222
|
+
{ re: /\bbase64\s+(?:-d|-D|--decode)\b[^\n]{0,120}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|python[23]?|node|perl|ruby)\b/i, label: 'base64 -d | shell' },
|
|
223
|
+
{ re: /\|\s*base64\s+(?:-d|-D|--decode)\b[^\n]{0,60}\|\s*(?:sh|bash|zsh|python[23]?)\b/i, label: 'piped base64 -d | shell' },
|
|
224
|
+
{ re: /\beval\s*\(\s*atob\s*\(/i, label: 'eval(atob(...))' },
|
|
225
|
+
{ re: /\beval\s*\(\s*Buffer\.from\s*\([^\n]{0,80}(?:base64|hex)/i, label: "eval(Buffer.from(..., 'base64'))" },
|
|
226
|
+
{ re: /\bnew\s+Function\s*\(\s*atob\s*\(/i, label: 'new Function(atob(...))' },
|
|
227
|
+
{ re: /\bexec\s*\(\s*base64\.b64decode\s*\(/i, label: 'exec(base64.b64decode(...))' },
|
|
228
|
+
{ re: /\b(?:exec|eval)\s*\(\s*(?:zlib|gzip|lzma|bz2)\.decompress\s*\(/i, label: 'exec(zlib.decompress(...))' },
|
|
229
|
+
{ re: /\b(?:exec|eval)\s*\(\s*codecs\.decode\s*\([^\n]{0,80}rot[_-]?13/i, label: 'exec(codecs.decode(..., rot13))' },
|
|
230
|
+
{ re: /\b(?:powershell|pwsh)(?:\.exe)?\b[^\n]{0,60}\s-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{24,}/i, label: 'powershell -EncodedCommand' },
|
|
231
|
+
{ re: /\[(?:System\.)?Convert\]::FromBase64String\s*\([^\n]{0,200}(?:iex|Invoke-Expression)/i, label: '[Convert]::FromBase64String + IEX' },
|
|
232
|
+
{ re: /\b(?:iex|Invoke-Expression)\b[^\n]{0,120}\[(?:System\.)?Convert\]::FromBase64String/i, label: 'IEX + [Convert]::FromBase64String' },
|
|
233
|
+
];
|
|
234
|
+
const obfDecodeExec = {
|
|
235
|
+
id: 'TM-OBF-001',
|
|
236
|
+
title: 'Decode-and-execute',
|
|
237
|
+
description: 'Decodes a base64, hex or compressed blob and immediately executes it. Encoding is used to hide the real command from a reader and from scanners.',
|
|
238
|
+
category: 'obfuscation',
|
|
239
|
+
severity: 'critical',
|
|
240
|
+
kinds: CODE_KINDS,
|
|
241
|
+
check(file) {
|
|
242
|
+
const c = file.content;
|
|
243
|
+
const matches = [];
|
|
244
|
+
for (const { re, label } of DECODE_EXEC) {
|
|
245
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
246
|
+
matches.push({ index: h.index, message: `Decodes and executes hidden code (${label})` });
|
|
247
|
+
}
|
|
248
|
+
return oncePerLine(c, matches);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
// --- TM-OBF-002 ----------------------------------------------------------
|
|
252
|
+
const HEX_ESCAPE_RUN = /(?:\\x[0-9a-fA-F]{2}){64,}/;
|
|
253
|
+
const FROMCHARCODE = /String\.fromCharCode\s*\(\s*(?:\d{1,6}\s*,\s*){29,}\d{1,6}/;
|
|
254
|
+
const CHR_CHAIN = /(?:chr\s*\(\s*\d{1,6}\s*\)\s*\+?\s*){20,}/i;
|
|
255
|
+
/** Linear scan for a long run of base64-alphabet characters with real character diversity. */
|
|
256
|
+
function findBase64Blobs(content, limit = 8) {
|
|
257
|
+
const out = [];
|
|
258
|
+
let i = 0;
|
|
259
|
+
const n = content.length;
|
|
260
|
+
while (i < n && out.length < limit) {
|
|
261
|
+
const ch = content.charCodeAt(i);
|
|
262
|
+
const isB64 = (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || (ch >= 48 && ch <= 57) || ch === 43 || ch === 47 || ch === 61 || ch === 95 || ch === 45;
|
|
263
|
+
if (!isB64) {
|
|
264
|
+
i++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const start = i;
|
|
268
|
+
let hasUpper = false;
|
|
269
|
+
let hasLower = false;
|
|
270
|
+
let hasDigit = false;
|
|
271
|
+
while (i < n) {
|
|
272
|
+
const c = content.charCodeAt(i);
|
|
273
|
+
if (c >= 65 && c <= 90)
|
|
274
|
+
hasUpper = true;
|
|
275
|
+
else if (c >= 97 && c <= 122)
|
|
276
|
+
hasLower = true;
|
|
277
|
+
else if (c >= 48 && c <= 57)
|
|
278
|
+
hasDigit = true;
|
|
279
|
+
else if (c === 43 || c === 47 || c === 61 || c === 95 || c === 45) {
|
|
280
|
+
/* symbol, keep going */
|
|
281
|
+
}
|
|
282
|
+
else
|
|
283
|
+
break;
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
const len = i - start;
|
|
287
|
+
if (len >= 400 && hasUpper && hasLower && hasDigit) {
|
|
288
|
+
// Skip data: URIs (the run follows "base64,") and Subresource-Integrity /
|
|
289
|
+
// lockfile hashes (the run itself starts with "sha256-/sha384-/sha512-").
|
|
290
|
+
const before = content.slice(Math.max(0, start - 16), start);
|
|
291
|
+
const run = content.slice(start, Math.min(start + 10, i));
|
|
292
|
+
if (!/base64,?$/i.test(before) && !/^sha(?:256|384|512)-/i.test(run)) {
|
|
293
|
+
out.push({ index: start, message: `Large encoded blob (${len} base64 characters) hidden in the file` });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
const obfLargeBlob = {
|
|
300
|
+
id: 'TM-OBF-002',
|
|
301
|
+
title: 'Large encoded blob',
|
|
302
|
+
description: 'Contains a long base64 run, dense hex escapes or a long fromCharCode/chr chain. These hide a payload or string inside otherwise readable code.',
|
|
303
|
+
category: 'obfuscation',
|
|
304
|
+
severity: 'medium',
|
|
305
|
+
kinds: CODE_KINDS,
|
|
306
|
+
check(file) {
|
|
307
|
+
const c = file.content;
|
|
308
|
+
const matches = [...findBase64Blobs(c)];
|
|
309
|
+
for (const h of findAll(HEX_ESCAPE_RUN, c, 8))
|
|
310
|
+
matches.push({ index: h.index, message: 'Dense hex escape sequence (obfuscated string)' });
|
|
311
|
+
for (const h of findAll(FROMCHARCODE, c, 8))
|
|
312
|
+
matches.push({ index: h.index, message: 'String.fromCharCode chain building a hidden string' });
|
|
313
|
+
for (const h of findAll(CHR_CHAIN, c, 8))
|
|
314
|
+
matches.push({ index: h.index, message: 'chr() chain building a hidden string' });
|
|
315
|
+
return oncePerLine(c, matches);
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
// --- TM-OBF-003 ----------------------------------------------------------
|
|
319
|
+
const EXEC_FETCHED = [
|
|
320
|
+
/\b(?:eval|exec)\s*\(\s*[^\n]{0,30}(?:fetch|axios|requests\.(?:get|post)|urllib|urlopen|http\.get|Invoke-WebRequest|Invoke-RestMethod)\s*\(/i,
|
|
321
|
+
/\bnew\s+Function\s*\(\s*[^\n]{0,30}(?:fetch|axios|requests\.(?:get|post)|urlopen|http\.get)\s*\(/i,
|
|
322
|
+
/\bvm\.runInNewContext\s*\(\s*[^\n]{0,30}(?:fetch|axios|requests\.get|urlopen|http\.get)\s*\(/i,
|
|
323
|
+
/\bfetch\s*\([^\n]{0,200}\)\s*\.then\s*\(\s*eval\b/i,
|
|
324
|
+
/\brequests\.(?:get|post)\s*\([^\n]{0,120}\)\s*\.(?:text|content)[^\n]{0,30}\)?\s*\)?\s*(?:#[^\n]{0,40})?\s*$/i,
|
|
325
|
+
];
|
|
326
|
+
const obfExecFetched = {
|
|
327
|
+
id: 'TM-OBF-003',
|
|
328
|
+
title: 'Executes fetched content',
|
|
329
|
+
description: 'Passes the body of an HTTP response straight to eval, exec or new Function. The executed code is controlled by whoever answers the request.',
|
|
330
|
+
category: 'obfuscation',
|
|
331
|
+
severity: 'critical',
|
|
332
|
+
kinds: CODE_KINDS,
|
|
333
|
+
check(file) {
|
|
334
|
+
const c = file.content;
|
|
335
|
+
const matches = [];
|
|
336
|
+
// The classic inline forms: eval(fetch(...)), new Function(await (await fetch()).text()), fetch().then(eval).
|
|
337
|
+
for (const re of EXEC_FETCHED.slice(0, 4)) {
|
|
338
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
339
|
+
const url = firstUrl(h.text);
|
|
340
|
+
matches.push({ index: h.index, message: url ? `Executes the response from ${url}` : 'Executes the body of an HTTP response' });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return oncePerLine(c, matches);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
// --- shared verb lists for credential/config access ----------------------
|
|
347
|
+
const READ_COPY_VERB = /\b(?:cat|less|more|head|tail|cp|scp|rsync|mv|tar|zip|gzip|gpg|curl|wget|base64|openssl|xxd|strings|type|Get-Content|gc|Copy-Item|Move-Item|Compress-Archive|readFile|readFileSync|read_text|open|fopen|load)\b|[<>]/i;
|
|
348
|
+
/**
|
|
349
|
+
* Text right before a path that makes the path the destination of a write:
|
|
350
|
+
* a shell redirect or tee into it, a cp/mv whose last argument it is, or a
|
|
351
|
+
* write API called with it. Checked against the same line only, so a "2>/dev/null"
|
|
352
|
+
* or an HTML tag elsewhere on the line does not count.
|
|
353
|
+
*/
|
|
354
|
+
const WRITE_INTO = [
|
|
355
|
+
/(?:(?<!<[^<>\n]{0,80})>>?|\btee(?:\s+-a)?)\s*["']?[^\s"'|;&<>]{0,60}$/,
|
|
356
|
+
/\b(?:cp|mv|install|rsync|ln|Copy-Item|Move-Item)\b[^\n|;&]{1,160}\s["']?[^\s"'|;&<>]{0,60}$/,
|
|
357
|
+
/\b(?:writeFileSync|writeFile|appendFileSync|appendFile|outputFile|copyFileSync|copyFile|write_text|write_bytes|Set-Content|Add-Content|Out-File)\b[^\n]{0,60}$/,
|
|
358
|
+
];
|
|
359
|
+
/** open(path, 'w'|'a') where the path starts the call. */
|
|
360
|
+
const OPEN_BEFORE = /\bopen\s*\(\s*[^\n),]{0,60}$/;
|
|
361
|
+
const OPEN_WRITE_MODE = /^[^\n)]{0,80},\s*["'][wa]\+?b?["']/;
|
|
362
|
+
/** True when the path at [index, index+length) is written to on its line. */
|
|
363
|
+
function writtenTo(content, index, length) {
|
|
364
|
+
const lineStart = content.lastIndexOf('\n', index - 1) + 1;
|
|
365
|
+
const before = content.slice(Math.max(lineStart, index - 200), index);
|
|
366
|
+
if (WRITE_INTO.some((re) => re.test(before)))
|
|
367
|
+
return true;
|
|
368
|
+
return OPEN_BEFORE.test(before) && OPEN_WRITE_MODE.test(content.slice(index + length, index + length + 120));
|
|
369
|
+
}
|
|
370
|
+
// --- TM-CRED-001 ---------------------------------------------------------
|
|
371
|
+
const credReadFiles = {
|
|
372
|
+
id: 'TM-CRED-001',
|
|
373
|
+
title: 'Reads SSH, cloud or developer credentials',
|
|
374
|
+
description: 'Reads, copies or archives credential files such as SSH keys, ~/.aws/credentials, ~/.kube/config or ~/.npmrc. Stealing these gives an attacker access to other systems.',
|
|
375
|
+
category: 'credential-access',
|
|
376
|
+
severity: 'high',
|
|
377
|
+
kinds: CODE_KINDS,
|
|
378
|
+
check(file) {
|
|
379
|
+
const c = file.content;
|
|
380
|
+
const matches = [];
|
|
381
|
+
for (const ind of SENSITIVE_CRED_PATHS) {
|
|
382
|
+
for (const h of findAll(ind.regex, c, MATCH_LIMIT)) {
|
|
383
|
+
const withVerb = near(c, h.index, h.text.length, READ_COPY_VERB, 120, 60);
|
|
384
|
+
matches.push({
|
|
385
|
+
index: h.index,
|
|
386
|
+
severity: withVerb ? 'high' : 'low',
|
|
387
|
+
message: withVerb ? `Reads or copies ${ind.name}` : `References ${ind.name}`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return oncePerLine(c, matches);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
// --- TM-CRED-002 ---------------------------------------------------------
|
|
395
|
+
const BROWSER_SECRETS = [
|
|
396
|
+
{ re: /\b(?:key4\.db|logins\.json|cookies\.sqlite|login\.keychain-db)\b/i, label: 'browser/keychain credential store' },
|
|
397
|
+
{ re: /(?:Chrome|Chromium|Edge|Brave|Opera)[^\n]{0,120}(?:Login Data|Web Data|Local State|Cookies)/i, label: 'Chromium credential store' },
|
|
398
|
+
{ re: /(?:Login Data|Local State|Web Data)[^\n]{0,120}(?:Chrome|Chromium|Edge|Brave|Opera)/i, label: 'Chromium credential store' },
|
|
399
|
+
{ re: /security\s+(?:find-generic-password|find-internet-password|dump-keychain)/i, label: 'macOS keychain dump' },
|
|
400
|
+
{ re: /\bcmdkey\s+\/list\b/i, label: 'Windows stored credentials (cmdkey)' },
|
|
401
|
+
{ re: /\bvaultcmd\b/i, label: 'Windows Credential Vault (vaultcmd)' },
|
|
402
|
+
{ re: /\bCryptUnprotectData\b/i, label: 'Windows DPAPI decryption' },
|
|
403
|
+
{ re: /\b(?:mimikatz|sekurlsa)\b/i, label: 'mimikatz credential dumping' },
|
|
404
|
+
{ re: /(?:procdump|comsvcs|MiniDumpWriteDump|rundll32)[^\n]{0,80}lsass/i, label: 'LSASS memory dump' },
|
|
405
|
+
{ re: /lsass[^\n]{0,60}(?:\.dmp|dump)/i, label: 'LSASS memory dump' },
|
|
406
|
+
];
|
|
407
|
+
const credBrowserKeychain = {
|
|
408
|
+
id: 'TM-CRED-002',
|
|
409
|
+
title: 'Browser password stores and OS keychains',
|
|
410
|
+
description: 'Accesses saved browser passwords/cookies, an OS keychain or LSASS memory. This is the behaviour of an infostealer harvesting logged-in sessions.',
|
|
411
|
+
category: 'credential-access',
|
|
412
|
+
severity: 'critical',
|
|
413
|
+
kinds: CODE_KINDS,
|
|
414
|
+
check(file) {
|
|
415
|
+
const c = file.content;
|
|
416
|
+
const matches = [];
|
|
417
|
+
for (const { re, label } of BROWSER_SECRETS) {
|
|
418
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
419
|
+
matches.push({ index: h.index, message: `Harvests credentials from a ${label}` });
|
|
420
|
+
}
|
|
421
|
+
return oncePerLine(c, matches);
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
// --- TM-CRED-003 ---------------------------------------------------------
|
|
425
|
+
const WALLET_INDICATORS = [
|
|
426
|
+
{ re: /\bwallet\.dat\b/i, label: 'wallet.dat' },
|
|
427
|
+
{ re: /\bnkbihfbeogaeaoehlefnkodbefgpgknn\b/i, label: 'MetaMask extension data' },
|
|
428
|
+
{ re: /\bbfnaelmomeimhlpmgjnjophhpkkoljpa\b/i, label: 'Phantom extension data' },
|
|
429
|
+
{ re: /\bhnfanknocfeofbddgcijnmhnfnkdnaad\b/i, label: 'Coinbase Wallet extension data' },
|
|
430
|
+
{ re: /\.solana[/\\]id\.json\b/i, label: 'Solana keypair' },
|
|
431
|
+
{ re: /keystore[/\\]UTC--/i, label: 'Ethereum keystore' },
|
|
432
|
+
{ re: /\b(?:Exodus|Electrum|Coinomi|Guarda)\b[/\\]/i, label: 'crypto wallet data directory' },
|
|
433
|
+
{ re: /Atomic Wallet[/\\]/i, label: 'Atomic Wallet data directory' },
|
|
434
|
+
{ re: /\b(?:mnemonicToSeed|walletFromMnemonic|importMnemonic|fromMnemonic|recoverWallet)\b/i, label: 'wallet mnemonic recovery' },
|
|
435
|
+
];
|
|
436
|
+
const credWallet = {
|
|
437
|
+
id: 'TM-CRED-003',
|
|
438
|
+
title: 'Crypto wallet theft',
|
|
439
|
+
description: 'Targets cryptocurrency wallet files, browser wallet extensions or seed phrases. These lead directly to theft of funds and are a hallmark of stealer malware.',
|
|
440
|
+
category: 'credential-access',
|
|
441
|
+
severity: 'critical',
|
|
442
|
+
kinds: CODE_KINDS,
|
|
443
|
+
check(file) {
|
|
444
|
+
const c = file.content;
|
|
445
|
+
const matches = [];
|
|
446
|
+
for (const { re, label } of WALLET_INDICATORS) {
|
|
447
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
448
|
+
matches.push({ index: h.index, message: `Accesses a cryptocurrency ${label}` });
|
|
449
|
+
}
|
|
450
|
+
return oncePerLine(c, matches);
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
// --- TM-CRED-004 ---------------------------------------------------------
|
|
454
|
+
const AGENT_TOKEN_PATHS = AGENT_CONFIG_PATHS.filter((p) => ['claude-json', 'claude-credentials', 'codex-auth', 'gh-hosts', 'openai-config', 'cursor-mcp', 'claude-desktop-config', 'gemini-settings'].includes(p.id));
|
|
455
|
+
const credAgentTokens = {
|
|
456
|
+
id: 'TM-CRED-004',
|
|
457
|
+
title: 'Reads AI agent or developer tool tokens',
|
|
458
|
+
description: 'Reads config files where AI agents and developer tools store API keys and OAuth tokens (~/.claude.json, ~/.codex/auth.json, gh hosts.yml). Stolen agent keys were sold after the OpenCode breach.',
|
|
459
|
+
category: 'credential-access',
|
|
460
|
+
severity: 'high',
|
|
461
|
+
kinds: CODE_KINDS,
|
|
462
|
+
check(file) {
|
|
463
|
+
const c = file.content;
|
|
464
|
+
const matches = [];
|
|
465
|
+
for (const ind of AGENT_TOKEN_PATHS) {
|
|
466
|
+
for (const h of findAll(ind.regex, c, MATCH_LIMIT)) {
|
|
467
|
+
const withVerb = near(c, h.index, h.text.length, READ_COPY_VERB, 120, 60);
|
|
468
|
+
matches.push({
|
|
469
|
+
index: h.index,
|
|
470
|
+
severity: withVerb ? 'high' : 'low',
|
|
471
|
+
message: withVerb ? `Reads agent/tool token store ${ind.name}` : `References agent/tool token store ${ind.name}`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return oncePerLine(c, matches);
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
// --- TM-EXFIL-001 --------------------------------------------------------
|
|
479
|
+
const PRIVATE_IP = /^(?:10\.|127\.|0\.0\.0\.0|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|255\.255\.255\.255)/;
|
|
480
|
+
const URL_WITH_IP = /\bhttps?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d{1,5})?/i;
|
|
481
|
+
const exfilEndpoint = {
|
|
482
|
+
id: 'TM-EXFIL-001',
|
|
483
|
+
title: 'Known exfiltration endpoint',
|
|
484
|
+
description: 'Sends data to a webhook sink, request bin, tunnel, out-of-band service, anonymous file drop or a raw IP address. These are the drop points stealers use to collect what they take.',
|
|
485
|
+
category: 'exfiltration',
|
|
486
|
+
severity: 'high',
|
|
487
|
+
kinds: CODE_KINDS,
|
|
488
|
+
check(file) {
|
|
489
|
+
const c = file.content;
|
|
490
|
+
const matches = [];
|
|
491
|
+
for (const host of EXFIL_HOSTS) {
|
|
492
|
+
for (const h of findAll(host.regex, c, MATCH_LIMIT)) {
|
|
493
|
+
matches.push({ index: h.index, severity: host.severity ?? 'high', message: `Contacts exfiltration endpoint: ${host.name}` });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const h of findAll(URL_WITH_IP, c, MATCH_LIMIT)) {
|
|
497
|
+
const ip = h.groups[0];
|
|
498
|
+
if (ip && !PRIVATE_IP.test(ip)) {
|
|
499
|
+
matches.push({ index: h.index, severity: 'medium', message: `Sends a request to a raw IP address (${ip})` });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return oncePerLine(c, matches);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
// --- TM-EXFIL-002 --------------------------------------------------------
|
|
506
|
+
const ENV_DUMP = [
|
|
507
|
+
{ re: /JSON\.stringify\s*\(\s*process\.env\b/i, label: 'JSON.stringify(process.env)' },
|
|
508
|
+
{ re: /\bObject\.(?:entries|keys|values|assign)\s*\(\s*process\.env\b/i, label: 'Object.*(process.env)' },
|
|
509
|
+
{ re: /\b(?:json\.dumps|str|repr)\s*\(\s*(?:dict\s*\(\s*)?os\.environ\b/i, label: 'serialises os.environ' },
|
|
510
|
+
{ re: /\b(?:env|printenv)\b[^\n|]{0,40}\|\s*(?:curl|wget|nc|ncat)\b/i, label: 'env | network tool' },
|
|
511
|
+
{ re: /Get-ChildItem\s+env:[^\n]{0,80}\|/i, label: 'Get-ChildItem env: piped out' },
|
|
512
|
+
];
|
|
513
|
+
const NET_REQUEST = /\b(?:curl|wget|fetch|axios|requests\.(?:get|post|put)|urlopen|http\.(?:get|post|request)|Invoke-WebRequest|Invoke-RestMethod|XMLHttpRequest|\.send\s*\()/i;
|
|
514
|
+
const exfilEnvDump = {
|
|
515
|
+
id: 'TM-EXFIL-002',
|
|
516
|
+
title: 'Bulk environment or secret dump',
|
|
517
|
+
description: 'Serialises the whole environment (process.env / os.environ) or pipes env output to a network tool. Environments hold API keys and tokens, so a bulk dump is a credential grab.',
|
|
518
|
+
category: 'exfiltration',
|
|
519
|
+
severity: 'medium',
|
|
520
|
+
kinds: CODE_KINDS,
|
|
521
|
+
check(file) {
|
|
522
|
+
const c = file.content;
|
|
523
|
+
const hasNet = NET_REQUEST.test(c);
|
|
524
|
+
const matches = [];
|
|
525
|
+
for (const { re, label } of ENV_DUMP) {
|
|
526
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
527
|
+
const piped = /\|/.test(h.text);
|
|
528
|
+
const escalate = hasNet || piped;
|
|
529
|
+
// Iterating the environment is routine; it only matters when the data can leave.
|
|
530
|
+
if (!escalate && /^Object\./.test(h.text))
|
|
531
|
+
continue;
|
|
532
|
+
matches.push({
|
|
533
|
+
index: h.index,
|
|
534
|
+
severity: escalate ? 'high' : 'medium',
|
|
535
|
+
message: escalate ? `Dumps the environment and sends it over the network (${label})` : `Dumps the whole environment (${label})`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return oncePerLine(c, matches);
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
// --- TM-PERSIST-001 ------------------------------------------------------
|
|
543
|
+
const SHELL_RC = /\.(?:bashrc|zshrc|bash_profile|profile|zprofile|zshenv)\b|config\.fish\b/i;
|
|
544
|
+
const PERSIST_DIRECT = [
|
|
545
|
+
{ re: /\|\s*crontab\s+-\s*$/im, label: 'installs a crontab entry' },
|
|
546
|
+
{ re: /\bcrontab\s+-(?=\s|$)/im, label: 'installs a crontab entry' },
|
|
547
|
+
{ re: /\/etc\/cron(?:\.d|tab|\.daily|\.hourly|\.weekly)?\b/i, label: 'writes a system cron job' },
|
|
548
|
+
{ re: /(?:LaunchAgents|LaunchDaemons)[/\\][^\n]{0,60}\.plist/i, label: 'installs a macOS launch agent/daemon' },
|
|
549
|
+
{ re: /\blaunchctl\s+(?:load|bootstrap|enable)\b/i, label: 'loads a launchd job' },
|
|
550
|
+
{ re: /\bsystemctl\s+(?:--user\s+)?enable\b/i, label: 'enables a systemd unit' },
|
|
551
|
+
{ re: /\breg(?:\.exe)?\s+add\b[^\n]{0,200}\\Run(?:Once)?\b/i, label: 'adds a Windows Run registry entry' },
|
|
552
|
+
{ re: /New-ItemProperty\b[^\n]{0,200}\\(?:CurrentVersion\\)?Run(?:Once)?\b/i, label: 'adds a Windows Run registry entry' },
|
|
553
|
+
{ re: /\bschtasks\b[^\n]{0,80}\/create\b/i, label: 'creates a scheduled task' },
|
|
554
|
+
{ re: /\bRegister-ScheduledTask\b/i, label: 'registers a scheduled task' },
|
|
555
|
+
{ re: /Start Menu\\Programs\\Startup/i, label: 'drops into the Startup folder' },
|
|
556
|
+
{ re: /\b(?:__EventFilter|CommandLineEventConsumer|ActiveScriptEventConsumer|Register-WmiEvent)\b/i, label: 'installs a WMI event subscription' },
|
|
557
|
+
];
|
|
558
|
+
const persistStartup = {
|
|
559
|
+
id: 'TM-PERSIST-001',
|
|
560
|
+
title: 'Startup persistence',
|
|
561
|
+
description: 'Installs itself to run automatically later: shell rc files, cron, launchd, systemd, a Windows Run key, a scheduled task, the Startup folder or a WMI subscription.',
|
|
562
|
+
category: 'persistence',
|
|
563
|
+
severity: 'high',
|
|
564
|
+
kinds: CODE_KINDS,
|
|
565
|
+
check(file) {
|
|
566
|
+
const c = file.content;
|
|
567
|
+
const matches = [];
|
|
568
|
+
for (const h of findAll(SHELL_RC, c, MATCH_LIMIT)) {
|
|
569
|
+
if (writtenTo(c, h.index, h.text.length)) {
|
|
570
|
+
matches.push({ index: h.index, message: 'Writes to a shell startup file (runs on every new shell)' });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const { re, label } of PERSIST_DIRECT) {
|
|
574
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
575
|
+
matches.push({ index: h.index, message: `Startup persistence: ${label}` });
|
|
576
|
+
}
|
|
577
|
+
return oncePerLine(c, matches);
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
// --- TM-PERSIST-002 ------------------------------------------------------
|
|
581
|
+
const persistAgentConfig = {
|
|
582
|
+
id: 'TM-PERSIST-002',
|
|
583
|
+
title: 'Tampers with AI agent configuration',
|
|
584
|
+
description: 'Writes to an AI agent config, instruction file (CLAUDE.md, AGENTS.md), MCP config, skills/plugins directory or .git/hooks. Rewriting these makes an injection or a malicious tool persist across sessions.',
|
|
585
|
+
category: 'persistence',
|
|
586
|
+
severity: 'high',
|
|
587
|
+
kinds: CODE_KINDS,
|
|
588
|
+
check(file) {
|
|
589
|
+
const c = file.content;
|
|
590
|
+
const matches = [];
|
|
591
|
+
for (const ind of AGENT_CONFIG_PATHS) {
|
|
592
|
+
for (const h of findAll(ind.regex, c, MATCH_LIMIT)) {
|
|
593
|
+
if (writtenTo(c, h.index, h.text.length)) {
|
|
594
|
+
matches.push({ index: h.index, message: `Writes to AI agent configuration (${ind.name})` });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return oncePerLine(c, matches);
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
// --- TM-DESTRUCT-001 -----------------------------------------------------
|
|
602
|
+
const RM_RECURSIVE = [
|
|
603
|
+
/\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b\s*([^\n]{0,60})/i,
|
|
604
|
+
/\brm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\b\s*([^\n]{0,60})/i,
|
|
605
|
+
];
|
|
606
|
+
const DANGEROUS_TARGET = /^(?:--[a-z-]+\s+)*(?:\/\s|\/$|\/\*|~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%|\*|\.\s|\.\*|[A-Za-z]:[\\/])/i;
|
|
607
|
+
const REMOVE_ITEM = /Remove-Item\b[^\n]{0,160}-Recurse\b[^\n]{0,80}-Force\b|Remove-Item\b[^\n]{0,160}-Force\b[^\n]{0,80}-Recurse\b/i;
|
|
608
|
+
const REMOVE_ITEM_ROOT = /(?:[A-Za-z]:[\\/](?:["'\s]|$)|\$HOME|~[\\/]?|\$env:USERPROFILE|\$env:SystemDrive|[\\/]$)/i;
|
|
609
|
+
const DESTRUCT_DIRECT = [
|
|
610
|
+
{ re: /--no-preserve-root/i, label: 'rm --no-preserve-root', critical: true },
|
|
611
|
+
{ re: /\bdel\s+(?:\/[sqf]\s+){1,3}[^\n]{0,40}[A-Za-z]:\\/i, label: 'del /s /q on a drive root', critical: true },
|
|
612
|
+
{ re: /\bformat\s+[a-zA-Z]:/i, label: 'format of a drive', critical: true },
|
|
613
|
+
{ re: /\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i, label: 'mkfs on a device' },
|
|
614
|
+
{ re: /\bdd\b[^\n]{0,60}if=\/dev\/(?:zero|urandom|random)[^\n]{0,60}of=\/dev\/(?:sd|nvme|hd|disk|vd)/i, label: 'dd wiping a disk', critical: true },
|
|
615
|
+
{ re: /\bdiskpart\b[^\n]{0,200}\bclean\b/i, label: 'diskpart clean' },
|
|
616
|
+
{ re: /\bvssadmin\s+delete\s+shadows/i, label: 'vssadmin delete shadows (destroys backups)' },
|
|
617
|
+
{ re: /\bwbadmin\s+delete\s+catalog/i, label: 'wbadmin delete catalog (destroys backups)' },
|
|
618
|
+
{ re: /\bcipher\s+\/w/i, label: 'cipher /w (wipes free space)' },
|
|
619
|
+
{ re: /\bshred\b[^\n]{0,40}(?:~|\$HOME|\/home\/|\/Users\/)/i, label: 'shred of home directory', critical: true },
|
|
620
|
+
];
|
|
621
|
+
const destructWipe = {
|
|
622
|
+
id: 'TM-DESTRUCT-001',
|
|
623
|
+
title: 'Destructive deletion or wiping',
|
|
624
|
+
description: 'Deletes or overwrites data at a system or home root, formats a disk, destroys backups/shadow copies or wipes a device. Catastrophic and irreversible.',
|
|
625
|
+
category: 'destructive',
|
|
626
|
+
severity: 'critical',
|
|
627
|
+
kinds: CODE_KINDS,
|
|
628
|
+
check(file) {
|
|
629
|
+
const c = file.content;
|
|
630
|
+
const matches = [];
|
|
631
|
+
for (const re of RM_RECURSIVE) {
|
|
632
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
633
|
+
const target = (h.groups[0] ?? '').trimStart();
|
|
634
|
+
if (/--no-preserve-root/i.test(h.text) || DANGEROUS_TARGET.test(target)) {
|
|
635
|
+
matches.push({ index: h.index, severity: 'critical', message: 'Recursive force-delete of a root or home directory (rm -rf)' });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const h of findAll(REMOVE_ITEM, c, MATCH_LIMIT)) {
|
|
640
|
+
if (REMOVE_ITEM_ROOT.test(h.text)) {
|
|
641
|
+
matches.push({ index: h.index, severity: 'critical', message: 'Recursive force-delete of a drive root or home directory (Remove-Item)' });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const { re, label, critical } of DESTRUCT_DIRECT) {
|
|
645
|
+
for (const h of findAll(re, c, MATCH_LIMIT)) {
|
|
646
|
+
matches.push({ index: h.index, severity: critical ? 'critical' : 'high', message: `Destructive operation: ${label}` });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return oncePerLine(c, matches);
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
// --- TM-DESTRUCT-002 -----------------------------------------------------
|
|
653
|
+
const ANTI_FORENSICS = [
|
|
654
|
+
{ re: /\bhistory\s+-c\b/i, label: 'clears shell history (history -c)' },
|
|
655
|
+
{ re: /\bunset\s+HISTFILE\b/i, label: 'unsets HISTFILE' },
|
|
656
|
+
{ re: /\bHISTFILESIZE=0\b/, label: 'sets HISTFILESIZE=0' },
|
|
657
|
+
{ re: /\b(?:rm|Remove-Item)\b[^\n]{0,40}\.(?:bash|zsh)_history\b/i, label: 'deletes shell history file' },
|
|
658
|
+
{ re: /\bClear-History\b/i, label: 'clears PowerShell history (Clear-History)' },
|
|
659
|
+
{ re: /\bwevtutil\s+cl\b/i, label: 'clears a Windows event log (wevtutil cl)' },
|
|
660
|
+
{ re: /\b(?:rm|del|Remove-Item)\b[^\n]{0,80}ConsoleHost_history\.txt/i, label: 'deletes PSReadLine history' },
|
|
661
|
+
];
|
|
662
|
+
const destructAntiForensics = {
|
|
663
|
+
id: 'TM-DESTRUCT-002',
|
|
664
|
+
title: 'Anti-forensics',
|
|
665
|
+
description: 'Clears shell, PowerShell or Windows event history to cover its tracks. Legitimate setup scripts do not wipe logs and command history.',
|
|
666
|
+
category: 'destructive',
|
|
667
|
+
severity: 'medium',
|
|
668
|
+
kinds: CODE_KINDS,
|
|
669
|
+
check(file) {
|
|
670
|
+
const c = file.content;
|
|
671
|
+
const matches = [];
|
|
672
|
+
for (const { re, label } of ANTI_FORENSICS) {
|
|
673
|
+
for (const h of findAll(re, c, MATCH_LIMIT))
|
|
674
|
+
matches.push({ index: h.index, message: `Anti-forensics: ${label}` });
|
|
675
|
+
}
|
|
676
|
+
return oncePerLine(c, matches);
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
const LOWER = { critical: 'high', high: 'medium', medium: 'low', low: 'info', info: 'info' };
|
|
680
|
+
const DISCUSSION = /\b(?:adversarial|payloads?|malicious|attackers?|attacks?|injection|exploits?|hostile|e\.g\.|for example|never|do not|don't|avoid|dangerous)\b/i;
|
|
681
|
+
/**
|
|
682
|
+
* In skills and instruction files a command often appears as a warning or an
|
|
683
|
+
* example ("never run `rm -rf ~`", "adversarial inputs: `; rm -rf ~`"). Lower
|
|
684
|
+
* those one level instead of treating them as instructions. Only one level, so
|
|
685
|
+
* an attacker sprinkling "e.g." over a real dropper still gets a high finding.
|
|
686
|
+
*/
|
|
687
|
+
function lowerInProse(rule) {
|
|
688
|
+
return {
|
|
689
|
+
...rule,
|
|
690
|
+
check(file) {
|
|
691
|
+
const matches = rule.check(file);
|
|
692
|
+
if (file.kind !== 'skill' && file.kind !== 'instructions')
|
|
693
|
+
return matches;
|
|
694
|
+
return matches.map((m) => {
|
|
695
|
+
if (m.index === undefined)
|
|
696
|
+
return m;
|
|
697
|
+
// URLs are not prose: a host like attacker.example must not count as discussion.
|
|
698
|
+
const prose = lineAt(file.content, lineColAt(file.content, m.index).line).replace(/\bhttps?:\/\/\S{1,300}/g, '');
|
|
699
|
+
if (!DISCUSSION.test(prose))
|
|
700
|
+
return m;
|
|
701
|
+
return {
|
|
702
|
+
...m,
|
|
703
|
+
severity: LOWER[m.severity ?? rule.severity],
|
|
704
|
+
message: `${m.message} (mentioned as an example or warning, so severity is lowered)`,
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
export const codeRules = [
|
|
711
|
+
execDownloadPipe,
|
|
712
|
+
execDownloadRun,
|
|
713
|
+
execPasswordArchive,
|
|
714
|
+
execDisableProtection,
|
|
715
|
+
execReverseShell,
|
|
716
|
+
obfDecodeExec,
|
|
717
|
+
obfLargeBlob,
|
|
718
|
+
obfExecFetched,
|
|
719
|
+
credReadFiles,
|
|
720
|
+
credBrowserKeychain,
|
|
721
|
+
credWallet,
|
|
722
|
+
credAgentTokens,
|
|
723
|
+
exfilEndpoint,
|
|
724
|
+
exfilEnvDump,
|
|
725
|
+
persistStartup,
|
|
726
|
+
persistAgentConfig,
|
|
727
|
+
destructWipe,
|
|
728
|
+
destructAntiForensics,
|
|
729
|
+
].map(lowerInProse);
|