@loophubs/agent-guard 0.3.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 +35 -20
- package/src/core.ts +7 -5
- package/src/docker-targets.ts +87 -0
- package/src/frontend.ts +9 -3
- package/src/git-targets.ts +125 -0
- package/src/guard.ts +4 -1
- package/src/interpreters.ts +61 -0
- package/src/links.ts +66 -72
- package/src/paths.ts +6 -60
- package/src/pipeline.ts +3 -7
- package/src/probes.ts +5 -0
- package/src/programs.ts +341 -0
- package/src/reasons.ts +3 -1
- package/src/record.ts +32 -3
- package/src/rules/appdata.ts +29 -62
- package/src/rules/credentials.ts +35 -322
- 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/paths.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Filesystem checks run after lexical denials to avoid touching protected trees.
|
|
2
|
-
import { realpathSync, statSync } from "node:fs";
|
|
3
2
|
import { userInfo } from "node:os";
|
|
4
3
|
import { basename, dirname, resolve } from "node:path";
|
|
5
4
|
|
|
@@ -48,10 +47,14 @@ export function expandHome(path: string, home: string): string {
|
|
|
48
47
|
return path;
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
// /System/Volumes/Data is the same tree as the root through firmlinks, so a path spelled through it is judged as the plain path. Case is ignored
|
|
51
|
+
// because the default APFS volume ignores it.
|
|
52
|
+
export const unfirmlink = (path: string): string => path.replace(/^\/System\/Volumes\/Data(?=\/|$)/i, "") || "/";
|
|
53
|
+
|
|
51
54
|
// curl, open and git read a file:// URL as the path it names.
|
|
52
55
|
export function absPath(path: string, cwd: string, home: string, quoted = false): string {
|
|
53
56
|
if (!quoted) path = expandHome(path, home);
|
|
54
|
-
return resolve(cwd, path.replace(/^file:\/\//i, ""));
|
|
57
|
+
return unfirmlink(resolve(cwd, path.replace(/^file:\/\//i, "")));
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
// Whether the shell could expand the glob's leading segments to `directory`, so
|
|
@@ -101,7 +104,7 @@ export function isLibrary(path: string, home: string): boolean {
|
|
|
101
104
|
return path.toLowerCase() === `${home}/library`.toLowerCase() || isAppdata(path, home);
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
const sshPublic = (name: string) => /^(config|config\..*|.*\.pub|allowed_signers|known_hosts.*)$/s.test(name);
|
|
107
|
+
export const sshPublic = (name: string) => /^(config|config\..*|.*\.pub|allowed_signers|known_hosts.*)$/s.test(name);
|
|
105
108
|
|
|
106
109
|
// Under ~/.ssh only a top-level client config, public key, allowed_signers or
|
|
107
110
|
// known_hosts file is public.
|
|
@@ -210,60 +213,3 @@ export function isSensitiveRoot(path: string, home: string): boolean {
|
|
|
210
213
|
);
|
|
211
214
|
return isSensitive(path) || credentialRoots.includes(basename(lower)) || listedDirectories.some((dir) => lower.endsWith(`/${dir}`)) || ancestors.includes(lower);
|
|
212
215
|
}
|
|
213
|
-
|
|
214
|
-
// A path that does not exist or cannot be searched has no inode to compare.
|
|
215
|
-
function stat(path: string) {
|
|
216
|
-
try {
|
|
217
|
-
return statSync(path);
|
|
218
|
-
} catch {
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function sameFile(a: string, b: string): boolean {
|
|
224
|
-
if (a === b) return true;
|
|
225
|
-
const x = stat(a);
|
|
226
|
-
const y = stat(b);
|
|
227
|
-
return x !== undefined && y !== undefined && x.dev === y.dev && x.ino === y.ino;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function real(path: string): string {
|
|
231
|
-
try {
|
|
232
|
-
return realpathSync(path);
|
|
233
|
-
} catch {
|
|
234
|
-
return path;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function kind(path: string): "dir" | "file" | "other" {
|
|
239
|
-
const st = stat(path);
|
|
240
|
-
return st?.isDirectory() ? "dir" : st?.isFile() ? "file" : "other";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Filesystem check for a file tool target, or a search root when search is
|
|
244
|
-
// set. It follows symlinks and compares inodes so a link or a case alias of
|
|
245
|
-
// ~/.ssh resolves to the directory it names.
|
|
246
|
-
export function sshScopeDenied(target: string, home: string, search: boolean): boolean {
|
|
247
|
-
const ssh = `${home}/.ssh`;
|
|
248
|
-
const candidates = [...new Set([target, real(target)])];
|
|
249
|
-
const roots = [...new Set([ssh, real(ssh)])];
|
|
250
|
-
for (const candidate of candidates) {
|
|
251
|
-
for (const root of roots) {
|
|
252
|
-
if (sameFile(candidate, root)) return true;
|
|
253
|
-
if (search) {
|
|
254
|
-
for (let parent = root; parent !== "/";) {
|
|
255
|
-
parent = dirname(parent);
|
|
256
|
-
if (sameFile(candidate, parent)) return true;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
for (let parent = candidate; parent !== "/";) {
|
|
260
|
-
parent = dirname(parent);
|
|
261
|
-
if (!sameFile(parent, root)) continue;
|
|
262
|
-
if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
|
|
263
|
-
if (kind(target) === "dir") return true;
|
|
264
|
-
if (search && kind(target) !== "file") return true;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return false;
|
|
269
|
-
}
|
package/src/pipeline.ts
CHANGED
|
@@ -2,18 +2,14 @@ import { basename } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import { stdinKind } from "./argv";
|
|
4
4
|
import type { Command, Word } from "./record";
|
|
5
|
-
|
|
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
|
-
}
|
|
5
|
+
import { showsHidden } from "./search-roles";
|
|
10
6
|
|
|
11
7
|
// A dotfile name, or a glob the shell expands to dotfiles; `.` and `..` are directories.
|
|
12
8
|
const hiddenName = (text: string) => /^\.(?!\.?$)/.test(basename(text));
|
|
13
9
|
|
|
14
10
|
// xargs passes the names a walk printed to its command, so mark that command.
|
|
15
11
|
export function markWalkedInput(left: Command[], right: Command[]) {
|
|
16
|
-
const
|
|
12
|
+
const walker = left.find((cmd) => {
|
|
17
13
|
const name = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
|
|
18
14
|
const args = cmd.argv.slice(cmd.program + 1);
|
|
19
15
|
const operands = args.filter((word) => !word.text.startsWith("-"));
|
|
@@ -21,7 +17,7 @@ export function markWalkedInput(left: Command[], right: Command[]) {
|
|
|
21
17
|
if (name === "echo" || name === "printf") return operands.some((word) => word.globs && hiddenName(word.text));
|
|
22
18
|
return name === "find" || (name === "fd" && showsHidden(args));
|
|
23
19
|
});
|
|
24
|
-
if (
|
|
20
|
+
if (walker) for (const cmd of right) if (cmd.wrappers.includes("xargs")) cmd.items = { root: walker.cwd, hidden: true };
|
|
25
21
|
}
|
|
26
22
|
|
|
27
23
|
// 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.
|
package/src/probes.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The only filesystem calls the guard makes about a path taken from a command. Tests replace these to record each probed path and check that
|
|
2
|
+
// none of them resolves into App Data, so every caller must reach the filesystem through here.
|
|
3
|
+
import { readlinkSync, statSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const probes = { readlink: readlinkSync, stat: statSync };
|
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,7 +3,9 @@ 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.",
|
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";
|
|
5
5
|
|
|
6
6
|
export interface Word {
|
|
7
7
|
text: string; // quotes removed; ~, $HOME and ${HOME} expanded
|
|
@@ -21,6 +21,28 @@ export interface Redirect {
|
|
|
21
21
|
vars: string[]; // parameters the shell expands in the target or body
|
|
22
22
|
}
|
|
23
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
|
+
|
|
24
46
|
export interface Command {
|
|
25
47
|
argv: Word[];
|
|
26
48
|
redirects: Redirect[];
|
|
@@ -29,11 +51,18 @@ export interface Command {
|
|
|
29
51
|
wrappers: string[];
|
|
30
52
|
shell: boolean; // the program runs in this shell rather than behind a wrapper
|
|
31
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;
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
export interface Script {
|
|
35
64
|
commands: Command[];
|
|
36
|
-
uninspectable:
|
|
65
|
+
uninspectable: Fragment[];
|
|
37
66
|
parseFailed: boolean;
|
|
38
67
|
}
|
|
39
68
|
|
|
@@ -52,6 +81,6 @@ export interface Request {
|
|
|
52
81
|
searchRoot: string;
|
|
53
82
|
glob: string;
|
|
54
83
|
commands: Command[];
|
|
55
|
-
uninspectable:
|
|
84
|
+
uninspectable: Fragment[];
|
|
56
85
|
parseFailed: boolean;
|
|
57
86
|
}
|