Skip to content

Commit 87d39e7

Browse files
Rome-1claude
andauthored
fix(audit): redact secret-named env-assignment prefixes before logging (ob-y5ep) (#191)
The audit logger wrote command lines through `scanner.redact()`, which only masked substrings matching a known secret *pattern*. A command run as `RAFTER_API_KEY=<value> rafter ...` logged the value in plaintext: the key's shape matches no built-in pattern, so `redact()` left it untouched — leaking real API keys into `~/.rafter/audit.jsonl` despite `redact_secrets: true`. Add env-assignment redaction to the shared redaction path (`PatternEngine.redactText` / `redact_text`), so every `scanner.redact(command)` call site benefits (both `logCommandIntercepted` and `logPolicyOverride`). Any `NAME=VALUE` token whose NAME looks secret-bearing (`/(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$/i`, which includes RAFTER_API_KEY) has its VALUE masked with the codebase's existing char-mask. This is additive to the existing pattern-based redaction — it catches values that match no pattern but sit behind a secret-named env var. Benign assignments like `FOO=bar` and `NODE_ENV=production` are left untouched. Both implementations updated in lockstep (identical regex, ordering, and mask). Tests added in both suites: a `RAFTER_API_KEY=<val> rafter scan .` command must not leak the raw value into the log, and benign assignments must be preserved. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2df71a9 commit 87d39e7

6 files changed

Lines changed: 196 additions & 4 deletions

File tree

node/src/core/pattern-engine.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
2424
const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
2525
const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/;
2626

27+
/**
28+
* Matches an environment-assignment prefix in a command line: an identifier
29+
* followed immediately by `=` and a run of non-whitespace (the value). Used to
30+
* find `NAME=VALUE` tokens like `RAFTER_API_KEY=<secret> rafter ...` so their
31+
* value can be redacted before the command is written to the audit log.
32+
*/
33+
const ENV_ASSIGN_RE = /(^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S+)/g;
34+
35+
/**
36+
* A `NAME` in a `NAME=VALUE` assignment is treated as secret-bearing when it
37+
* ends in a credential-suggesting word (e.g. RAFTER_API_KEY, GITHUB_TOKEN,
38+
* DB_PASSWORD, AUTH). Values behind such names are redacted even when they
39+
* don't match any known secret pattern (that's the whole point — the value of
40+
* a bespoke API key won't match a built-in pattern, but it's still a secret).
41+
* Plain names like FOO or NODE_ENV do not match, so `FOO=bar` is left intact.
42+
*/
43+
const SECRET_ENV_NAME_RE = /(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$/i;
44+
2745
export class PatternEngine {
2846
private patterns: Pattern[];
2947

@@ -85,10 +103,18 @@ export class PatternEngine {
85103
}
86104

87105
/**
88-
* Redact text by replacing sensitive patterns
106+
* Redact text by replacing sensitive patterns.
107+
*
108+
* Two layers, both additive:
109+
* 1. Env-assignment redaction: any `NAME=VALUE` whose NAME looks
110+
* secret-bearing has its VALUE masked, even if the VALUE matches no
111+
* known pattern. This catches leaks like `RAFTER_API_KEY=<key> rafter …`
112+
* in a logged command line, where the key's shape is unknown.
113+
* 2. Pattern-based redaction: values that match a built-in secret pattern
114+
* are masked wherever they appear.
89115
*/
90116
redactText(text: string): string {
91-
let redacted = text;
117+
let redacted = this.redactEnvAssignments(text);
92118

93119
for (const pattern of this.patterns) {
94120
const regex = this.createRegex(pattern.regex);
@@ -100,6 +126,16 @@ export class PatternEngine {
100126
return redacted;
101127
}
102128

129+
/**
130+
* Mask the VALUE of every `NAME=VALUE` token whose NAME looks
131+
* secret-bearing. Non-secret names (FOO, NODE_ENV, …) are left untouched.
132+
*/
133+
private redactEnvAssignments(text: string): string {
134+
return text.replace(ENV_ASSIGN_RE, (full, prefix: string, name: string, value: string) =>
135+
SECRET_ENV_NAME_RE.test(name) ? `${prefix}${name}=${this.redact(value)}` : full
136+
);
137+
}
138+
103139
/**
104140
* Check if text contains any sensitive patterns
105141
*/

node/tests/audit-logger.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ describe("AuditLogger", () => {
141141
expect(onDisk).not.toContain(token);
142142
});
143143

144+
// ob-y5ep: a RAFTER_API_KEY env-assignment prefix leaked verbatim because
145+
// the key's value matches no built-in secret pattern. Redaction must key
146+
// off the secret-named env var, not just the value's shape.
147+
it("logCommandIntercepted redacts a RAFTER_API_KEY env-assignment prefix", () => {
148+
const logger = new AuditLogger(logPath);
149+
const key = "sk-deadbeefdeadbeef";
150+
logger.logCommandIntercepted(`RAFTER_API_KEY=${key} rafter scan .`, true, "allowed");
151+
const entries = logger.read();
152+
const logged = entries[0].action?.command as string;
153+
expect(logged).not.toContain(key);
154+
expect(logged).toContain("RAFTER_API_KEY=");
155+
expect(logged).toMatch(/\*/);
156+
// and never on disk
157+
expect(fs.readFileSync(logPath, "utf-8")).not.toContain(key);
158+
});
159+
160+
it("logCommandIntercepted preserves benign env assignments", () => {
161+
const logger = new AuditLogger(logPath);
162+
logger.logCommandIntercepted("FOO=bar NODE_ENV=production rafter scan .", true, "allowed");
163+
const logged = logger.read()[0].action?.command as string;
164+
expect(logged).toContain("FOO=bar");
165+
expect(logged).toContain("NODE_ENV=production");
166+
});
167+
144168
it("auto-populates cwd and gitRepo on every entry", () => {
145169
const logger = new AuditLogger(logPath);
146170
logger.logCommandIntercepted("ls", true, "allowed");

node/tests/pattern-engine.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,36 @@ line 3`;
240240
expect(critical.length).toBe(2);
241241
expect(critical.every(p => p.severity === "critical")).toBe(true);
242242
});
243+
244+
// ob-y5ep: env-assignment redaction. A value whose NAME looks secret-bearing
245+
// must be masked even when the value matches no known secret pattern.
246+
describe("env-assignment redaction (ob-y5ep)", () => {
247+
const engine = new PatternEngine(testPatterns);
248+
249+
it("redacts a RAFTER_API_KEY value that matches no pattern", () => {
250+
const out = engine.redactText("RAFTER_API_KEY=sk-deadbeefdeadbeef rafter scan .");
251+
expect(out).not.toContain("sk-deadbeefdeadbeef");
252+
expect(out).toContain("RAFTER_API_KEY=");
253+
expect(out).toContain("rafter scan .");
254+
});
255+
256+
it("redacts values behind common secret-named env vars", () => {
257+
for (const name of ["API_KEY", "GITHUB_TOKEN", "DB_PASSWORD", "AWS_SECRET", "AUTH", "PASSWD"]) {
258+
const out = engine.redactText(`${name}=supersecretvalue123 next`);
259+
expect(out, name).not.toContain("supersecretvalue123");
260+
}
261+
});
262+
263+
it("leaves benign assignments untouched", () => {
264+
expect(engine.redactText("FOO=bar")).toBe("FOO=bar");
265+
expect(engine.redactText("NODE_ENV=production")).toBe("NODE_ENV=production");
266+
expect(engine.redactText("PATH=/usr/bin rafter scan .")).toBe("PATH=/usr/bin rafter scan .");
267+
});
268+
269+
it("redacts only the secret assignment in a mixed command", () => {
270+
const out = engine.redactText("FOO=bar SECRET_TOKEN=abcdef1234567890 rafter");
271+
expect(out).toContain("FOO=bar");
272+
expect(out).not.toContain("abcdef1234567890");
273+
});
274+
});
243275
});

python/rafter_cli/core/pattern_engine.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ class PatternMatch:
3333
_LOWERCASE_IDENT_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$")
3434
_QUOTED_VALUE_RE = re.compile(r"""['\"]([^'\"]+)['\"]""")
3535

36+
# Matches an environment-assignment prefix in a command line: an identifier
37+
# followed immediately by ``=`` and a run of non-whitespace (the value). Used
38+
# to find ``NAME=VALUE`` tokens like ``RAFTER_API_KEY=<secret> rafter ...`` so
39+
# their value can be redacted before the command is written to the audit log.
40+
_ENV_ASSIGN_RE = re.compile(r"(^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S+)")
41+
42+
# A ``NAME`` in a ``NAME=VALUE`` assignment is treated as secret-bearing when
43+
# it ends in a credential-suggesting word (RAFTER_API_KEY, GITHUB_TOKEN,
44+
# DB_PASSWORD, AUTH, ...). Values behind such names are redacted even when they
45+
# don't match any known secret pattern -- the value of a bespoke API key won't
46+
# match a built-in pattern, but it's still a secret. Plain names like FOO or
47+
# NODE_ENV do not match, so ``FOO=bar`` is left intact.
48+
_SECRET_ENV_NAME_RE = re.compile(
49+
r"(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$",
50+
re.IGNORECASE,
51+
)
52+
3653

3754
class PatternEngine:
3855
def __init__(self, patterns: Sequence[Pattern]):
@@ -76,8 +93,18 @@ def scan_with_position(self, text: str) -> list[PatternMatch]:
7693
return matches
7794

7895
def redact_text(self, text: str) -> str:
79-
"""Replace all pattern matches in *text* with redacted versions."""
80-
result = text
96+
"""Replace all pattern matches in *text* with redacted versions.
97+
98+
Two layers, both additive:
99+
1. Env-assignment redaction: any ``NAME=VALUE`` whose NAME looks
100+
secret-bearing has its VALUE masked, even if the VALUE matches no
101+
known pattern. This catches leaks like
102+
``RAFTER_API_KEY=<key> rafter ...`` in a logged command line,
103+
where the key's shape is unknown.
104+
2. Pattern-based redaction: values matching a built-in secret
105+
pattern are masked wherever they appear.
106+
"""
107+
result = self._redact_env_assignments(text)
81108
for pattern in self._patterns:
82109
compiled = self._compile(pattern.regex)
83110
if compiled is None:
@@ -88,6 +115,18 @@ def redact_text(self, text: str) -> str:
88115
)
89116
return result
90117

118+
@staticmethod
119+
def _redact_env_assignments(text: str) -> str:
120+
"""Mask the VALUE of every ``NAME=VALUE`` token whose NAME looks
121+
secret-bearing. Non-secret names (FOO, NODE_ENV, ...) are untouched."""
122+
def _sub(m: re.Match) -> str:
123+
prefix, name, value = m.group(1), m.group(2), m.group(3)
124+
if _SECRET_ENV_NAME_RE.search(name):
125+
return f"{prefix}{name}={PatternEngine._redact(value)}"
126+
return m.group(0)
127+
128+
return _ENV_ASSIGN_RE.sub(_sub, text)
129+
91130
def has_matches(self, text: str) -> bool:
92131
return len(self.scan(text)) > 0
93132

python/tests/test_audit_logger_redaction.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,37 @@ def test_log_command_intercepted_redacts_github_token(audit_path, enabled_config
5555
assert entry["eventType"] == "command_intercepted"
5656

5757

58+
def test_log_command_intercepted_redacts_rafter_api_key_env_prefix(audit_path, enabled_config):
59+
"""ob-y5ep: a RAFTER_API_KEY=<val> env prefix leaked verbatim because the
60+
key's value matches no built-in pattern. It must be redacted anyway."""
61+
logger = AuditLogger(log_path=audit_path)
62+
key = "sk-deadbeefdeadbeef"
63+
logger.log_command_intercepted(
64+
f"RAFTER_API_KEY={key} rafter scan .",
65+
passed=True,
66+
action_taken="allowed",
67+
)
68+
on_disk = audit_path.read_text()
69+
assert key not in on_disk, "raw RAFTER_API_KEY leaked into audit.jsonl"
70+
entry = json.loads(on_disk.strip())
71+
assert "RAFTER_API_KEY=" in entry["action"]["command"]
72+
assert "*" in entry["action"]["command"]
73+
74+
75+
def test_log_command_intercepted_preserves_benign_env_assignments(audit_path, enabled_config):
76+
"""ob-y5ep: non-secret env assignments must survive redaction intact."""
77+
logger = AuditLogger(log_path=audit_path)
78+
logger.log_command_intercepted(
79+
"FOO=bar NODE_ENV=production rafter scan .",
80+
passed=True,
81+
action_taken="allowed",
82+
)
83+
entry = json.loads(audit_path.read_text().strip())
84+
command = entry["action"]["command"]
85+
assert "FOO=bar" in command
86+
assert "NODE_ENV=production" in command
87+
88+
5889
def test_log_command_intercepted_preserves_risk_assessment(audit_path, enabled_config):
5990
logger = AuditLogger(log_path=audit_path)
6091
logger.log_command_intercepted("rm -rf /", passed=False, action_taken="blocked")

python/tests/test_pattern_engine.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,33 @@ def test_all_patterns_present():
299299
assert len(DEFAULT_SECRET_PATTERNS) >= 22
300300
names = [p.name for p in DEFAULT_SECRET_PATTERNS]
301301
assert len(names) == len(set(names)), "pattern names must be unique"
302+
303+
304+
# -- Env-assignment redaction (ob-y5ep) --------------------------------------
305+
# A value whose NAME looks secret-bearing must be masked even when the value
306+
# matches no known secret pattern (e.g. a bespoke RAFTER_API_KEY).
307+
308+
def test_env_redaction_masks_rafter_api_key_value():
309+
out = _engine().redact_text("RAFTER_API_KEY=sk-deadbeefdeadbeef rafter scan .")
310+
assert "sk-deadbeefdeadbeef" not in out
311+
assert "RAFTER_API_KEY=" in out
312+
assert "rafter scan ." in out
313+
314+
315+
def test_env_redaction_masks_common_secret_named_vars():
316+
for name in ("API_KEY", "GITHUB_TOKEN", "DB_PASSWORD", "AWS_SECRET", "AUTH", "PASSWD"):
317+
out = _engine().redact_text(f"{name}=supersecretvalue123 next")
318+
assert "supersecretvalue123" not in out, name
319+
320+
321+
def test_env_redaction_preserves_benign_assignments():
322+
engine = _engine()
323+
assert engine.redact_text("FOO=bar") == "FOO=bar"
324+
assert engine.redact_text("NODE_ENV=production") == "NODE_ENV=production"
325+
assert engine.redact_text("PATH=/usr/bin rafter scan .") == "PATH=/usr/bin rafter scan ."
326+
327+
328+
def test_env_redaction_only_touches_secret_assignment_in_mixed_command():
329+
out = _engine().redact_text("FOO=bar SECRET_TOKEN=abcdef1234567890 rafter")
330+
assert "FOO=bar" in out
331+
assert "abcdef1234567890" not in out

0 commit comments

Comments
 (0)