@loophubs/agent-guard 0.2.0 → 0.4.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 +69 -22
- package/src/core.ts +7 -5
- package/src/docker-targets.ts +87 -0
- package/src/frontend.ts +54 -18
- package/src/git-targets.ts +125 -0
- package/src/guard.ts +4 -1
- package/src/interpreters.ts +61 -0
- package/src/links.ts +66 -59
- package/src/paths.ts +85 -66
- package/src/pipeline.ts +120 -14
- package/src/probes.ts +5 -0
- package/src/programs.ts +341 -0
- package/src/reasons.ts +5 -1
- package/src/record.ts +33 -2
- package/src/rules/appdata.ts +29 -59
- package/src/rules/credentials.ts +38 -219
- package/src/rules/secrets.ts +143 -0
- package/src/search-roles.ts +5 -0
- package/src/search-targets.ts +33 -0
- package/src/ssh-targets.ts +46 -0
- package/src/ssh.ts +65 -0
- package/src/targets.ts +151 -0
- package/src/words.ts +16 -4
package/src/programs.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// How each program obtains the paths it touches and what it does with them.
|
|
2
|
+
// A program with no entry reads every path it is handed.
|
|
3
|
+
import { dockerTargets } from "./docker-targets";
|
|
4
|
+
import { gitTargets } from "./git-targets";
|
|
5
|
+
import type { Command, Effect, Target, Word } from "./record";
|
|
6
|
+
import { searchTargets } from "./search-targets";
|
|
7
|
+
import { sshTargets } from "./ssh-targets";
|
|
8
|
+
|
|
9
|
+
type Walk = Target["walk"];
|
|
10
|
+
|
|
11
|
+
interface Make {
|
|
12
|
+
via?: Target["via"];
|
|
13
|
+
base?: string | undefined;
|
|
14
|
+
glob?: boolean;
|
|
15
|
+
sends?: boolean;
|
|
16
|
+
walk?: Walk;
|
|
17
|
+
search?: boolean;
|
|
18
|
+
quoted?: boolean; // the path is literal: no ~ expansion
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Context {
|
|
22
|
+
cmd: Command;
|
|
23
|
+
words: Word[];
|
|
24
|
+
walk: Walk;
|
|
25
|
+
claimed: Set<Word>;
|
|
26
|
+
make: (path: string, word: Word | undefined, effect: Effect, options?: Make) => Target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProgramSpec {
|
|
30
|
+
operands?: Effect; // default "read": a program the table does not model reads every path it is handed
|
|
31
|
+
walk?: Walk | { recursive: RegExp }; // default "visible"; { recursive } is "visible" only when an option matches
|
|
32
|
+
options?: Record<string, Effect>; // effect of an option's value, glued with = or separate
|
|
33
|
+
last?: Effect; // the final operand
|
|
34
|
+
sends?: boolean; // reads leave the machine
|
|
35
|
+
remote?: RegExp; // an operand on another machine is a name; with `sends`, reads leave only when an operand is remote
|
|
36
|
+
cwd?: "cwd" | "scan"; // a command with no path operand lists its working directory
|
|
37
|
+
targets?: (ctx: Context) => Target[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_EFFECT: Effect = "read";
|
|
41
|
+
|
|
42
|
+
// Programs that read only the paths they are handed, so a command that names another directory does not read the working directory.
|
|
43
|
+
export const readers = (
|
|
44
|
+
"cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
|
|
45
|
+
"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"
|
|
46
|
+
).split(" ");
|
|
47
|
+
export const dataPrograms = "echo printf print : true false export set unset typeset declare local".split(" ");
|
|
48
|
+
// Metadata, counts and digests: no content reaches the output.
|
|
49
|
+
const meta = "stat test [ chmod chown chgrp chflags touch rm rmdir mkdir mv ln wc file shasum sha1sum sha256sum md5 md5sum cksum realpath readlink basename dirname".split(" ");
|
|
50
|
+
const noWalk = new Set("stat test [ mkdir mv".split(" "));
|
|
51
|
+
|
|
52
|
+
// The value of these options is consumed by the program, not read into the output.
|
|
53
|
+
const intoDirectory: Record<string, Effect> = { "-t": "write", "--target-directory": "write" };
|
|
54
|
+
export const globalOptions: Record<string, Effect> = { "--exclude": "name", "--exclude-dir": "name", "--include": "name" };
|
|
55
|
+
|
|
56
|
+
const remote = /^(rsync:\/\/|([^/@:]+@)?[^/@:]+:)/;
|
|
57
|
+
const lsRecursive = /^(--recursive$|-[^-]*R)/;
|
|
58
|
+
const anyRecursive = /^(--recursive$|-[^-]*[rR])/;
|
|
59
|
+
|
|
60
|
+
// curl's short options that take a value; the rest of a cluster is that value.
|
|
61
|
+
export const curlValueLetters = "AbcCdDeEFHKmoPQrTtuUwxXyYz";
|
|
62
|
+
const curlDataOptions = /^(d|data|data-ascii|data-binary|data-urlencode|json|H|header|proxy-header|url-query|variable)$/;
|
|
63
|
+
|
|
64
|
+
// The options whose value is a file curl writes; `-` sends the output to standard output.
|
|
65
|
+
const curlWrites = ["o", "output", "D", "dump-header", "c", "cookie-jar", "etag-save", "libcurl", "stderr", "hsts", "alt-svc", "trace", "trace-ascii", "ssl-sessions"];
|
|
66
|
+
|
|
67
|
+
function curlTargets({ words, make, claimed }: Context): Target[] {
|
|
68
|
+
const targets: Target[] = [];
|
|
69
|
+
// The text after an @ or < inside a value is literal: neither the shell nor curl expands a ~ there.
|
|
70
|
+
const read = (path: string, word: Word, quoted?: boolean, sends = true) => {
|
|
71
|
+
claimed.add(word);
|
|
72
|
+
targets.push(make(path, word, "read", { via: "option", sends, ...(quoted && { quoted }) }));
|
|
73
|
+
};
|
|
74
|
+
// -O and --remote-name save each download under its remote name, in the working directory or the --output-dir.
|
|
75
|
+
let remoteName = false;
|
|
76
|
+
let outputDir: Word | undefined;
|
|
77
|
+
for (let i = 0; i < words.length; i++) {
|
|
78
|
+
const word = words[i]!;
|
|
79
|
+
const text = word.text;
|
|
80
|
+
if (text === "--") break;
|
|
81
|
+
remoteName ||= /^(--remote-name(-all)?|-[^-]*O)$/.test(text);
|
|
82
|
+
if (/^--output-dir(=|$)/.test(text)) {
|
|
83
|
+
outputDir = text.includes("=") ? word : words[++i];
|
|
84
|
+
if (outputDir) claimed.add(outputDir);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// curl reads `file:path` and `file://path` alike, decodes %XX, and expands `[a-z]` and `{a,b}` ranges.
|
|
88
|
+
const url = /^(--url=)?(file:.*)$/is.exec(text);
|
|
89
|
+
if (url) {
|
|
90
|
+
claimed.add(word);
|
|
91
|
+
const path = url[2]!.replace(/^file:(\/\/)?/i, "").replace(/%([0-9a-f]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
|
92
|
+
targets.push(make(path, word, "read", { via: "operand", glob: /[[{]/.test(path) }));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
let key = "";
|
|
96
|
+
let value = "";
|
|
97
|
+
const long =
|
|
98
|
+
/^--(data|data-ascii|data-binary|data-urlencode|json|form|header|proxy-header|url-query|variable|upload-file|config|output|dump-header|write-out|cookie|etag-compare|cookie-jar|etag-save|libcurl|stderr|hsts|alt-svc|trace|trace-ascii|ssl-sessions)(=|$)/s.exec(
|
|
99
|
+
text,
|
|
100
|
+
);
|
|
101
|
+
if (long) {
|
|
102
|
+
key = long[1]!;
|
|
103
|
+
value = text.includes("=") ? text.slice(text.indexOf("=") + 1) : (words[++i]?.text ?? "");
|
|
104
|
+
} else if (/^-[^-]/.test(text)) {
|
|
105
|
+
for (let k = 1; k < text.length; k++) {
|
|
106
|
+
if (!curlValueLetters.includes(text[k]!)) continue;
|
|
107
|
+
key = text[k]!;
|
|
108
|
+
value = text.slice(k + 1) || (words[++i]?.text ?? "");
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const from = words[i]!;
|
|
113
|
+
if (curlDataOptions.test(key)) {
|
|
114
|
+
if (/@./s.test(value)) read(value.slice(value.indexOf("@") + 1), from, true);
|
|
115
|
+
} else if (key === "F" || key === "form") {
|
|
116
|
+
const file = value.slice(value.indexOf("=") + 1).replace(/^[@<]/, "");
|
|
117
|
+
// A quoted file name may hold a `;`.
|
|
118
|
+
read(/^"([^"]*)"/.exec(file)?.[1] ?? file.split(";")[0]!, from, true);
|
|
119
|
+
} else if (["T", "upload-file", "K", "config", "etag-compare"].includes(key)) read(value, from);
|
|
120
|
+
// The response is written out with the text of a `@file` template; `@-` is standard input.
|
|
121
|
+
else if ((key === "w" || key === "write-out") && /^@./.test(value) && value !== "@-") read(value.slice(1), from, true, false);
|
|
122
|
+
// A cookie value with no `=` names a cookie file that curl parses itself.
|
|
123
|
+
else if ((key === "b" || key === "cookie") && value && !value.includes("=")) {
|
|
124
|
+
claimed.add(from);
|
|
125
|
+
targets.push(make(value, from, "use", { via: "option" }));
|
|
126
|
+
} else if (curlWrites.includes(key) && value) {
|
|
127
|
+
claimed.add(from);
|
|
128
|
+
// `-o -` sends the response to standard output, so it names no file.
|
|
129
|
+
if (value !== "-") targets.push(make(value, from, "write", { via: "option" }));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (remoteName) targets.push(make(outputDir ? outputDir.text.replace(/^--output-dir=/, "") : ".", outputDir, "write", { via: "option", walk: "none" }));
|
|
133
|
+
return targets;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ddTargets({ words, make, claimed }: Context): Target[] {
|
|
137
|
+
const targets: Target[] = [];
|
|
138
|
+
for (const word of words) {
|
|
139
|
+
claimed.add(word);
|
|
140
|
+
if (word.text.startsWith("if=")) targets.push(make(word.text.slice(3), word, "read", { via: "option" }));
|
|
141
|
+
else if (word.text.startsWith("of=")) targets.push(make(word.text.slice(3), word, "write", { via: "option" }));
|
|
142
|
+
}
|
|
143
|
+
return targets;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A cluster of tar's short options, such as `-czf`, or the first word without a dash.
|
|
147
|
+
const tarCluster = (word: Word, i: number) => !word.text.startsWith("--") && (word.text.startsWith("-") || i === 0);
|
|
148
|
+
|
|
149
|
+
// The archive is the value of -f or --file: the rest of a cluster such as `-cfout.tar`, or the next word.
|
|
150
|
+
function tarArchive(words: Word[]): { word: Word; path: string } | undefined {
|
|
151
|
+
for (const [i, word] of words.entries()) {
|
|
152
|
+
const text = word.text;
|
|
153
|
+
if (text.startsWith("--file=")) return { word, path: text.slice("--file=".length) };
|
|
154
|
+
const next = words[i + 1];
|
|
155
|
+
if (text === "--file" && next) return { word: next, path: next.text };
|
|
156
|
+
const cluster = tarCluster(word, i) ? /^-?[^Cf]*f(.*)$/s.exec(text) : null;
|
|
157
|
+
if (cluster?.[1]) return { word, path: cluster[1] };
|
|
158
|
+
if (cluster && next) return { word: next, path: next.text };
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A directory given to -C is entered, and later operands are relative to it.
|
|
164
|
+
function tarTargets({ words, make, claimed }: Context): Target[] {
|
|
165
|
+
const targets: Target[] = [];
|
|
166
|
+
let base: string | undefined;
|
|
167
|
+
const creates = words.some((word, i) => word.text === "--create" || (tarCluster(word, i) && /^-?[^CfT]*c/.test(word.text)));
|
|
168
|
+
// Extraction writes into the directory -C names, so the directory is judged as a write.
|
|
169
|
+
const extracts = words.some((word, i) => /^--(extract|get)$/.test(word.text) || (tarCluster(word, i) && /^-?[^CfT]*x/.test(word.text)));
|
|
170
|
+
const archive = tarArchive(words);
|
|
171
|
+
if (archive) {
|
|
172
|
+
claimed.add(archive.word);
|
|
173
|
+
targets.push(make(archive.path, archive.word, creates ? "write" : "read", { via: "option" }));
|
|
174
|
+
}
|
|
175
|
+
for (const [i, word] of words.entries()) {
|
|
176
|
+
const text = word.text;
|
|
177
|
+
if (word === archive?.word) continue;
|
|
178
|
+
if (/^--exclude=/.test(text)) {
|
|
179
|
+
claimed.add(word);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// The directory is the word after -C, --directory or --cd, or the value glued to them, as in `-C/dir` and `-xC/dir`.
|
|
183
|
+
const glued = /^(?:--directory=|-[^-]*C)(.+)$/s.exec(text)?.[1];
|
|
184
|
+
const enters = glued !== undefined || /^(--directory|--cd|-[^-]*C)$/.test(words[i - 1]?.text ?? "");
|
|
185
|
+
if (!enters) {
|
|
186
|
+
if (base !== undefined && !text.startsWith("-")) {
|
|
187
|
+
claimed.add(word);
|
|
188
|
+
targets.push(make(text, word, "read", { via: "operand", base }));
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
claimed.add(word);
|
|
193
|
+
const dir = glued ?? text;
|
|
194
|
+
const target = make(dir, word, extracts ? "write" : "enter", { via: "option", base });
|
|
195
|
+
targets.push(target);
|
|
196
|
+
base = target.path;
|
|
197
|
+
}
|
|
198
|
+
// Without -C the members land in the working directory, unless -O sends them to stdout.
|
|
199
|
+
const toStdout = words.some((word, i) => word.text === "--to-stdout" || (tarCluster(word, i) && /^-?[^CfT]*O/.test(word.text)));
|
|
200
|
+
if (extracts && base === undefined && !toStdout) targets.push(make(".", undefined, "write", { via: "option", walk: "none" }));
|
|
201
|
+
return targets;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const wgetShort: Record<string, string> = { i: "input-file", O: "output-document", e: "execute", P: "directory-prefix", o: "output-file", a: "append-output" };
|
|
205
|
+
// The options whose value is written: the download, the log, or the directory the downloads go to.
|
|
206
|
+
const wgetWrites = ["output-document", "output-file", "append-output", "directory-prefix", "save-cookies", "warc-file", "hsts-file"];
|
|
207
|
+
// wgetrc command names ignore case, underscores and hyphens.
|
|
208
|
+
const wgetrc: Record<string, string> = {
|
|
209
|
+
postfile: "post-file",
|
|
210
|
+
bodyfile: "body-file",
|
|
211
|
+
input: "input-file",
|
|
212
|
+
outputdocument: "output-document",
|
|
213
|
+
logfile: "output-file",
|
|
214
|
+
dirprefix: "directory-prefix",
|
|
215
|
+
loadcookies: "load-cookies",
|
|
216
|
+
savecookies: "save-cookies",
|
|
217
|
+
warcfile: "warc-file",
|
|
218
|
+
hstsfile: "hsts-file",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// The file that holds the request body leaves the machine, as can a wgetrc file that names one, and the output document is written; `-e` runs a wgetrc command that can name either.
|
|
222
|
+
function wgetTargets({ words, make, claimed }: Context): Target[] {
|
|
223
|
+
const targets: Target[] = [];
|
|
224
|
+
// A download lands in the working directory unless -O or a directory prefix says otherwise.
|
|
225
|
+
let placed = false;
|
|
226
|
+
for (const [i, word] of words.entries()) {
|
|
227
|
+
const text = word.text;
|
|
228
|
+
if (text === "--spider") placed = true;
|
|
229
|
+
const long = /^--(post-file|body-file|input-file|output-document|output-file|append-output|directory-prefix|save-cookies|warc-file|hsts-file|execute|config|load-cookies)(=|$)/.exec(text);
|
|
230
|
+
// The value is glued to the flag (-i.env) or follows it.
|
|
231
|
+
const short = /^-[A-Za-z]*?([ieOPoa])(.*)$/.exec(text);
|
|
232
|
+
let key = long?.[1] ?? wgetShort[short?.[1] ?? ""];
|
|
233
|
+
if (!key) continue;
|
|
234
|
+
const glued = long ? text.includes("=") : !!short?.[2];
|
|
235
|
+
const value = glued ? word : words[i + 1];
|
|
236
|
+
if (!value) continue;
|
|
237
|
+
let path = !glued ? value.text : long ? text.slice(text.indexOf("=") + 1) : short![2]!;
|
|
238
|
+
if (key === "execute") {
|
|
239
|
+
const command = /^\s*([A-Za-z_-]+)\s*=\s*(.*)$/.exec(path);
|
|
240
|
+
const name = wgetrc[command?.[1]!.toLowerCase().replace(/[_-]/g, "") ?? ""];
|
|
241
|
+
if (!command || !name) continue;
|
|
242
|
+
key = name;
|
|
243
|
+
path = command[2]!;
|
|
244
|
+
}
|
|
245
|
+
claimed.add(value);
|
|
246
|
+
placed ||= key === "output-document" || key === "directory-prefix";
|
|
247
|
+
// `-O -` writes the document to standard output, so it names no file.
|
|
248
|
+
if (key === "output-document" && path === "-") continue;
|
|
249
|
+
targets.push(make(path, value, wgetWrites.includes(key) ? "write" : "read", { via: "option", walk: "none", sends: ["post-file", "body-file", "config"].includes(key) }));
|
|
250
|
+
}
|
|
251
|
+
if (!placed) targets.push(make(".", undefined, "write", { via: "option", walk: "none" }));
|
|
252
|
+
return targets;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const findExpression = /^(-|\(|!)/;
|
|
256
|
+
const findExec = ["-exec", "-execdir", "-ok", "-okdir"];
|
|
257
|
+
|
|
258
|
+
// The paths before find's first expression word, including the one `-f` names.
|
|
259
|
+
export function findRoots(words: Word[]): Word[] {
|
|
260
|
+
const roots: Word[] = [];
|
|
261
|
+
let i = 0;
|
|
262
|
+
while (words[i]) {
|
|
263
|
+
if (words[i]!.text === "-f" && words[i + 1]) roots.push(words[(i += 2) - 1]!);
|
|
264
|
+
else if (words[i]!.text === "--" || /^-[HLPEXxdsO]/.test(words[i]!.text)) i++;
|
|
265
|
+
else break;
|
|
266
|
+
}
|
|
267
|
+
for (; words[i] && !findExpression.test(words[i]!.text); i++) roots.push(words[i]!);
|
|
268
|
+
return roots;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function findTargets({ words, make, claimed }: Context): Target[] {
|
|
272
|
+
const targets: Target[] = [];
|
|
273
|
+
for (const root of findRoots(words)) {
|
|
274
|
+
claimed.add(root);
|
|
275
|
+
targets.push(make(root.text, root, "list", { via: "operand", walk: "hidden" }));
|
|
276
|
+
}
|
|
277
|
+
for (const word of words) if (!findExec.includes(word.text)) claimed.add(word);
|
|
278
|
+
return targets;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The first operand is the filter, unless -f names a file that holds it; yq names its evaluation command before the filter.
|
|
282
|
+
const filterTargets =
|
|
283
|
+
(commands: string[]) =>
|
|
284
|
+
({ words, claimed }: Context): Target[] => {
|
|
285
|
+
const operands = words.filter((word) => !word.text.startsWith("-"));
|
|
286
|
+
const filter = words.some((word) => word.text === "-f" || word.text === "--from-file") ? undefined : operands[commands.includes(operands[0]?.text ?? "") ? 1 : 0];
|
|
287
|
+
if (filter) claimed.add(filter);
|
|
288
|
+
return [];
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const specs = new Map<string, ProgramSpec>();
|
|
292
|
+
for (const name of readers) specs.set(name, {});
|
|
293
|
+
for (const name of dataPrograms) specs.set(name, { operands: "name", walk: "none" });
|
|
294
|
+
for (const name of meta) specs.set(name, { operands: "meta", ...(noWalk.has(name) && { walk: "none" as const }) });
|
|
295
|
+
for (const name of ["cd", "pushd", "popd"]) specs.set(name, { operands: "enter", walk: "none" });
|
|
296
|
+
specs.set("jq", { targets: filterTargets([]) });
|
|
297
|
+
specs.set("yq", { targets: filterTargets(["eval", "e", "eval-all", "ea"]) });
|
|
298
|
+
specs.set("gh", {});
|
|
299
|
+
specs.set("ls", { operands: "list", walk: { recursive: lsRecursive }, cwd: "cwd" });
|
|
300
|
+
specs.set("tree", { operands: "list", cwd: "scan" });
|
|
301
|
+
specs.set("du", { operands: "list", cwd: "scan" });
|
|
302
|
+
specs.set("cp", { options: intoDirectory, last: "write", walk: { recursive: anyRecursive } });
|
|
303
|
+
specs.set("dd", { walk: "none", targets: ddTargets });
|
|
304
|
+
specs.set("tar", { targets: tarTargets });
|
|
305
|
+
specs.set("tee", { operands: "write" });
|
|
306
|
+
specs.set("install", { options: intoDirectory, last: "write" });
|
|
307
|
+
specs.set("scp", { last: "write", sends: true, remote, targets: sshTargets("scp") });
|
|
308
|
+
specs.set("sftp", { targets: sshTargets("sftp") });
|
|
309
|
+
specs.set("rsync", { options: { "--files-from": "read", "--exclude-from": "read", "--include-from": "read" }, last: "write", sends: true, remote });
|
|
310
|
+
specs.set("wget", {
|
|
311
|
+
operands: "name",
|
|
312
|
+
options: { "--ca-certificate": "use", "--ca-directory": "use", "--certificate": "use", "--private-key": "use", "--crl-file": "use", "--random-file": "use" },
|
|
313
|
+
targets: wgetTargets,
|
|
314
|
+
});
|
|
315
|
+
// The value of these options is a certificate, key or list file that curl uses itself.
|
|
316
|
+
const curlUsedFiles =
|
|
317
|
+
"--cacert --capath --cert --key -E --netrc-file --crlfile --egd-file --knownhosts --proxy-cacert --proxy-capath --proxy-cert --proxy-crlfile --proxy-key --random-file --pubkey --pinnedpubkey --proxy-pinnedpubkey --unix-socket";
|
|
318
|
+
specs.set("curl", { operands: "name", options: Object.fromEntries(curlUsedFiles.split(" ").map((option) => [option, "use" as const])), targets: curlTargets });
|
|
319
|
+
specs.set("git", { targets: gitTargets });
|
|
320
|
+
specs.set("docker", { operands: "name", options: { "--env-file": "use" }, targets: dockerTargets });
|
|
321
|
+
for (const name of ["node", "bun", "deno"]) specs.set(name, { options: { "--env-file": "use" } });
|
|
322
|
+
specs.set("kubectl", { options: { "--kubeconfig": "use" } });
|
|
323
|
+
specs.set("ssh", { operands: "name", targets: sshTargets("ssh") });
|
|
324
|
+
specs.set("ssh-add", { operands: "use" });
|
|
325
|
+
specs.set("ssh-keygen", { options: { "-f": "use" } });
|
|
326
|
+
specs.set("dotenvx", { options: { "-f": "use", "--file": "use", "--env-file": "use" } });
|
|
327
|
+
for (const name of ["npm", "pnpm", "yarn"]) specs.set(name, { options: { "--userconfig": "use" } });
|
|
328
|
+
for (const name of ["rg", "grep", "ag", "ack"]) specs.set(name, { targets: (ctx) => searchTargets(name, ctx) });
|
|
329
|
+
specs.set("find", { operands: "list", cwd: "scan", targets: findTargets });
|
|
330
|
+
specs.set("fd", { operands: "list", cwd: "scan" });
|
|
331
|
+
|
|
332
|
+
export function specFor(name: string): ProgramSpec {
|
|
333
|
+
return specs.get(name) ?? {};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function walkOf(spec: ProgramSpec, name: string, words: Word[]): Walk {
|
|
337
|
+
const { walk } = spec;
|
|
338
|
+
if (typeof walk === "object") return words.some((word) => walk.recursive.test(word.text)) ? "visible" : "none";
|
|
339
|
+
if (name === "git" && words.some((word) => word.text === "config")) return "none";
|
|
340
|
+
return walk ?? "visible";
|
|
341
|
+
}
|
package/src/reasons.ts
CHANGED
|
@@ -3,12 +3,16 @@ export const reasons = {
|
|
|
3
3
|
appdata:
|
|
4
4
|
"This reads a protected macOS app-data directory. Name a specific non-sensitive file under ~/Library/Application Support instead, or ask the user to inspect the protected file and share the needed fact.",
|
|
5
5
|
broad: "A scan rooted at the home directory or ~/Library reaches every app-data entry. Scope the scan to a project path.",
|
|
6
|
-
file: "This reads a credential or environment file.
|
|
6
|
+
file: "This reads a credential or environment file. If a client the guard models only needs to use the file, pass it through that program's own option, such as `--env-file`, `--kubeconfig`, or `ssh -i`; the guard does not control what the client does with the contents. Otherwise read a non-sensitive config file, or ask the user to inspect the file and share only the fact needed.",
|
|
7
|
+
codeFile:
|
|
8
|
+
"This inline code names a credential or environment file. To write text that mentions the file, use the Write or Edit tool; to run code that needs its values, pass the file through a modelled runtime's option, such as `node --env-file=.env`, though the guard does not control what the runtime does with the contents; otherwise ask the user to inspect the file and share only the fact needed.",
|
|
7
9
|
hiddenSearch: "A recursive search that includes hidden files can read credentials. Use default rg on a project path, or search an exact non-sensitive file without recursive or hidden-file flags.",
|
|
8
10
|
dump: "This dumps environment or shell variables, including secrets. Name the non-sensitive variable needed and read only that variable.",
|
|
9
11
|
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
12
|
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
13
|
keychain: "This extracts a password from the macOS Keychain. State the intended use and run the authorized client that consumes it without printing it.",
|
|
14
|
+
secretPrint:
|
|
15
|
+
"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
16
|
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
17
|
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
18
|
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
|
@@ -11,14 +11,38 @@ 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
|
|
|
24
|
+
export type Effect = "read" | "write" | "list" | "meta" | "use" | "enter" | "name";
|
|
25
|
+
|
|
26
|
+
// The file names a walk hands to a command through find -exec, fd -x or xargs.
|
|
27
|
+
export interface Items {
|
|
28
|
+
root: string;
|
|
29
|
+
hidden: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A path a command touches, with what the command does to it.
|
|
33
|
+
export interface Target {
|
|
34
|
+
path: string; // absolute; ~, $HOME and $PWD expanded when unquoted, resolved against the command's cwd or a tar -C base
|
|
35
|
+
unresolved: string; // the same path before `..` is folded, for the symlink walk
|
|
36
|
+
glob: boolean; // the shell expands it before the program runs
|
|
37
|
+
effect: Effect;
|
|
38
|
+
walk: "none" | "visible" | "hidden"; // a read or list of a directory reaches what is under it; hidden includes dotfiles
|
|
39
|
+
sends: boolean; // the program transmits what it reads
|
|
40
|
+
expands: boolean; // the word held an expansion the front end could not resolve
|
|
41
|
+
via: "tool" | "operand" | "option" | "redirect" | "items" | "cwd" | "scan" | "code";
|
|
42
|
+
search: boolean; // root of a content search
|
|
43
|
+
command: number; // index of the command in Request.commands, -1 for a tool request
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
export interface Command {
|
|
23
47
|
argv: Word[];
|
|
24
48
|
redirects: Redirect[];
|
|
@@ -27,11 +51,18 @@ export interface Command {
|
|
|
27
51
|
wrappers: string[];
|
|
28
52
|
shell: boolean; // the program runs in this shell rather than behind a wrapper
|
|
29
53
|
flags: Set<Flag>;
|
|
54
|
+
items?: Items;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Text the front end cannot structure into commands, such as the code an interpreter runs.
|
|
58
|
+
export interface Fragment {
|
|
59
|
+
text: string;
|
|
60
|
+
cwd: string;
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
export interface Script {
|
|
33
64
|
commands: Command[];
|
|
34
|
-
uninspectable:
|
|
65
|
+
uninspectable: Fragment[];
|
|
35
66
|
parseFailed: boolean;
|
|
36
67
|
}
|
|
37
68
|
|
|
@@ -50,6 +81,6 @@ export interface Request {
|
|
|
50
81
|
searchRoot: string;
|
|
51
82
|
glob: string;
|
|
52
83
|
commands: Command[];
|
|
53
|
-
uninspectable:
|
|
84
|
+
uninspectable: Fragment[];
|
|
54
85
|
parseFailed: boolean;
|
|
55
86
|
}
|
package/src/rules/appdata.ts
CHANGED
|
@@ -1,74 +1,44 @@
|
|
|
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
|
-
|
|
6
|
-
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
|
|
4
|
+
import { appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
|
|
7
5
|
import { reasons } from "../reasons";
|
|
8
|
-
import type {
|
|
6
|
+
import type { Request, Target } from "../record";
|
|
9
7
|
|
|
10
8
|
const { appdata: appdataReason, broad: broadReason } = reasons;
|
|
11
9
|
|
|
12
10
|
const trees = appdataTrees.join("|");
|
|
13
|
-
// These print or assign their arguments; only a glob the shell expands before
|
|
14
|
-
// they run reads a directory.
|
|
15
|
-
export const dataPrograms = new Set(["echo", "printf", "print", ":", "true", "false", "export", "set", "unset", "typeset", "declare", "local"]);
|
|
16
|
-
const noWalkPrograms = new Set(["mv", "stat", "test", "[", "mkdir"]);
|
|
17
11
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
for (const cmd of req.commands) {
|
|
26
|
-
const reason = appdataCommand(cmd, req.home);
|
|
27
|
-
if (reason) denials.push(reason);
|
|
28
|
-
}
|
|
29
|
-
const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
-
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
|
|
31
|
-
for (const fragment of req.uninspectable) if (signature.test(fragment)) denials.push(appdataReason);
|
|
32
|
-
return denials;
|
|
12
|
+
// A name is text, not a file, unless the shell expands it as a glob.
|
|
13
|
+
const touches = (target: Target) => target.effect !== "name" || target.glob;
|
|
14
|
+
|
|
15
|
+
function appdataHit(target: Target, home: string): boolean {
|
|
16
|
+
if (!touches(target) || target.via === "scan") return false;
|
|
17
|
+
// An expansion the front end cannot resolve may well be $HOME.
|
|
18
|
+
return (target.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(target.path)) || isAppdata(target.path, home, target.glob);
|
|
33
19
|
}
|
|
34
20
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// cd reads its target but walks nothing.
|
|
40
|
-
const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
41
|
-
const gitConfig = prog === "git" && cmd.argv.slice(cmd.program + 1).some((word) => word.text === "config");
|
|
42
|
-
const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
|
|
43
|
-
const paths: { path: string; glob: boolean }[] = [];
|
|
44
|
-
for (const [i, word] of cmd.argv.entries()) {
|
|
45
|
-
if (!word.value || ["pattern", "code", "option:pattern", "optarg"].includes(word.role)) continue;
|
|
46
|
-
if (word.role === "program" && !word.value.includes("/")) continue;
|
|
47
|
-
if (data && !word.globs && i > cmd.program) continue;
|
|
48
|
-
// An expansion the front end cannot resolve may well be $HOME.
|
|
49
|
-
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(word.value)) return appdataReason;
|
|
50
|
-
paths.push({ path: absPath(word.value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
|
|
51
|
-
}
|
|
52
|
-
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: false });
|
|
54
|
-
}
|
|
55
|
-
if (paths.some(({ path, glob }) => isAppdata(path, home, glob))) return appdataReason;
|
|
21
|
+
function broadHit(target: Target, home: string): boolean {
|
|
22
|
+
if (target.via === "scan") return isBroad(target.path, home) || isAppdata(target.path, home);
|
|
23
|
+
if (!touches(target)) return false;
|
|
24
|
+
if (target.via === "tool") return target.search && isLibrary(target.path, home);
|
|
56
25
|
// Shell glob expansion touches directories even when the command does not walk them.
|
|
57
|
-
|
|
58
|
-
|
|
26
|
+
return isBroad(target.path, home, target.glob) && (target.walk !== "none" || target.glob);
|
|
27
|
+
}
|
|
59
28
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
29
|
+
export function appdataRules(req: Request, targets: Target[]): string[] {
|
|
30
|
+
const denials: string[] = [];
|
|
31
|
+
const groups = Map.groupBy(
|
|
32
|
+
// The producer of a walk that feeds another command was judged on its own targets.
|
|
33
|
+
targets.filter((target) => target.via !== "items" && !(target.via === "tool" && target.glob)),
|
|
34
|
+
(target) => target.command,
|
|
35
|
+
);
|
|
36
|
+
for (const group of groups.values()) {
|
|
37
|
+
if (group.some((target) => appdataHit(target, req.home))) denials.push(appdataReason);
|
|
38
|
+
else if (group.some((target) => broadHit(target, req.home))) denials.push(broadReason);
|
|
66
39
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
else if (prog === "ls" || prog === "grep") denied = !scoped && recursive && isBroad(cwd, home);
|
|
72
|
-
else if (["rg", "fd", "ag", "ack"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isLibrary(cwd, home));
|
|
73
|
-
return denied ? broadReason : undefined;
|
|
40
|
+
const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
|
|
42
|
+
for (const fragment of req.uninspectable) if (signature.test(fragment.text)) denials.push(appdataReason);
|
|
43
|
+
return denials;
|
|
74
44
|
}
|