@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/rules/credentials.ts
CHANGED
|
@@ -1,230 +1,49 @@
|
|
|
1
|
-
// Credential rules: keep credential files
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
|
|
1
|
+
// Credential rules: keep credential files and private keys out of what the
|
|
2
|
+
// model reads. Clients the program table models as consuming a credential file
|
|
3
|
+
// themselves (dotenvx, node --env-file, ssh -i) are not readers and stay
|
|
4
|
+
// allowed; the guard does not control what they do with the contents.
|
|
5
|
+
import { isSensitive, isSensitiveRoot, sshPrivate } from "../paths";
|
|
8
6
|
import { reasons } from "../reasons";
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
keychain: keychainReason,
|
|
23
|
-
trace: traceReason,
|
|
24
|
-
upload: uploadReason,
|
|
25
|
-
ssh: sshReason,
|
|
26
|
-
grepSsh: grepSshReason,
|
|
27
|
-
hiddenSearch: hiddenSearchReason,
|
|
28
|
-
} = reasons;
|
|
29
|
-
|
|
30
|
-
const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
|
|
31
|
-
|
|
32
|
-
export function credentialRules(req: Request): string[] {
|
|
33
|
-
const denials: string[] = [];
|
|
34
|
-
if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
|
|
35
|
-
if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
|
|
36
|
-
if (req.operation === "search") {
|
|
37
|
-
if (isSensitiveRoot(req.searchRoot)) denials.push(fileReason);
|
|
38
|
-
// A positive glob selects files even past ignore rules.
|
|
39
|
-
if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
|
|
40
|
-
}
|
|
41
|
-
for (const cmd of req.commands) denials.push(...credentialCommand(cmd, req.home));
|
|
42
|
-
for (const fragment of req.uninspectable) denials.push(...credentialSignatures(fragment));
|
|
43
|
-
return denials;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// The core runs this last: it follows symlinks, so it touches the filesystem.
|
|
47
|
-
export function credentialFilesystemRules(req: Request): string[] {
|
|
48
|
-
if ((req.operation === "read" || req.operation === "write") && sshScopeDenied(req.target, req.home, false)) return [sshReason];
|
|
49
|
-
if (req.operation === "search" && sshScopeDenied(req.searchRoot, req.home, true)) return [grepSshReason];
|
|
50
|
-
for (const cmd of req.commands) {
|
|
51
|
-
for (const redirect of cmd.redirects) {
|
|
52
|
-
if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target && sshScopeDenied(absPath(redirect.target, cmd.cwd, req.home), req.home, false)) return [sshReason];
|
|
53
|
-
}
|
|
54
|
-
if (cmd.program < 0) continue;
|
|
55
|
-
const name = basename(cmd.argv[cmd.program]!.text);
|
|
56
|
-
const readsPath = ["ls", "find", "tree", "rg", "grep", "ag", "ack", "fd"].includes(name);
|
|
57
|
-
if (!readsPath && !readerPrograms.has(name)) continue;
|
|
58
|
-
const words = cmd.argv.slice(cmd.program + 1);
|
|
59
|
-
// A search with no path operand reads the working directory.
|
|
60
|
-
const searches = ["rg", "ag", "ack"].includes(name) || (name === "grep" && cmd.flags.has("recursive"));
|
|
61
|
-
if (searches && !cmd.flags.has("help") && !words.some((word) => word.role === "path") && sshScopeDenied(cmd.cwd, req.home, true)) return [grepSshReason];
|
|
62
|
-
const filter = name === "jq" ? jqFilter(words) : undefined;
|
|
63
|
-
for (const word of words) {
|
|
64
|
-
if (word === filter) continue;
|
|
65
|
-
if (!["arg", "path", "patfile", "option:patfile"].includes(word.role) || word.value.startsWith("-")) continue;
|
|
66
|
-
if (sshScopeDenied(absPath(word.value, cmd.cwd, req.home, /^['"]/.test(word.raw)), req.home, false)) return [sshReason];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function credentialCommand(cmd: Command, home: string): string[] {
|
|
73
|
-
const denials: string[] = [];
|
|
74
|
-
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);
|
|
76
|
-
if (cmd.program < 0) {
|
|
77
|
-
if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
|
|
78
|
-
return denials;
|
|
79
|
-
}
|
|
80
|
-
const program = cmd.argv[cmd.program]!.text;
|
|
81
|
-
const name = basename(program);
|
|
82
|
-
const words = cmd.argv.slice(cmd.program + 1);
|
|
83
|
-
const args = words.map((w) => w.text);
|
|
84
|
-
// Shell variables exist only in the shell, so a path or a wrapper that
|
|
85
|
-
// execs a program never reaches these builtins.
|
|
86
|
-
if (cmd.shell && !program.includes("/")) {
|
|
87
|
-
if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
|
|
88
|
-
if (name === "set" && !args.length) denials.push(dumpReason);
|
|
89
|
-
if (name === "typeset" || name === "declare") {
|
|
90
|
-
if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
|
|
91
|
-
for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
|
|
95
|
-
const operands = args.filter((a) => !a.startsWith("-"));
|
|
96
|
-
switch (name) {
|
|
97
|
-
case "printenv":
|
|
98
|
-
if (!operands.length) denials.push(dumpReason);
|
|
99
|
-
for (const a of operands) if (secretName(a)) denials.push(varReason);
|
|
100
|
-
break;
|
|
101
|
-
case "echo":
|
|
102
|
-
case "printf":
|
|
103
|
-
case "print":
|
|
104
|
-
if (name !== "echo" && args[0] === "-v") display = false;
|
|
105
|
-
if (display && words.some((w) => w.vars.some(secretName))) denials.push(varReason);
|
|
106
|
-
break;
|
|
107
|
-
case "rg":
|
|
108
|
-
case "grep":
|
|
109
|
-
case "ag":
|
|
110
|
-
case "ack":
|
|
111
|
-
if (cmd.flags.has("help")) break;
|
|
112
|
-
if ((name === "grep" && cmd.flags.has("recursive")) || (["rg", "ag"].includes(name) && cmd.flags.has("hidden"))) denials.push(hiddenSearchReason);
|
|
113
|
-
for (const w of words) {
|
|
114
|
-
if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
|
|
115
|
-
}
|
|
116
|
-
if (cmd.flags.has("files")) break;
|
|
117
|
-
// Search operands, pattern files and positive globs are read targets;
|
|
118
|
-
// the pattern is not.
|
|
119
|
-
for (const w of words) {
|
|
120
|
-
const quoted = /^['"]/.test(w.raw);
|
|
121
|
-
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);
|
|
123
|
-
if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
|
|
124
|
-
}
|
|
125
|
-
break;
|
|
126
|
-
case "gh":
|
|
127
|
-
if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
|
|
128
|
-
if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
|
|
129
|
-
break;
|
|
130
|
-
case "glab":
|
|
131
|
-
if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
|
|
132
|
-
break;
|
|
133
|
-
case "security":
|
|
134
|
-
if (args.includes("-w") || args.includes("-g")) denials.push(keychainReason);
|
|
135
|
-
break;
|
|
136
|
-
case "curl":
|
|
137
|
-
denials.push(...curlRules(args, sensitive));
|
|
138
|
-
break;
|
|
139
|
-
default:
|
|
140
|
-
if (!readerPrograms.has(name)) return denials;
|
|
141
|
-
const filter = name === "jq" ? jqFilter(words) : undefined;
|
|
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
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// A heredoc or here-string body is standard input the command prints.
|
|
149
|
-
if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
|
|
150
|
-
return denials;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function jqFilter(words: Word[]): Word | undefined {
|
|
154
|
-
if (words.some((word) => word.text === "-f" || word.text === "--from-file")) return undefined;
|
|
155
|
-
return words.find((word) => !word.text.startsWith("-"));
|
|
7
|
+
import type { Request, Target } from "../record";
|
|
8
|
+
import { sshScopeDenied } from "../ssh";
|
|
9
|
+
import { secretReasons, secretSignatures } from "./secrets";
|
|
10
|
+
|
|
11
|
+
const { file: fileReason, codeFile: codeFileReason, upload: uploadReason, ssh: sshReason, grepSsh: grepSshReason, hiddenSearch: hiddenSearchReason } = reasons;
|
|
12
|
+
|
|
13
|
+
function targetReason(target: Target, home: string): string | undefined {
|
|
14
|
+
const reason = target.via === "code" ? codeFileReason : target.sends ? uploadReason : fileReason;
|
|
15
|
+
if (target.effect === "write") return sshPrivate(target.path) ? sshReason : undefined;
|
|
16
|
+
if (target.effect !== "read") return undefined;
|
|
17
|
+
if (target.walk === "hidden") return hiddenSearchReason;
|
|
18
|
+
// A directory that holds a listed file is read whole.
|
|
19
|
+
return isSensitive(target.path, target.glob) || (!target.glob && isSensitiveRoot(target.path, home)) ? reason : undefined;
|
|
156
20
|
}
|
|
157
21
|
|
|
158
|
-
|
|
159
|
-
function ghShowsToken(args: string[]): boolean {
|
|
160
|
-
for (let i = 0; i < args.length; i++) {
|
|
161
|
-
const a = args[i]!;
|
|
162
|
-
if (a === "--") return false;
|
|
163
|
-
if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
|
|
164
|
-
else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
|
|
165
|
-
}
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// curl reads ~/.curlrc and may add credentials the command line never shows,
|
|
170
|
-
// so any trace is denied; @file data and uploads name files it sends.
|
|
171
|
-
function curlRules(args: string[], sensitive: (word: string) => boolean): string[] {
|
|
22
|
+
export function credentialRules(req: Request, targets: Target[]): string[] {
|
|
172
23
|
const denials: string[] = [];
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (word === "--") break;
|
|
179
|
-
if (/^file:\/\//i.test(word) && sensitive(word)) denials.push(fileReason);
|
|
180
|
-
if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) denials.push(traceReason);
|
|
181
|
-
else if (long.test(word)) {
|
|
182
|
-
key = word.slice(2).split("=")[0]!;
|
|
183
|
-
value = word.includes("=") ? word.slice(word.indexOf("=") + 1) : (args[++i] ?? "");
|
|
184
|
-
} else if (/^-[^-]/.test(word)) {
|
|
185
|
-
for (let k = 1; k < word.length; k++) {
|
|
186
|
-
if (word[k] === "v") denials.push(traceReason);
|
|
187
|
-
if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k]!)) continue;
|
|
188
|
-
key = word[k]!;
|
|
189
|
-
value = word.slice(k + 1) || (args[++i] ?? "");
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
24
|
+
const groups = Map.groupBy(targets, (target) => target.command);
|
|
25
|
+
const judge = (command: number) => {
|
|
26
|
+
for (const target of groups.get(command) ?? []) {
|
|
27
|
+
const reason = targetReason(target, req.home);
|
|
28
|
+
if (reason) denials.push(reason);
|
|
192
29
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
.slice(value.indexOf("=") + 1)
|
|
199
|
-
.replace(/^[@<]/, "")
|
|
200
|
-
.split(";")[0]!;
|
|
201
|
-
} else if (!["T", "upload-file", "K", "config"].includes(key)) continue;
|
|
202
|
-
if (sensitive(value)) denials.push(uploadReason);
|
|
30
|
+
};
|
|
31
|
+
judge(-1);
|
|
32
|
+
for (const [i, cmd] of req.commands.entries()) {
|
|
33
|
+
judge(i);
|
|
34
|
+
denials.push(...secretReasons(cmd));
|
|
203
35
|
}
|
|
36
|
+
for (const fragment of req.uninspectable) denials.push(...secretSignatures(fragment.text));
|
|
204
37
|
return denials;
|
|
205
38
|
}
|
|
206
39
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const denials: string[] = [];
|
|
215
|
-
for (const segment of fragment.replace(/&&|\|\||\n/g, ";").split(";")) {
|
|
216
|
-
if (
|
|
217
|
-
/(^|[^A-Za-z0-9_-])printenv([^A-Za-z0-9_-]|$)/.test(segment) ||
|
|
218
|
-
/(^|[|(])\s*(env|export|set|typeset|declare)\s*($|[|>)])/.test(segment) ||
|
|
219
|
-
/(declare|typeset|export)\s+-[a-z]*[px]/.test(segment)
|
|
220
|
-
)
|
|
221
|
-
denials.push(dumpReason);
|
|
222
|
-
const reader = segment.match(readerSignature);
|
|
223
|
-
if (reader) denials.push(fileReason);
|
|
224
|
-
if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
|
|
225
|
-
if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
|
|
226
|
-
if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
|
|
227
|
-
if (/(ECHO|PRINTF|PRINT)\s.*\$\{?[A-Z0-9_]*(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/s.test(segment.toUpperCase())) denials.push(varReason);
|
|
40
|
+
// The core runs this last: it compares inodes, so it touches the filesystem.
|
|
41
|
+
export function credentialFilesystemRules(req: Request, targets: Target[]): string[] {
|
|
42
|
+
for (const target of targets) {
|
|
43
|
+
if (!["read", "write", "list"].includes(target.effect) || target.via === "items" || (target.via === "tool" && target.glob)) continue;
|
|
44
|
+
// A working directory is checked only for a search that reads what is under it.
|
|
45
|
+
if ((target.via === "cwd" || target.via === "scan") && !target.search) continue;
|
|
46
|
+
if (sshScopeDenied(target.path, req.home, target.search)) return [target.search ? grepSshReason : sshReason];
|
|
228
47
|
}
|
|
229
|
-
return
|
|
48
|
+
return [];
|
|
230
49
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Secrets that print without a path: environment and variable dumps, stored
|
|
2
|
+
// tokens, verbose HTTP traces. No target exists to check, so these name the
|
|
3
|
+
// command that prints them. Each is a workaround that stays until the operating
|
|
4
|
+
// system's read restrictions cover the store; do not extend the list.
|
|
5
|
+
import { programName } from "../argv";
|
|
6
|
+
import { curlValueLetters, readers } from "../programs";
|
|
7
|
+
import { reasons } from "../reasons";
|
|
8
|
+
import type { Command } from "../record";
|
|
9
|
+
|
|
10
|
+
const { dump: dumpReason, variable: varReason, token: tokenReason, keychain: keychainReason, trace: traceReason, secretPrint: secretReason } = reasons;
|
|
11
|
+
|
|
12
|
+
const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
|
|
13
|
+
|
|
14
|
+
export function secretReasons(cmd: Command): string[] {
|
|
15
|
+
const denials: string[] = [];
|
|
16
|
+
if (cmd.program < 0) {
|
|
17
|
+
if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
|
|
18
|
+
return denials;
|
|
19
|
+
}
|
|
20
|
+
const program = cmd.argv[cmd.program]!.text;
|
|
21
|
+
const name = programName(program);
|
|
22
|
+
const words = cmd.argv.slice(cmd.program + 1);
|
|
23
|
+
const args = words.map((w) => w.text);
|
|
24
|
+
// Shell variables exist only in the shell, so a path or a wrapper that
|
|
25
|
+
// execs a program never reaches these builtins.
|
|
26
|
+
if (cmd.shell && !program.includes("/")) {
|
|
27
|
+
if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
|
|
28
|
+
if (name === "set" && !args.length) denials.push(dumpReason);
|
|
29
|
+
if (name === "typeset" || name === "declare") {
|
|
30
|
+
if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
|
|
31
|
+
for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
let display = readers.includes(name) || ["echo", "printf", "print"].includes(name);
|
|
35
|
+
const operands = args.filter((a) => !a.startsWith("-"));
|
|
36
|
+
switch (name) {
|
|
37
|
+
case "printenv":
|
|
38
|
+
if (!operands.length) denials.push(dumpReason);
|
|
39
|
+
for (const a of operands) if (secretName(a)) denials.push(varReason);
|
|
40
|
+
break;
|
|
41
|
+
case "echo":
|
|
42
|
+
case "printf":
|
|
43
|
+
case "print":
|
|
44
|
+
if (name !== "echo" && args[0] === "-v") display = false;
|
|
45
|
+
if (display && words.some((w) => w.vars.some(secretName))) denials.push(varReason);
|
|
46
|
+
break;
|
|
47
|
+
case "gh":
|
|
48
|
+
if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
|
|
49
|
+
if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
|
|
50
|
+
break;
|
|
51
|
+
case "glab":
|
|
52
|
+
if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
|
|
53
|
+
break;
|
|
54
|
+
case "security":
|
|
55
|
+
if (args.some((a) => /^-[A-Za-z]*[wg]/.test(a) && !a.startsWith("--")) || ["dump-keychain", "export"].includes(args[0] ?? "")) denials.push(keychainReason);
|
|
56
|
+
break;
|
|
57
|
+
case "gcloud":
|
|
58
|
+
if (args.includes("print-access-token") || args.includes("print-identity-token")) denials.push(secretReason);
|
|
59
|
+
break;
|
|
60
|
+
case "az":
|
|
61
|
+
if (args[0] === "account" && args[1] === "get-access-token") denials.push(secretReason);
|
|
62
|
+
break;
|
|
63
|
+
case "aws":
|
|
64
|
+
if (args[0] === "configure" && args[1] === "get" && secretName(args[2] ?? "")) denials.push(secretReason);
|
|
65
|
+
break;
|
|
66
|
+
case "npm":
|
|
67
|
+
if (args[0] === "config" && args[1] === "get" && /auth|token|password/i.test(args[2] ?? "")) denials.push(secretReason);
|
|
68
|
+
break;
|
|
69
|
+
case "kubectl":
|
|
70
|
+
if (args[0] === "config" && args[1] === "view" && args.includes("--raw")) denials.push(secretReason);
|
|
71
|
+
break;
|
|
72
|
+
case "gpg":
|
|
73
|
+
if (args.some((a) => /^--export-secret-(sub)?keys$/.test(a))) denials.push(secretReason);
|
|
74
|
+
break;
|
|
75
|
+
case "curl":
|
|
76
|
+
if (curlTraces(args)) denials.push(traceReason);
|
|
77
|
+
break;
|
|
78
|
+
case "git":
|
|
79
|
+
if (gitCredentialFill(args)) denials.push(secretReason);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
// A heredoc or here-string body is standard input the command prints.
|
|
83
|
+
if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
|
|
84
|
+
return denials;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// git credential fill prints the stored credential; the subcommand follows any global options.
|
|
88
|
+
function gitCredentialFill(args: string[]): boolean {
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < args.length && args[i]!.startsWith("-")) i += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(args[i]!) ? 2 : 1;
|
|
91
|
+
return args[i] === "credential" && args[i + 1] === "fill";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A token-display flag counts even when a later flag cancels it.
|
|
95
|
+
function ghShowsToken(args: string[]): boolean {
|
|
96
|
+
for (let i = 0; i < args.length; i++) {
|
|
97
|
+
const a = args[i]!;
|
|
98
|
+
if (a === "--") return false;
|
|
99
|
+
if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
|
|
100
|
+
else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// curl reads ~/.curlrc and may add credentials the command line never shows,
|
|
106
|
+
// so any trace is denied. A value letter takes the rest of its cluster, so the
|
|
107
|
+
// v in -uvictor:secret is part of a user name.
|
|
108
|
+
function curlTraces(args: string[]): boolean {
|
|
109
|
+
for (let i = 0; i < args.length; i++) {
|
|
110
|
+
const word = args[i]!;
|
|
111
|
+
if (word === "--") break;
|
|
112
|
+
if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) return true;
|
|
113
|
+
// A separate value is not an option.
|
|
114
|
+
if (/^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)$/.test(word)) i++;
|
|
115
|
+
if (!/^-[^-]/.test(word)) continue;
|
|
116
|
+
for (let k = 1; k < word.length; k++) {
|
|
117
|
+
if (word[k] === "v") return true;
|
|
118
|
+
if (!curlValueLetters.includes(word[k]!)) continue;
|
|
119
|
+
if (k === word.length - 1) i++;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Text the front end could not structure: interpreter code, case bodies, or
|
|
127
|
+
// source the parser rejects. Match the commands that would expose a secret.
|
|
128
|
+
export function secretSignatures(fragment: string): string[] {
|
|
129
|
+
const denials: string[] = [];
|
|
130
|
+
for (const segment of fragment.replace(/&&|\|\||\n/g, ";").split(";")) {
|
|
131
|
+
if (
|
|
132
|
+
/(^|[^A-Za-z0-9_-])printenv([^A-Za-z0-9_-]|$)/.test(segment) ||
|
|
133
|
+
/(^|[|(])\s*(env|export|set|typeset|declare)\s*($|[|>)])/.test(segment) ||
|
|
134
|
+
/(declare|typeset|export)\s+-[a-z]*[px]/.test(segment)
|
|
135
|
+
)
|
|
136
|
+
denials.push(dumpReason);
|
|
137
|
+
if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
|
|
138
|
+
if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
|
|
139
|
+
if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
|
|
140
|
+
if (/(ECHO|PRINTF|PRINT)\s.*\$\{?[A-Z0-9_]*(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/s.test(segment.toUpperCase())) denials.push(varReason);
|
|
141
|
+
}
|
|
142
|
+
return denials;
|
|
143
|
+
}
|
package/src/search-roles.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Command, ValueRole, Word } from "./record";
|
|
2
2
|
|
|
3
|
+
// fd and ls skip hidden files unless asked; find never does.
|
|
4
|
+
export function showsHidden(words: Word[]): boolean {
|
|
5
|
+
return words.some((word) => /^(--hidden|--unrestricted)$/.test(word.text) || /^-[A-Za-z]*[Hu][A-Za-z]*$/.test(word.text));
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
const rgShortValues = "efgtTEABCmMjrd";
|
|
4
9
|
const rgLongValues = new Set(
|
|
5
10
|
(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Context } from "./programs";
|
|
4
|
+
import type { Effect, Target } from "./record";
|
|
5
|
+
|
|
6
|
+
// rg, grep, ag and ack: the roles search-roles.ts gave the words say which are roots, pattern files and globs.
|
|
7
|
+
export function searchTargets(name: string, { cmd, words, make, claimed }: Context): Target[] {
|
|
8
|
+
const help = cmd.flags.has("help");
|
|
9
|
+
const files = cmd.flags.has("files");
|
|
10
|
+
const recursive = name === "grep" && cmd.flags.has("recursive");
|
|
11
|
+
const walk = recursive || (["rg", "ag"].includes(name) && cmd.flags.has("hidden")) ? "hidden" : "visible";
|
|
12
|
+
// Listing names, or printing help, reads no content; a hidden listing feeds what reads it.
|
|
13
|
+
const effect: Effect = help || (files && walk !== "hidden") ? "list" : "read";
|
|
14
|
+
const targets: Target[] = [];
|
|
15
|
+
for (const [i, word] of words.entries()) {
|
|
16
|
+
// The file these options name holds ignore rules that the search reads.
|
|
17
|
+
const owner = word.role === "optarg" ? words[i - 1] : word.role === "option:optarg" ? word : undefined;
|
|
18
|
+
if (word.role === "path") targets.push(make(word.value, word, effect, { via: "operand", walk }));
|
|
19
|
+
else if (word.role === "patfile" || word.role === "option:patfile") targets.push(make(word.value, word, effect, { via: "option", walk: "none" }));
|
|
20
|
+
else if (owner && /^--(ignore-file|exclude-from)(=|$)/.test(owner.text)) targets.push(make(word.value, word, "read", { via: "option", walk: "none" }));
|
|
21
|
+
else continue;
|
|
22
|
+
claimed.add(word);
|
|
23
|
+
}
|
|
24
|
+
const scoped = targets.some((target) => target.via === "operand");
|
|
25
|
+
if (!scoped && (name !== "grep" || recursive)) targets.push(make(cmd.cwd, undefined, help ? "list" : effect, { via: name === "grep" ? "cwd" : "scan", walk, search: !help }));
|
|
26
|
+
for (const word of words) {
|
|
27
|
+
if (word.role !== "glob" && word.role !== "option:glob") continue;
|
|
28
|
+
claimed.add(word);
|
|
29
|
+
// A positive glob selects files even past ignore rules.
|
|
30
|
+
targets.push(make(basename(word.value), word, effect, { via: "option", glob: true, walk: "none" }));
|
|
31
|
+
}
|
|
32
|
+
return targets;
|
|
33
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Context } from "./programs";
|
|
2
|
+
import type { Effect, Target } from "./record";
|
|
3
|
+
|
|
4
|
+
// `-o KEY=VALUE`, `-o "KEY VALUE"` and `-oKEY=VALUE` set a client option; these keys name a file the client reads or writes.
|
|
5
|
+
const sshFileOptions: Record<string, Effect> = { identityfile: "use", certificatefile: "use", globalknownhostsfile: "use", userknownhostsfile: "write", revokedhostkeys: "use", pkcs11provider: "use" };
|
|
6
|
+
|
|
7
|
+
// The option letters that take a value, from each client's synopsis in the OpenSSH 10.3p1 manual page; the first one in a cluster takes the rest of the word or the next word.
|
|
8
|
+
const valueLetters: Record<string, string> = { ssh: "BDEFIJLOPQRSWbceilmopw", scp: "DFJPSXcilo", sftp: "BDFJPRSXbcilos" };
|
|
9
|
+
|
|
10
|
+
// The client uses the identity and configuration files itself (sftp passes them to ssh), and ssh writes its log file.
|
|
11
|
+
const letterEffects: Record<string, Effect> = { i: "use", F: "use", E: "write" };
|
|
12
|
+
|
|
13
|
+
// ssh reads options before and right after the destination, scp and sftp before their first operand; later words are the remote command or operands.
|
|
14
|
+
export const sshTargets =
|
|
15
|
+
(client: "ssh" | "scp" | "sftp") =>
|
|
16
|
+
({ words, make, claimed }: Context): Target[] => {
|
|
17
|
+
const targets: Target[] = [];
|
|
18
|
+
let operands = 0;
|
|
19
|
+
for (let i = 0; i < words.length; i++) {
|
|
20
|
+
const word = words[i]!;
|
|
21
|
+
if (word.text === "--") break;
|
|
22
|
+
if (!/^-./.test(word.text)) {
|
|
23
|
+
if (++operands === (client === "ssh" ? 2 : 1)) break;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const at = [...word.text.slice(1)].findIndex((letter) => valueLetters[client]!.includes(letter));
|
|
27
|
+
if (at < 0) continue;
|
|
28
|
+
const letter = word.text[at + 1]!;
|
|
29
|
+
const glued = word.text.slice(at + 2);
|
|
30
|
+
const holder = glued ? word : words[++i];
|
|
31
|
+
if (!holder) break;
|
|
32
|
+
const value = glued || holder.text;
|
|
33
|
+
let effect: Effect | undefined = client === "sftp" && letter === "b" && value !== "-" ? "read" : letterEffects[letter];
|
|
34
|
+
let paths = [value];
|
|
35
|
+
if (letter === "o") {
|
|
36
|
+
const setting = /^(\w+)(?:\s*=\s*|\s+)(.+)$/s.exec(value);
|
|
37
|
+
effect = sshFileOptions[setting?.[1]?.toLowerCase() ?? ""];
|
|
38
|
+
// ssh strips double quotes, takes several known-hosts files separated by spaces, and expands %d and ${HOME} to the home directory.
|
|
39
|
+
paths = (setting?.[2] ?? "").match(/"[^"]*"|\S+/g)?.map((path) => path.replace(/"/g, "").replace(/^(%d|\$\{HOME\})(?=\/|$)/, "~")) ?? [];
|
|
40
|
+
}
|
|
41
|
+
if (!effect) continue;
|
|
42
|
+
claimed.add(holder);
|
|
43
|
+
for (const path of paths) targets.push(make(path, holder, effect, { via: "option", ...(letter === "o" && { quoted: false }) }));
|
|
44
|
+
}
|
|
45
|
+
return targets;
|
|
46
|
+
};
|
package/src/ssh.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { basename, dirname } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { followLinks, notALink } from "./links";
|
|
4
|
+
import { isAppdata, sshPublic } from "./paths";
|
|
5
|
+
import { probes } from "./probes";
|
|
6
|
+
|
|
7
|
+
// A path that does not exist has no inode to compare. Any other failure means the comparison did not run, so it propagates.
|
|
8
|
+
function stat(path: string) {
|
|
9
|
+
try {
|
|
10
|
+
return probes.stat(path);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (notALink.includes((error as NodeJS.ErrnoException).code ?? "")) return undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sameFile(a: string, b: string): boolean {
|
|
18
|
+
if (a === b) return true;
|
|
19
|
+
const x = stat(a);
|
|
20
|
+
const y = stat(b);
|
|
21
|
+
return x !== undefined && y !== undefined && x.dev === y.dev && x.ino === y.ino;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function kind(path: string): "dir" | "file" | "other" {
|
|
25
|
+
const st = stat(path);
|
|
26
|
+
return st?.isDirectory() ? "dir" : st?.isFile() ? "file" : "other";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Whether the spelling puts a path at or under `root`, or for a search above it. Case is ignored because the default APFS volume ignores it.
|
|
30
|
+
function near(path: string, root: string, search: boolean): boolean {
|
|
31
|
+
const [spelled, base] = [path.toLowerCase().replace(/\/$/, "") || "/", root.toLowerCase()];
|
|
32
|
+
return spelled === base || spelled.startsWith(`${base}/`) || (search && (spelled === "/" || base.startsWith(`${spelled}/`)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Filesystem check for a file tool target, or a search root when search is set. It compares inodes so a case alias of ~/.ssh resolves to the
|
|
36
|
+
// directory it names. Only a path whose spelling, or whose readlink-followed spelling, is already at or under ~/.ssh is compared: the inode
|
|
37
|
+
// probes follow links, so a target that is not established to be in ~/.ssh scope is never handed to them. When ~/.ssh scope leads into App Data
|
|
38
|
+
// the comparison is skipped and the target is denied, because stat would search that tree.
|
|
39
|
+
export function sshScopeDenied(target: string, home: string, search: boolean): boolean {
|
|
40
|
+
const ssh = `${home}/.ssh`;
|
|
41
|
+
const appdataOnly = (path: string) => isAppdata(path, home);
|
|
42
|
+
const roots = [...new Set([ssh, followLinks(ssh, home, appdataOnly)])];
|
|
43
|
+
const candidates = [...new Set([target, followLinks(target, home, appdataOnly)])];
|
|
44
|
+
if (!candidates.some((candidate) => roots.some((root) => near(candidate, root, search)))) return false;
|
|
45
|
+
if ([...roots, ...candidates].some((path) => isAppdata(path, home))) return true;
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
if (sameFile(candidate, root)) return true;
|
|
49
|
+
if (search) {
|
|
50
|
+
for (let parent = root; parent !== "/";) {
|
|
51
|
+
parent = dirname(parent);
|
|
52
|
+
if (sameFile(candidate, parent)) return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (let parent = candidate; parent !== "/";) {
|
|
56
|
+
parent = dirname(parent);
|
|
57
|
+
if (!sameFile(parent, root)) continue;
|
|
58
|
+
if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
|
|
59
|
+
if (kind(target) === "dir") return true;
|
|
60
|
+
if (search && kind(target) !== "file") return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|