8f825569ca0b3787396f444570511b8c7bfc33b6

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Allow static Bash environment assignments

Diff

  1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
  2index 6b5904ac796ec9f4c3a34a05d25bc3154c5297ad..2ff930c42c1c2f0822ffd9737d619e1e86175edc 100644
  3--- a/src/core/deterministic.ts
  4+++ b/src/core/deterministic.ts
  5@@ -88,6 +88,23 @@ function shellWords(command: string): string[] {
  6   });
  7 }
  8 
  9+const ENVIRONMENT_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
 10+
 11+function commandWords(command: string): string[] {
 12+  const words = shellWords(command);
 13+  const programIndex = words.findIndex((word) => !ENVIRONMENT_ASSIGNMENT_RE.test(word));
 14+  return programIndex === -1 ? [] : words.slice(programIndex);
 15+}
 16+
 17+function withoutEnvironmentAssignments(command: string): string {
 18+  let source = command.trimStart();
 19+  while (true) {
 20+    const assignment = source.match(/^[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+/)?.[0];
 21+    if (!assignment) return source;
 22+    source = source.slice(assignment.length);
 23+  }
 24+}
 25+
 26 function variableCharacter(character: string | undefined): boolean {
 27   return character !== undefined && /[A-Za-z0-9_]/.test(character);
 28 }
 29@@ -264,7 +281,7 @@ function hasOption(arguments_: string[], optionNames: ReadonlySet<string>): bool
 30 }
 31 
 32 function hasSecretPathOperand(command: string): boolean {
 33-  const [program, ...arguments_] = shellWords(command);
 34+  const [program, ...arguments_] = commandWords(command);
 35   if (!program || program === "cd") return false;
 36 
 37   if (program === "grep" || program === "rg") {
 38@@ -368,7 +385,7 @@ function isSafeFind(arguments_: string[]): boolean {
 39 }
 40 
 41 function checkHomeFind(command: string): Decision | undefined {
 42-  const [program, ...arguments_] = shellWords(command);
 43+  const [program, ...arguments_] = commandWords(command);
 44   const home = process.env.HOME;
 45   if (
 46     program !== "find" ||
 47@@ -383,7 +400,7 @@ function checkHomeFind(command: string): Decision | undefined {
 48 }
 49 
 50 function checkDestructiveRm(command: string): Decision | undefined {
 51-  const [program, ...arguments_] = shellWords(command);
 52+  const [program, ...arguments_] = commandWords(command);
 53   if (program !== "rm") return undefined;
 54 
 55   if (!positionalWords(arguments_).some(isDestructiveRmTarget)) return undefined;
 56@@ -404,7 +421,7 @@ function isDevNull(word: string): boolean {
 57 }
 58 
 59 function checkSystemPathMutation(command: string): Decision | undefined {
 60-  const [program, ...arguments_] = shellWords(command);
 61+  const [program, ...arguments_] = commandWords(command);
 62   if (!LOCAL_MUTATION_COMMANDS.has(program)) return undefined;
 63   if (!arguments_.some((argument) => argument.split("=", 2).some(isSystemPath))) return undefined;
 64   return {
 65@@ -420,7 +437,7 @@ function dockerRemovesVolumes(argument: string): boolean {
 66 
 67 function checkDocker(command: string, parsed: boolean): Decision | undefined {
 68   if (!parsed && hasShellControl(command)) return undefined;
 69-  const [program, ...arguments_] = shellWords(command);
 70+  const [program, ...arguments_] = commandWords(command);
 71   if (program !== "docker") return undefined;
 72 
 73   const commandIndex = arguments_.findIndex(
 74@@ -480,7 +497,7 @@ function isSpecialAllowedCommand(program: string, arguments_: string[]): boolean
 75 function checkAllow(command: string, parsed: boolean): Decision | undefined {
 76   const cmd = command.trim();
 77   if (!cmd || (!parsed && hasShellControl(cmd))) return undefined;
 78-  const [program, ...arguments_] = shellWords(cmd);
 79+  const [program, ...arguments_] = commandWords(cmd);
 80   if (!program || (!ALLOW_COMMANDS.has(program) && !isSpecialAllowedCommand(program, arguments_))) return undefined;
 81   return {
 82     decision: "allow",
 83@@ -542,13 +559,14 @@ function checkBash(command: string, redirects: readonly BashRedirect[] = [], par
 84   if (redirects.some((redirect) => redirect.dynamic)) return undefined;
 85   const redirectDecision = checkRedirects(redirects);
 86   if (redirectDecision) return redirectDecision;
 87+  const semanticCommand = withoutEnvironmentAssignments(cleaned);
 88   const askDecision =
 89-    checkAskPatterns(cleaned) ??
 90+    checkAskPatterns(semanticCommand) ??
 91     checkHomeFind(cleaned) ??
 92     checkDestructiveRm(cleaned) ??
 93     checkSystemPathMutation(cleaned);
 94   if (askDecision) return askDecision;
 95-  const [program, ...arguments_] = shellWords(cleaned);
 96+  const [program, ...arguments_] = commandWords(cleaned);
 97   if (hasUnsafeWordSyntax(source, program === "ls")) return undefined;
 98   if (program && hasInlineInterpreterCode(program, arguments_)) return undefined;
 99   return checkDocker(cleaned, parsed) ?? checkAllow(cleaned, parsed);
100diff --git a/src/core/rules.ts b/src/core/rules.ts
101index eeb2be5615b1327d16ee50330a57a391120ef989..cbe280bf3be91fb4de5c6e45abc186afc320097f 100644
102--- a/src/core/rules.ts
103+++ b/src/core/rules.ts
104@@ -1,7 +1,7 @@
105 import { DECISION_CATEGORIES } from "./types";
106 
107 // Bump to invalidate all cached decisions when rules change.
108-export const POLICY_VERSION = 36;
109+export const POLICY_VERSION = 37;
110 
111 // Trailing stderr redirections that are safe to strip before pattern matching.
112 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
113diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
114index ff8465775814ecac2306a97e46d53968aa1e95ac..3fc24d649735d25ad5743d9957da1b4e1eec95e2 100644
115--- a/test/core/deterministic.test.ts
116+++ b/test/core/deterministic.test.ts
117@@ -75,6 +75,17 @@ test("allows recognized deterministic command grammars", () => {
118   }
119 });
120 
121+test("supports static environment assignments", () => {
122+  expect(
123+    checkParsedBash("GIT_SEQUENCE_EDITOR=: GIT_EDITOR=true git rebase --onto origin/profiling 7c7f359", []),
124+  ).toMatchObject({ decision: "allow" });
125+  expect(checkDeterministic("bash", { command: "GIT_EDITOR=true git reset --hard" })).toMatchObject({
126+    decision: "ask",
127+  });
128+  expect(checkDeterministic("bash", { command: "EDITOR=true cat .env" })).toMatchObject({ decision: "ask" });
129+  expect(checkDeterministic("bash", { command: 'EDITOR="$EDITOR" git status' })?.decision).not.toBe("allow");
130+});
131+
132 test("allows static pathname globs only for ls", () => {
133   expect(checkDeterministic("bash", { command: "ls internal/*/" })).toMatchObject({ decision: "allow" });
134   expect(checkDeterministic("bash", { command: "ls internal/[ab]?/" })).toMatchObject({ decision: "allow" });