@aliou/pi-guardrails 0.9.5 → 0.11.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/README.md +51 -3
- package/docs/defaults.md +140 -0
- package/docs/examples.md +170 -0
- package/package.json +7 -3
- package/src/commands/onboarding-command.ts +76 -0
- package/src/commands/onboarding.ts +390 -0
- package/src/commands/settings-command.ts +158 -3
- package/src/config.ts +102 -3
- package/src/hooks/index.ts +4 -2
- package/src/hooks/path-access.ts +396 -0
- package/src/hooks/permission-gate/dangerous-commands.test.ts +336 -0
- package/src/hooks/permission-gate/dangerous-commands.ts +345 -0
- package/src/hooks/permission-gate/index.test.ts +332 -0
- package/src/hooks/{permission-gate.ts → permission-gate/index.ts} +275 -159
- package/src/hooks/policies.ts +20 -4
- package/src/index.ts +62 -3
- package/src/utils/bash-paths.test.ts +91 -0
- package/src/utils/bash-paths.ts +96 -0
- package/src/utils/events.ts +1 -1
- package/src/utils/migration.ts +55 -1
- package/src/utils/path-access.test.ts +154 -0
- package/src/utils/path-access.ts +62 -0
- package/src/utils/path.test.ts +177 -0
- package/src/utils/path.ts +74 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { BUILTIN_MATCHERS, matchDangerousCommand } from "./dangerous-commands";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper to run all matchers against a command string.
|
|
6
|
+
* Returns the first match description, or undefined if none match.
|
|
7
|
+
*/
|
|
8
|
+
function findMatch(words: string[]): string | undefined {
|
|
9
|
+
for (const matcher of BUILTIN_MATCHERS) {
|
|
10
|
+
const result = matcher(words);
|
|
11
|
+
if (result) return result;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("rm matcher", () => {
|
|
17
|
+
it("matches rm -rf", () => {
|
|
18
|
+
expect(findMatch(["rm", "-rf", "/tmp/test"])).toBe(
|
|
19
|
+
"recursive force delete",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("matches rm -fr (reversed flags)", () => {
|
|
24
|
+
expect(findMatch(["rm", "-fr", "/tmp/test"])).toBe(
|
|
25
|
+
"recursive force delete",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("matches rm -r -f (separate flags)", () => {
|
|
30
|
+
expect(findMatch(["rm", "-r", "-f", "/tmp/test"])).toBe(
|
|
31
|
+
"recursive force delete",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("matches rm --recursive --force (long options)", () => {
|
|
36
|
+
expect(findMatch(["rm", "--recursive", "--force", "/tmp/test"])).toBe(
|
|
37
|
+
"recursive force delete",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("matches rm --force --recursive (reversed long options)", () => {
|
|
42
|
+
expect(findMatch(["rm", "--force", "--recursive", "/tmp/test"])).toBe(
|
|
43
|
+
"recursive force delete",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("matches rm -Rfv (grouped with extra flags)", () => {
|
|
48
|
+
expect(findMatch(["rm", "-Rfv", "/tmp/test"])).toBe(
|
|
49
|
+
"recursive force delete",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not match rm -r (no force)", () => {
|
|
54
|
+
expect(findMatch(["rm", "-r", "/tmp/test"])).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not match rm -f (no recursive)", () => {
|
|
58
|
+
expect(findMatch(["rm", "-f", "/tmp/test"])).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not match echo rm -rf", () => {
|
|
62
|
+
expect(findMatch(["echo", "rm", "-rf", "/"])).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("sudo matcher", () => {
|
|
67
|
+
it("matches sudo", () => {
|
|
68
|
+
expect(findMatch(["sudo", "apt", "update"])).toBe("superuser command");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("matches sudo at start only", () => {
|
|
72
|
+
expect(findMatch(["echo", "sudo", "something"])).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("doas matcher", () => {
|
|
77
|
+
it("matches doas", () => {
|
|
78
|
+
expect(findMatch(["doas", "pkg_add", "vim"])).toBe(
|
|
79
|
+
"privileged command execution",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("pkexec matcher", () => {
|
|
85
|
+
it("matches pkexec", () => {
|
|
86
|
+
expect(findMatch(["pkexec", "apt", "install", "firefox"])).toBe(
|
|
87
|
+
"privileged command execution",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("dd matcher", () => {
|
|
93
|
+
it("matches dd with of= (output file)", () => {
|
|
94
|
+
expect(findMatch(["dd", "if=/dev/zero", "of=/dev/sda"])).toBe(
|
|
95
|
+
"disk write operation",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("matches dd with of= in any order", () => {
|
|
100
|
+
expect(findMatch(["dd", "of=/dev/sda", "if=/dev/zero"])).toBe(
|
|
101
|
+
"disk write operation",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("matches dd with progress and of=", () => {
|
|
106
|
+
expect(
|
|
107
|
+
findMatch(["dd", "status=progress", "of=/dev/sdb", "if=image.img"]),
|
|
108
|
+
).toBe("disk write operation");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not match dd with only if= (input only)", () => {
|
|
112
|
+
expect(findMatch(["dd", "if=/dev/sda", "of=/dev/null"])).toBe(
|
|
113
|
+
"disk write operation",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not match dd with only if= (read-only)", () => {
|
|
118
|
+
expect(findMatch(["dd", "if=/dev/sda"])).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("mkfs matcher", () => {
|
|
123
|
+
it("matches mkfs.ext4", () => {
|
|
124
|
+
expect(findMatch(["mkfs.ext4", "/dev/sda1"])).toBe("filesystem format");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("matches mkfs.xfs", () => {
|
|
128
|
+
expect(findMatch(["mkfs.xfs", "/dev/sdb1"])).toBe("filesystem format");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("matches plain mkfs", () => {
|
|
132
|
+
expect(findMatch(["mkfs", "/dev/sda1"])).toBe("filesystem format");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("matches mkfs.vfat", () => {
|
|
136
|
+
expect(findMatch(["mkfs.vfat", "/dev/sdc1"])).toBe("filesystem format");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("shred matcher", () => {
|
|
141
|
+
it("matches shred", () => {
|
|
142
|
+
expect(findMatch(["shred", "-u", "secret.txt"])).toBe(
|
|
143
|
+
"secure file overwrite",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("wipefs matcher", () => {
|
|
149
|
+
it("matches wipefs", () => {
|
|
150
|
+
expect(findMatch(["wipefs", "-a", "/dev/sda"])).toBe(
|
|
151
|
+
"filesystem signature wipe",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("blkdiscard matcher", () => {
|
|
157
|
+
it("matches blkdiscard", () => {
|
|
158
|
+
expect(findMatch(["blkdiscard", "/dev/nvme0n1"])).toBe(
|
|
159
|
+
"block device discard",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("fdisk matcher", () => {
|
|
165
|
+
it("matches fdisk", () => {
|
|
166
|
+
expect(findMatch(["fdisk", "/dev/sda"])).toBe("disk partitioning");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("matches sfdisk", () => {
|
|
170
|
+
expect(findMatch(["sfdisk", "/dev/sda"])).toBe("disk partitioning");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("matches cfdisk", () => {
|
|
174
|
+
expect(findMatch(["cfdisk", "/dev/sda"])).toBe("disk partitioning");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("parted matcher", () => {
|
|
179
|
+
it("matches parted", () => {
|
|
180
|
+
expect(findMatch(["parted", "/dev/sda"])).toBe("disk partitioning");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("matches sgdisk", () => {
|
|
184
|
+
expect(findMatch(["sgdisk", "-l", "/dev/sda"])).toBe("disk partitioning");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("chmod matcher", () => {
|
|
189
|
+
it("matches chmod -R 777", () => {
|
|
190
|
+
expect(findMatch(["chmod", "-R", "777", "/tmp"])).toBe(
|
|
191
|
+
"insecure recursive permissions",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("matches chmod --recursive 777", () => {
|
|
196
|
+
expect(findMatch(["chmod", "--recursive", "777", "/tmp"])).toBe(
|
|
197
|
+
"insecure recursive permissions",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("matches chmod -R 0777", () => {
|
|
202
|
+
expect(findMatch(["chmod", "-R", "0777", "/tmp"])).toBe(
|
|
203
|
+
"insecure recursive permissions",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("matches chmod -R a+rwx", () => {
|
|
208
|
+
expect(findMatch(["chmod", "-R", "a+rwx", "/tmp"])).toBe(
|
|
209
|
+
"insecure recursive permissions",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("matches chmod -R ugo+rwx", () => {
|
|
214
|
+
expect(findMatch(["chmod", "-R", "ugo+rwx", "/tmp"])).toBe(
|
|
215
|
+
"insecure recursive permissions",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not match chmod 755 (not world-writable)", () => {
|
|
220
|
+
expect(findMatch(["chmod", "755", "file"])).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not match chmod -R 755 (not world-writable)", () => {
|
|
224
|
+
expect(findMatch(["chmod", "-R", "755", "/tmp"])).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("chown matcher", () => {
|
|
229
|
+
it("matches chown -R", () => {
|
|
230
|
+
expect(findMatch(["chown", "-R", "user:group", "/tmp"])).toBe(
|
|
231
|
+
"recursive ownership change",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("matches chown --recursive", () => {
|
|
236
|
+
expect(findMatch(["chown", "--recursive", "user", "/tmp"])).toBe(
|
|
237
|
+
"recursive ownership change",
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("does not match chown without -R", () => {
|
|
242
|
+
expect(findMatch(["chown", "user:group", "/tmp/file"])).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("container matcher (docker/podman)", () => {
|
|
247
|
+
describe("docker", () => {
|
|
248
|
+
it("matches docker run --privileged", () => {
|
|
249
|
+
expect(findMatch(["docker", "run", "--privileged", "alpine"])).toBe(
|
|
250
|
+
"container with privileged mode",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("matches docker run --pid=host", () => {
|
|
255
|
+
expect(findMatch(["docker", "run", "--pid=host", "alpine"])).toBe(
|
|
256
|
+
"container with host PID namespace",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("matches docker run --network=host", () => {
|
|
261
|
+
expect(findMatch(["docker", "run", "--network=host", "alpine"])).toBe(
|
|
262
|
+
"container with host network",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("matches docker run --userns=host", () => {
|
|
267
|
+
expect(findMatch(["docker", "run", "--userns=host", "alpine"])).toBe(
|
|
268
|
+
"container with host user namespace",
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("matches docker run with root mount", () => {
|
|
273
|
+
expect(findMatch(["docker", "run", "-v/:/host", "alpine"])).toBe(
|
|
274
|
+
"container with root filesystem mount",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("matches docker run with docker socket", () => {
|
|
279
|
+
expect(
|
|
280
|
+
findMatch([
|
|
281
|
+
"docker",
|
|
282
|
+
"run",
|
|
283
|
+
"-v",
|
|
284
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
285
|
+
"alpine",
|
|
286
|
+
]),
|
|
287
|
+
).toBe("container with docker socket access");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not match docker build", () => {
|
|
291
|
+
expect(
|
|
292
|
+
findMatch(["docker", "build", "-t", "myimage", "."]),
|
|
293
|
+
).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("does not match docker run without dangerous flags", () => {
|
|
297
|
+
expect(
|
|
298
|
+
findMatch(["docker", "run", "alpine", "echo", "hello"]),
|
|
299
|
+
).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("podman", () => {
|
|
304
|
+
it("matches podman run --privileged", () => {
|
|
305
|
+
expect(findMatch(["podman", "run", "--privileged", "alpine"])).toBe(
|
|
306
|
+
"container with privileged mode",
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("matches podman create --privileged", () => {
|
|
311
|
+
expect(findMatch(["podman", "create", "--privileged", "alpine"])).toBe(
|
|
312
|
+
"container with privileged mode",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("matchDangerousCommand", () => {
|
|
319
|
+
it("returns description and pattern for dangerous commands", () => {
|
|
320
|
+
const result = matchDangerousCommand(["sudo", "apt", "update"]);
|
|
321
|
+
expect(result).toEqual({
|
|
322
|
+
description: "superuser command",
|
|
323
|
+
pattern: "sudo",
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns undefined for safe commands", () => {
|
|
328
|
+
expect(matchDangerousCommand(["echo", "hello"])).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("returns first match when multiple could apply", () => {
|
|
332
|
+
// sudo comes before dd in the matcher list
|
|
333
|
+
const result = matchDangerousCommand(["sudo", "dd", "of=/dev/sda"]);
|
|
334
|
+
expect(result?.pattern).toBe("sudo");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dangerous command matchers for the permission gate.
|
|
3
|
+
*
|
|
4
|
+
* Built-in dangerous patterns are matched structurally via AST parsing.
|
|
5
|
+
* Each matcher receives the parsed command words and returns a description
|
|
6
|
+
* if the command is dangerous, or undefined if not matched.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type StructuralMatcher = (words: string[]) => string | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Helper to check if any word starts with a given prefix.
|
|
13
|
+
*/
|
|
14
|
+
function hasArg(words: string[], prefix: string): boolean {
|
|
15
|
+
return words.some((w) => w.startsWith(prefix));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Helper to check if short options contain specific flags.
|
|
20
|
+
* Handles grouped short options like -rf, -fr, -Rfv, etc.
|
|
21
|
+
*/
|
|
22
|
+
function hasShortFlag(words: string[], flag: string): boolean {
|
|
23
|
+
return words.some(
|
|
24
|
+
(w) =>
|
|
25
|
+
w === `-${flag}` ||
|
|
26
|
+
(w.startsWith("-") && !w.startsWith("--") && w.includes(flag)),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Helper to check for long options.
|
|
32
|
+
*/
|
|
33
|
+
function hasLongOption(words: string[], option: string): boolean {
|
|
34
|
+
return words.some((w) => w === `--${option}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// File/Directory Destruction
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* rm -rf, rm -r -f, rm --recursive --force, etc.
|
|
43
|
+
* Catches recursive force delete in any form.
|
|
44
|
+
*/
|
|
45
|
+
const rmMatcher: StructuralMatcher = (words) => {
|
|
46
|
+
if (words[0] !== "rm") return undefined;
|
|
47
|
+
|
|
48
|
+
const hasRecursive =
|
|
49
|
+
hasShortFlag(words, "r") ||
|
|
50
|
+
hasShortFlag(words, "R") ||
|
|
51
|
+
hasLongOption(words, "recursive") ||
|
|
52
|
+
hasLongOption(words, "dir");
|
|
53
|
+
|
|
54
|
+
const hasForce = hasShortFlag(words, "f") || hasLongOption(words, "force");
|
|
55
|
+
|
|
56
|
+
return hasRecursive && hasForce ? "recursive force delete" : undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* shred - secure file/device overwrite
|
|
61
|
+
*/
|
|
62
|
+
const shredMatcher: StructuralMatcher = (words) => {
|
|
63
|
+
if (words[0] === "shred") return "secure file overwrite";
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Privilege Escalation
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* sudo - superuser command
|
|
73
|
+
*/
|
|
74
|
+
const sudoMatcher: StructuralMatcher = (words) => {
|
|
75
|
+
if (words[0] === "sudo") return "superuser command";
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* doas - privilege escalation (OpenBSD-style sudo alternative)
|
|
81
|
+
*/
|
|
82
|
+
const doasMatcher: StructuralMatcher = (words) => {
|
|
83
|
+
if (words[0] === "doas") return "privileged command execution";
|
|
84
|
+
return undefined;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* pkexec - PolicyKit privilege escalation
|
|
89
|
+
*/
|
|
90
|
+
const pkexecMatcher: StructuralMatcher = (words) => {
|
|
91
|
+
if (words[0] === "pkexec") return "privileged command execution";
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Disk/Filesystem Operations
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* dd of= - disk write operation
|
|
101
|
+
* Any dd command with an output file is potentially dangerous.
|
|
102
|
+
*/
|
|
103
|
+
const ddMatcher: StructuralMatcher = (words) => {
|
|
104
|
+
if (words[0] !== "dd") return undefined;
|
|
105
|
+
return hasArg(words, "of=") ? "disk write operation" : undefined;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* mkfs, mkfs.* - filesystem format
|
|
110
|
+
*/
|
|
111
|
+
const mkfsMatcher: StructuralMatcher = (words) => {
|
|
112
|
+
const cmd = words[0];
|
|
113
|
+
if (cmd === "mkfs" || cmd?.startsWith("mkfs.")) return "filesystem format";
|
|
114
|
+
return undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* wipefs - filesystem signature wipe
|
|
119
|
+
*/
|
|
120
|
+
const wipefsMatcher: StructuralMatcher = (words) => {
|
|
121
|
+
if (words[0] === "wipefs") return "filesystem signature wipe";
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* blkdiscard - block device discard (destroys data)
|
|
127
|
+
*/
|
|
128
|
+
const blkdiscardMatcher: StructuralMatcher = (words) => {
|
|
129
|
+
if (words[0] === "blkdiscard") return "block device discard";
|
|
130
|
+
return undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Disk Partitioning
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* fdisk, sfdisk, cfdisk - disk partitioning
|
|
139
|
+
*/
|
|
140
|
+
const fdiskMatcher: StructuralMatcher = (words) => {
|
|
141
|
+
const cmd = words[0];
|
|
142
|
+
if (cmd === "fdisk" || cmd === "sfdisk" || cmd === "cfdisk") {
|
|
143
|
+
return "disk partitioning";
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* parted, sgdisk - advanced disk partitioning
|
|
150
|
+
*/
|
|
151
|
+
const partedMatcher: StructuralMatcher = (words) => {
|
|
152
|
+
const cmd = words[0];
|
|
153
|
+
if (cmd === "parted" || cmd === "sgdisk") return "disk partitioning";
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// Permission Changes
|
|
159
|
+
// =============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* chmod -R 777, chmod --recursive 777, chmod -R 0777, etc.
|
|
163
|
+
* Insecure recursive world-writable permissions.
|
|
164
|
+
*/
|
|
165
|
+
const chmodMatcher: StructuralMatcher = (words) => {
|
|
166
|
+
if (words[0] !== "chmod") return undefined;
|
|
167
|
+
|
|
168
|
+
const hasRecursive =
|
|
169
|
+
hasShortFlag(words, "R") || hasLongOption(words, "recursive");
|
|
170
|
+
|
|
171
|
+
const hasWorldWritable = words.some(
|
|
172
|
+
(w) =>
|
|
173
|
+
w === "777" ||
|
|
174
|
+
w === "0777" ||
|
|
175
|
+
w === "a+rwx" ||
|
|
176
|
+
w === "ugo+rwx" ||
|
|
177
|
+
w === "7777" || // setuid/setgid/sticky + world writable
|
|
178
|
+
w === "1777", // sticky + world writable
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return hasRecursive && hasWorldWritable
|
|
182
|
+
? "insecure recursive permissions"
|
|
183
|
+
: undefined;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* chown -R, chown --recursive - recursive ownership change
|
|
188
|
+
*/
|
|
189
|
+
const chownMatcher: StructuralMatcher = (words) => {
|
|
190
|
+
if (words[0] !== "chown") return undefined;
|
|
191
|
+
|
|
192
|
+
const hasRecursive =
|
|
193
|
+
hasShortFlag(words, "R") || hasLongOption(words, "recursive");
|
|
194
|
+
|
|
195
|
+
return hasRecursive ? "recursive ownership change" : undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// =============================================================================
|
|
199
|
+
// Container Escape / Dangerous Container Operations
|
|
200
|
+
// =============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Docker/Podman dangerous run/create patterns.
|
|
204
|
+
* Flags: --privileged, --pid=host, --network=host, --userns=host,
|
|
205
|
+
* --uts=host, --ipc=host, -v /:/host, docker socket mounts
|
|
206
|
+
*/
|
|
207
|
+
const containerMatcher: StructuralMatcher = (words) => {
|
|
208
|
+
const cmd = words[0];
|
|
209
|
+
if (!cmd) return undefined;
|
|
210
|
+
|
|
211
|
+
// Match docker or podman commands
|
|
212
|
+
const isDocker = cmd === "docker" || cmd === "podman";
|
|
213
|
+
if (!isDocker) return undefined;
|
|
214
|
+
|
|
215
|
+
// Only check run and create commands (not build, pull, etc.)
|
|
216
|
+
const subcommand = words[1];
|
|
217
|
+
if (subcommand !== "run" && subcommand !== "create") return undefined;
|
|
218
|
+
|
|
219
|
+
// Check for dangerous flags
|
|
220
|
+
const hasPrivileged = words.some(
|
|
221
|
+
(w) => w === "--privileged" || w.startsWith("--privileged="),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const hasHostPid = words.some(
|
|
225
|
+
(w) => w === "--pid=host" || w.startsWith("--pid=host"),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const hasHostNetwork = words.some(
|
|
229
|
+
(w) => w === "--network=host" || w.startsWith("--network=host"),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const hasHostUsers = words.some(
|
|
233
|
+
(w) => w === "--userns=host" || w.startsWith("--userns=host"),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const hasHostUts = words.some(
|
|
237
|
+
(w) => w === "--uts=host" || w.startsWith("--uts=host"),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const hasHostIpc = words.some(
|
|
241
|
+
(w) => w === "--ipc=host" || w.startsWith("--ipc=host"),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Check for root filesystem bind mount
|
|
245
|
+
const hasRootMount = words.some(
|
|
246
|
+
(w) =>
|
|
247
|
+
w.startsWith("-v/:") ||
|
|
248
|
+
w.startsWith("-v/=>") ||
|
|
249
|
+
w.startsWith("--volume=/:") ||
|
|
250
|
+
w.startsWith("--mount=type=bind,source=/,"),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Check for docker socket mount
|
|
254
|
+
const hasDockerSocket = words.some(
|
|
255
|
+
(w) =>
|
|
256
|
+
w.includes("/var/run/docker.sock") ||
|
|
257
|
+
w.includes("/run/docker.sock") ||
|
|
258
|
+
w.includes("/var/run/podman.sock") ||
|
|
259
|
+
w.includes("/run/podman.sock"),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (hasPrivileged) return "container with privileged mode";
|
|
263
|
+
if (hasHostPid) return "container with host PID namespace";
|
|
264
|
+
if (hasHostNetwork) return "container with host network";
|
|
265
|
+
if (hasHostUsers) return "container with host user namespace";
|
|
266
|
+
if (hasHostUts) return "container with host UTS namespace";
|
|
267
|
+
if (hasHostIpc) return "container with host IPC";
|
|
268
|
+
if (hasRootMount) return "container with root filesystem mount";
|
|
269
|
+
if (hasDockerSocket) return "container with docker socket access";
|
|
270
|
+
|
|
271
|
+
return undefined;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// =============================================================================
|
|
275
|
+
// Matcher Registry
|
|
276
|
+
// =============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* All built-in dangerous command matchers.
|
|
280
|
+
* Order matters - earlier matchers take precedence if multiple match.
|
|
281
|
+
*/
|
|
282
|
+
export const BUILTIN_MATCHERS: StructuralMatcher[] = [
|
|
283
|
+
// Destruction (highest priority)
|
|
284
|
+
rmMatcher,
|
|
285
|
+
shredMatcher,
|
|
286
|
+
|
|
287
|
+
// Privilege escalation
|
|
288
|
+
sudoMatcher,
|
|
289
|
+
doasMatcher,
|
|
290
|
+
pkexecMatcher,
|
|
291
|
+
|
|
292
|
+
// Disk/filesystem operations
|
|
293
|
+
ddMatcher,
|
|
294
|
+
mkfsMatcher,
|
|
295
|
+
wipefsMatcher,
|
|
296
|
+
blkdiscardMatcher,
|
|
297
|
+
fdiskMatcher,
|
|
298
|
+
partedMatcher,
|
|
299
|
+
|
|
300
|
+
// Permission changes
|
|
301
|
+
chmodMatcher,
|
|
302
|
+
chownMatcher,
|
|
303
|
+
|
|
304
|
+
// Container escapes
|
|
305
|
+
containerMatcher,
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Keywords for each built-in matcher, used for documentation/UI.
|
|
310
|
+
* These should match the patterns in DEFAULT_CONFIG in config.ts.
|
|
311
|
+
*/
|
|
312
|
+
export const BUILTIN_KEYWORD_PATTERNS = new Set([
|
|
313
|
+
"rm -rf",
|
|
314
|
+
"sudo",
|
|
315
|
+
"dd of=",
|
|
316
|
+
"mkfs.",
|
|
317
|
+
"chmod -R 777",
|
|
318
|
+
"chown -R",
|
|
319
|
+
"doas",
|
|
320
|
+
"pkexec",
|
|
321
|
+
"shred",
|
|
322
|
+
"wipefs",
|
|
323
|
+
"blkdiscard",
|
|
324
|
+
"fdisk",
|
|
325
|
+
"parted",
|
|
326
|
+
"docker run --privileged",
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Match a command against all built-in dangerous patterns.
|
|
331
|
+
* Returns the first match found, or undefined if no match.
|
|
332
|
+
*/
|
|
333
|
+
export function matchDangerousCommand(
|
|
334
|
+
words: string[],
|
|
335
|
+
): { description: string; pattern: string } | undefined {
|
|
336
|
+
for (const matcher of BUILTIN_MATCHERS) {
|
|
337
|
+
const description = matcher(words);
|
|
338
|
+
if (description) {
|
|
339
|
+
// Use the command itself as the pattern identifier
|
|
340
|
+
const pattern = words[0] ?? "unknown";
|
|
341
|
+
return { description, pattern };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|