Skip to content

Commit 4c5b55a

Browse files
committed
fix(secret-guard): inspect curl default file protocol
1 parent 7980a30 commit 4c5b55a

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

‎src/permission/auto-shell-policy.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,39 @@ describe("local file URL glob expansion", () => {
8787
});
8888
});
8989

90+
describe("curl proto-default file", () => {
91+
test("requires approval for inferred local file operands through wrappers", () => {
92+
for (const command of [
93+
"curl --silent --proto-default file $PWD/%2Eenv",
94+
"curl --proto-default=file /tmp/%2Eenv",
95+
"curl --proto-default FILE ./%2Eenv",
96+
"curl /tmp/%2Eenv --proto-default file",
97+
"curl --proto-default file //localhost/tmp/%2Eenv",
98+
"curl --proto-default file README.md /tmp/%2Eenv",
99+
"env curl --proto-default file /tmp/%2Eenv",
100+
"bash -c 'curl --proto-default file /tmp/%2Eenv'",
101+
"sh -c 'curl --proto-default file /tmp/%2Eenv'",
102+
]) {
103+
expect(autoShellRuleForCall(shellCall(command))).toMatchObject({
104+
name: "sensitive-path",
105+
effect: "ask",
106+
});
107+
}
108+
});
109+
110+
test("keeps explicit and default HTTPS operands unflagged", () => {
111+
for (const command of [
112+
"curl --proto-default file https://example.com/%2Eenv",
113+
"curl --proto-default FILE HTTP://example.com/%2Eenv",
114+
"curl --proto-default https example.com/%2Eenv",
115+
"curl example.com/%2Eenv",
116+
"curl --output /tmp/%2Eenv --proto-default file https://example.com",
117+
]) {
118+
expect(autoShellRuleForCall(shellCall(command))).toBeUndefined();
119+
}
120+
});
121+
});
122+
90123
// Base git-global-config routing (--global/--system/--edit, --file targets,
91124
// unset/reassignment of GIT_CONFIG_GLOBAL, repo-local pass-through) is pinned
92125
// in classify-security.test.ts. This file pins the surface that file does not:

‎src/plugins/secret-guard-plugin.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isSensitiveShellToken,
1414
commandReferencesSensitivePath,
1515
expandShellToken,
16+
inspectShellSecretReference,
1617
} from "./secret-guard-plugin.js";
1718

