Skip to content

SECURITY: close RIPGREP_CONFIG_PATH RCE (#32) + ship Security dashboard subcommand - #33

Merged
thehoff merged 2 commits into
developfrom
fix/grep-rce-env-and-pre-args
May 18, 2026
Merged

thehoff merged 2 commits into
developfrom
fix/grep-rce-env-and-pre-args

Conversation

@thehoff

@thehoff thehoff commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

Two changes, both surfaced by tonight's adversarial security review of the new grep passthrough.

1. P1 RCE fix (issue #32)

Confirmed end-to-end this evening:

  • RIPGREP_CONFIG_PATH=/path/to/conf contextcrawler grep needle file.txt → arbitrary code execution via rg's --pre per-file preprocessor
  • No CLI args required — env-var-only attack vector
  • Reachable through BOTH canonical grep filter AND the new format-flag passthrough
  • All 4 attack paths reproduced pre-fix; first 3 (in-binary) blocked post-fix

Two-layer defense in src/core/utils.rs:

  • secure_rg_command() — strips RIPGREP_CONFIG_PATH / RIPGREP_CONFIG_FILE from inherited env before subprocess spawn
  • check_forbidden_rg_args() — rejects --pre, --pre-glob, --search-zip, -z (and bundled short forms like -cz per codex P1 catch). Helpful error points at escape hatch contextcrawler proxy rg ...

2. contextcrawler security dashboard

The CONTEXTCRAWLER.md template has long advertised this subcommand, but no implementation existed — invocations fell through to macOS /usr/bin/security. Now shipped:

  • Tirith binary resolution + status
  • Gate enabled / required state (env-aware)
  • Downgrade log path + tail of last 10 events
  • --all and --json modes
  • Scope note explaining env-var-attack defense is NOT in Tirith's scope — handled separately in secure_rg_command (makes the layered defense visible to operators)

Reviewed by Codex

  • P1: bundle bypass of -z via -cz / -rzL → fixed with short-letter bundle scanner
  • P2: line-oriented downgrade-log parsing would break on multi-line records → fixed with brace-balanced + serde_json-validated extraction
  • P3: hand-rolled JSON escape missing control chars → replaced with serde_json::json!() envelope

Verified

  • cargo test --bin contextcrawler — 2034 pass, 0 fail (+7 new secure_rg_tests)
  • Empirical PoC: all 3 in-binary attack paths blocked (env-canonical, env-bypass, CLI-canonical)
  • Bundle bypass test: -cz rejected; normal -cn still works
  • --json dashboard parses cleanly via python3 -m json.tool with all records intact

Closes #32

thehoff and others added 2 commits May 18, 2026 18:18
#32)

Two changes, one PR — both surfaced by tonight's adversarial security
review of the new grep passthrough.

## P1 RCE fix (issue #32)

Confirmed end-to-end this evening: `RIPGREP_CONFIG_PATH=/path/to/conf
contextcrawler grep needle file.txt` → arbitrary code execution via
rg's `--pre <script>` per-file preprocessor mechanism. **No CLI args
required** — the attack vector is env-only. Reachable through BOTH
the canonical grep filter (`src/cmds/system/grep_cmd.rs`) and the new
format-flag passthrough (`src/main.rs::run_grep_format_passthrough`).

Empirically verified all 4 attack paths (env-via-canonical,
env-via-bypass, CLI-via-canonical, control direct-rg) fired the
preprocessor pre-fix, and the first 3 are blocked post-fix.

Two-layer defense in `src/core/utils.rs`:

1. **`secure_rg_command(name)`** — wraps `resolved_command()` with
   `env_remove("RIPGREP_CONFIG_PATH")` + `env_remove("RIPGREP_CONFIG_FILE")`.
   Strips the hijack vector from the inherited env before spawn. Both
   call sites that invoke rg/grep migrated to it.

2. **`check_forbidden_rg_args(&[…])`** — rejects `--pre`, `--pre-glob`,
   `--search-zip`, `-z` (long-form and `=value` form). Returns an
   `Err` with a user-facing message pointing at the escape hatch
   `contextcrawler proxy rg ...`. Both call sites gate on this before
   spawning.

6 unit tests cover the deny-list (each short/long form, both `=value`
and space-separated, normal args still pass, error message mentions
escape hatch + issue ref).

## `contextcrawler security` dashboard

The CONTEXTCRAWLER.md template has long advertised `contextcrawler
security` as "Tirith defense-in-depth gate (if installed)" — but no
such subcommand existed. The invocation fell through `run_fallback`
to macOS's `/usr/bin/security` (keychain tool). Users had no
documented way to inspect Tirith status or see recent gate decisions.

`src/hooks/tirith_gate.rs::run_security_dashboard()` ships the actual
feature. Two modes:

- **Default (human-readable)**: tirith binary path resolution, gate
  enabled/disabled state (with `CONTEXTCRAWLER_TIRITH_DISABLED` /
  `_REQUIRED` env-var awareness), downgrade log path + existence, and
  the last 10 downgrade events.
- **`--json`**: machine-readable, suitable for piping into jq.

The dashboard also includes a scope note: "tirith inspects COMMAND
STRINGS only — env-var-driven attacks (e.g. RIPGREP_CONFIG_PATH
hijack, see issue #32) are blocked separately inside the spawning
code path via env_remove + arg deny-list." Makes the layered defense
visible to operators.

`Commands::Security { all, json }` added to the enum + dispatcher.
`security` added to `RTK_META_COMMANDS` so flag-only invocations show
the proper clap error instead of falling through to `/usr/bin/security`.

## Verified

- `cargo test --bin contextcrawler` — 2033 pass, 0 fail (+6 new
  `secure_rg_tests`)
- Empirical PoC re-run post-fix: all 3 in-binary attack paths blocked
  (env-canonical, env-bypass, CLI-canonical); direct rg control case
  still pwns (by design — that's rg, not us)
- `contextcrawler security` produces real dashboard (your existing
  Tirith blocks for homograph URLs + private-network SSRF surface
  correctly from the downgrades.jsonl)

Closes #32

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Background security-review agent (codex+claude) <noreply@anthropic.com>
Three findings raised by codex review on the initial draft, all valid:

1. **[P1] Bundle bypass of `-z`.** The deny-list only compared whole
   arg strings, so `-cz` / `-rzL` / `-Lzn` slipped through and reached
   rg, where `z` enables --search-zip — the exact thing the deny-list
   was meant to block. Same regression family as the bypass we just
   shipped the original fix for; same risk profile too (RCE-adjacent
   for archives with traversal).

   Fix: extend `check_forbidden_rg_args` to detect short-flag bundles
   (single `-`, all-alphabetic body) and reject any bundle containing
   `z`. Mirrors the bundle-detection logic in main.rs's
   `grep_format_flag_present` so the scanner agrees with how rg/grep
   actually parses short-flag groups.

   New regression test `rejects_z_inside_bundled_short_flags` covers
   `-cz`, `-rz`, `-rzL`, `-Lzn`. Empirical: `-cz` now exits with the
   deny error, the preprocessor doesn't fire.

2. **[P2] Line-oriented downgrade-log parsing breaks on multi-line
   records.** The dashboard split the JSONL file with `content.lines()`.
   Today's `log_downgrade` writes single-line JSON so this works, but
   any future change to pretty-print would silently corrupt the
   `--json` dashboard output for those records.

   Fix: `read_recent_downgrades` now does brace-balanced + string-aware
   scanning to find each top-level `{...}` region, validates by
   round-tripping through `serde_json`, and re-serialises to canonical
   compact form. Multi-line / pretty-printed records get coalesced
   into single logical entries before they reach the dashboard.

3. **[P3] Hand-rolled JSON escaping for the `--json` envelope.** The
   previous `json_escape_inner` only handled `\` and `"`, so a path
   containing tab / newline / control chars would emit invalid JSON.

   Fix: build the entire envelope as `serde_json::json!({...})` and
   render with `to_string_pretty`. serde_json handles all escaping
   correctly. Removed the hand-rolled helper.

Verified empirically:
- `-cz` bundle → deny error, no exec
- `-cn` (legitimate count + line-numbers) → still works
- `contextcrawler security --json` output parses cleanly via Python's
  `json.load` with all 10 records intact

Tests: 2034 bin pass (+7 secure_rg_tests incl. the bundle case).

Co-Authored-By: Codex review <noreply@openai.com>
@thehoff
thehoff merged commit 1d40635 into develop May 18, 2026
3 checks passed
@thehoff
thehoff deleted the fix/grep-rce-env-and-pre-args branch May 18, 2026 08:25
noogalabs pushed a commit to noogalabs/contextcrawler that referenced this pull request Jun 4, 2026
…hehoff#39)

Ships the generic counterpart to secure_rg_command (PR thehoff#33) so future
per-tool wrapped CLIs can opt in via a declarative ToolPolicy instead
of copy-pasting the env-strip + arg-deny scaffolding.

- UNIVERSAL_ENV_STRIP covers loader hijacks (LD_PRELOAD, DYLD_*),
  pager/editor (EDITOR, PAGER, LESS, ...), per-lang loader injection
  (PERL5OPT, LUA_INIT, ...), and shell metaprogramming (BASH_ENV,
  PROMPT_COMMAND, IFS, ...). Dynamic BASH_FUNC_* and DYLD_* are
  stripped by walking std::env::vars().
- ToolPolicy struct + secure_command_with_policy() and
  check_args_with_policy() mirror the rg helpers' semantics, including
  the codex-P1 short-bundle deny that catches '-cz'-style bypasses.
- New docs/security/zero-trust-wrapped-cli.md documents the threat
  model (LLM mistakes + hostile inherited env, not local-shell
  attacker), the defense-in-depth layers, and the new-tool checklist.
- 6 new unit tests in policy_registry_tests; full bin suite still
  2040 passing.

This PR deliberately does NOT refactor secure_rg_command or the
per-tool helpers from in-flight PRs thehoff#34-thehoff#38 to use the new primitive
yet -- that refactor is a follow-up so this lands cleanly while
those PRs are also in flight.

Closes thehoff#39

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
noogalabs pushed a commit to noogalabs/contextcrawler that referenced this pull request Jun 4, 2026
Follow-up to PR thehoff#33's rg/grep hardening — close the equivalent class of
env-var-driven and arg-driven RCE vectors against the `git` wrapper.

## What's added

Two new helpers in `src/core/utils.rs`, mirroring `secure_rg_command` /
`check_forbidden_rg_args` exactly in shape and style:

1. **`secure_git_command()`** — wraps `resolved_command("git")` with
   `env_remove` for every git env var that lets an attacker steer the
   child into exec'ing an arbitrary program:

     - exec sinks: GIT_EXTERNAL_DIFF, GIT_SSH, GIT_SSH_COMMAND,
       GIT_PROXY_COMMAND, GIT_PAGER, GIT_EDITOR, GIT_SEQUENCE_EDITOR,
       GIT_ASKPASS, SSH_ASKPASS
     - config-file overrides: GIT_CONFIG, GIT_CONFIG_GLOBAL,
       GIT_CONFIG_SYSTEM
     - env-driven `-c key=val` injection: GIT_CONFIG_COUNT plus
       GIT_CONFIG_KEY_0..63 / GIT_CONFIG_VALUE_0..63 (covers
       GIT_CONFIG_COUNT values up to 64)
     - path/helper redirection: GIT_TEMPLATE_DIR, GIT_EXEC_PATH,
       GIT_HOOKS_PATH

2. **`check_forbidden_git_args(&[…])`** — rejects:

     - `--upload-pack` / `--receive-pack` / `--exec-path` in both
       `--flag value` and `--flag=value` shapes
     - `-c key=val` (and the rarer `-c=key=val` single-arg shape)
       where the key matches any of these case-insensitive prefixes:
       `diff.external`, `core.editor`, `core.pager`, `core.sshCommand`,
       `core.fsmonitor`, `core.gitProxy`, `core.hooksPath`, `protocol.`,
       `uploadpack.packObjectsHook`, `safe.directory`

   Error message format matches `rg_deny_message` — names the offending
   flag, references issue thehoff#35, points at the escape hatch
   `contextcrawler proxy git ...`.

## Wire-in sites

- `src/cmds/git/git.rs::git_cmd` — every git invocation through the
  filtered passthrough (the single spawn point that fans out to status /
  diff / log / show / commit / push / pull / branch / fetch / stash /
  worktree / add) now uses `secure_git_command`.
- `src/main.rs::Commands::Git` — every `--config-override` (-c) entry
  passed via the CLI is validated through `check_forbidden_git_args`
  before being forwarded into git's global args. Rejection prints the
  deny message to stderr and returns exit code 2.
- `src/hooks/permissions.rs::find_project_root` — the
  `git rev-parse --show-toplevel` fallback (runs inside Claude Code's
  PreToolUse hook) switched to `secure_git_command` for defense in depth.

The `gh_cmd.rs`, `glab_cmd.rs`, `gt_cmd.rs`, and `diff_cmd.rs` files
spawn `gh`/`glab`/`gt` — they do not invoke `git` directly, so no
changes were needed there.

## Tests

13 new unit tests in `src/core/utils.rs::secure_git_tests`:

  - `secure_git_command_strips_all_listed_env_vars` introspects the
    Command via `get_envs()` and asserts every named var (plus the
    indexed `GIT_CONFIG_KEY_0..63`/`VALUE_0..63` pairs) has been
    removed
  - one test per denied flag class, covering both `--flag value` and
    `--flag=value` shapes
  - case-insensitive key match test for `core.sshCommand` vs
    `core.sshcommand`
  - benign-args allowlist test (`status`, `log -3 --oneline`,
    `diff HEAD~1`, `-c user.email=…`, `-c color.ui=always` all pass)
  - error-message format test (mentions escape hatch + issue ref)

6 new integration tests in `tests/git_hardening.rs`, each spawning the
built binary against a real throwaway git repo with a marker-script
attack armed. Critically, each env-var test first runs raw `git` with
the same env to confirm the PoC actually fires (so a passing test
proves the hardening, not just that git was busy with something else):

  - GIT_EXTERNAL_DIFF=<script> ./contextcrawler git diff
  - GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external
    GIT_CONFIG_VALUE_0=<script> ./contextcrawler git diff
  - GIT_CONFIG_GLOBAL=<evil.gitconfig> ./contextcrawler git diff
  - contextcrawler git -c diff.external=… diff exits non-zero with the
    deny message
  - contextcrawler git status / git log -3 --oneline still succeed

All three env-var PoC sanity checks confirm the attack vector is real
on this platform (raw git did exec the marker script), and all three
post-hardening assertions confirm the wrapper neutralizes it.

## Empirical verification

Pre-fix raw-git runs:
  GIT_EXTERNAL_DIFF=… git diff               → marker touched (RCE)
  GIT_CONFIG_COUNT=1 KEY_0=… VALUE_0=… git diff → marker touched (RCE)
  GIT_CONFIG_GLOBAL=… git diff               → marker touched (RCE)

Post-fix contextcrawler runs, same env, same args:
  ./contextcrawler git diff (×3)             → marker NOT touched

Plus:
  ./contextcrawler git -c diff.external=… diff → exit 2 + deny error

## Test counts

  cargo test --bin contextcrawler:  2034 → 2047  (+13 unit tests)
  cargo test --test git_hardening:  0    → 6     (+6 integration tests)

One pre-existing test (`test_git_cmd_c_locale_sets_stable_env`) was
updated to skip env entries that are now intentionally removed via
`env_remove` — it was unwrapping `Option<&OsStr>` with `expect` and
new None entries (from the hardening's strip list) tripped it. The
test still asserts the LC_ALL=C entry is present; coverage of the
strip behaviour lives in the new
`secure_git_command_strips_all_listed_env_vars` test.

Closes thehoff#35

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SECURITY [P1]: RIPGREP_CONFIG_PATH env var + rg --pre enables silent arbitrary-code-execution via every contextcrawler grep call

1 participant