diff --git a/README.md b/README.md index 9141e2a634..fedbded0d0 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ rtk session # Show RTK adoption across recent sessions ```bash -u, --ultra-compact # ASCII icons, inline format (extra token savings) -v, --verbose # Increase verbosity (-v, -vv, -vvv) + --no-redact # Disable PII redaction for this invocation ``` ## Examples @@ -400,8 +401,16 @@ exclude_commands = ["curl", "playwright"] # skip rewrite for these [tee] enabled = true # save raw output on failure (default: true) mode = "failures" # "failures", "always", or "never" + +[redaction] +enabled = true # mask PII/secrets in ALL output, incl. proxy (default: true) ``` +PII (emails, phone numbers, PAN, Aadhaar, card numbers, secrets) is redacted +as `[REDACTED:]` before output reaches the LLM — see +[docs/redaction.md](docs/redaction.md) for categories, custom patterns, +allowlist and known gaps. + When a command fails, RTK saves the full unfiltered output so the LLM can read it without re-executing: ``` diff --git a/docs/redaction.md b/docs/redaction.md new file mode 100644 index 0000000000..b14a35ae89 --- /dev/null +++ b/docs/redaction.md @@ -0,0 +1,78 @@ +# PII Redaction + +RTK redacts PII and secrets from **all** command output before it reaches the +LLM — including `rtk proxy` raw passthrough. Redaction is **ON by default**. + +## What gets redacted + +| Category | Examples | Replacement | +|----------|----------|-------------| +| `email` | `ravi@example.com` | `[REDACTED:email]` | +| `phone` | `+91 9876543210`, `+1 415 555 0132`, `(022) 4000 1234` | `[REDACTED:phone]` | +| `pan` | Indian PAN `ABCDE1234F` | `[REDACTED:pan]` | +| `aadhaar` | 12-digit numbers that pass the **Verhoeff** checksum | `[REDACTED:aadhaar]` | +| `card` | 13–19 digit numbers that pass the **Luhn** checksum (spaces/dashes allowed) | `[REDACTED:card]` | +| `secrets` | AWS access keys (`AKIA…`/`ASIA…`), JWTs, `Bearer` tokens, PEM private-key blocks, `api_key=`/`password=`/`secret=` assignments | `[REDACTED:aws_key]`, `[REDACTED:jwt]`, `[REDACTED:token]`, `[REDACTED:private_key]`, `[REDACTED:secret]` | +| `ip` | IPv4 addresses (loopback `127.*` and `0.0.0.0` stay visible) | `[REDACTED:ip]` | + +Checksum gating (Luhn/Verhoeff) prevents false positives on commit SHAs, +epoch timestamps, UUIDs, job IDs and other long digit runs. The per-category +tag (rather than `****`) keeps output debuggable without leaking the value. + +## Why this design is leak-proof + +Redaction runs where raw process output is **first captured** +(`exec_capture`, `run_streaming`, `run_fallback`, pipe mode, and a +line-buffered copy loop in proxy mode) — not at print time. Filters, the +tee/recovery file, token tracking and the `never_worse` guard only ever see +already-redacted text, so no downstream "show raw instead" logic can +resurrect PII. + +In proxy mode output is redacted per complete line, so a value split across +the 8 KiB pipe read boundary is still caught. + +## Disabling / tuning + +Per invocation: + +```bash +rtk --no-redact proxy git log # raw output for this run only +``` + +Persistently, in `~/.config/rtk/config.toml` +(macOS: `~/Library/Application Support/rtk/config.toml`): + +```toml +[redaction] +enabled = true # master switch (default: true) +email = true # per-category toggles (default: true) +phone = true +pan = true +aadhaar = true +card = true +secrets = true +ip = true + +# lines matching these regexes are never redacted (e.g. test fixtures) +allowlist = ["EXAMPLE-DO-NOT-REDACT"] + +# extra org-specific patterns +[[redaction.custom]] +name = "employee_id" +pattern = "EMP-\\d{6}" +``` + +Custom patterns are replaced with `[REDACTED:]`. An invalid custom +regex is skipped with a warning — it never breaks the command. + +## Known accepted gaps + +- Commands with **no filter match** in the fallback path, and + `RunMode::Passthrough` commands, inherit stdio directly (interactive + tools, TTYs, `$EDITOR`) — RTK never sees their bytes, so it cannot redact + them. Same class of bypass as documented for pre-redaction `rtk proxy`. +- In proxy mode, a single line longer than **1 MiB** with no newline is + force-flushed in segments to bound memory; a value split exactly across + that forced flush point can be missed (pathological output only). +- The `allowlist` is line-scoped; multi-line PEM blocks are redacted before + the allowlist is applied. diff --git a/scripts/claude-srtk-hook.sh b/scripts/claude-srtk-hook.sh new file mode 100755 index 0000000000..8cc5cf0054 --- /dev/null +++ b/scripts/claude-srtk-hook.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Claude Code PreToolUse hook: rewrite Bash commands to their srtk equivalent. +# srtk = rtk build with PII redaction (slice-ravichopra/rtk, feat/pii-redaction). +# Only rewrites the command text — permission checks still apply to the +# rewritten command as usual (no permissionDecision is emitted). +# Exits 0 with no output → command runs unchanged. +# +# Installed to ~/.claude/hooks/rtk-rewrite.sh by scripts/install-srtk.sh. +# Requires jq (brew install jq). + +SRTK="$HOME/.local/bin/srtk" +[ -x "$SRTK" ] || exit 0 + +JQ="$(command -v jq)" +[ -n "$JQ" ] || exit 0 + +INPUT=$(cat) +CMD=$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.command // empty' 2>/dev/null) +[ -n "$CMD" ] || exit 0 + +# Never rewrite commands already using rtk/srtk. +case "$CMD" in + srtk\ *|rtk\ *|*"/srtk "*|*"/rtk "*) exit 0 ;; +esac + +# srtk rewrite exit codes: 0 = rewritten (allow-listed), 3 = rewritten (no +# allow rule yet — Claude will ask as usual), 1 = no equivalent, 2 = deny. +REWRITTEN=$("$SRTK" rewrite "$CMD" 2>/dev/null) +rc=$? +{ [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; } || exit 0 +[ -n "$REWRITTEN" ] || exit 0 + +# srtk emits "rtk ..." — force the srtk binary name. +REWRITTEN="srtk ${REWRITTEN#rtk }" + +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":%s}}}' \ + "$(printf '%s' "$REWRITTEN" | "$JQ" -Rs .)" diff --git a/scripts/install-srtk.sh b/scripts/install-srtk.sh new file mode 100755 index 0000000000..b2aae25693 --- /dev/null +++ b/scripts/install-srtk.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Install srtk (rtk with PII redaction) and wire it into Claude Code. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/slice-ravichopra/rtk/feat/pii-redaction/scripts/install-srtk.sh | bash +# +# What it does: +# 1. Downloads the srtk binary (Apple Silicon) to ~/.local/bin/srtk +# 2. Installs the Claude Code rewrite hook to ~/.claude/hooks/rtk-rewrite.sh +# 3. Registers the hook and renames rtk permission rules to srtk in +# ~/.claude/settings.json (backup written first) +# 4. Adds an "always use srtk" note to ~/.claude/RTK.md +# +# Safe to re-run (idempotent). + +set -euo pipefail + +REPO="slice-ravichopra/rtk" +TAG="${SRTK_TAG:-v0.42.4-pii.1}" +ASSET="srtk-darwin-arm64" +BIN="$HOME/.local/bin/srtk" +HOOK="$HOME/.claude/hooks/rtk-rewrite.sh" +SETTINGS="$HOME/.claude/settings.json" + +if [ "$(uname -sm)" != "Darwin arm64" ]; then + echo "error: prebuilt binary is Apple Silicon only — build from source:" >&2 + echo " git clone git@github.com:$REPO.git && cd rtk && cargo build --release" >&2 + echo " cp target/release/rtk $BIN" >&2 + exit 1 +fi + +command -v jq >/dev/null || { echo "installing jq..."; brew install jq; } + +echo "==> downloading srtk $TAG" +mkdir -p "$(dirname "$BIN")" +curl -fsSL -o "$BIN" "https://github.com/$REPO/releases/download/$TAG/$ASSET" +chmod +x "$BIN" +"$BIN" --version + +echo "==> smoke test" +OUT=$("$BIN" proxy sh -c 'echo probe a@b.co card 4111-1111-1111-1111') +echo "$OUT" | grep -q 'REDACTED:email' || { echo "error: redaction not working: $OUT" >&2; exit 1; } +echo " $OUT" + +echo "==> installing Claude Code hook" +mkdir -p "$(dirname "$HOOK")" +# Embedded inline (kept in sync with scripts/claude-srtk-hook.sh) so a single +# fetch installs everything — no second download that could version-skew. +cat >"$HOOK" <<'HOOK_EOF' +#!/bin/bash +# Claude Code PreToolUse hook: rewrite Bash commands to their srtk equivalent. +# srtk = rtk build with PII redaction (slice-ravichopra/rtk, feat/pii-redaction). +# Only rewrites the command text — permission checks still apply to the +# rewritten command as usual (no permissionDecision is emitted). +# Exits 0 with no output → command runs unchanged. + +SRTK="$HOME/.local/bin/srtk" +[ -x "$SRTK" ] || exit 0 + +JQ="$(command -v jq)" +[ -n "$JQ" ] || exit 0 + +INPUT=$(cat) +CMD=$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.command // empty' 2>/dev/null) +[ -n "$CMD" ] || exit 0 + +# Never rewrite commands already using rtk/srtk. +case "$CMD" in + srtk\ *|rtk\ *|*"/srtk "*|*"/rtk "*) exit 0 ;; +esac + +# srtk rewrite exit codes: 0 = rewritten (allow-listed), 3 = rewritten (no +# allow rule yet — Claude will ask as usual), 1 = no equivalent, 2 = deny. +REWRITTEN=$("$SRTK" rewrite "$CMD" 2>/dev/null) +rc=$? +{ [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; } || exit 0 +[ -n "$REWRITTEN" ] || exit 0 + +# srtk emits "rtk ..." — force the srtk binary name. +REWRITTEN="srtk ${REWRITTEN#rtk }" + +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":%s}}}' \ + "$(printf '%s' "$REWRITTEN" | "$JQ" -Rs .)" +HOOK_EOF +chmod +x "$HOOK" + +if [ -f "$SETTINGS" ]; then + cp "$SETTINGS" "$SETTINGS.bak-srtk" + echo "==> updating $SETTINGS (backup: $SETTINGS.bak-srtk)" + # Rename rtk permission rules to srtk. + tmp=$(mktemp) + sed -e 's/Bash(rtk /Bash(srtk /g' -e 's/Bash(AWS_PROFILE=\* rtk /Bash(AWS_PROFILE=* srtk /g' \ + "$SETTINGS" >"$tmp" + # Register the PreToolUse hook if not present. + jq --arg hook "$HOOK" ' + .hooks.PreToolUse = ((.hooks.PreToolUse // []) as $p + | if ($p | map(select(.hooks[]?.command == $hook)) | length) > 0 then $p + else $p + [{"matcher":"Bash","hooks":[{"type":"command","command":$hook}]}] end) + ' "$tmp" >"$SETTINGS" + rm -f "$tmp" + jq empty "$SETTINGS" || { echo "error: settings.json broken — restoring backup" >&2; cp "$SETTINGS.bak-srtk" "$SETTINGS"; exit 1; } +else + echo "==> creating $SETTINGS with hook registration" + mkdir -p "$(dirname "$SETTINGS")" + jq -n --arg hook "$HOOK" \ + '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":$hook}]}]}}' \ + >"$SETTINGS" +fi + +RTK_MD="$HOME/.claude/RTK.md" +if [ ! -f "$RTK_MD" ] || ! grep -q '## srtk — PII Redaction' "$RTK_MD"; then + echo "==> adding srtk note to $RTK_MD" + cat >>"$RTK_MD" <<'EOF' + +## srtk — PII Redaction (IMPORTANT) + +**Always use `srtk`, never plain `rtk`.** srtk masks PII (emails, phones, +PAN, Aadhaar, card numbers, AWS keys, IPs) as `[REDACTED:]` before +output reaches the LLM — including `srtk proxy`. Disable per run with +`--no-redact` (only when the user asks). Details: +https://github.com/slice-ravichopra/rtk/blob/feat/pii-redaction/docs/redaction.md +EOF +fi + +echo +echo "✅ srtk installed. Restart Claude Code sessions to pick up the hook." diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 563d54a10f..42621e7731 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -258,6 +258,7 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { if buf.len() > RAW_CAP { anyhow::bail!("stdin exceeds {} byte limit", RAW_CAP); } + let buf = crate::core::redact::redact(&buf).into_owned(); let filter_fn = match filter_name { Some(name) => resolve_filter(name).ok_or_else(|| { diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c9..a149ce104e 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -21,6 +21,67 @@ pub struct Config { pub hooks: HooksConfig, #[serde(default)] pub limits: LimitsConfig, + #[serde(default)] + pub redaction: RedactionConfig, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RedactionConfig { + /// Master switch. PII redaction is ON by default; `--no-redact` overrides per invocation. + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_true")] + pub email: bool, + #[serde(default = "default_true")] + pub phone: bool, + /// Indian PAN card (ABCDE1234F). + #[serde(default = "default_true")] + pub pan: bool, + /// Aadhaar: 12-digit candidates are Verhoeff-validated before redaction. + #[serde(default = "default_true")] + pub aadhaar: bool, + /// Card numbers: 13-19 digit candidates are Luhn-validated before redaction. + #[serde(default = "default_true")] + pub card: bool, + /// AWS keys, JWTs, bearer tokens, PEM private keys, api_key=/password= assignments. + #[serde(default = "default_true")] + pub secrets: bool, + /// IPv4 addresses (loopback and 0.0.0.0 are kept visible). + #[serde(default = "default_true")] + pub ip: bool, + /// Extra user-defined patterns: [[redaction.custom]] name = "...", pattern = "..." + #[serde(default)] + pub custom: Vec, + /// Lines matching any of these regexes are never redacted. + #[serde(default)] + pub allowlist: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomPattern { + pub name: String, + pub pattern: String, +} + +impl Default for RedactionConfig { + fn default() -> Self { + Self { + enabled: true, + email: true, + phone: true, + pan: true, + aadhaar: true, + card: true, + secrets: true, + ip: true, + custom: Vec::new(), + allowlist: Vec::new(), + } + } +} + +fn default_true() -> bool { + true } #[derive(Debug, Serialize, Deserialize, Default)] @@ -281,6 +342,75 @@ enabled = true assert!(config.telemetry.consent_given.is_none()); } + #[test] + fn test_redaction_config_default_enabled() { + let config = Config::default(); + assert!(config.redaction.enabled); + assert!(config.redaction.email); + assert!(config.redaction.phone); + assert!(config.redaction.pan); + assert!(config.redaction.aadhaar); + assert!(config.redaction.card); + assert!(config.redaction.secrets); + assert!(config.redaction.custom.is_empty()); + assert!(config.redaction.allowlist.is_empty()); + } + + #[test] + fn test_redaction_config_deserialize_disabled() { + let toml = r#" +[redaction] +enabled = false +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(!config.redaction.enabled); + // Category toggles keep their defaults when the section only sets `enabled`. + assert!(config.redaction.email); + } + + #[test] + fn test_redaction_config_per_category_toggle() { + let toml = r#" +[redaction] +email = false +card = false +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(config.redaction.enabled); + assert!(!config.redaction.email); + assert!(!config.redaction.card); + assert!(config.redaction.phone); + } + + #[test] + fn test_redaction_config_custom_patterns_deserialize() { + let toml = r#" +[redaction] +allowlist = ["EXAMPLE-DO-NOT-REDACT"] + +[[redaction.custom]] +name = "employee_id" +pattern = "EMP-\\d{6}" +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert_eq!(config.redaction.custom.len(), 1); + assert_eq!(config.redaction.custom[0].name, "employee_id"); + assert_eq!(config.redaction.custom[0].pattern, "EMP-\\d{6}"); + assert_eq!(config.redaction.allowlist, vec!["EXAMPLE-DO-NOT-REDACT"]); + } + + #[test] + fn test_config_without_redaction_section_is_valid() { + // Older configs that predate [redaction] must still parse, with redaction ON. + let toml = r#" +[tracking] +enabled = true +history_days = 90 +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(config.redaction.enabled); + } + #[test] fn test_telemetry_consent_roundtrip() { let toml = r#" diff --git a/src/core/mod.rs b/src/core/mod.rs index aaa747e083..46baa0e074 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod constants; pub mod display_helpers; pub mod filter; pub mod guard; +pub mod redact; pub mod runner; pub mod stream; pub mod tee; diff --git a/src/core/redact.rs b/src/core/redact.rs new file mode 100644 index 0000000000..1becda0f33 --- /dev/null +++ b/src/core/redact.rs @@ -0,0 +1,900 @@ +//! PII redaction: emails, phone numbers, Indian PAN, Aadhaar, card numbers, secrets. +//! +//! Applied centrally where raw command output is first captured (see +//! `core::stream::exec_capture`, `core::stream::run_streaming`, `run_fallback` +//! and the `rtk proxy` streaming loop in `main.rs`), so every rtk output path +//! is covered without touching each of the ~70 `cmds/**` filter modules. +//! Because redaction runs before `guard::never_worse` ever sees the strings, +//! the never-worse guard can never resurrect raw PII by picking the raw side. +//! +//! ON by default; disable via `[redaction] enabled = false` in +//! `~/.config/rtk/config.toml` or the global `--no-redact` flag. + +use std::borrow::Cow; +use std::io::{Read, Write}; +use std::sync::OnceLock; + +use lazy_static::lazy_static; +use regex::{Regex, RegexSet}; + +use crate::core::config::{Config, RedactionConfig}; + +lazy_static! { + static ref EMAIL_RE: Regex = + Regex::new(r"(?i)\b[a-z0-9][a-z0-9._%+\-]*@[a-z0-9][a-z0-9.\-]*\.[a-z]{2,}\b").unwrap(); + + // Indian mobile: optional +91 prefix, 10 digits starting 6-9. + static ref PHONE_IN_RE: Regex = + Regex::new(r"(?:\+91[\s\-]?)?\b[6-9]\d{9}\b").unwrap(); + + // Generic phone: only high-confidence shapes — a (area) prefix or a +CC + // prefix with grouped digits. Bare pairs like "1234 5678" are deliberately + // NOT matched: columnar numeric CLI output would false-positive constantly. + static ref PHONE_GENERIC_RE: Regex = Regex::new( + r"\b(?:\+\d{1,3}[\s\-]?)?\(\d{2,4}\)[\s\-]?\d{3,4}[\s\-]?\d{3,4}\b|\+\d{1,3}[\s\-]\d{3,4}[\s\-]\d{3,4}(?:[\s\-]\d{2,4})?\b" + ).unwrap(); + + static ref PAN_RE: Regex = Regex::new(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b").unwrap(); + + // Aadhaar candidate: 12 digits, optionally 4-4-4 grouped. Verhoeff-gated in code. + static ref AADHAAR_RE: Regex = + Regex::new(r"\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b").unwrap(); + + // Card candidate: 13-19 digits with optional single space/dash separators, + // starting and ending on a digit. Luhn-gated in code. + static ref CARD_RE: Regex = Regex::new(r"\b\d(?:[ \-]?\d){12,18}\b").unwrap(); + + // AKIA = long-term access key, ASIA = temporary (STS) access key. + static ref AWS_KEY_RE: Regex = Regex::new(r"\bA(?:KIA|SIA)[0-9A-Z]{16}\b").unwrap(); + + // IPv4 candidate: 4 dotted octets, not embedded in a longer dotted run + // (so semver "1.2.3" never matches and "1.2.3.4.5" is left alone). + // Octet range + loopback/unspecified skips are validated in code. + static ref IPV4_RE: Regex = + Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap(); + static ref JWT_RE: Regex = Regex::new( + r"\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b" + ).unwrap(); + static ref BEARER_RE: Regex = + Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*").unwrap(); + static ref PEM_RE: Regex = Regex::new( + r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----" + ).unwrap(); + static ref KV_SECRET_RE: Regex = Regex::new( + r#"(?i)\b(api[_-]?key|password|passwd|secret|access[_-]?key|auth[_-]?token)\s*[:=]\s*['"]?[^\s'",;]+['"]?"# + ).unwrap(); + + // Fast reject: if none of these match, the input is clean and the whole + // pipeline is skipped (single scan, no allocation). + static ref PRECHECK_SET: RegexSet = RegexSet::new([ + EMAIL_RE.as_str(), + PHONE_IN_RE.as_str(), + PHONE_GENERIC_RE.as_str(), + PAN_RE.as_str(), + AADHAAR_RE.as_str(), + CARD_RE.as_str(), + AWS_KEY_RE.as_str(), + IPV4_RE.as_str(), + JWT_RE.as_str(), + BEARER_RE.as_str(), + r"-----BEGIN", + KV_SECRET_RE.as_str(), + ]).unwrap(); +} + +/// Standard Luhn checksum over an ASCII-digit string. +fn luhn_valid(digits: &str) -> bool { + let sum: u32 = digits + .chars() + .rev() + .enumerate() + .map(|(i, c)| { + let d = c.to_digit(10).unwrap_or(0); + if i % 2 == 1 { + let doubled = d * 2; + if doubled > 9 { + doubled - 9 + } else { + doubled + } + } else { + d + } + }) + .sum(); + sum.is_multiple_of(10) +} + +// Standard Verhoeff tables (dihedral group D5) — see Wikipedia "Verhoeff algorithm". +const VERHOEFF_D: [[u8; 10]; 10] = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], + [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], + [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], + [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], + [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], + [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], + [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], + [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], + [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], +]; +const VERHOEFF_P: [[u8; 10]; 8] = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], + [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], + [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], + [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], + [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], + [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], + [7, 0, 4, 6, 9, 1, 3, 2, 5, 8], +]; + +/// Verhoeff checksum validation (Aadhaar check digit scheme). +fn verhoeff_valid(digits: &str) -> bool { + let mut c = 0u8; + for (i, ch) in digits.chars().rev().enumerate() { + let d = match ch.to_digit(10) { + Some(d) => d as usize, + None => return false, + }; + c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][d] as usize]; + } + c == 0 +} + +pub struct Redactor { + enabled: bool, + email: bool, + phone: bool, + pan: bool, + aadhaar: bool, + card: bool, + secrets: bool, + ip: bool, + custom: Vec<(String, Regex)>, + allowlist: Vec, +} + +static NO_REDACT_FLAG: OnceLock = OnceLock::new(); +static GLOBAL: OnceLock = OnceLock::new(); + +/// Record the `--no-redact` CLI flag. Call once in `main()` right after +/// `Cli::parse()`, before any command dispatch. +pub fn init_from_cli(no_redact: bool) { + NO_REDACT_FLAG.set(no_redact).ok(); +} + +/// Redact all configured PII categories from `input` using the process-wide +/// redactor (config + CLI flag, built once). `Cow::Borrowed` when clean. +pub fn redact(input: &str) -> Cow<'_, str> { + global().redact(input) +} + +/// Owned-line variant for streaming loops: reuses the incoming allocation when +/// the line is clean instead of copying it. +pub fn redact_line(line: String) -> String { + match global().redact(&line) { + Cow::Borrowed(_) => line, + Cow::Owned(o) => o, + } +} + +fn global() -> &'static Redactor { + GLOBAL.get_or_init(|| { + let cfg = Config::load().map(|c| c.redaction).unwrap_or_default(); + Redactor::from_config(&cfg, *NO_REDACT_FLAG.get().unwrap_or(&false)) + }) +} + +impl Redactor { + pub fn from_config(cfg: &RedactionConfig, no_redact_flag: bool) -> Self { + let compile = |name: &str, pattern: &str| -> Option { + match Regex::new(pattern) { + Ok(re) => Some(re), + Err(e) => { + eprintln!("[rtk] warning: invalid redaction pattern '{}': {}", name, e); + None + } + } + }; + Self { + enabled: cfg.enabled && !no_redact_flag, + email: cfg.email, + phone: cfg.phone, + pan: cfg.pan, + aadhaar: cfg.aadhaar, + card: cfg.card, + secrets: cfg.secrets, + ip: cfg.ip, + custom: cfg + .custom + .iter() + .filter_map(|c| compile(&c.name, &c.pattern).map(|re| (c.name.clone(), re))) + .collect(), + allowlist: cfg + .allowlist + .iter() + .filter_map(|p| compile("allowlist", p)) + .collect(), + } + } + + pub fn redact<'a>(&self, input: &'a str) -> Cow<'a, str> { + if !self.enabled { + return Cow::Borrowed(input); + } + if !PRECHECK_SET.is_match(input) && self.custom.is_empty() { + return Cow::Borrowed(input); + } + + let out = if self.allowlist.is_empty() { + self.redact_blob(input) + } else { + // Allowlist is line-scoped: PEM blocks (multi-line) are redacted + // first on the whole blob, then every non-allowlisted line goes + // through the remaining single-line stages. + let pem_done: Cow<'_, str> = if self.secrets { + PEM_RE.replace_all(input, "[REDACTED:private_key]") + } else { + Cow::Borrowed(input) + }; + let mut result = String::with_capacity(pem_done.len()); + for segment in pem_done.split_inclusive('\n') { + if self.allowlist.iter().any(|re| re.is_match(segment)) { + result.push_str(segment); + } else { + result.push_str(&self.redact_blob(segment)); + } + } + result + }; + + if out == input { + Cow::Borrowed(input) + } else { + Cow::Owned(out) + } + } + + /// Sequential replace pipeline, most-specific first. Each stage runs on the + /// previous stage's output, so `[REDACTED:*]` tags can't be re-matched by + /// looser later patterns — no overlapping-span resolution needed. + fn redact_blob(&self, input: &str) -> String { + let mut s = Cow::Borrowed(input); + if self.secrets { + s = chain(s, &PEM_RE, "[REDACTED:private_key]"); + s = chain(s, &AWS_KEY_RE, "[REDACTED:aws_key]"); + s = chain(s, &JWT_RE, "[REDACTED:jwt]"); + s = chain(s, &BEARER_RE, "Bearer [REDACTED:token]"); + s = chain(s, &KV_SECRET_RE, "$1=[REDACTED:secret]"); + } + if self.email { + s = chain(s, &EMAIL_RE, "[REDACTED:email]"); + } + if self.pan { + s = chain(s, &PAN_RE, "[REDACTED:pan]"); + } + if self.card { + s = chain_validated(s, &CARD_RE, "[REDACTED:card]", |d| { + (13..=19).contains(&d.len()) && luhn_valid(d) + }); + } + if self.aadhaar { + s = chain_validated(s, &AADHAAR_RE, "[REDACTED:aadhaar]", |d| { + d.len() == 12 && verhoeff_valid(d) + }); + } + if self.phone { + s = chain(s, &PHONE_IN_RE, "[REDACTED:phone]"); + s = chain(s, &PHONE_GENERIC_RE, "[REDACTED:phone]"); + } + if self.ip { + s = chain_matched(s, &IPV4_RE, "[REDACTED:ip]", |m| { + // All octets 0-255, and skip loopback/unspecified — masking + // "listening on 127.0.0.1" hurts debugging with zero PII value. + let octets: Vec> = m.split('.').map(|o| o.parse().ok()).collect(); + octets.len() == 4 + && octets.iter().all(|o| o.is_some()) + && !m.starts_with("127.") + && m != "0.0.0.0" + }); + } + for (name, re) in &self.custom { + let tag = format!("[REDACTED:{}]", name); + s = chain(s, re, &tag); + } + s.into_owned() + } +} + +/// Streaming copy for `rtk proxy`: accumulates bytes until a newline, redacts +/// each complete line, writes it immediately. PII can never straddle an 8 KiB +/// read boundary because redaction only runs on whole lines. The final partial +/// line (no trailing newline) is redacted and flushed at EOF. A single line +/// growing past 1 MiB is force-flushed to bound memory (a match spanning that +/// forced flush point can be missed — pathological-only, documented). +/// +/// When redaction is disabled (config or `--no-redact`) this degrades to the +/// original raw chunk copy — zero per-line overhead. +pub fn redacting_copy( + reader: R, + writer: W, + cap: usize, +) -> std::io::Result> { + copy_with(global(), reader, writer, cap) +} + +const MAX_LINE_BUF: usize = 1_048_576; + +fn copy_with( + redactor: &Redactor, + mut reader: R, + mut writer: W, + cap: usize, +) -> std::io::Result> { + let mut captured = Vec::new(); + let mut buf = [0u8; 8192]; + + let emit = |text: &[u8], captured: &mut Vec, writer: &mut W| -> std::io::Result<()> { + if captured.len() < cap { + let take = text.len().min(cap - captured.len()); + captured.extend_from_slice(&text[..take]); + } + writer.write_all(text)?; + writer.flush() + }; + + if !redactor.enabled { + loop { + let count = reader.read(&mut buf)?; + if count == 0 { + break; + } + emit(&buf[..count], &mut captured, &mut writer)?; + } + return Ok(captured); + } + + // Byte-level pending buffer: lossy UTF-8 conversion happens per complete + // line, so multibyte characters split across reads stay intact. + let mut pending: Vec = Vec::new(); + + loop { + let count = reader.read(&mut buf)?; + if count == 0 { + break; + } + pending.extend_from_slice(&buf[..count]); + + while let Some(pos) = pending.iter().position(|&b| b == b'\n') { + let line: Vec = pending.drain(..=pos).collect(); + let redacted = redactor + .redact(&String::from_utf8_lossy(&line)) + .into_owned(); + emit(redacted.as_bytes(), &mut captured, &mut writer)?; + } + + if pending.len() > MAX_LINE_BUF { + let redacted = redactor + .redact(&String::from_utf8_lossy(&pending)) + .into_owned(); + pending.clear(); + emit(redacted.as_bytes(), &mut captured, &mut writer)?; + } + } + + if !pending.is_empty() { + let redacted = redactor + .redact(&String::from_utf8_lossy(&pending)) + .into_owned(); + emit(redacted.as_bytes(), &mut captured, &mut writer)?; + } + + Ok(captured) +} + +fn chain<'a>(s: Cow<'a, str>, re: &Regex, rep: &str) -> Cow<'a, str> { + match re.replace_all(&s, rep) { + Cow::Borrowed(_) => s, + Cow::Owned(o) => Cow::Owned(o), + } +} + +/// Like [`chain`] but the match is only replaced when the raw match text +/// passes `valid` (octet range for IPs). +fn chain_matched<'a>( + s: Cow<'a, str>, + re: &Regex, + tag: &str, + valid: impl Fn(&str) -> bool, +) -> Cow<'a, str> { + let mut changed = false; + let replaced = re.replace_all(&s, |caps: ®ex::Captures| { + let m = caps.get(0).map_or("", |m| m.as_str()); + if valid(m) { + changed = true; + tag.to_string() + } else { + m.to_string() + } + }); + if changed { + Cow::Owned(replaced.into_owned()) + } else { + s + } +} + +/// Like [`chain`] but the match is only replaced when its digits pass `valid` +/// (Luhn for cards, Verhoeff for Aadhaar) — kills numeric false positives. +fn chain_validated<'a>( + s: Cow<'a, str>, + re: &Regex, + tag: &str, + valid: impl Fn(&str) -> bool, +) -> Cow<'a, str> { + let mut changed = false; + let replaced = re.replace_all(&s, |caps: ®ex::Captures| { + let m = caps.get(0).map_or("", |m| m.as_str()); + let digits: String = m.chars().filter(|c| c.is_ascii_digit()).collect(); + if valid(&digits) { + changed = true; + tag.to_string() + } else { + m.to_string() + } + }); + if changed { + Cow::Owned(replaced.into_owned()) + } else { + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn redactor() -> Redactor { + Redactor::from_config(&RedactionConfig::default(), false) + } + + fn apply(input: &str) -> String { + redactor().redact(input).into_owned() + } + + /// Build a Verhoeff-valid 12-digit synthetic Aadhaar-like number by brute + /// forcing the check digit. Never a real Aadhaar. + fn synthetic_valid_aadhaar() -> String { + let base = "23456789012"; + for check in 0..10 { + let candidate = format!("{}{}", base, check); + if verhoeff_valid(&candidate) { + return candidate; + } + } + unreachable!("one of the ten check digits must validate"); + } + + // --- positive: must redact --- + + #[test] + fn test_redact_email() { + let out = apply("contact ravi.chopra@slicebank.com now"); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(!out.contains("slicebank.com"), "out={}", out); + } + + #[test] + fn test_redact_indian_mobile_with_plus91() { + let out = apply("call +91 9876543210 today"); + assert!(out.contains("[REDACTED:phone]"), "out={}", out); + assert!(!out.contains("9876543210"), "out={}", out); + } + + #[test] + fn test_redact_indian_mobile_bare_10_digit() { + let out = apply("mobile: 9876543210"); + assert!(out.contains("[REDACTED:phone]"), "out={}", out); + } + + #[test] + fn test_redact_generic_phone_with_country_code() { + let out = apply("US office +1 415 555 0132"); + assert!(out.contains("[REDACTED:phone]"), "out={}", out); + } + + #[test] + fn test_redact_pan() { + let out = apply("PAN: ABCDE1234F"); + assert!(out.contains("[REDACTED:pan]"), "out={}", out); + assert!(!out.contains("ABCDE1234F"), "out={}", out); + } + + #[test] + fn test_redact_valid_aadhaar() { + let aadhaar = synthetic_valid_aadhaar(); + let out = apply(&format!("aadhaar no {}", aadhaar)); + assert!(out.contains("[REDACTED:aadhaar]"), "out={}", out); + assert!(!out.contains(&aadhaar), "out={}", out); + } + + #[test] + fn test_redact_luhn_valid_visa_16_digit() { + // Public Visa test number. + let out = apply("card 4111111111111111 charged"); + assert!(out.contains("[REDACTED:card]"), "out={}", out); + } + + #[test] + fn test_redact_luhn_valid_amex_15_digit() { + // Public Amex test number. + let out = apply("amex 378282246310005"); + assert!(out.contains("[REDACTED:card]"), "out={}", out); + } + + #[test] + fn test_redact_card_with_spaces_and_dashes() { + let spaced = apply("pay 4111 1111 1111 1111 now"); + assert!(spaced.contains("[REDACTED:card]"), "out={}", spaced); + let dashed = apply("pay 4111-1111-1111-1111 now"); + assert!(dashed.contains("[REDACTED:card]"), "out={}", dashed); + } + + #[test] + fn test_redact_aws_access_key() { + let out = apply("key AKIAIOSFODNN7EXAMPLE leaked"); + assert!(out.contains("[REDACTED:aws_key]"), "out={}", out); + } + + #[test] + fn test_redact_jwt() { + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + let out = apply(&format!("auth {}", jwt)); + assert!(out.contains("[REDACTED:jwt]"), "out={}", out); + assert!(!out.contains("dozjgNryP4J3jVmNHl0w5N"), "out={}", out); + } + + #[test] + fn test_redact_bearer_token() { + let out = apply("Authorization: Bearer abc123def456ghi789"); + assert!(out.contains("Bearer [REDACTED:token]"), "out={}", out); + assert!(!out.contains("abc123def456ghi789"), "out={}", out); + } + + #[test] + fn test_redact_pem_private_key_block() { + let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA7\nqqq\n-----END RSA PRIVATE KEY-----"; + let out = apply(&format!("dump:\n{}\ndone", pem)); + assert!(out.contains("[REDACTED:private_key]"), "out={}", out); + assert!(!out.contains("MIIEpAIBAAKCAQEA7"), "out={}", out); + } + + #[test] + fn test_redact_generic_api_key_assignment() { + let out = apply("api_key=sk_live_abcdef123456 loaded"); + assert!(out.contains("[REDACTED:secret]"), "out={}", out); + assert!(!out.contains("sk_live_abcdef123456"), "out={}", out); + } + + #[test] + fn test_redact_generic_password_assignment() { + let out = apply(r#"password: "hunter2""#); + assert!(out.contains("[REDACTED:secret]"), "out={}", out); + assert!(!out.contains("hunter2"), "out={}", out); + } + + #[test] + fn test_redact_multiple_pii_in_one_line() { + let out = apply("ravi@slicebank.com paid with 4111111111111111 from +91 9876543210"); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(out.contains("[REDACTED:card]"), "out={}", out); + assert!(out.contains("[REDACTED:phone]"), "out={}", out); + } + + // --- config-driven behavior --- + + #[test] + fn test_disabled_via_config_returns_unchanged() { + let cfg = RedactionConfig { + enabled: false, + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let input = "email ravi@slicebank.com"; + assert_eq!(r.redact(input), input); + } + + #[test] + fn test_no_redact_flag_overrides_enabled_config() { + let r = Redactor::from_config(&RedactionConfig::default(), true); + let input = "email ravi@slicebank.com"; + assert_eq!(r.redact(input), input); + } + + #[test] + fn test_per_category_toggle_disables_only_that_category() { + let cfg = RedactionConfig { + email: false, + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let out = r + .redact("ravi@slicebank.com and card 4111111111111111") + .into_owned(); + assert!(out.contains("ravi@slicebank.com"), "out={}", out); + assert!(out.contains("[REDACTED:card]"), "out={}", out); + } + + #[test] + fn test_custom_pattern_from_config_is_applied() { + let cfg = RedactionConfig { + custom: vec![crate::core::config::CustomPattern { + name: "employee_id".into(), + pattern: r"EMP-\d{6}".into(), + }], + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let out = r.redact("badge EMP-123456 scanned").into_owned(); + assert!(out.contains("[REDACTED:employee_id]"), "out={}", out); + assert!(!out.contains("EMP-123456"), "out={}", out); + } + + #[test] + fn test_allowlist_pattern_suppresses_redaction() { + let cfg = RedactionConfig { + allowlist: vec!["EXAMPLE-DO-NOT-REDACT".into()], + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let input = "fixture@example.com EXAMPLE-DO-NOT-REDACT\nreal ravi@slicebank.com\n"; + let out = r.redact(input).into_owned(); + assert!(out.contains("fixture@example.com"), "out={}", out); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(!out.contains("ravi@slicebank.com"), "out={}", out); + } + + #[test] + fn test_invalid_custom_pattern_is_skipped_not_fatal() { + let cfg = RedactionConfig { + custom: vec![crate::core::config::CustomPattern { + name: "broken".into(), + pattern: "([unclosed".into(), + }], + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + assert_eq!(r.redact("plain text"), "plain text"); + } + + // --- negative: must NOT redact (false-positive guards) --- + + #[test] + fn test_git_commit_sha_not_redacted() { + let short = "abc1234"; + let long = "34550b4d9e549c90d235051c6acc05586ac9b29e"; + let input = format!("commit {} and {}", short, long); + assert_eq!(apply(&input), input); + } + + #[test] + fn test_iso_timestamp_not_redacted() { + let input = "at 2026-07-11T12:34:56Z done"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_unix_epoch_millis_not_redacted() { + // 13-digit epoch that fails Luhn (guarded by an assert on the test data). + let epoch = "1770000000001"; + assert!(!luhn_valid(epoch), "pick a non-Luhn epoch for this test"); + let input = format!("ts={}", epoch); + assert_eq!(apply(&input), input); + } + + #[test] + fn test_luhn_invalid_16_digit_number_not_redacted() { + let n = "1234567890123456"; + assert!(!luhn_valid(n)); + let input = format!("job id {}", n); + assert_eq!(apply(&input), input); + } + + #[test] + fn test_verhoeff_invalid_12_digit_number_not_redacted() { + let n = "123456789012"; + assert!(!verhoeff_valid(n)); + let input = format!("ref {}", n); + assert_eq!(apply(&input), input); + } + + #[test] + fn test_ipv4_address_redacted() { + let out = apply("connection from 10.17.63.85 accepted"); + assert!(out.contains("[REDACTED:ip]"), "out={}", out); + assert!(!out.contains("10.17.63.85"), "out={}", out); + } + + #[test] + fn test_ipv4_with_port_redacted_keeps_port() { + let out = apply("listening on 192.168.1.100:8080"); + assert_eq!(out, "listening on [REDACTED:ip]:8080"); + } + + #[test] + fn test_loopback_and_unspecified_ip_not_redacted() { + let input = "dev server on 127.0.0.1:3000 bound to 0.0.0.0"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_invalid_octet_not_redacted_as_ip() { + let input = "weird id 300.400.500.600 here"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_ip_category_toggle() { + let cfg = RedactionConfig { + ip: false, + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let input = "from 10.17.63.85"; + assert_eq!(r.redact(input), input); + } + + #[test] + fn test_redact_aws_temporary_sts_key() { + let out = apply("AccessKeyId ASIA5F4KDTECND7YZQPX used"); + assert!(out.contains("[REDACTED:aws_key]"), "out={}", out); + assert!(!out.contains("ASIA5F4KDTECND7YZQPX"), "out={}", out); + } + + #[test] + fn test_semver_not_redacted() { + let input = "rtk v1.2.3 and lib 1.42.4 released"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_line_col_position_not_redacted() { + let input = "error at src/main.rs:2570:12"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_uuid_not_redacted() { + let input = "id d5efc3b9-7297-4e72-864e-7a1be53850c9"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_docker_sha256_digest_not_redacted() { + let input = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_already_redacted_text_not_double_processed() { + let input = "[REDACTED:email] and [REDACTED:card]"; + assert_eq!(apply(input), input); + } + + #[test] + fn test_clean_output_returns_borrowed_cow() { + let r = redactor(); + let input = "Compiling rtk v0.28.2 — 3 warnings emitted"; + assert!(matches!(r.redact(input), Cow::Borrowed(_))); + } + + #[test] + fn test_fixture_mixed_pii_golden() { + let raw = include_str!("../../tests/fixtures/redact_mixed_pii_raw.txt"); + let expected = include_str!("../../tests/fixtures/redact_mixed_pii_expected.txt"); + assert_eq!(apply(raw), expected); + } + + // --- redacting_copy (proxy streaming path) --- + + /// Reader yielding fixed-size chunks — simulates PII split across reads. + struct ChunkReader<'a> { + data: &'a [u8], + pos: usize, + chunk: usize, + } + + impl Read for ChunkReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.chunk.min(buf.len()).min(self.data.len() - self.pos); + buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } + } + + fn run_copy(input: &[u8], chunk: usize, enabled: bool) -> (String, Vec) { + let cfg = RedactionConfig { + enabled, + ..RedactionConfig::default() + }; + let r = Redactor::from_config(&cfg, false); + let reader = ChunkReader { + data: input, + pos: 0, + chunk, + }; + let mut out: Vec = Vec::new(); + let captured = copy_with(&r, reader, &mut out, 1_048_576).expect("copy ok"); + (String::from_utf8_lossy(&out).into_owned(), captured) + } + + #[test] + fn test_redacting_copy_redacts_lines() { + let (out, captured) = run_copy(b"hello ravi@slicebank.com\nclean line\n", 8192, true); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(out.contains("clean line\n"), "out={}", out); + assert!(!out.contains("ravi@slicebank.com"), "out={}", out); + assert_eq!(captured, out.as_bytes()); + } + + #[test] + fn test_redacting_copy_pii_split_across_chunk_boundary() { + // 3-byte reads guarantee the email spans many read() calls. + let (out, _) = run_copy(b"contact ravi.chopra@slicebank.com now\n", 3, true); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(!out.contains("slicebank.com"), "out={}", out); + } + + #[test] + fn test_redacting_copy_partial_final_line_no_trailing_newline() { + let (out, _) = run_copy(b"final ravi@slicebank.com", 8192, true); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + } + + #[test] + fn test_redacting_copy_disabled_is_raw_passthrough() { + let input = b"raw ravi@slicebank.com stays\n"; + let (out, captured) = run_copy(input, 8192, false); + assert_eq!(out.as_bytes(), input); + assert_eq!(captured, input); + } + + #[test] + fn test_redacting_copy_no_newline_valve_bounds_memory_and_still_redacts_tail() { + // >1 MiB single line with PII at the very end: valve force-flushes the + // padding, EOF flush still redacts the trailing email. + let mut input = vec![b'a'; MAX_LINE_BUF + 100]; + input.extend_from_slice(b" ravi@slicebank.com"); + let (out, _) = run_copy(&input, 8192, true); + assert!(out.contains("[REDACTED:email]"), "tail not redacted"); + assert!(!out.contains("ravi@slicebank.com")); + } + + // --- checksum algorithms in isolation --- + + #[test] + fn test_luhn_valid_known_test_card_numbers() { + // Public payment-processor test numbers, never real cards. + assert!(luhn_valid("4111111111111111")); // Visa + assert!(luhn_valid("5555555555554444")); // Mastercard + assert!(luhn_valid("378282246310005")); // Amex + } + + #[test] + fn test_luhn_rejects_invalid_checksum() { + assert!(!luhn_valid("4111111111111112")); + } + + #[test] + fn test_verhoeff_valid_known_value() { + // Classic worked example: "236" with check digit 3. + assert!(verhoeff_valid("2363")); + } + + #[test] + fn test_verhoeff_rejects_invalid_checksum() { + assert!(!verhoeff_valid("2364")); + } +} diff --git a/src/core/stream.rs b/src/core/stream.rs index cd573c4cc9..8cd8ed1d08 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -1,3 +1,4 @@ +use crate::core::redact; use anyhow::{Context, Result}; use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::process::{Command, Stdio}; @@ -341,7 +342,10 @@ pub fn run_streaming( let tx_out = tx.clone(); let stdout_thread = std::thread::spawn(move || { for line in BufReader::new(stdout).lines().map_while(Result::ok) { - if tx_out.send(StreamLine::Stdout(line)).is_err() { + if tx_out + .send(StreamLine::Stdout(redact::redact_line(line))) + .is_err() + { break; } } @@ -349,7 +353,10 @@ pub fn run_streaming( let tx_err = tx; let stderr_thread = std::thread::spawn(move || { for line in BufReader::new(stderr).lines().map_while(Result::ok) { - if tx_err.send(StreamLine::Stderr(line)).is_err() { + if tx_err + .send(StreamLine::Stderr(redact::redact_line(line))) + .is_err() + { break; } } @@ -417,7 +424,11 @@ pub fn run_streaming( let stderr_thread = std::thread::spawn(move || -> String { let mut raw_err = String::new(); let mut capped = false; - for line in BufReader::new(stderr).lines().map_while(Result::ok) { + for line in BufReader::new(stderr) + .lines() + .map_while(Result::ok) + .map(redact::redact_line) + { if raw_err.len() + line.len() < RAW_CAP { raw_err.push_str(&line); raw_err.push('\n'); @@ -436,7 +447,11 @@ pub fn run_streaming( FilterMode::Passthrough => unreachable!("handled by early-return above"), FilterMode::Streaming(_) => unreachable!("handled by is_streaming branch"), FilterMode::Buffered(filter_fn) => { - for line in BufReader::new(stdout).lines().map_while(Result::ok) { + for line in BufReader::new(stdout) + .lines() + .map_while(Result::ok) + .map(redact::redact_line) + { if raw_stdout.len() + line.len() < RAW_CAP { raw_stdout.push_str(&line); raw_stdout.push('\n'); @@ -461,7 +476,11 @@ pub fn run_streaming( } } FilterMode::CaptureOnly => { - for line in BufReader::new(stdout).lines().map_while(Result::ok) { + for line in BufReader::new(stdout) + .lines() + .map_while(Result::ok) + .map(redact::redact_line) + { if raw_stdout.len() + line.len() < RAW_CAP { raw_stdout.push_str(&line); raw_stdout.push('\n'); @@ -535,8 +554,8 @@ pub fn exec_capture(cmd: &mut Command) -> Result { cmd.stdin(Stdio::null()); let output = cmd.output().context("Failed to execute command")?; Ok(CaptureResult { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + stdout: redact::redact(&String::from_utf8_lossy(&output.stdout)).into_owned(), + stderr: redact::redact(&String::from_utf8_lossy(&output.stderr)).into_owned(), exit_code: status_to_exit_code(output.status), }) } @@ -546,8 +565,8 @@ pub fn exec_capture_stdin(cmd: &mut Command) -> Result { cmd.stdin(Stdio::inherit()); let output = cmd.output().context("Failed to execute command")?; Ok(CaptureResult { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + stdout: redact::redact(&String::from_utf8_lossy(&output.stdout)).into_owned(), + stderr: redact::redact(&String::from_utf8_lossy(&output.stderr)).into_owned(), exit_code: status_to_exit_code(output.status), }) } diff --git a/src/main.rs b/src/main.rs index 1c85bb55f9..606a67729c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,10 @@ struct Cli { /// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma) #[arg(long = "skip-env", global = true)] skip_env: bool, + + /// Disable PII redaction for this invocation (overrides [redaction] enabled=true) + #[arg(long = "no-redact", global = true)] + no_redact: bool, } #[derive(Debug, Subcommand)] @@ -1306,8 +1310,10 @@ fn run_fallback(parse_error: clap::Error) -> Result { match result { Ok(output) => { let exit_code = core::utils::exit_code_from_output(&output, &raw_command); - let stdout_raw = String::from_utf8_lossy(&output.stdout); - let stderr_raw = String::from_utf8_lossy(&output.stderr); + let stdout_raw = + core::redact::redact(&String::from_utf8_lossy(&output.stdout)).into_owned(); + let stderr_raw = + core::redact::redact(&String::from_utf8_lossy(&output.stderr)).into_owned(); // Merge stderr into the text to filter when filter_stderr is enabled; // otherwise emit stderr directly so it is always visible. @@ -1553,6 +1559,9 @@ fn run_cli() -> Result { } }; + // Must run before any command dispatch so every output path sees the flag. + core::redact::init_from_cli(cli.no_redact); + // Warn if installed hook is outdated/missing (1/day, non-blocking). // Skip for Gain — it shows its own inline hook warning. if !matches!(cli.command, Commands::Gain { .. }) { @@ -2447,7 +2456,6 @@ fn run_cli() -> Result { } Commands::Proxy { args } => { - use std::io::{Read, Write}; use std::process::Stdio; use std::sync::atomic::{AtomicU32, Ordering}; use std::thread; @@ -2553,48 +2561,16 @@ fn run_cli() -> Result { const CAP: usize = 1_048_576; + // Line-buffered redacting copy: PII (emails, cards, secrets…) is + // masked before it ever reaches the caller, even in proxy mode. let stdout_handle = thread::spawn(move || -> std::io::Result> { - let mut reader = stdout_pipe; - let mut captured = Vec::new(); - let mut buf = [0u8; 8192]; - - loop { - let count = reader.read(&mut buf)?; - if count == 0 { - break; - } - if captured.len() < CAP { - let take = count.min(CAP - captured.len()); - captured.extend_from_slice(&buf[..take]); - } - let mut out = std::io::stdout().lock(); - out.write_all(&buf[..count])?; - out.flush()?; - } - - Ok(captured) + let out = std::io::stdout(); + core::redact::redacting_copy(stdout_pipe, out.lock(), CAP) }); let stderr_handle = thread::spawn(move || -> std::io::Result> { - let mut reader = stderr_pipe; - let mut captured = Vec::new(); - let mut buf = [0u8; 8192]; - - loop { - let count = reader.read(&mut buf)?; - if count == 0 { - break; - } - if captured.len() < CAP { - let take = count.min(CAP - captured.len()); - captured.extend_from_slice(&buf[..take]); - } - let mut err = std::io::stderr().lock(); - err.write_all(&buf[..count])?; - err.flush()?; - } - - Ok(captured) + let err = std::io::stderr(); + core::redact::redacting_copy(stderr_pipe, err.lock(), CAP) }); let status = child diff --git a/tests/fixtures/redact_mixed_pii_expected.txt b/tests/fixtures/redact_mixed_pii_expected.txt new file mode 100644 index 0000000000..0e6f32d167 --- /dev/null +++ b/tests/fixtures/redact_mixed_pii_expected.txt @@ -0,0 +1,9 @@ +commit 34550b4d9e549c90d235051c6acc05586ac9b29e +Author: Ravi Chopra <[REDACTED:email]> +Date: 2026-07-11T12:34:56Z + charge card [REDACTED:card] via gateway + customer phone [REDACTED:phone] + PAN [REDACTED:pan] on file + api_key=[REDACTED:secret] rotated + id d5efc3b9-7297-4e72-864e-7a1be53850c9 at [REDACTED:ip]:8080 + job 1234567890123456 finished in 1.42.4 diff --git a/tests/fixtures/redact_mixed_pii_raw.txt b/tests/fixtures/redact_mixed_pii_raw.txt new file mode 100644 index 0000000000..3d6620d601 --- /dev/null +++ b/tests/fixtures/redact_mixed_pii_raw.txt @@ -0,0 +1,9 @@ +commit 34550b4d9e549c90d235051c6acc05586ac9b29e +Author: Ravi Chopra +Date: 2026-07-11T12:34:56Z + charge card 4111 1111 1111 1111 via gateway + customer phone +91 9876543210 + PAN ABCDE1234F on file + api_key=sk_live_abc123 rotated + id d5efc3b9-7297-4e72-864e-7a1be53850c9 at 192.168.1.100:8080 + job 1234567890123456 finished in 1.42.4 diff --git a/tests/redact_proxy_test.rs b/tests/redact_proxy_test.rs new file mode 100644 index 0000000000..948fb750ff --- /dev/null +++ b/tests/redact_proxy_test.rs @@ -0,0 +1,125 @@ +//! End-to-end proof of PII redaction (src/core/redact.rs) through the real +//! binary: proxy passthrough, pipe mode, and the never-worse interaction. +//! +//! These tests assume the default config (`[redaction]` absent or enabled) — +//! the same guarantee older configs get after upgrade. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn rtk(args: &[&str]) -> String { + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run rtk"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +fn rtk_stdin(args: &[&str], input: &str) -> String { + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait rtk"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn proxy_redacts_pii_in_child_stdout() { + let out = rtk(&[ + "proxy", + "sh", + "-c", + "echo contact ravi.chopra@slicebank.com", + ]); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(!out.contains("ravi.chopra@slicebank.com"), "out={}", out); +} + +#[test] +fn proxy_redacts_pii_in_child_stderr() { + let out = rtk(&["proxy", "sh", "-c", "echo card 4111111111111111 >&2"]); + assert!(out.contains("[REDACTED:card]"), "out={}", out); + assert!(!out.contains("4111111111111111"), "out={}", out); +} + +#[test] +fn proxy_no_redact_flag_disables_redaction() { + let out = rtk(&[ + "--no-redact", + "proxy", + "sh", + "-c", + "echo contact ravi.chopra@slicebank.com", + ]); + assert!(out.contains("ravi.chopra@slicebank.com"), "out={}", out); + assert!(!out.contains("[REDACTED:email]"), "out={}", out); +} + +#[test] +fn proxy_redacts_pii_split_across_chunk_boundary() { + // A >8 KiB line forces the email across multiple 8 KiB pipe reads; the + // line-buffered redactor must still catch it. + let out = rtk(&[ + "proxy", + "sh", + "-c", + r#"awk 'BEGIN{for(i=0;i<9000;i++)printf "a"; print " ravi.chopra@slicebank.com"}'"#, + ]); + assert!( + out.contains("[REDACTED:email]"), + "boundary-split email not redacted" + ); + assert!(!out.contains("ravi.chopra@slicebank.com")); +} + +#[test] +fn proxy_redacts_partial_final_line_no_trailing_newline() { + let out = rtk(&[ + "proxy", + "sh", + "-c", + "printf 'email ravi.chopra@slicebank.com'", + ]); + assert!( + out.contains("[REDACTED:email]"), + "EOF partial line not redacted" + ); + assert!(!out.contains("ravi.chopra@slicebank.com")); +} + +#[test] +fn pipe_mode_redacts_stdin() { + let out = rtk_stdin(&["pipe"], "user ravi.chopra@slicebank.com logged in\n"); + assert!(out.contains("[REDACTED:email]"), "out={}", out); + assert!(!out.contains("ravi.chopra@slicebank.com"), "out={}", out); +} + +#[test] +fn redaction_survives_never_worse_guard() { + // "a@b.co" (6 chars) redacts to "[REDACTED:email]" (16 chars) — the + // redacted form is LARGER than raw, so if redaction ran after the + // never-worse token guard, the guard would pick the raw side and leak. + let out = rtk_stdin(&["pipe"], "a@b.co\n"); + assert!( + out.contains("[REDACTED:email]"), + "never_worse resurrected raw PII: out={}", + out + ); + assert!(!out.contains("a@b.co"), "out={}", out); +}