1819
const next = async (call: ToolCall): Promise<ToolResult> => ({
@@ -570,6 +571,90 @@ describe("secret-guard file URL normalization", () => {
570571
}
571572
});
572573

574+
test.skipIf(Bun.which("curl") === null)(
575+
"flags proto-default file after real curl reads an encoded .env",
576+
async () => {
577+
const cwd = await mkdtemp(join(tmpdir(), "secret-guard-proto-default-"));
578+
try {
579+
await writeFile(join(cwd, ".env"), "CURL_PROTO_PROOF=exfiltrated\n");
580+
const operand = `${cwd}/%2Eenv`;
581+
const result = Bun.spawnSync([
582+
"curl",
583+
"--silent",
584+
"--show-error",
585+
"--proto-default",
586+
"file",
587+
operand,
588+
]);
589+
590+
expect(result.stdout.toString()).toContain(
591+
"CURL_PROTO_PROOF=exfiltrated",
592+
);
593+
expect(
594+
commandReferencesSensitivePath(
595+
`curl --silent --proto-default file '${operand}'`,
596+
cwd,
597+
),
598+
).toBeDefined();
599+
} finally {
600+
await rm(cwd, { recursive: true, force: true });
601+
}
602+
},
603+
);
604+
605+
test("classifies proto-default file operands across supported spellings", () => {
606+
const cwd = "/tmp/proto-default-fixture";
607+
for (const command of [
608+
"curl --proto-default file /tmp/proto-default-fixture/%2Eenv",
609+
"curl /tmp/proto-default-fixture/%2Eenv --proto-default file",
610+
"curl --proto-default=file /tmp/proto-default-fixture/%2Eenv",
611+
"curl --proto-default=FILE /tmp/proto-default-fixture/%2Eenv",
612+
"curl --proto-default FILE /tmp/proto-default-fixture/%2Eenv",
613+
"curl --proto-default file --url /tmp/proto-default-fixture/%2Eenv",
614+
"curl --proto-default file ./%2Eenv",
615+
"curl --proto-default file $PWD/%2Eenv",
616+
"curl --proto-default file //localhost/tmp/proto-default-fixture/%2Eenv",
617+
"curl --proto-default file README.md /tmp/proto-default-fixture/%2Eenv",
618+
"env curl --proto-default file /tmp/proto-default-fixture/%2Eenv",
619+
"bash -c 'curl --proto-default file /tmp/proto-default-fixture/%2Eenv'",
620+
"sh -c 'curl --proto-default file /tmp/proto-default-fixture/%2Eenv'",
621+
]) {
622+
expect(commandReferencesSensitivePath(command, cwd)).toBeDefined();
623+
}
624+
});
625+
626+
test("decodes inferred file operands exactly once", () => {
627+
expect(
628+
commandReferencesSensitivePath("curl --proto-default file /tmp/%252Eenv"),
629+
).toBeUndefined();
630+
});
631+
632+
test("keeps explicit and default HTTPS operands remote", () => {
633+
for (const command of [
634+
"curl --proto-default file https://example.com/%2Eenv",
635+
"curl --proto-default FILE HTTP://example.com/%2Eenv",
636+
"curl --proto-default https example.com/%2Eenv",
637+
"curl example.com/%2Eenv",
638+
"curl --output /tmp/%2Eenv --proto-default file https://example.com",
639+
"curl --header /tmp/%2Eenv --proto-default file https://example.com",
640+
]) {
641+
expect(commandReferencesSensitivePath(command)).toBeUndefined();
642+
}
643+
});
644+
645+
test("fails closed for malformed or ambiguous proto-default file options", () => {
646+
for (const command of [
647+
"curl /tmp/%2Eenv --proto-default",
648+
"curl --proto-default= /tmp/%2Eenv",
649+
"curl --proto-default file --proto-default https /tmp/%2Eenv",
650+
]) {
651+
const inspection = inspectShellSecretReference(command);
652+
expect(inspection.opaque || inspection.reference !== undefined).toBe(
653+
true,
654+
);
655+
}
656+
});
657+
573658
for (const malformed of ["%", "%2", "%GG", "%E0%A4%A"]) {
574659
test(`fails closed for malformed file URL escape: ${malformed}`, () => {
575660
expect(

‎src/plugins/secret-guard-plugin.ts‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,158 @@ interface LiteralPathCandidates {
978978
opaque: boolean;
979979
}
980980

981+
const CURL_LONG_VALUE_OPTIONS = new Set(
982+
`--abstract-unix-socket --alt-svc --aws-sigv4 --cacert --capath --cert
983+
--cert-type --ciphers --config --connect-timeout --connect-to --continue-at
984+
--cookie --cookie-jar --create-file-mode --crlfile --curves --data
985+
--data-ascii --data-binary --data-raw --data-urlencode --delegation
986+
--dns-interface --dns-ipv4-addr --dns-ipv6-addr --dns-servers --doh-url
987+
--dump-header --egd-file --engine --etag-compare --etag-save
988+
--expect100-timeout --form --form-string --ftp-account
989+
--ftp-alternative-to-user --ftp-method --ftp-port --ftp-ssl-ccc-mode
990+
--happy-eyeballs-timeout-ms --haproxy-clientip --header --help
991+
--hostpubmd5 --hostpubsha256 --hsts --interface --ipfs-gateway --json
992+
--keepalive-time --key --key-type --krb --libcurl --limit-rate
993+
--local-port --login-options --mail-auth --mail-from --mail-rcpt
994+
--max-filesize --max-redirs --max-time --netrc-file --noproxy
995+
--oauth2-bearer --output --output-dir --parallel-max --pass --pinnedpubkey
996+
--proto --proto-default --proto-redir --proxy-cacert --proxy-capath
997+
--proxy-cert --proxy-cert-type --proxy-ciphers --proxy-crlfile
998+
--proxy-header --proxy-key --proxy-key-type --proxy-pass
999+
--proxy-pinnedpubkey --proxy-service-name --proxy-tls13-ciphers
1000+
--proxy-tlsauthtype --proxy-tlspassword --proxy-tlsuser --proxy-user
1001+
--proxy1.0 --pubkey --quote --random-file --range --rate --referer
1002+
--request --request-target --resolve --retry --retry-delay --retry-max-time
1003+
--sasl-authzid --service-name --socks4 --socks4a --socks5
1004+
--socks5-gssapi-service --socks5-hostname --speed-limit --speed-time
1005+
--stderr --telnet-option --tftp-blksize --time-cond --tls-max
1006+
--tls13-ciphers --tlsauthtype --tlspassword --tlsuser --trace
1007+
--trace-ascii --trace-config --unix-socket --upload-file --url
1008+
--url-query --user --user-agent --variable --write-out`
1009+
.split(/\s+/)
1010+
.filter(Boolean),
1011+
);
1012+
const CURL_SHORT_VALUE_OPTIONS = new Set(
1013+
"AbcCdDeEFhHKmoPQrtTuUwXyYz".split(""),
1014+
);
1015+
const EXPLICIT_URL_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
1016+
1017+
interface CurlFileOperandInspection {
1018+
values: string[];
1019+
opaque: boolean;
1020+
}
1021+
1022+
function normalizeCurlFileOperand(
1023+
operand: string,
1024+
dialect: ShellDialect,
1025+
): { value?: string; opaque: boolean } {
1026+
const expansion = expandShellToken(operand, dialect);
1027+
if (!expansion.expandable) return { opaque: true };
1028+
let localPath = expansion.expanded;
1029+
if (localPath.startsWith("//")) {
1030+
const localhost = /^\/\/localhost(?=\/|$)/i.exec(localPath)?.[0];
1031+
if (localhost === undefined) return { opaque: true };
1032+
localPath = localPath.slice(localhost.length) || "/";
1033+
}
1034+
try {
1035+
localPath = decodeURIComponent(localPath);
1036+
} catch {
1037+
return { opaque: true };
1038+
}
1039+
if (/[{}]/.test(localPath)) return { opaque: true };
1040+
return { value: localPath, opaque: false };
1041+
}
1042+
1043+
function curlFileOperands(
1044+
command: readonly string[],
1045+
executableIndex: number,
1046+
program: string,
1047+
dialect: ShellDialect,
1048+
): CurlFileOperandInspection {
1049+
if (program !== "curl" || dialect !== "posix") {
1050+
return { values: [], opaque: false };
1051+
}
1052+
1053+
const operands: string[] = [];
1054+
const protocols: string[] = [];
1055+
let malformedProtocol = false;
1056+
let optionsEnded = false;
1057+
1058+
for (let index = executableIndex + 1; index < command.length; index++) {
1059+
const token = command[index] ?? "";
1060+
if (!optionsEnded && token === "--") {
1061+
optionsEnded = true;
1062+
continue;
1063+
}
1064+
if (!optionsEnded && token === "--proto-default") {
1065+
const protocol = command[index + 1];
1066+
if (protocol === undefined || protocol.startsWith("-")) {
1067+
malformedProtocol = true;
1068+
} else {
1069+
protocols.push(protocol.toLowerCase());
1070+
index++;
1071+
}
1072+
continue;
1073+
}
1074+
if (!optionsEnded && token.startsWith("--proto-default=")) {
1075+
const protocol = token.slice("--proto-default=".length);
1076+
if (protocol.length === 0) malformedProtocol = true;
1077+
else protocols.push(protocol.toLowerCase());
1078+
continue;
1079+
}
1080+
if (!optionsEnded && token === "--url") {
1081+
const operand = command[index + 1];
1082+
if (operand === undefined) malformedProtocol = true;
1083+
else {
1084+
operands.push(operand);
1085+
index++;
1086+
}
1087+
continue;
1088+
}
1089+
if (!optionsEnded && token.startsWith("--url=")) {
1090+
operands.push(token.slice("--url=".length));
1091+
continue;
1092+
}
1093+
if (!optionsEnded && token.startsWith("--")) {
1094+
const equalsIndex = token.indexOf("=");
1095+
const option = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
1096+
if (equalsIndex === -1 && CURL_LONG_VALUE_OPTIONS.has(option)) index++;
1097+
continue;
1098+
}
1099+
if (!optionsEnded && token.startsWith("-") && token !== "-") {
1100+
const options = token.slice(1);
1101+
for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
1102+
if (!CURL_SHORT_VALUE_OPTIONS.has(options[optionIndex] ?? "")) continue;
1103+
if (optionIndex === options.length - 1) index++;
1104+
break;
1105+
}
1106+
continue;
1107+
}
1108+
operands.push(token);
1109+
}
1110+
1111+
const protocolSet = new Set(protocols);
1112+
const fileCapable = protocolSet.has("file");
1113+
let opaque =
1114+
operands.length > 0 &&
1115+
(malformedProtocol || (fileCapable && protocolSet.size > 1));
1116+
if (!fileCapable) return { values: [], opaque };
1117+
1118+
const values: string[] = [];
1119+
for (const operand of operands) {
1120+
const expansion = expandShellToken(operand, dialect);
1121+
if (!expansion.expandable) {
1122+
opaque = true;
1123+
continue;
1124+
}
1125+
if (EXPLICIT_URL_SCHEME.test(expansion.expanded)) continue;
1126+
const normalized = normalizeCurlFileOperand(operand, dialect);
1127+
opaque ||= normalized.opaque;
1128+
if (normalized.value !== undefined) values.push(normalized.value);
1129+
}
1130+
return { values, opaque };
1131+
}
1132+
9811133
function literalPathCandidates(
9821134
commands: string[][],
9831135
dialect: ShellDialect,
@@ -1002,6 +1154,14 @@ function literalPathCandidates(
10021154
);
10031155
candidates.push(...fileOptions.values);
10041156
opaque ||= fileOptions.opaque;
1157+
const curlOperands = curlFileOperands(
1158+
command,
1159+
transparent.executableIndex,
1160+
program,
1161+
dialect,
1162+
);
1163+
candidates.push(...curlOperands.values);
1164+
opaque ||= curlOperands.opaque;
10051165
for (const token of command) {
10061166
if (token.startsWith("--env-file=")) {
10071167
candidates.push(token.slice("--env-file=".length));

0 commit comments

Comments
 (0)