@loophubs/agent-guard 0.2.0 → 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.
- package/package.json +1 -1
- package/src/argv.ts +34 -2
- package/src/frontend.ts +45 -15
- package/src/links.ts +14 -1
- package/src/paths.ts +79 -6
- package/src/pipeline.ts +124 -14
- package/src/reasons.ts +2 -0
- package/src/record.ts +3 -1
- package/src/rules/appdata.ts +11 -8
- package/src/rules/credentials.ts +119 -13
- package/src/words.ts +16 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loophubs/agent-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Blocks filesystem scans and credential reads that macOS records as TCC App Data access, and points the agent at a safe alternative.",
|
|
5
5
|
"homepage": "https://github.com/LoopHubs/agent-guard#readme",
|
|
6
6
|
"bugs": {
|
package/src/argv.ts
CHANGED
|
@@ -4,10 +4,17 @@ import { expandHome } from "./paths";
|
|
|
4
4
|
import type { Command, Word } from "./record";
|
|
5
5
|
import { searchRoles } from "./search-roles";
|
|
6
6
|
|
|
7
|
-
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
7
|
+
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh"]);
|
|
8
8
|
const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
|
|
9
9
|
const zshBuiltins = new Set(["echo", "printf", "print", "export", "typeset", "declare", "set", "command", "eval", "source", "."]);
|
|
10
10
|
const shellCodeFlag = /^-[a-z]*c[a-z]*$/;
|
|
11
|
+
const sudoValueOption = /^(-[A-Za-z]*[ughpCDRTrtU]|--(user|group|host|prompt|chdir|chroot|role|type|other-user|close-from|command-timeout))$/;
|
|
12
|
+
|
|
13
|
+
// egrep and fgrep are grep with a fixed matcher.
|
|
14
|
+
export function programName(text: string): string {
|
|
15
|
+
const name = basename(text);
|
|
16
|
+
return name === "egrep" || name === "fgrep" ? "grep" : name;
|
|
17
|
+
}
|
|
11
18
|
|
|
12
19
|
export function resolveCommand(cmd: Command, home: string): { children: string[]; code: string[] } {
|
|
13
20
|
const w = cmd.argv;
|
|
@@ -58,6 +65,31 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
58
65
|
if (w[i]?.text === "-n" || w[i]?.text === "--adjustment") i += 2;
|
|
59
66
|
else if (/^(-n|--adjustment=|-\d)/.test(w[i]?.text ?? "")) i++;
|
|
60
67
|
break;
|
|
68
|
+
case "sudo":
|
|
69
|
+
case "doas":
|
|
70
|
+
w[i++]!.role = "precommand";
|
|
71
|
+
while (i < w.length && w[i]!.text !== "--" && (w[i]!.text.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]!.text))) {
|
|
72
|
+
if (sudoValueOption.test(w[i]!.text)) w[++i]!.role = "precommand";
|
|
73
|
+
w[i++]!.role = "precommand";
|
|
74
|
+
}
|
|
75
|
+
if (w[i]?.text === "--") w[i++]!.role = "precommand";
|
|
76
|
+
break;
|
|
77
|
+
case "script":
|
|
78
|
+
// script [-adkpqr] [-F pipe] [-t time] [file [command ...]]
|
|
79
|
+
w[i++]!.role = "precommand";
|
|
80
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
81
|
+
if (["-F", "-t"].includes(w[i]!.text)) i++;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
if (w[i]) w[i++]!.role = "precommand";
|
|
85
|
+
break;
|
|
86
|
+
case "arch":
|
|
87
|
+
w[i++]!.role = "precommand";
|
|
88
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
89
|
+
if (["-e", "-d", "-arch"].includes(w[i]!.text)) i++;
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
61
93
|
case "stdbuf":
|
|
62
94
|
w[i++]!.role = "precommand";
|
|
63
95
|
while (/^-[ioe]/.test(w[i]?.text ?? "")) {
|
|
@@ -130,7 +162,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
130
162
|
cmd.program = i;
|
|
131
163
|
w[i]!.role = "program";
|
|
132
164
|
if (w[i]!.text.startsWith("=") && w[i]!.text.length > 1) w[i]!.text = w[i]!.text.slice(1);
|
|
133
|
-
const name =
|
|
165
|
+
const name = programName(w[i]!.text);
|
|
134
166
|
const rest = w.slice(i + 1);
|
|
135
167
|
if (["rg", "grep", "ag", "ack"].includes(name)) {
|
|
136
168
|
searchRoles(cmd, rest, name);
|
package/src/frontend.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
|
-
import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
|
|
8
|
+
import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, FuncDecl, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
|
|
9
9
|
import sh from "mvdan-sh";
|
|
10
10
|
|
|
11
11
|
import { resolveCommand, stdinKind } from "./argv";
|
|
@@ -13,7 +13,7 @@ import { misreadComment } from "./comments";
|
|
|
13
13
|
import { boundedDirectories } from "./cwd";
|
|
14
14
|
import { movedOnSuccess } from "./moves";
|
|
15
15
|
import { expandHome } from "./paths";
|
|
16
|
-
import { xargsReplacements } from "./pipeline";
|
|
16
|
+
import { markWalkedInput, shellInput, xargsHereInput, xargsReplacements } from "./pipeline";
|
|
17
17
|
import type { Command, Redirect, Script, Word } from "./record";
|
|
18
18
|
import { readWord } from "./words";
|
|
19
19
|
|
|
@@ -40,6 +40,10 @@ interface Scope {
|
|
|
40
40
|
|
|
41
41
|
const type = (node: Node) => syntax.NodeType(node);
|
|
42
42
|
|
|
43
|
+
// A function call runs the body in the caller's shell; bound the work a chain of
|
|
44
|
+
// calls can multiply.
|
|
45
|
+
const maxFunctionRuns = 256;
|
|
46
|
+
|
|
43
47
|
export function parseScript(source: string, cwd: string, home: string): Script {
|
|
44
48
|
const script: Script = { commands: [], uninspectable: [], parseFailed: false };
|
|
45
49
|
|
|
@@ -60,6 +64,10 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
60
64
|
for (const stmt of file.Stmts) statement(stmt!, scope);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
const functions = new Map<string, { body: Stmt; slice: Scope["slice"] }>();
|
|
68
|
+
const running = new Set<string>();
|
|
69
|
+
let functionRuns = 0;
|
|
70
|
+
|
|
63
71
|
const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
|
|
64
72
|
// A cd inside runs in a separate shell and leaves this scope's cwd alone.
|
|
65
73
|
const isolated = (scope: Scope): Scope => ({ ...scope, dir: { ...scope.dir }, vars: new Map(scope.vars) });
|
|
@@ -97,16 +105,26 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
97
105
|
scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
|
|
98
106
|
return;
|
|
99
107
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
// zsh runs the last element of a pipeline in this shell and bash forks it,
|
|
109
|
+
// so a cd there may or may not move the commands after it.
|
|
110
|
+
const right = op === "||" ? failed : op === "&&" ? scope : isolated(scope);
|
|
111
|
+
statement(cmd.Y!, right);
|
|
112
|
+
if (op === "|" || op === "|&") {
|
|
113
|
+
const left = script.commands.slice(start, middle);
|
|
114
|
+
const rest = script.commands.slice(middle);
|
|
115
|
+
markWalkedInput(left, rest);
|
|
116
|
+
for (const item of [...xargsReplacements(left, rest), ...shellInput(left, rest)]) parse(item.source, item.cwd);
|
|
104
117
|
}
|
|
118
|
+
if (op !== "&&") scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), right.dir.cwd, ...(right.dir.alternatives ?? [])], home);
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
// Compound words may hold substitutions; branch bodies may not run.
|
|
108
122
|
function children(node: Node, scope: Scope) {
|
|
109
|
-
if (type(node) === "FuncDecl")
|
|
123
|
+
if (type(node) === "FuncDecl") {
|
|
124
|
+
const func = node as FuncDecl;
|
|
125
|
+
if (func.Name && func.Body) functions.set(func.Name.Value, { body: func.Body, slice: scope.slice });
|
|
126
|
+
scope = isolated(scope);
|
|
127
|
+
}
|
|
110
128
|
if (type(node) === "IfClause") return conditional(node as IfClause, scope);
|
|
111
129
|
const outer = scope;
|
|
112
130
|
const loop = type(node) === "WhileClause" || type(node) === "ForClause";
|
|
@@ -157,7 +175,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
157
175
|
}
|
|
158
176
|
|
|
159
177
|
function word(node: WordNode, scope: Scope): Word {
|
|
160
|
-
return readWord(node, scope.slice, scope.vars, home, (part, names) => expansions(part, scope, names));
|
|
178
|
+
return readWord(node, scope.slice, scope.vars, home, scope.dir.cwd, (part, names) => expansions(part, scope, names));
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
function assign(node: Assign, scope: Scope, role: "assign" | "arg"): Word {
|
|
@@ -171,7 +189,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
171
189
|
return type(child) !== "Word";
|
|
172
190
|
});
|
|
173
191
|
return {
|
|
174
|
-
...(value ?? { expands: false, globs: false, vars: [] }),
|
|
192
|
+
...(value ?? { expands: false, globs: false, vars: [], pwd: false }),
|
|
175
193
|
text: `${name}=${value?.text ?? ""}`,
|
|
176
194
|
raw: text(node, scope),
|
|
177
195
|
role,
|
|
@@ -180,7 +198,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
function literal(value: string): Word {
|
|
183
|
-
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
|
|
201
|
+
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value, pwd: false };
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
function simple(cmd: Node | null, redirs: (RedirectNode | null)[], scope: Scope) {
|
|
@@ -208,7 +226,8 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
208
226
|
};
|
|
209
227
|
script.commands.push(command);
|
|
210
228
|
const { children: sources, code } = resolveCommand(command, home);
|
|
211
|
-
|
|
229
|
+
const moved = (cwd: string) => command.argv.map((w) => (w.pwd ? { ...w, text: w.text.replaceAll(scope.dir.cwd, cwd), value: w.value.replaceAll(scope.dir.cwd, cwd) } : w));
|
|
230
|
+
for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd, argv: moved(cwd) });
|
|
212
231
|
for (const src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
|
|
213
232
|
script.uninspectable.push(...code);
|
|
214
233
|
for (const r of redirects) {
|
|
@@ -216,6 +235,17 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
216
235
|
const stdin = stdinKind(command);
|
|
217
236
|
if (stdin === "shell") parse(r.target, command.cwd);
|
|
218
237
|
if (stdin === "code") script.uninspectable.push(r.target);
|
|
238
|
+
if (command.wrappers.includes("xargs")) for (const item of xargsHereInput(command, r.target)) parse(item.source, item.cwd);
|
|
239
|
+
}
|
|
240
|
+
const called = command.wrappers.every((w) => w === "time") ? command.argv[command.program]?.text : undefined;
|
|
241
|
+
const func = called === undefined ? undefined : functions.get(called);
|
|
242
|
+
if (func && !running.has(called!)) {
|
|
243
|
+
if (++functionRuns > maxFunctionRuns) script.parseFailed = true;
|
|
244
|
+
else {
|
|
245
|
+
running.add(called!);
|
|
246
|
+
statement(func.body, { ...scope, slice: func.slice });
|
|
247
|
+
running.delete(called!);
|
|
248
|
+
}
|
|
219
249
|
}
|
|
220
250
|
track(command, scope);
|
|
221
251
|
}
|
|
@@ -226,13 +256,13 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
226
256
|
const quoted = node.Word!.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
|
|
227
257
|
const vars: string[] = [];
|
|
228
258
|
if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
|
|
229
|
-
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
|
|
259
|
+
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", globs: false, vars };
|
|
230
260
|
}
|
|
231
261
|
const target = word(node.Word!, scope);
|
|
232
|
-
if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
|
|
262
|
+
if (op === "<<<") return { direction: "herestring", target: target.text, globs: false, vars: target.vars };
|
|
233
263
|
if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
|
|
234
264
|
const direction = op === "<" || op === "<>" ? "in" : "out";
|
|
235
|
-
return { direction, target: target.text, vars: [] };
|
|
265
|
+
return { direction, target: target.text, globs: target.globs, vars: [] };
|
|
236
266
|
}
|
|
237
267
|
|
|
238
268
|
function track(command: Command, scope: Scope) {
|
|
@@ -245,7 +275,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
245
275
|
if (!program || !command.shell || !["cd", "pushd"].includes(program.text)) return;
|
|
246
276
|
const args = command.argv.slice(command.program + 1);
|
|
247
277
|
let target = args.find((w) => !w.text.startsWith("-"))?.text;
|
|
248
|
-
if (target === undefined &&
|
|
278
|
+
if (target === undefined && args.every((w) => /^(--|-[PLqs]+)$/.test(w.text)) && program.text === "cd") target = home;
|
|
249
279
|
if (target !== undefined) {
|
|
250
280
|
scope.dir.alternatives = boundedDirectories(
|
|
251
281
|
resolve(scope.dir.cwd, target),
|
package/src/links.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readlinkSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { programName } from "./argv";
|
|
4
5
|
import { absPath, expandHome, isAppdata, isSensitive } from "./paths";
|
|
5
6
|
import type { Request, Word } from "./record";
|
|
6
7
|
import { dataPrograms } from "./rules/appdata";
|
|
@@ -45,14 +46,26 @@ export function linkedRequest(req: Request): Request | undefined {
|
|
|
45
46
|
const value = linked(w.value, cwd, /^['"]/.test(w.raw));
|
|
46
47
|
return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
|
|
47
48
|
};
|
|
49
|
+
// curl reads the file after @ or < in a data or form value, or one glued to -T, -K, --upload-file or --config.
|
|
50
|
+
const curlOperand = /^((?:-[A-Za-z]|--[a-z-]+=|[^=@<-][^=@<]*=)?[@<])([^;]+)(.*)$/s;
|
|
51
|
+
const curlFile = /^(-[TK]|--(?:upload-file|config)=)([^;]+)()$/s;
|
|
52
|
+
const curlWord = (w: Word, cwd: string): Word | undefined => {
|
|
53
|
+
const operand = curlOperand.exec(w.text) ?? curlFile.exec(w.text);
|
|
54
|
+
if (!operand || w.expands) return undefined;
|
|
55
|
+
const value = linked(operand[2]!, cwd);
|
|
56
|
+
return value === operand[2] ? w : { ...w, text: `${operand[1]}${value}${operand[3]}` };
|
|
57
|
+
};
|
|
48
58
|
const commands = req.commands.map((cmd) => {
|
|
59
|
+
const curl = cmd.program >= 0 && programName(cmd.argv[cmd.program]!.text) === "curl";
|
|
49
60
|
const checkedCwd = linked(cmd.cwd, "/");
|
|
50
61
|
const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
|
|
51
62
|
if (cwd !== cmd.cwd) changed = true;
|
|
52
63
|
return {
|
|
53
64
|
...cmd,
|
|
54
65
|
cwd,
|
|
55
|
-
argv: cmd.argv.map((w, i) =>
|
|
66
|
+
argv: cmd.argv.map((w, i) =>
|
|
67
|
+
dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : curl && i > cmd.program ? (curlWord(w, cmd.cwd) ?? word(w, cmd.cwd)) : word(w, cmd.cwd),
|
|
68
|
+
),
|
|
56
69
|
redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
|
|
57
70
|
};
|
|
58
71
|
});
|
package/src/paths.ts
CHANGED
|
@@ -17,6 +17,14 @@ export const sensitivePaths = [
|
|
|
17
17
|
"**/auth.json*",
|
|
18
18
|
"**/.credentials.json*",
|
|
19
19
|
"**/.aws/credentials*",
|
|
20
|
+
"**/.netrc",
|
|
21
|
+
"**/.git-credentials",
|
|
22
|
+
"**/.docker/config.json",
|
|
23
|
+
"**/.kube/config",
|
|
24
|
+
"**/.pypirc",
|
|
25
|
+
"**/.pgpass",
|
|
26
|
+
"**/.cargo/credentials*",
|
|
27
|
+
"**/.config/gh/hosts.yml",
|
|
20
28
|
"**/private-keys-v1.d",
|
|
21
29
|
"**/private-keys-v1.d/**",
|
|
22
30
|
];
|
|
@@ -27,6 +35,11 @@ const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
|
|
|
27
35
|
// ~/.ssh has its own inode-based check and reason.
|
|
28
36
|
const credentialRoots = [".aws", ".gnupg"];
|
|
29
37
|
const credentialDirectories = [".ssh", ...credentialRoots];
|
|
38
|
+
// The directory part of each listed file that sits in a named directory, such as .kube for .kube/config.
|
|
39
|
+
const listedDirectories = sensitivePaths.flatMap((listed) => {
|
|
40
|
+
const tail = listed.replace(/^\*\*\//, "").split("/");
|
|
41
|
+
return tail.length > 1 && !tail.slice(0, -1).some((part) => part.includes("*")) ? [tail.slice(0, -1).join("/")] : [];
|
|
42
|
+
});
|
|
30
43
|
|
|
31
44
|
export function expandHome(path: string, home: string): string {
|
|
32
45
|
for (const prefix of ["~", `~${userInfo().username}`]) {
|
|
@@ -112,14 +125,66 @@ export function isSensitive(path: string, glob = false): boolean {
|
|
|
112
125
|
if (!glob) return false;
|
|
113
126
|
if (globDirectory(path)) return true;
|
|
114
127
|
const base = basename(lower);
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
// A bare wildcard reaches every file in the directory, so a directory that holds a listed file counts.
|
|
129
|
+
if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower))) || listedDirectories.some((dir) => dirname(lower).endsWith(`/${dir}`));
|
|
130
|
+
// A listed file inside a named directory needs the glob's directory to match too.
|
|
131
|
+
const segments = lower.split("/");
|
|
117
132
|
return sensitivePaths.some((listed) => {
|
|
118
|
-
const
|
|
119
|
-
|
|
133
|
+
const tail = listed.replace(/^\*\*\//, "").split("/");
|
|
134
|
+
if (tail.length > segments.length) return false;
|
|
135
|
+
if (/^\**$/.test(tail.at(-1)!)) return false;
|
|
136
|
+
const names = tail.map((part) => part.replaceAll("*", "x"));
|
|
137
|
+
const offset = segments.length - tail.length;
|
|
138
|
+
if (!names.slice(0, -1).every((name, i) => new Bun.Glob(segments[offset + i]!).match(name))) return false;
|
|
139
|
+
// A named directory already narrows the match, so a glob that could reach any listed name in it counts.
|
|
140
|
+
return tail.length > 1 ? globsIntersect(segments.at(-1)!, tail.at(-1)!) : new Bun.Glob(segments.at(-1)!).match(names.at(-1)!);
|
|
120
141
|
});
|
|
121
142
|
}
|
|
122
143
|
|
|
144
|
+
type GlobToken = "*" | ((char: string) => boolean);
|
|
145
|
+
|
|
146
|
+
// Reads `*`, `?`, `[set]` and literal characters; a backslash escapes the next one.
|
|
147
|
+
function globTokens(glob: string): GlobToken[] {
|
|
148
|
+
const tokens: GlobToken[] = [];
|
|
149
|
+
for (let i = 0; i < glob.length; i++) {
|
|
150
|
+
const char = glob[i]!;
|
|
151
|
+
const close = char === "[" ? glob.indexOf("]", i + 2) : -1;
|
|
152
|
+
if (char === "*") tokens.push("*");
|
|
153
|
+
else if (char === "?") tokens.push(() => true);
|
|
154
|
+
else if (close > 0) {
|
|
155
|
+
const matcher = new Bun.Glob(glob.slice(i, close + 1));
|
|
156
|
+
tokens.push((c) => matcher.match(c));
|
|
157
|
+
i = close;
|
|
158
|
+
} else {
|
|
159
|
+
const literal = char === "\\" ? (glob[++i] ?? char) : char;
|
|
160
|
+
tokens.push((c) => c === literal);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return tokens;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Whether some file name matches both wildcard patterns.
|
|
167
|
+
function globsIntersect(a: string, b: string): boolean {
|
|
168
|
+
const [left, right] = [globTokens(a), globTokens(b)];
|
|
169
|
+
const probes = Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i));
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
const walk = (i: number, j: number): boolean => {
|
|
172
|
+
const key = `${i},${j}`;
|
|
173
|
+
if (seen.has(key)) return false;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
const [x, y] = [left[i], right[j]];
|
|
176
|
+
if (x === undefined && y === undefined) return true;
|
|
177
|
+
if (x === "*" && walk(i + 1, j)) return true;
|
|
178
|
+
if (y === "*" && walk(i, j + 1)) return true;
|
|
179
|
+
if (x === "*" && y === "*") return false;
|
|
180
|
+
if (x === "*") return y !== undefined && walk(i, j + 1);
|
|
181
|
+
if (y === "*") return x !== undefined && walk(i + 1, j);
|
|
182
|
+
if (x === undefined || y === undefined) return false;
|
|
183
|
+
return probes.some((c) => x(c) && y(c)) && walk(i + 1, j + 1);
|
|
184
|
+
};
|
|
185
|
+
return walk(0, 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
123
188
|
// A wildcard directory segment such as `.s*` may expand to a credential
|
|
124
189
|
// directory. The shell's wildcards skip a leading dot, so only a segment that
|
|
125
190
|
// starts with one counts.
|
|
@@ -134,8 +199,16 @@ function globDirectory(path: string): boolean {
|
|
|
134
199
|
|
|
135
200
|
// A search reads everything under its root, so a credential directory counts as
|
|
136
201
|
// its listed files do.
|
|
137
|
-
export function isSensitiveRoot(path: string): boolean {
|
|
138
|
-
|
|
202
|
+
export function isSensitiveRoot(path: string, home: string): boolean {
|
|
203
|
+
const lower = path.toLowerCase();
|
|
204
|
+
// A search from the home directory's .config reaches .config/gh because gh is not hidden.
|
|
205
|
+
const ancestors = listedDirectories.flatMap((dir) =>
|
|
206
|
+
dir
|
|
207
|
+
.split("/")
|
|
208
|
+
.slice(0, -1)
|
|
209
|
+
.map((_, i, parts) => `${home.toLowerCase()}/${parts.slice(0, i + 1).join("/")}`),
|
|
210
|
+
);
|
|
211
|
+
return isSensitive(path) || credentialRoots.includes(basename(lower)) || listedDirectories.some((dir) => lower.endsWith(`/${dir}`)) || ancestors.includes(lower);
|
|
139
212
|
}
|
|
140
213
|
|
|
141
214
|
// A path that does not exist or cannot be searched has no inode to compare.
|
package/src/pipeline.ts
CHANGED
|
@@ -1,15 +1,84 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { stdinKind } from "./argv";
|
|
4
|
+
import type { Command, Word } from "./record";
|
|
4
5
|
|
|
5
|
-
//
|
|
6
|
-
export function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// fd and ls skip hidden files unless asked; find never does.
|
|
7
|
+
export function showsHidden(words: Word[]): boolean {
|
|
8
|
+
return words.some((word) => /^(--hidden|--unrestricted)$/.test(word.text) || /^-[A-Za-z]*[Hu][A-Za-z]*$/.test(word.text));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A dotfile name, or a glob the shell expands to dotfiles; `.` and `..` are directories.
|
|
12
|
+
const hiddenName = (text: string) => /^\.(?!\.?$)/.test(basename(text));
|
|
13
|
+
|
|
14
|
+
// xargs passes the names a walk printed to its command, so mark that command.
|
|
15
|
+
export function markWalkedInput(left: Command[], right: Command[]) {
|
|
16
|
+
const walks = left.some((cmd) => {
|
|
17
|
+
const name = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
|
|
18
|
+
const args = cmd.argv.slice(cmd.program + 1);
|
|
19
|
+
const operands = args.filter((word) => !word.text.startsWith("-"));
|
|
20
|
+
if (name === "ls") return args.some((word) => /^(--all|--almost-all|-[A-Za-z0-9]*[aA][A-Za-z0-9]*)$/.test(word.text)) || operands.some((word) => hiddenName(word.text));
|
|
21
|
+
if (name === "echo" || name === "printf") return operands.some((word) => word.globs && hiddenName(word.text));
|
|
22
|
+
return name === "find" || (name === "fd" && showsHidden(args));
|
|
23
|
+
});
|
|
24
|
+
if (walks) for (const cmd of right) if (cmd.wrappers.includes("xargs")) cmd.flags.add("walked");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// printf and zsh's echo decode these escapes and stop at `\c`; a NUL separates xargs -0 items, so it reads as a line break.
|
|
28
|
+
// A printf format takes up to three octal digits after the backslash; %b and echo take a leading 0 as well.
|
|
29
|
+
const escapes: Record<string, string> = { n: "\n", t: "\t", "\\": "\\" };
|
|
30
|
+
const unescape = (text: string, operand = false) =>
|
|
31
|
+
text
|
|
32
|
+
.split("\\c", 1)[0]!
|
|
33
|
+
.replace(
|
|
34
|
+
new RegExp(`\\\\(?:x([0-9a-fA-F]{1,2})|(${operand ? "0[0-7]{0,3}|[1-7][0-7]{0,2}" : "[0-7]{1,3}"})|([nt\\\\])|u([0-9a-fA-F]{1,4})|U([0-9a-fA-F]{1,8}))`, "g"),
|
|
35
|
+
(_, hex?: string, octal?: string, named?: string, unicode?: string, wide?: string) => {
|
|
36
|
+
const hexadecimal = hex ?? unicode ?? wide;
|
|
37
|
+
const code = hexadecimal !== undefined ? parseInt(hexadecimal, 16) : octal !== undefined ? parseInt(octal, 8) : undefined;
|
|
38
|
+
return code === undefined ? escapes[named!]! : code === 0 ? "\n" : String.fromCharCode(code);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// What printf writes for a format built from literal text, %s, %b and %%; another conversion is unknown.
|
|
43
|
+
function printfOutput(args: Word[]): string | undefined {
|
|
44
|
+
const [format, ...operands] = (args[0]?.text === "--" ? args.slice(1) : args).map((word) => word.text);
|
|
45
|
+
if (format === undefined) return "";
|
|
46
|
+
const pieces = format.split(/(%[sb%])/);
|
|
47
|
+
if (pieces.some((piece) => piece.includes("%") && !/^%[sb%]$/.test(piece))) return undefined;
|
|
48
|
+
const converts = pieces.some((piece) => /^%[sb]$/.test(piece));
|
|
49
|
+
let out = "";
|
|
50
|
+
do {
|
|
51
|
+
for (const piece of pieces) {
|
|
52
|
+
if (piece === "%%") out += "%";
|
|
53
|
+
else if (piece === "%s") out += operands.shift() ?? "";
|
|
54
|
+
else if (piece === "%b") out += unescape(operands.shift() ?? "", true);
|
|
55
|
+
else out += unescape(piece);
|
|
56
|
+
}
|
|
57
|
+
} while (converts && operands.length);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
12
60
|
|
|
61
|
+
// A literal echo or printf value piped into a shell is the command line it runs.
|
|
62
|
+
export function shellInput(left: Command[], right: Command[]): { source: string; cwd: string }[] {
|
|
63
|
+
const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
|
|
64
|
+
const shell = right.find((cmd) => cmd.program >= 0 && stdinKind(cmd) === "shell");
|
|
65
|
+
if (!producer || !shell) return [];
|
|
66
|
+
const args = producer.argv.slice(producer.program + 1);
|
|
67
|
+
const source =
|
|
68
|
+
basename(producer.argv[producer.program]!.text) === "printf"
|
|
69
|
+
? printfOutput(args)
|
|
70
|
+
: unescape(
|
|
71
|
+
args
|
|
72
|
+
.filter((word) => !word.text.startsWith("-"))
|
|
73
|
+
.map((word) => word.text)
|
|
74
|
+
.join(" "),
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
return source === undefined ? [] : [{ source, cwd: shell.cwd }];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// xargs runs its command once with every item, or once per item with -I.
|
|
81
|
+
function xargsCommands(xargs: Command, items: string[]): { source: string; cwd: string }[] {
|
|
13
82
|
const options = xargs.argv.slice(0, xargs.program);
|
|
14
83
|
let marker = "";
|
|
15
84
|
for (let i = 0; i < options.length; i++) {
|
|
@@ -18,11 +87,52 @@ export function xargsReplacements(left: Command[], right: Command[]): { source:
|
|
|
18
87
|
else if (option.startsWith("-I")) marker = option.slice(2);
|
|
19
88
|
else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
|
|
20
89
|
}
|
|
21
|
-
|
|
90
|
+
// xargs strips quotes and backslashes from what it reads, with or without -I.
|
|
91
|
+
const stripped = (value: string) => value.replace(/["'\\]/g, "");
|
|
22
92
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
93
|
+
const command = xargs.argv.slice(xargs.program);
|
|
94
|
+
// Without -I, xargs splits its input on blanks, so a printed line can become several arguments.
|
|
95
|
+
if (!marker)
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
source: [
|
|
99
|
+
...command.map((word) => word.raw),
|
|
100
|
+
...items
|
|
101
|
+
.flatMap((item) => item.split(/\s+/))
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.flatMap((item) => [item, stripped(item)])
|
|
104
|
+
.map(quote),
|
|
105
|
+
].join(" "),
|
|
106
|
+
cwd: xargs.cwd,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
return items
|
|
110
|
+
.flatMap((line) => [line, stripped(line)])
|
|
111
|
+
.map((item) => ({ source: command.map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, item)) : word.raw)).join(" "), cwd: xargs.cwd }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Literal printf and echo values are known before xargs passes them to a command.
|
|
115
|
+
export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
|
|
116
|
+
const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
|
|
117
|
+
const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
|
|
118
|
+
if (!producer || !xargs) return [];
|
|
119
|
+
const args = producer.argv.slice(producer.program + 1);
|
|
120
|
+
if (basename(producer.argv[producer.program]!.text) !== "printf")
|
|
121
|
+
return xargsCommands(
|
|
122
|
+
xargs,
|
|
123
|
+
args.filter((word) => !word.text.startsWith("-")).map((word) => unescape(word.text, true)),
|
|
124
|
+
);
|
|
125
|
+
const output = printfOutput(args);
|
|
126
|
+
return output === undefined ? [] : xargsCommands(xargs, output.split("\n").filter(Boolean));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A here-string or here-document body is the item list xargs reads.
|
|
130
|
+
export function xargsHereInput(xargs: Command, body: string): { source: string; cwd: string }[] {
|
|
131
|
+
return xargsCommands(
|
|
132
|
+
xargs,
|
|
133
|
+
body
|
|
134
|
+
.split("\n")
|
|
135
|
+
.flatMap((line) => line.split(/\s+/))
|
|
136
|
+
.filter(Boolean),
|
|
137
|
+
);
|
|
28
138
|
}
|
package/src/reasons.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const reasons = {
|
|
|
9
9
|
variable: "This prints the value of a credential variable. Ask the user for the specific non-sensitive fact needed, or let the authorized client consume the credential without printing it.",
|
|
10
10
|
token: "This prints a Git hosting token. Use auth status without token-display flags; if authentication needs repair, ask the user to update the credential in their terminal.",
|
|
11
11
|
keychain: "This extracts a password from the macOS Keychain. State the intended use and run the authorized client that consumes it without printing it.",
|
|
12
|
+
secretPrint:
|
|
13
|
+
"This prints a stored secret or access token. Run the command that uses the credential without printing it, or ask the user to run it in their own terminal and share only the non-secret fact needed.",
|
|
12
14
|
trace: "curl verbose or trace output can print HTTP headers including Authorization. Drop -v and --trace; use a normal curl request for the needed result.",
|
|
13
15
|
upload: "This sends the contents of a credential file. Send only the required non-sensitive fields explicitly, and let the client obtain authentication from its normal credential source.",
|
|
14
16
|
ssh: "This reads private material under ~/.ssh. Search public material in the project or request the exact public key or client-config path; ask the user to inspect private material locally if a specific non-sensitive fact is needed.",
|
package/src/record.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A search option's value keeps the option's word as option:ROLE.
|
|
2
2
|
export type ValueRole = "pattern" | "patfile" | "glob" | "nglob" | "optarg";
|
|
3
3
|
export type Role = "arg" | "assign" | "precommand" | "namespace" | "program" | "code" | "option" | "path" | ValueRole | `option:${ValueRole}`;
|
|
4
|
-
export type Flag = "explicit" | "files" | "help" | "fixed" | "recursive" | "include" | "replace" | "hidden";
|
|
4
|
+
export type Flag = "explicit" | "files" | "help" | "fixed" | "recursive" | "include" | "replace" | "hidden" | "walked";
|
|
5
5
|
|
|
6
6
|
export interface Word {
|
|
7
7
|
text: string; // quotes removed; ~, $HOME and ${HOME} expanded
|
|
@@ -11,11 +11,13 @@ export interface Word {
|
|
|
11
11
|
vars: string[]; // parameter names the shell expands
|
|
12
12
|
role: Role; // set by argv.ts
|
|
13
13
|
value: string; // an option's value without its flag, set by argv.ts
|
|
14
|
+
pwd: boolean; // holds $PWD, $(pwd) or ~+, expanded to the directory the command was read in
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface Redirect {
|
|
17
18
|
direction: "in" | "out" | "herestring" | "heredoc";
|
|
18
19
|
target: string; // file, word, or heredoc body
|
|
20
|
+
globs: boolean; // the target holds an unquoted glob character
|
|
19
21
|
vars: string[]; // parameters the shell expands in the target or body
|
|
20
22
|
}
|
|
21
23
|
|
package/src/rules/appdata.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// App Data rules. macOS records a Files & Folders App Data entry whenever a
|
|
2
2
|
// process reads or enumerates another app's ~/Library data tree, so these deny
|
|
3
3
|
// those reads and the broad walks that reach them.
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { programName } from "../argv";
|
|
6
5
|
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
|
|
7
6
|
import { reasons } from "../reasons";
|
|
8
7
|
import type { Command, Request } from "../record";
|
|
@@ -34,7 +33,7 @@ export function appdataRules(req: Request): string[] {
|
|
|
34
33
|
|
|
35
34
|
function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
36
35
|
const cwd = cmd.cwd;
|
|
37
|
-
const prog = cmd.program >= 0 ?
|
|
36
|
+
const prog = cmd.program >= 0 ? programName(cmd.argv[cmd.program]!.text) : "";
|
|
38
37
|
const data = dataPrograms.has(prog);
|
|
39
38
|
// cd reads its target but walks nothing.
|
|
40
39
|
const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
@@ -42,15 +41,19 @@ function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
|
42
41
|
const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
|
|
43
42
|
const paths: { path: string; glob: boolean }[] = [];
|
|
44
43
|
for (const [i, word] of cmd.argv.entries()) {
|
|
45
|
-
if (!word.value || ["pattern", "code", "option:pattern"
|
|
44
|
+
if (!word.value || ["pattern", "code", "option:pattern"].includes(word.role)) continue;
|
|
46
45
|
if (word.role === "program" && !word.value.includes("/")) continue;
|
|
47
46
|
if (data && !word.globs && i > cmd.program) continue;
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// A value glued to its option, as in --env-file=PATH, is a path too.
|
|
48
|
+
const values = word.value.startsWith("-") && word.value.includes("=") ? [word.value, word.value.slice(word.value.indexOf("=") + 1)] : [word.value];
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
// An expansion the front end cannot resolve may well be $HOME.
|
|
51
|
+
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(value)) return appdataReason;
|
|
52
|
+
paths.push({ path: absPath(value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
|
|
53
|
+
}
|
|
51
54
|
}
|
|
52
55
|
for (const r of cmd.redirects) {
|
|
53
|
-
if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob:
|
|
56
|
+
if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob: r.globs });
|
|
54
57
|
}
|
|
55
58
|
if (paths.some(({ path, glob }) => isAppdata(path, home, glob))) return appdataReason;
|
|
56
59
|
// Shell glob expansion touches directories even when the command does not walk them.
|
package/src/rules/credentials.ts
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
// ssh -i) are not readers and stay allowed.
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { programName } from "../argv";
|
|
7
8
|
import { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
|
|
9
|
+
import { showsHidden } from "../pipeline";
|
|
8
10
|
import { reasons } from "../reasons";
|
|
9
11
|
import type { Command, Request, Word } from "../record";
|
|
10
12
|
|
|
11
13
|
const readerPrograms = new Set(
|
|
12
14
|
(
|
|
13
15
|
"cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
|
|
14
|
-
"uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack"
|
|
16
|
+
"uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack tac column pr vim vi nvim view perl ruby dd scp rsync zip ed ex hg svn sh bash zsh dash ksh wget php zgrep zless zmore"
|
|
15
17
|
).split(" "),
|
|
16
18
|
);
|
|
19
|
+
// Programs a find or fd -exec command can run to print or forward what the walk found.
|
|
20
|
+
const execReaders = new Set([...readerPrograms, "rg", "grep", "sh", "bash", "zsh", "dash", "ksh", "env", "sudo", "doas", "xargs", "command", "nohup", "timeout", "nice", "time", "stdbuf", "arch"]);
|
|
21
|
+
// git subcommands that print the content of a file, a commit, or the index.
|
|
22
|
+
const gitReaders = new Set(["show", "diff", "log", "cat-file", "blame", "annotate", "grep", "archive", "format-patch", "whatchanged", "difftool", "diff-index", "diff-tree"]);
|
|
17
23
|
const {
|
|
18
24
|
file: fileReason,
|
|
19
25
|
dump: dumpReason,
|
|
@@ -25,6 +31,7 @@ const {
|
|
|
25
31
|
ssh: sshReason,
|
|
26
32
|
grepSsh: grepSshReason,
|
|
27
33
|
hiddenSearch: hiddenSearchReason,
|
|
34
|
+
secretPrint: secretReason,
|
|
28
35
|
} = reasons;
|
|
29
36
|
|
|
30
37
|
const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
|
|
@@ -34,7 +41,7 @@ export function credentialRules(req: Request): string[] {
|
|
|
34
41
|
if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
|
|
35
42
|
if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
|
|
36
43
|
if (req.operation === "search") {
|
|
37
|
-
if (isSensitiveRoot(req.searchRoot)) denials.push(fileReason);
|
|
44
|
+
if (isSensitiveRoot(req.searchRoot, req.home)) denials.push(fileReason);
|
|
38
45
|
// A positive glob selects files even past ignore rules.
|
|
39
46
|
if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
|
|
40
47
|
}
|
|
@@ -52,7 +59,7 @@ export function credentialFilesystemRules(req: Request): string[] {
|
|
|
52
59
|
if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target && sshScopeDenied(absPath(redirect.target, cmd.cwd, req.home), req.home, false)) return [sshReason];
|
|
53
60
|
}
|
|
54
61
|
if (cmd.program < 0) continue;
|
|
55
|
-
const name =
|
|
62
|
+
const name = programName(cmd.argv[cmd.program]!.text);
|
|
56
63
|
const readsPath = ["ls", "find", "tree", "rg", "grep", "ag", "ack", "fd"].includes(name);
|
|
57
64
|
if (!readsPath && !readerPrograms.has(name)) continue;
|
|
58
65
|
const words = cmd.argv.slice(cmd.program + 1);
|
|
@@ -72,13 +79,13 @@ export function credentialFilesystemRules(req: Request): string[] {
|
|
|
72
79
|
function credentialCommand(cmd: Command, home: string): string[] {
|
|
73
80
|
const denials: string[] = [];
|
|
74
81
|
const sensitive = (word: string, glob = false, quoted = false) => isSensitive(absPath(word, cmd.cwd, home, quoted), glob);
|
|
75
|
-
for (const r of cmd.redirects) if (r.direction === "in" && sensitive(r.target)) denials.push(fileReason);
|
|
82
|
+
for (const r of cmd.redirects) if (r.direction === "in" && sensitive(r.target, r.globs)) denials.push(fileReason);
|
|
76
83
|
if (cmd.program < 0) {
|
|
77
84
|
if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
|
|
78
85
|
return denials;
|
|
79
86
|
}
|
|
80
87
|
const program = cmd.argv[cmd.program]!.text;
|
|
81
|
-
const name =
|
|
88
|
+
const name = programName(program);
|
|
82
89
|
const words = cmd.argv.slice(cmd.program + 1);
|
|
83
90
|
const args = words.map((w) => w.text);
|
|
84
91
|
// Shell variables exist only in the shell, so a path or a wrapper that
|
|
@@ -91,6 +98,36 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
91
98
|
for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
|
|
92
99
|
}
|
|
93
100
|
}
|
|
101
|
+
// A hidden-file walk piped into xargs hands the reader every dotfile it finds.
|
|
102
|
+
if (cmd.flags.has("walked") && execReaders.has(name)) denials.push(hiddenSearchReason);
|
|
103
|
+
const options = cmd.argv.slice(0, cmd.program);
|
|
104
|
+
if (cmd.wrappers.includes("xargs")) {
|
|
105
|
+
// xargs reads its arguments from the -a file.
|
|
106
|
+
for (const [i, word] of options.entries()) {
|
|
107
|
+
const file = word.text === "-a" || word.text === "--arg-file" ? options[i + 1]?.text : word.text.startsWith("--arg-file=") ? word.text.slice("--arg-file=".length) : undefined;
|
|
108
|
+
if (file && sensitive(file)) denials.push(fileReason);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Every operand, and the value of an option glued with `=`, is a file the program may read.
|
|
112
|
+
// A destination is written and a tar -C directory is entered; later operands are read relative to it.
|
|
113
|
+
const operandWords = words.filter((w) => !w.text.startsWith("-"));
|
|
114
|
+
const destination = ["cp", "rsync", "scp"].includes(name) ? operandWords.at(-1) : undefined;
|
|
115
|
+
const enters = (w: Word, i: number) => name === "tar" && (["-C", "--directory"].includes(words[i - 1]?.text ?? "") || w.text.startsWith("--directory="));
|
|
116
|
+
const readTargets = (filter?: Word) => {
|
|
117
|
+
const found: string[] = [];
|
|
118
|
+
let base = cmd.cwd;
|
|
119
|
+
for (const [i, w] of words.entries()) {
|
|
120
|
+
if (w === filter || /^--exclude=/.test(w.text)) continue;
|
|
121
|
+
const target = name === "dd" ? (w.text.startsWith("if=") ? w.text.slice(3) : "") : /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
|
|
122
|
+
if (!target || target.startsWith("-")) continue;
|
|
123
|
+
const quoted = /^['"]/.test(w.raw);
|
|
124
|
+
if (enters(w, i)) base = absPath(target, base, home, quoted);
|
|
125
|
+
// A directory that holds a listed file is read whole by tar, zip, scp and rsync.
|
|
126
|
+
if (sensitive(target, w.globs, quoted) || (!w.globs && w !== destination && !enters(w, i) && isSensitiveRoot(absPath(target, base, home, quoted), home)))
|
|
127
|
+
found.push(name === "scp" || name === "rsync" ? uploadReason : fileReason);
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
};
|
|
94
131
|
let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
|
|
95
132
|
const operands = args.filter((a) => !a.startsWith("-"));
|
|
96
133
|
switch (name) {
|
|
@@ -114,42 +151,111 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
114
151
|
if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
|
|
115
152
|
}
|
|
116
153
|
if (cmd.flags.has("files")) break;
|
|
154
|
+
// A search with no path operand reads the working directory.
|
|
155
|
+
if ((name !== "grep" || cmd.flags.has("recursive")) && !words.some((w) => w.role === "path") && isSensitiveRoot(cmd.cwd, home)) denials.push(fileReason);
|
|
117
156
|
// Search operands, pattern files and positive globs are read targets;
|
|
118
157
|
// the pattern is not.
|
|
119
158
|
for (const w of words) {
|
|
120
159
|
const quoted = /^['"]/.test(w.raw);
|
|
121
160
|
if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, quoted)) denials.push(fileReason);
|
|
122
|
-
if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted))) denials.push(fileReason);
|
|
161
|
+
if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted), home)) denials.push(fileReason);
|
|
123
162
|
if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
|
|
124
163
|
}
|
|
125
164
|
break;
|
|
126
165
|
case "gh":
|
|
127
166
|
if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
|
|
128
167
|
if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
|
|
168
|
+
// gist create and --body-file upload the files they are given.
|
|
169
|
+
denials.push(...readTargets());
|
|
129
170
|
break;
|
|
130
171
|
case "glab":
|
|
131
172
|
if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
|
|
132
173
|
break;
|
|
133
174
|
case "security":
|
|
134
|
-
if (args.
|
|
175
|
+
if (args.some((a) => /^-[A-Za-z]*[wg]/.test(a) && !a.startsWith("--")) || ["dump-keychain", "export"].includes(args[0] ?? "")) denials.push(keychainReason);
|
|
176
|
+
break;
|
|
177
|
+
// A print command gives the guard no target path, so these name the subcommand. Each is a
|
|
178
|
+
// workaround that stays until the operating system's read restrictions cover the store; do not extend the list.
|
|
179
|
+
case "gcloud":
|
|
180
|
+
if (args.includes("print-access-token") || args.includes("print-identity-token")) denials.push(secretReason);
|
|
181
|
+
break;
|
|
182
|
+
case "az":
|
|
183
|
+
if (args[0] === "account" && args[1] === "get-access-token") denials.push(secretReason);
|
|
184
|
+
break;
|
|
185
|
+
case "aws":
|
|
186
|
+
if (args[0] === "configure" && args[1] === "get" && secretName(args[2] ?? "")) denials.push(secretReason);
|
|
187
|
+
break;
|
|
188
|
+
case "npm":
|
|
189
|
+
if (args[0] === "config" && args[1] === "get" && /auth|token|password/i.test(args[2] ?? "")) denials.push(secretReason);
|
|
190
|
+
break;
|
|
191
|
+
case "kubectl":
|
|
192
|
+
if (args[0] === "config" && args[1] === "view" && args.includes("--raw")) denials.push(secretReason);
|
|
193
|
+
break;
|
|
194
|
+
case "gpg":
|
|
195
|
+
if (args.some((a) => /^--export-secret-(sub)?keys$/.test(a))) denials.push(secretReason);
|
|
135
196
|
break;
|
|
136
197
|
case "curl":
|
|
137
198
|
denials.push(...curlRules(args, sensitive));
|
|
138
199
|
break;
|
|
200
|
+
case "git": {
|
|
201
|
+
const git = gitCommand(words);
|
|
202
|
+
// A rev:path operand names a file in a commit or the index; pathspecs may be globs.
|
|
203
|
+
if (git.sub === "credential" && git.operands[0]?.text === "fill") denials.push(secretReason);
|
|
204
|
+
for (const w of git.operands) {
|
|
205
|
+
if (w.text.startsWith("-")) continue;
|
|
206
|
+
if (git.sub === "grep" && isSensitiveRoot(absPath(w.text, cmd.cwd, home, /^['"]/.test(w.raw)), home)) denials.push(fileReason);
|
|
207
|
+
if (sensitive(w.text, true) || (w.text.includes(":") && sensitive(w.text.slice(w.text.indexOf(":") + 1), true))) denials.push(fileReason);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "find":
|
|
212
|
+
case "fd":
|
|
213
|
+
if (execPrograms(name, words).some((exec) => execReaders.has(programName(exec))) && (name === "find" || showsHidden(words))) denials.push(hiddenSearchReason);
|
|
214
|
+
break;
|
|
139
215
|
default:
|
|
140
216
|
if (!readerPrograms.has(name)) return denials;
|
|
141
|
-
|
|
142
|
-
for (const w of words) {
|
|
143
|
-
if (w === filter) continue;
|
|
144
|
-
const target = /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
|
|
145
|
-
if (target && !target.startsWith("-") && sensitive(target, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
|
|
146
|
-
}
|
|
217
|
+
denials.push(...readTargets(name === "jq" ? jqFilter(words) : undefined));
|
|
147
218
|
}
|
|
148
219
|
// A heredoc or here-string body is standard input the command prints.
|
|
149
220
|
if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
|
|
150
221
|
return denials;
|
|
151
222
|
}
|
|
152
223
|
|
|
224
|
+
// The subcommand and the operands that can name a file; git grep's pattern is not one.
|
|
225
|
+
function gitCommand(words: Word[]): { sub: string; operands: Word[] } {
|
|
226
|
+
let i = 0;
|
|
227
|
+
while (i < words.length && words[i]!.text.startsWith("-")) i += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(words[i]!.text) ? 2 : 1;
|
|
228
|
+
const sub = words[i]?.text ?? "";
|
|
229
|
+
let operands = words.slice(i + 1);
|
|
230
|
+
if (sub === "grep") {
|
|
231
|
+
const rest: Word[] = [];
|
|
232
|
+
let patterned = false;
|
|
233
|
+
let options = true;
|
|
234
|
+
for (let n = 0; n < operands.length; n++) {
|
|
235
|
+
const text = operands[n]!.text;
|
|
236
|
+
if (options && text === "--") options = false;
|
|
237
|
+
else if (options && text === "-e") {
|
|
238
|
+
patterned = true;
|
|
239
|
+
n++;
|
|
240
|
+
} else if (options && text === "-f") {
|
|
241
|
+
// The file holds the patterns; git reads it.
|
|
242
|
+
patterned = true;
|
|
243
|
+
if (operands[n + 1]) rest.push(operands[++n]!);
|
|
244
|
+
} else if (options && text.startsWith("-")) rest.push(operands[n]!);
|
|
245
|
+
else if (!patterned) patterned = true;
|
|
246
|
+
else rest.push(operands[n]!);
|
|
247
|
+
}
|
|
248
|
+
operands = rest;
|
|
249
|
+
}
|
|
250
|
+
return { sub, operands: sub === "credential" || gitReaders.has(sub) ? operands : [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The commands a find -exec or an fd -x runs on the matches; find takes several.
|
|
254
|
+
function execPrograms(name: string, words: Word[]): string[] {
|
|
255
|
+
const flags = name === "find" ? ["-exec", "-execdir", "-ok", "-okdir"] : ["-x", "-X", "--exec", "--exec-batch"];
|
|
256
|
+
return words.flatMap((word, i) => (flags.includes(word.text) && words[i + 1] ? [words[i + 1]!.text] : []));
|
|
257
|
+
}
|
|
258
|
+
|
|
153
259
|
function jqFilter(words: Word[]): Word | undefined {
|
|
154
260
|
if (words.some((word) => word.text === "-f" || word.text === "--from-file")) return undefined;
|
|
155
261
|
return words.find((word) => !word.text.startsWith("-"));
|
package/src/words.ts
CHANGED
|
@@ -6,13 +6,16 @@ import type { Word } from "./record";
|
|
|
6
6
|
|
|
7
7
|
const type = sh.syntax.NodeType;
|
|
8
8
|
|
|
9
|
-
export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, visit: (part: Node, names: string[]) => void): Word {
|
|
9
|
+
export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, pwd: string, visit: (part: Node, names: string[]) => void): Word {
|
|
10
10
|
const text = (part: Node) => slice(part.Pos().Offset(), part.End().Offset());
|
|
11
|
-
const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "" };
|
|
11
|
+
const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "", pwd: false };
|
|
12
12
|
const expansion = (part: Node) => {
|
|
13
13
|
const param = part as ParamExp;
|
|
14
14
|
const plain = type(part) === "ParamExp" && !(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
|
|
15
|
-
const
|
|
15
|
+
const name = plain ? param.Param!.Value : "";
|
|
16
|
+
const printsPwd = name === "PWD" || (type(part) === "CmdSubst" && /^(\$\(|`)\s*pwd(\s+-[LP])?\s*(\)|`)$/.test(text(part)));
|
|
17
|
+
const known = printsPwd ? pwd : name === "HOME" ? home : plain ? vars.get(name) : undefined;
|
|
18
|
+
out.pwd ||= printsPwd;
|
|
16
19
|
out.text += known ?? text(part);
|
|
17
20
|
out.expands ||= known === undefined;
|
|
18
21
|
visit(part, out.vars);
|
|
@@ -21,7 +24,16 @@ export function readWord(node: WordNode, slice: (start: number, end: number) =>
|
|
|
21
24
|
const kind = type(part);
|
|
22
25
|
if (kind === "Lit") {
|
|
23
26
|
let value = (part as Lit).Value;
|
|
24
|
-
if (index === 0
|
|
27
|
+
if (index === 0 && /^~\+(\/|$)/.test(value)) {
|
|
28
|
+
value = pwd + value.slice(2);
|
|
29
|
+
out.pwd = true;
|
|
30
|
+
} else if (index === 0) value = expandHome(value, home);
|
|
31
|
+
// Bun expands {a,b} but not the sequence {a..z}, so read a sequence as a wildcard.
|
|
32
|
+
const sequence = /(?<!\\)\{(?:-?\d+|[A-Za-z])\.\.(?:-?\d+|[A-Za-z])(?:\.\.-?\d+)?\}/g;
|
|
33
|
+
if (sequence.test(value)) {
|
|
34
|
+
value = value.replace(sequence, "*");
|
|
35
|
+
out.globs = true;
|
|
36
|
+
}
|
|
25
37
|
if (Bun.$.braces(value).length > 1) out.globs = true;
|
|
26
38
|
for (let i = 0; i < value.length; i++) {
|
|
27
39
|
if (value[i] === "\\") {
|