pum-agent 0.2.1-beta.1 → 0.2.2-beta.1
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/README.md +40 -7
- package/package.json +3 -2
- package/src/app.tsx +49 -8
- package/src/check-mode.ts +4 -1
- package/src/check-policy.ts +414 -24
- package/src/main.tsx +16 -0
- package/src/sandbox/index.ts +207 -0
- package/src/sandbox/linux.ts +362 -0
- package/src/sandbox/types.ts +62 -0
- package/src/sandbox/windows.ts +327 -0
- package/src/sandbox-policy.ts +204 -0
- package/src/settings-popup.tsx +2 -0
- package/src/settings.ts +10 -0
- package/src/terminal-title.ts +41 -0
package/src/check-policy.ts
CHANGED
|
@@ -14,11 +14,13 @@ export type CheckPolicyFindingCode =
|
|
|
14
14
|
| "privilege-escalation"
|
|
15
15
|
| "persistence"
|
|
16
16
|
| "remote-script-execution"
|
|
17
|
+
| "external-read-exfiltration"
|
|
17
18
|
| "destructive-git"
|
|
18
19
|
| "broad-deletion"
|
|
19
20
|
| "suspicious-execution"
|
|
20
21
|
| "shell-complexity"
|
|
21
22
|
| "mutation"
|
|
23
|
+
| "unknown-path-access"
|
|
22
24
|
| "unrecognized-command";
|
|
23
25
|
|
|
24
26
|
export type CheckPolicyFinding = {
|
|
@@ -29,6 +31,23 @@ export type CheckPolicyFinding = {
|
|
|
29
31
|
path?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
export type CheckPathAccessMode = "read" | "write" | "execute" | "location" | "unknown";
|
|
35
|
+
|
|
36
|
+
export type CheckPathAccess = {
|
|
37
|
+
path: string;
|
|
38
|
+
/** Deterministic absolute path resolved during Check mode analysis. */
|
|
39
|
+
resolvedPath: string;
|
|
40
|
+
mode: CheckPathAccessMode;
|
|
41
|
+
source: "operand" | "redirection" | "executable";
|
|
42
|
+
stage: number;
|
|
43
|
+
external: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CheckNetworkIntent = {
|
|
47
|
+
access: "none" | "host";
|
|
48
|
+
commands: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
32
51
|
export type BashOperator = {
|
|
33
52
|
operator: ";;&" | ";;" | ";&" | ";" | "newline" | "&&" | "||" | "&" | "|" | "|&";
|
|
34
53
|
start: number;
|
|
@@ -80,11 +99,15 @@ export type BashAnalysis = {
|
|
|
80
99
|
};
|
|
81
100
|
|
|
82
101
|
export type CheckPolicyResult = {
|
|
102
|
+
/** Exact command text, or canonical direct argv text, that produced this result. */
|
|
103
|
+
exactCommand: string;
|
|
83
104
|
profile: CheckPolicyProfile;
|
|
84
105
|
decision: CheckPolicyDecision;
|
|
85
106
|
reason: string;
|
|
86
107
|
findings: CheckPolicyFinding[];
|
|
108
|
+
accesses: CheckPathAccess[];
|
|
87
109
|
analysis: BashAnalysis;
|
|
110
|
+
network: CheckNetworkIntent;
|
|
88
111
|
};
|
|
89
112
|
|
|
90
113
|
export type CheckPolicyLimits = {
|
|
@@ -109,6 +132,8 @@ export type AnalyzeCheckPolicyOptions = {
|
|
|
109
132
|
profile?: CheckPolicyProfile;
|
|
110
133
|
limits?: Partial<CheckPolicyLimits>;
|
|
111
134
|
fileSystem?: CheckPolicyFileSystem;
|
|
135
|
+
/** Canonical sensitive roots that no checked command may access. */
|
|
136
|
+
protectedPaths?: readonly string[];
|
|
112
137
|
};
|
|
113
138
|
|
|
114
139
|
export type ProcessCheckOperation = "create" | "start" | "resume" | "repeat" | "invoke-run";
|
|
@@ -132,6 +157,8 @@ export type AnalyzeExecutablePolicyOptions = Pick<ProcessCheckProposal, "executa
|
|
|
132
157
|
profile?: CheckPolicyProfile;
|
|
133
158
|
limits?: Partial<CheckPolicyLimits>;
|
|
134
159
|
fileSystem?: CheckPolicyFileSystem;
|
|
160
|
+
/** Canonical sensitive roots that no checked process may access. */
|
|
161
|
+
protectedPaths?: readonly string[];
|
|
135
162
|
};
|
|
136
163
|
|
|
137
164
|
export const DEFAULT_CHECK_POLICY_LIMITS: Readonly<CheckPolicyLimits> = Object.freeze({
|
|
@@ -159,6 +186,7 @@ const MUTATION_COMMANDS = new Map<string, string>([
|
|
|
159
186
|
|
|
160
187
|
const SHELL_INTERPRETERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
|
|
161
188
|
const REMOTE_COMMANDS = new Set(["curl", "wget", "fetch", "invoke-webrequest", "iwr"]);
|
|
189
|
+
const STDIN_NETWORK_COMMANDS = new Set(["nc", "ncat", "netcat", "socat", "ssh"]);
|
|
162
190
|
const PATH_OPERAND_COMMANDS = new Set([
|
|
163
191
|
"cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath",
|
|
164
192
|
"readlink", "rm", "rmdir", "unlink", "shred", "mv", "cp", "install", "mkdir", "touch", "truncate",
|
|
@@ -177,6 +205,10 @@ const SAFE_READ_COMMANDS = new Set([
|
|
|
177
205
|
"pwd", "printf", "echo", "true", "false", "test", "[", "ls", "tree", "cat", "head", "tail",
|
|
178
206
|
"wc", "stat", "file", "du", "realpath", "readlink", "grep", "rg", "git",
|
|
179
207
|
]);
|
|
208
|
+
const EXPLICIT_READ_COMMANDS = new Set([
|
|
209
|
+
"cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath", "readlink",
|
|
210
|
+
]);
|
|
211
|
+
const DATA_OPERAND_COMMANDS = new Set(["printf", "echo"]);
|
|
180
212
|
function commandName(argv0: string | undefined): string {
|
|
181
213
|
if (!argv0) return "";
|
|
182
214
|
const basename = argv0.replaceAll("\\", "/").split("/").at(-1) ?? argv0;
|
|
@@ -570,6 +602,7 @@ function isExactPosixNullDevice(value: string, cwd: string): boolean {
|
|
|
570
602
|
function pathOperands(stage: BashStage): string[] {
|
|
571
603
|
const name = commandName(stage.argv[0]);
|
|
572
604
|
const values = stage.redirections.flatMap((item) => item.target ? [item.target] : []);
|
|
605
|
+
if (DATA_OPERAND_COMMANDS.has(name)) return [...new Set(values)];
|
|
573
606
|
const pathValueOptions = new Set(["-C", "--cwd", "--directory", "--chdir", "--git-dir", "--work-tree", "--prefix"]);
|
|
574
607
|
for (let index = 1; index < stage.argv.length; index++) {
|
|
575
608
|
const value = stage.argv[index]!;
|
|
@@ -592,6 +625,220 @@ function pathOperands(stage: BashStage): string[] {
|
|
|
592
625
|
return [...new Set(values)];
|
|
593
626
|
}
|
|
594
627
|
|
|
628
|
+
type ClassifiedAccess = Omit<CheckPathAccess, "stage" | "external" | "resolvedPath">;
|
|
629
|
+
|
|
630
|
+
function positionalOperands(argv: string[]): string[] {
|
|
631
|
+
const values: string[] = [];
|
|
632
|
+
for (let index = 1; index < argv.length; index++) {
|
|
633
|
+
const value = argv[index]!;
|
|
634
|
+
if (value === "--") return [...values, ...argv.slice(index + 1)];
|
|
635
|
+
if (!value.startsWith("-") || value === "-") values.push(value);
|
|
636
|
+
}
|
|
637
|
+
return values;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function grepReadOperands(argv: string[]): ClassifiedAccess[] {
|
|
641
|
+
const accesses: ClassifiedAccess[] = [];
|
|
642
|
+
let hasExplicitPattern = false;
|
|
643
|
+
let consumedPattern = false;
|
|
644
|
+
const valueOptions = new Set(["-A", "-B", "-C", "-m", "--after-context", "--before-context", "--context", "--max-count", "--type", "-t", "-T", "-g", "--glob"]);
|
|
645
|
+
for (let index = 1; index < argv.length; index++) {
|
|
646
|
+
const value = argv[index]!;
|
|
647
|
+
if (value === "--") {
|
|
648
|
+
const remaining = argv.slice(index + 1);
|
|
649
|
+
if (!hasExplicitPattern && !consumedPattern) remaining.shift();
|
|
650
|
+
accesses.push(...remaining.map((path) => ({ path, mode: "read" as const, source: "operand" as const })));
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
if (["-e", "--regexp"].includes(value)) {
|
|
654
|
+
hasExplicitPattern = true;
|
|
655
|
+
index++;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (["-f", "--file"].includes(value) && argv[index + 1]) {
|
|
659
|
+
hasExplicitPattern = true;
|
|
660
|
+
accesses.push({ path: argv[++index]!, mode: "read", source: "operand" });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const fileOption = value.match(/^(?:--file|-f)=(.+)$/)?.[1];
|
|
664
|
+
if (fileOption) {
|
|
665
|
+
hasExplicitPattern = true;
|
|
666
|
+
accesses.push({ path: fileOption, mode: "read", source: "operand" });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (["--pre"].includes(value) && argv[index + 1]) {
|
|
670
|
+
accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const preProgram = value.match(/^--pre=(.+)$/)?.[1];
|
|
674
|
+
if (preProgram) {
|
|
675
|
+
accesses.push({ path: preProgram, mode: "execute", source: "operand" });
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (valueOptions.has(value)) {
|
|
679
|
+
index++;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (value.startsWith("-")) continue;
|
|
683
|
+
if (!hasExplicitPattern && !consumedPattern) {
|
|
684
|
+
consumedPattern = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
accesses.push({ path: value, mode: "read", source: "operand" });
|
|
688
|
+
}
|
|
689
|
+
return accesses;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function interpreterAccesses(name: string, argv: string[]): ClassifiedAccess[] {
|
|
693
|
+
const accesses: ClassifiedAccess[] = [];
|
|
694
|
+
const inlineFlags = name === "cmd" ? new Set(["/c", "/k"])
|
|
695
|
+
: name === "powershell" || name === "pwsh" ? new Set(["-c", "-command", "-encodedcommand", "-enc"])
|
|
696
|
+
: new Set(["-c", "-e", "-E", "--eval", "--print", "-p"]);
|
|
697
|
+
const executionOptions = new Set(["-r", "--require", "--import", "--loader", "--preload", "--import-map", "--rcfile", "--init-file", "-file"]);
|
|
698
|
+
let inline = false;
|
|
699
|
+
for (let index = 1; index < argv.length; index++) {
|
|
700
|
+
const value = argv[index]!;
|
|
701
|
+
const lower = value.toLowerCase();
|
|
702
|
+
if (inlineFlags.has(lower)) {
|
|
703
|
+
inline = true;
|
|
704
|
+
index++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (executionOptions.has(lower) && argv[index + 1]) {
|
|
708
|
+
accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const executionOption = value.match(/^(?:--require|--import|--loader|--preload|--import-map|--rcfile|--init-file)=(.+)$/i)?.[1]
|
|
712
|
+
?? value.match(/^-I(.+)$/)?.[1];
|
|
713
|
+
if (executionOption) {
|
|
714
|
+
accesses.push({ path: executionOption, mode: "execute", source: "operand" });
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (value.startsWith("-")) continue;
|
|
718
|
+
if (!inline) accesses.push({ path: value, mode: "execute", source: "operand" });
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
return accesses;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function classifyStageAccesses(stage: BashStage): ClassifiedAccess[] {
|
|
725
|
+
const argv = effectiveArgv(stage.argv);
|
|
726
|
+
const name = commandName(argv[0]);
|
|
727
|
+
const accesses: ClassifiedAccess[] = [];
|
|
728
|
+
if (argv[0] && looksLikePath(argv[0])) accesses.push({ path: argv[0], mode: "execute", source: "executable" });
|
|
729
|
+
|
|
730
|
+
for (const redirection of stage.redirections) {
|
|
731
|
+
if (!redirection.target) continue;
|
|
732
|
+
const operator = redirection.operator.replace(/^\d+/, "");
|
|
733
|
+
if (["<<", "<<-", "<<<", "<&"].includes(operator)) continue;
|
|
734
|
+
if (operator === ">&" && (/^\d+$/.test(redirection.target) || redirection.target === "-")) continue;
|
|
735
|
+
const interpreterInput = operator === "<"
|
|
736
|
+
&& (SHELL_INTERPRETERS.has(name) || ["node", "bun", "deno", "python", "python3", "ruby", "perl", "php", "java"].includes(name));
|
|
737
|
+
accesses.push({
|
|
738
|
+
path: redirection.target,
|
|
739
|
+
mode: operator === "<" ? (interpreterInput ? "execute" : "read") : "write",
|
|
740
|
+
source: "redirection",
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (DATA_OPERAND_COMMANDS.has(name)) return accesses;
|
|
745
|
+
if (["cd", "chdir", "set-location"].includes(name)) {
|
|
746
|
+
return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "location" as const, source: "operand" as const }))];
|
|
747
|
+
}
|
|
748
|
+
if (EXPLICIT_READ_COMMANDS.has(name)) {
|
|
749
|
+
return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "read" as const, source: "operand" as const }))];
|
|
750
|
+
}
|
|
751
|
+
if (name === "grep" || name === "rg") return [...accesses, ...grepReadOperands(argv)];
|
|
752
|
+
if (REMOTE_COMMANDS.has(name)) {
|
|
753
|
+
const uploadOptions = new Set(["-d", "--data", "--data-binary", "--data-raw", "--data-urlencode", "-F", "--form", "-T", "--upload-file", "--post-file"]);
|
|
754
|
+
for (let index = 1; index < argv.length; index++) {
|
|
755
|
+
const value = argv[index]!;
|
|
756
|
+
if (uploadOptions.has(value) && argv[index + 1]) {
|
|
757
|
+
const operand = argv[++index]!;
|
|
758
|
+
const atPath = operand.match(/@(.+)$/)?.[1];
|
|
759
|
+
const path = atPath ?? (value === "-T" || value === "--upload-file" || value === "--post-file" ? operand : undefined);
|
|
760
|
+
if (path && path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (["-InFile", "-Body"].includes(value) && argv[index + 1]) {
|
|
764
|
+
const operand = argv[++index]!;
|
|
765
|
+
const path = operand.startsWith("@") ? operand.slice(1) : operand;
|
|
766
|
+
if (path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const attached = value.match(/^(?:--data|--data-binary|--data-raw|--data-urlencode|--form|--upload-file|--post-file)=(.+)$/)?.[1];
|
|
770
|
+
if (attached) {
|
|
771
|
+
const atPath = attached.match(/@(.+)$/)?.[1];
|
|
772
|
+
const path = atPath ?? (/^--(?:upload-file|post-file)=/.test(value) ? attached : undefined);
|
|
773
|
+
if (path && path !== "-") accesses.push({ path, mode: "read", source: "operand" });
|
|
774
|
+
}
|
|
775
|
+
const configPath = value.match(/^--config=(.+)$/)?.[1];
|
|
776
|
+
if (configPath) accesses.push({ path: configPath, mode: "execute", source: "operand" });
|
|
777
|
+
else if (value === "--config" && argv[index + 1]) accesses.push({ path: argv[++index]!, mode: "execute", source: "operand" });
|
|
778
|
+
}
|
|
779
|
+
return accesses;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const operands = positionalOperands(argv);
|
|
783
|
+
if (name === "cp" || name === "install") {
|
|
784
|
+
const targetOption = argv.find((arg) => arg.startsWith("--target-directory="))?.slice("--target-directory=".length);
|
|
785
|
+
const targetIndex = argv.findIndex((arg) => arg === "-t" || arg === "--target-directory");
|
|
786
|
+
const target = targetOption ?? (targetIndex >= 0 ? argv[targetIndex + 1] : undefined) ?? operands.at(-1);
|
|
787
|
+
const sources = targetIndex >= 0 || targetOption ? operands.filter((path) => path !== target) : operands.slice(0, -1);
|
|
788
|
+
accesses.push(...sources.map((path) => ({ path, mode: "read" as const, source: "operand" as const })));
|
|
789
|
+
if (target) accesses.push({ path: target, mode: "write", source: "operand" });
|
|
790
|
+
return accesses;
|
|
791
|
+
}
|
|
792
|
+
if (name === "mv") return [...accesses, ...operands.map((path) => ({ path, mode: "write" as const, source: "operand" as const }))];
|
|
793
|
+
if (["rm", "rmdir", "unlink", "shred", "mkdir", "touch", "truncate", "chmod", "chown", "chgrp", "setfacl", "tee"].includes(name)) {
|
|
794
|
+
return [...accesses, ...operands.map((path) => ({ path, mode: "write" as const, source: "operand" as const }))];
|
|
795
|
+
}
|
|
796
|
+
if (name === "dd") {
|
|
797
|
+
for (const value of argv.slice(1)) {
|
|
798
|
+
const match = /^(if|of)=(.+)$/.exec(value);
|
|
799
|
+
if (match) accesses.push({ path: match[2]!, mode: match[1] === "if" ? "read" : "write", source: "operand" });
|
|
800
|
+
}
|
|
801
|
+
return accesses;
|
|
802
|
+
}
|
|
803
|
+
if (name === "sed") {
|
|
804
|
+
const inPlace = argv.slice(1).some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || /^-[^-]*i/.test(arg));
|
|
805
|
+
const programFiles: string[] = [];
|
|
806
|
+
for (let index = 1; index < argv.length; index++) {
|
|
807
|
+
if ((argv[index] === "-f" || argv[index] === "--file") && argv[index + 1]) programFiles.push(argv[++index]!);
|
|
808
|
+
else {
|
|
809
|
+
const program = argv[index]!.match(/^(?:--file|-f)=(.+)$/)?.[1] ?? argv[index]!.match(/^-f(.+)$/)?.[1];
|
|
810
|
+
if (program) programFiles.push(program);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
accesses.push(...programFiles.map((path) => ({ path, mode: "execute" as const, source: "operand" as const })));
|
|
814
|
+
const files = operands.slice(argv.slice(1).some((arg) => arg === "-e" || arg === "--expression" || arg === "-f" || arg === "--file") ? 0 : 1)
|
|
815
|
+
.filter((path) => !programFiles.includes(path));
|
|
816
|
+
accesses.push(...files.map((path) => ({ path, mode: inPlace ? "write" as const : "read" as const, source: "operand" as const })));
|
|
817
|
+
return accesses;
|
|
818
|
+
}
|
|
819
|
+
if (name === "perl") {
|
|
820
|
+
const inPlace = argv.slice(1).some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || /^-[^-]*i/.test(arg));
|
|
821
|
+
const inline = argv.slice(1).some((arg) => arg === "-e" || arg === "-E");
|
|
822
|
+
if (!inline) return [...accesses, ...interpreterAccesses(name, argv)];
|
|
823
|
+
return [...accesses, ...operands.map((path) => ({ path, mode: inPlace ? "write" as const : "read" as const, source: "operand" as const }))];
|
|
824
|
+
}
|
|
825
|
+
if (name === "bun" && ["test", "run"].includes(argv[1] ?? "")) {
|
|
826
|
+
return [...accesses, ...argv.slice(2).filter(looksLikePath).map((path) => ({ path, mode: "execute" as const, source: "operand" as const }))];
|
|
827
|
+
}
|
|
828
|
+
if (SHELL_INTERPRETERS.has(name) || ["node", "bun", "deno", "python", "python3", "ruby", "php", "java"].includes(name)) {
|
|
829
|
+
return [...accesses, ...interpreterAccesses(name, argv)];
|
|
830
|
+
}
|
|
831
|
+
const optionPaths = argv.slice(1).flatMap((argument) => {
|
|
832
|
+
const value = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : undefined;
|
|
833
|
+
return value && looksLikePath(value) ? [value] : [];
|
|
834
|
+
});
|
|
835
|
+
const possiblePaths = [...pathOperands(stage), ...optionPaths].filter((path) => looksLikePath(path) && !/[\s$`]/.test(path));
|
|
836
|
+
if (PATH_OPERAND_COMMANDS.has(name) || possiblePaths.length > 0) {
|
|
837
|
+
accesses.push(...possiblePaths.map((path) => ({ path, mode: "unknown" as const, source: "operand" as const })));
|
|
838
|
+
}
|
|
839
|
+
return accesses;
|
|
840
|
+
}
|
|
841
|
+
|
|
595
842
|
function symlinkEscapes(path: string, root: string, fs: CheckPolicyFileSystem): boolean {
|
|
596
843
|
const flavor = pathFlavor(path, root);
|
|
597
844
|
let current = path;
|
|
@@ -636,18 +883,46 @@ function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding)
|
|
|
636
883
|
if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
|
|
637
884
|
}
|
|
638
885
|
|
|
886
|
+
function insideProtectedPath(path: string, protectedPaths: readonly string[]): boolean {
|
|
887
|
+
return protectedPaths.some((protectedPath) => {
|
|
888
|
+
const flavor = pathFlavor(path, protectedPath);
|
|
889
|
+
const relative = flavor.relative(flavor.resolve(protectedPath), flavor.resolve(path));
|
|
890
|
+
return relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function externalPathUsesLink(path: string, fs: CheckPolicyFileSystem): boolean {
|
|
895
|
+
const flavor = pathFlavor(path, path);
|
|
896
|
+
const resolved = flavor.resolve(path);
|
|
897
|
+
const parsed = flavor.parse(resolved);
|
|
898
|
+
let component = parsed.root;
|
|
899
|
+
try {
|
|
900
|
+
for (const part of resolved.slice(parsed.root.length).split(/[\\/]+/).filter(Boolean)) {
|
|
901
|
+
component = flavor.join(component, part);
|
|
902
|
+
if (!fs.exists(component)) return false;
|
|
903
|
+
if (fs.isSymbolicLink(component)) return true;
|
|
904
|
+
}
|
|
905
|
+
return false;
|
|
906
|
+
} catch {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
639
911
|
function inspectHardBlocks(
|
|
640
912
|
analysis: BashAnalysis,
|
|
641
913
|
cwd: string,
|
|
642
914
|
fs: CheckPolicyFileSystem,
|
|
915
|
+
profile: CheckPolicyProfile,
|
|
643
916
|
depth = 0,
|
|
644
917
|
projectCwd = cwd,
|
|
645
918
|
allowedPaths: readonly string[] = [],
|
|
646
|
-
|
|
919
|
+
protectedPaths: readonly string[] = [],
|
|
920
|
+
): { findings: CheckPolicyFinding[]; accesses: CheckPathAccess[] } {
|
|
647
921
|
const findings: CheckPolicyFinding[] = [];
|
|
922
|
+
const accesses: CheckPathAccess[] = [];
|
|
648
923
|
if (!analysis.complete) {
|
|
649
924
|
addFinding(findings, { code: analysis.truncated ? "analysis-limit" : "unbalanced-shell", severity: "hard-block", message: analysis.errors.join("; ") || "shell analysis is incomplete" });
|
|
650
|
-
return findings;
|
|
925
|
+
return { findings, accesses };
|
|
651
926
|
}
|
|
652
927
|
|
|
653
928
|
let activeCwd = cwd;
|
|
@@ -694,17 +969,44 @@ function inspectHardBlocks(
|
|
|
694
969
|
}
|
|
695
970
|
|
|
696
971
|
const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
|
|
697
|
-
for (const
|
|
698
|
-
if (
|
|
972
|
+
for (const redirection of stage.redirections) {
|
|
973
|
+
if (!redirection.target) {
|
|
974
|
+
addFinding(findings, { code: "unknown-path-access", severity: "hard-block", message: "redirection target is missing", stage: stage.index });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
for (const classified of classifyStageAccesses(effectiveStage)) {
|
|
978
|
+
const value = classified.path;
|
|
979
|
+
const dynamic = /(?:\$\(|`|\$\{|\$[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%)/.test(value);
|
|
980
|
+
const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
|
|
981
|
+
accesses.push({ ...classified, resolvedPath: resolved.absolute, stage: stage.index, external: !resolved.inside });
|
|
982
|
+
if (dynamic) {
|
|
983
|
+
addFinding(findings, { code: "unknown-path-access", severity: "hard-block", message: "path access is dynamic or ambiguous", stage: stage.index, path: value });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (isCredentialSensitivePath(value) || insideProtectedPath(resolved.absolute, protectedPaths)) {
|
|
987
|
+
addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential, secret, or PUM configuration path", stage: stage.index, path: value });
|
|
988
|
+
}
|
|
699
989
|
if (isExactPosixNullDevice(value, activeCwd)) continue;
|
|
700
990
|
if (value.startsWith("~")) {
|
|
701
|
-
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path
|
|
991
|
+
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path cannot be resolved safely", stage: stage.index, path: value });
|
|
702
992
|
continue;
|
|
703
993
|
}
|
|
704
|
-
const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
|
|
705
994
|
const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
|
|
706
995
|
if (!resolved.inside) {
|
|
707
|
-
|
|
996
|
+
if (profile !== "balanced" || classified.mode !== "read") {
|
|
997
|
+
const message = classified.mode === "write"
|
|
998
|
+
? "write access resolves outside the project and approved roots"
|
|
999
|
+
: classified.mode === "execute"
|
|
1000
|
+
? "execution operand resolves outside the project and approved roots"
|
|
1001
|
+
: classified.mode === "location"
|
|
1002
|
+
? "directory change resolves outside the project and approved roots"
|
|
1003
|
+
: classified.mode === "unknown"
|
|
1004
|
+
? "unknown path access resolves outside the project and approved roots"
|
|
1005
|
+
: "path resolves outside the project boundary";
|
|
1006
|
+
addFinding(findings, { code: classified.mode === "unknown" ? "unknown-path-access" : "outside-project", severity: "hard-block", message, stage: stage.index, path: value });
|
|
1007
|
+
} else if (externalPathUsesLink(resolved.absolute, fs)) {
|
|
1008
|
+
addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "external read traverses a symlink or junction", stage: stage.index, path: value });
|
|
1009
|
+
}
|
|
708
1010
|
} else if (rootProblem === "missing") {
|
|
709
1011
|
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
|
|
710
1012
|
} else if (rootProblem === "link") {
|
|
@@ -741,8 +1043,9 @@ function inspectHardBlocks(
|
|
|
741
1043
|
: SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
|
|
742
1044
|
if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
|
|
743
1045
|
for (const nestedCommand of nestedCommands) {
|
|
744
|
-
const
|
|
745
|
-
for (const
|
|
1046
|
+
const nested = inspectHardBlocks(analyzeBashCommand(nestedCommand), activeCwd, fs, profile, depth + 1, projectCwd, allowedPaths, protectedPaths);
|
|
1047
|
+
for (const access of nested.accesses) accesses.push({ ...access, stage: stage.index });
|
|
1048
|
+
for (const finding of nested.findings) {
|
|
746
1049
|
addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
|
|
747
1050
|
}
|
|
748
1051
|
}
|
|
@@ -762,7 +1065,24 @@ function inspectHardBlocks(
|
|
|
762
1065
|
addFinding(findings, { code: "remote-script-execution", severity: "hard-block", message: "command executes code received from a remote source", stage: stage.index });
|
|
763
1066
|
}
|
|
764
1067
|
}
|
|
765
|
-
|
|
1068
|
+
|
|
1069
|
+
const externalReadStages = new Set(accesses.filter((access) => access.external && access.mode === "read").map((access) => access.stage));
|
|
1070
|
+
for (let index = 0; index < analysis.stages.length; index++) {
|
|
1071
|
+
const stage = analysis.stages[index]!;
|
|
1072
|
+
const argv = effectiveArgv(stage.argv);
|
|
1073
|
+
const name = commandName(argv[0]);
|
|
1074
|
+
if (!REMOTE_COMMANDS.has(name) && !STDIN_NETWORK_COMMANDS.has(name)) continue;
|
|
1075
|
+
const hasUpload = STDIN_NETWORK_COMMANDS.has(name)
|
|
1076
|
+
|| argv.slice(1).some((arg) => /^(?:-d|-F|-T|--data(?:-binary|-raw|-urlencode)?|--form|--upload-file|--post-file|-InFile|-Body)(?:=|$)/i.test(arg));
|
|
1077
|
+
const directlyReadsExternal = accesses.some((access) => access.stage === stage.index && access.external && access.mode === "read");
|
|
1078
|
+
const pipedExternalRead = hasUpload && index > 0
|
|
1079
|
+
&& (analysis.stages[index - 1]?.operatorAfter === "|" || analysis.stages[index - 1]?.operatorAfter === "|&")
|
|
1080
|
+
&& externalReadStages.has(index - 1);
|
|
1081
|
+
if (directlyReadsExternal || pipedExternalRead) {
|
|
1082
|
+
addFinding(findings, { code: "external-read-exfiltration", severity: "hard-block", message: "network command can upload data read outside the approved project roots", stage: stage.index });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return { findings, accesses };
|
|
766
1086
|
}
|
|
767
1087
|
|
|
768
1088
|
function shellCommandFlag(name: string, argv: string[]): number {
|
|
@@ -859,37 +1179,88 @@ function balancedCompleteShellLimits(command: string): Partial<CheckPolicyLimits
|
|
|
859
1179
|
};
|
|
860
1180
|
}
|
|
861
1181
|
|
|
1182
|
+
const NETWORK_COMMANDS = new Set([
|
|
1183
|
+
"curl", "wget", "fetch", "invoke-webrequest", "iwr", "invoke-restmethod", "irm",
|
|
1184
|
+
"nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
|
|
1185
|
+
"gh", "glab",
|
|
1186
|
+
]);
|
|
1187
|
+
|
|
1188
|
+
function stageNetworkCommand(stage: BashStage): string | undefined {
|
|
1189
|
+
const argv = effectiveArgv(stage.argv);
|
|
1190
|
+
const name = commandName(argv[0]);
|
|
1191
|
+
if (NETWORK_COMMANDS.has(name)) return name;
|
|
1192
|
+
const subcommand = (argv[1] ?? "").toLowerCase();
|
|
1193
|
+
if (name === "git" && new Set(["clone", "fetch", "pull", "push", "ls-remote"]).has(subcommand)) return `git ${subcommand}`;
|
|
1194
|
+
if (["npm", "pnpm", "yarn"].includes(name)
|
|
1195
|
+
&& new Set(["add", "audit", "install", "publish", "search", "update", "upgrade", "view", "info"]).has(subcommand)) {
|
|
1196
|
+
return `${name} ${subcommand}`;
|
|
1197
|
+
}
|
|
1198
|
+
if (name === "bun" && new Set(["add", "install", "publish", "update"]).has(subcommand)) return `bun ${subcommand}`;
|
|
1199
|
+
if (["pip", "pip3"].includes(name) && new Set(["download", "index", "install", "search"]).has(subcommand)) return `${name} ${subcommand}`;
|
|
1200
|
+
if (name === "cargo" && new Set(["install", "login", "owner", "publish", "search"]).has(subcommand)) return `cargo ${subcommand}`;
|
|
1201
|
+
if (name === "go" && new Set(["get", "install"]).has(subcommand)) return `go ${subcommand}`;
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Return deterministic network intent from the same shell analysis used by Check mode. */
|
|
1206
|
+
export function checkNetworkIntent(analysis: BashAnalysis, depth = 0): CheckNetworkIntent {
|
|
1207
|
+
const commands: string[] = [];
|
|
1208
|
+
for (const stage of analysis.stages) {
|
|
1209
|
+
const direct = stageNetworkCommand(stage);
|
|
1210
|
+
if (direct) commands.push(direct);
|
|
1211
|
+
if (depth >= 2) continue;
|
|
1212
|
+
const argv = effectiveArgv(stage.argv);
|
|
1213
|
+
const nested = stage.substitutions.map((substitution) => substitution.text);
|
|
1214
|
+
const flag = shellCommandFlag(commandName(argv[0]), argv);
|
|
1215
|
+
if (flag >= 0 && argv[flag + 1]) nested.push(argv[flag + 1]!);
|
|
1216
|
+
for (const command of nested) {
|
|
1217
|
+
commands.push(...checkNetworkIntent(analyzeBashCommand(command), depth + 1).commands);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const unique = [...new Set(commands)].sort();
|
|
1221
|
+
return { access: unique.length > 0 ? "host" : "none", commands: unique };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
862
1224
|
/** Analyze a bash tool call and return a deterministic profile decision. */
|
|
863
1225
|
export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPolicyResult {
|
|
864
1226
|
const profile = options.profile ?? "balanced";
|
|
1227
|
+
const exactCommand = options.command;
|
|
865
1228
|
const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
|
|
866
1229
|
const analysis = analyzeBashCommand(options.command, limits);
|
|
867
|
-
const findings = inspectHardBlocks(
|
|
1230
|
+
const { findings, accesses } = inspectHardBlocks(
|
|
868
1231
|
analysis,
|
|
869
1232
|
options.cwd,
|
|
870
1233
|
options.fileSystem ?? nodeFileSystem,
|
|
1234
|
+
profile,
|
|
871
1235
|
0,
|
|
872
1236
|
options.cwd,
|
|
873
1237
|
options.allowedPaths,
|
|
1238
|
+
options.protectedPaths,
|
|
874
1239
|
);
|
|
1240
|
+
const network = checkNetworkIntent(analysis);
|
|
875
1241
|
if (findings.some((item) => item.severity === "hard-block")) {
|
|
876
|
-
return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1242
|
+
return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
877
1243
|
}
|
|
878
1244
|
|
|
879
1245
|
if (profile === "ask") {
|
|
880
1246
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
|
|
881
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1247
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
882
1248
|
}
|
|
883
1249
|
|
|
884
1250
|
for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
|
|
885
1251
|
if (profile === "balanced") {
|
|
886
|
-
if (findings.length) return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1252
|
+
if (findings.length) return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
887
1253
|
return {
|
|
1254
|
+
exactCommand,
|
|
888
1255
|
profile,
|
|
889
1256
|
decision: "allow",
|
|
890
|
-
reason:
|
|
1257
|
+
reason: accesses.some((access) => access.external)
|
|
1258
|
+
? "complete command uses only deterministically classified external reads"
|
|
1259
|
+
: "complete project-local command passed deterministic hard rules",
|
|
891
1260
|
findings: [],
|
|
1261
|
+
accesses,
|
|
892
1262
|
analysis,
|
|
1263
|
+
network,
|
|
893
1264
|
};
|
|
894
1265
|
}
|
|
895
1266
|
|
|
@@ -899,11 +1270,11 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
|
|
|
899
1270
|
|
|
900
1271
|
const allNarrowReads = !hasComplexShell && analysis.stages.length === 1 && analysis.stages.every(isNarrowRead);
|
|
901
1272
|
if (allNarrowReads) {
|
|
902
|
-
return { profile, decision: "allow", reason: "narrow project-local inspection", findings: [], analysis };
|
|
1273
|
+
return { exactCommand, profile, decision: "allow", reason: "narrow project-local inspection", findings: [], accesses, analysis, network };
|
|
903
1274
|
}
|
|
904
1275
|
|
|
905
1276
|
if (!findings.length) addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
|
|
906
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1277
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
907
1278
|
}
|
|
908
1279
|
|
|
909
1280
|
/** Analyze direct executable arguments without converting the arguments to shell text. */
|
|
@@ -932,11 +1303,12 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
932
1303
|
}
|
|
933
1304
|
|
|
934
1305
|
const argv = [options.executable, ...options.args];
|
|
1306
|
+
const exactCommand = JSON.stringify(argv);
|
|
935
1307
|
const stage: BashStage = {
|
|
936
1308
|
index: 0,
|
|
937
1309
|
start: 0,
|
|
938
1310
|
end: 0,
|
|
939
|
-
text:
|
|
1311
|
+
text: exactCommand,
|
|
940
1312
|
pipeline: 0,
|
|
941
1313
|
argv,
|
|
942
1314
|
envAssignments: {},
|
|
@@ -957,7 +1329,17 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
957
1329
|
};
|
|
958
1330
|
const projectCwd = options.projectCwd ?? options.cwd;
|
|
959
1331
|
const fs = options.fileSystem ?? nodeFileSystem;
|
|
960
|
-
const findings = inspectHardBlocks(
|
|
1332
|
+
const { findings, accesses } = inspectHardBlocks(
|
|
1333
|
+
analysis,
|
|
1334
|
+
options.cwd,
|
|
1335
|
+
fs,
|
|
1336
|
+
profile,
|
|
1337
|
+
0,
|
|
1338
|
+
projectCwd,
|
|
1339
|
+
options.allowedPaths,
|
|
1340
|
+
options.protectedPaths,
|
|
1341
|
+
);
|
|
1342
|
+
const network = checkNetworkIntent(analysis);
|
|
961
1343
|
const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
|
|
962
1344
|
const executionRootProblem = executionDirectory.additional
|
|
963
1345
|
? additionalRootProblem(executionDirectory.root, fs)
|
|
@@ -992,21 +1374,26 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
992
1374
|
});
|
|
993
1375
|
}
|
|
994
1376
|
if (findings.some((item) => item.severity === "hard-block")) {
|
|
995
|
-
return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1377
|
+
return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
996
1378
|
}
|
|
997
1379
|
if (profile === "ask") {
|
|
998
1380
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "ask profile requires review for every command" });
|
|
999
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1381
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
1000
1382
|
}
|
|
1001
1383
|
for (const finding of inspectSuspiciousExecution(analysis)) addFinding(findings, finding);
|
|
1002
1384
|
if (profile === "balanced") {
|
|
1003
|
-
if (findings.length) return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
1385
|
+
if (findings.length) return { exactCommand, profile, decision: "block", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
1004
1386
|
return {
|
|
1387
|
+
exactCommand,
|
|
1005
1388
|
profile,
|
|
1006
1389
|
decision: "allow",
|
|
1007
|
-
reason:
|
|
1390
|
+
reason: accesses.some((access) => access.external)
|
|
1391
|
+
? "complete process uses only deterministically classified external reads"
|
|
1392
|
+
: "complete project-local process passed deterministic hard rules",
|
|
1008
1393
|
findings: [],
|
|
1394
|
+
accesses,
|
|
1009
1395
|
analysis,
|
|
1396
|
+
network,
|
|
1010
1397
|
};
|
|
1011
1398
|
}
|
|
1012
1399
|
if (analysis.mutationIntent.possible) {
|
|
@@ -1015,15 +1402,18 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
1015
1402
|
const narrowRead = isNarrowRead(stage);
|
|
1016
1403
|
if (narrowRead) {
|
|
1017
1404
|
return {
|
|
1405
|
+
exactCommand,
|
|
1018
1406
|
profile,
|
|
1019
1407
|
decision: "allow",
|
|
1020
1408
|
reason: "narrow project-local inspection",
|
|
1021
1409
|
findings: [],
|
|
1410
|
+
accesses,
|
|
1022
1411
|
analysis,
|
|
1412
|
+
network,
|
|
1023
1413
|
};
|
|
1024
1414
|
}
|
|
1025
1415
|
if (!findings.length) {
|
|
1026
1416
|
addFinding(findings, { code: "unrecognized-command", severity: "review", message: "command is not a rigorously narrow built-in policy case" });
|
|
1027
1417
|
}
|
|
1028
|
-
return { profile, decision: "ask", reason: findings[0]!.message, findings, analysis };
|
|
1418
|
+
return { exactCommand, profile, decision: "ask", reason: findings[0]!.message, findings, accesses, analysis, network };
|
|
1029
1419
|
}
|