diff --git a/docs/security/working-with-the-gate.md b/docs/security/working-with-the-gate.md index 44fabf915b..7a4c0a782b 100644 --- a/docs/security/working-with-the-gate.md +++ b/docs/security/working-with-the-gate.md @@ -228,6 +228,14 @@ executes. When a gate flags a proxied command, proxy refuses with exit 126: - A gate **block** (supply-chain hard block) cannot be overridden by the ack variable — restructure the command or use `tirith trust`. +Proxy also refuses known content-reading tools (`grep`, `rg`, `cat`, +`sed`, `head`, `tail`, `diff`, and similar) when a path operand targets a +secret env file such as `.env` or `.env.local`. Template/example files are +allowed: `.env.example`, `.env.sample`, `.env.template`, and matching +`.example` / `.sample` / `.template` suffixes. If you intentionally need +raw secret-file contents through proxy, re-run with +`CONTEXTCRAWLER_ALLOW_SENSITIVE_ENV_READ=1`. + ## Enabling the supply-chain gate The supply-chain gate is off until you write a config file. The first of diff --git a/src/cli.rs b/src/cli.rs index 6f9fc9481a..162d00e0c5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1531,6 +1531,124 @@ fn token_has_path_separator(token: &str) -> bool { token.contains('/') || token.contains('\\') } +fn proxy_sensitive_env_read_path(tool: &str, args: &[String]) -> Option { + let tool = bin_basename(tool); + if !matches!( + tool, + "cat" + | "grep" + | "egrep" + | "fgrep" + | "rg" + | "ripgrep" + | "sed" + | "awk" + | "head" + | "tail" + | "nl" + | "less" + | "more" + | "bat" + | "batcat" + | "diff" + ) { + return None; + } + + let candidates = match tool { + "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" => proxy_search_path_args(args), + "diff" => args + .iter() + .filter(|arg| !arg.starts_with('-')) + .cloned() + .collect(), + _ => proxy_generic_file_args(args), + }; + + candidates + .into_iter() + .find(|arg| core::sensitive_paths::is_sensitive_env_path(std::path::Path::new(arg))) +} + +fn proxy_generic_file_args(args: &[String]) -> Vec { + let mut out = Vec::new(); + let mut after_double_dash = false; + let mut skip_next = false; + + for arg in args { + if skip_next { + skip_next = false; + continue; + } + if !after_double_dash && arg == "--" { + after_double_dash = true; + continue; + } + if !after_double_dash && arg.starts_with('-') { + if matches!( + arg.as_str(), + "-n" | "--lines" | "-c" | "--bytes" | "-s" | "-f" | "-e" + ) { + skip_next = !arg.contains('='); + } + continue; + } + out.push(arg.clone()); + } + + out +} + +fn proxy_search_path_args(args: &[String]) -> Vec { + let mut positionals = Vec::new(); + let mut after_double_dash = false; + let mut skip_next = false; + let mut pattern_from_flag = false; + + for arg in args { + if skip_next { + skip_next = false; + continue; + } + if !after_double_dash && arg == "--" { + after_double_dash = true; + continue; + } + if !after_double_dash && matches!(arg.as_str(), "-e" | "--regexp") { + pattern_from_flag = true; + skip_next = true; + continue; + } + if !after_double_dash + && matches!( + arg.as_str(), + "-f" | "--file" + | "-m" | "--max-count" + | "-A" | "--after-context" + | "-B" | "--before-context" + | "-C" | "--context" + | "-g" | "--glob" + | "--iglob" + | "-t" | "--type" + | "-T" | "--type-not" + ) + { + skip_next = true; + continue; + } + if !after_double_dash && arg.starts_with('-') { + continue; + } + positionals.push(arg.clone()); + } + + if pattern_from_flag { + positionals + } else { + positionals.into_iter().skip(1).collect() + } +} + /// `true` when the proxy nudge should be printed to stderr. Three independent /// suppression knobs (any one silences the nudge): explicit env opt-out, /// `CI=*` env marker, stderr is not a tty. The override env var can carry @@ -4201,6 +4319,28 @@ fn run_cli() -> Result { .map(|s| s.to_string_lossy().into_owned()) .collect(); + if !core::sensitive_paths::sensitive_env_override_enabled() { + if let Some(path) = + proxy_sensitive_env_read_path(&cmd_name_display, &cmd_args_display) + { + eprintln!( + "contextcrawler: refusing to proxy sensitive env file read `{}` via `{}`. \ + Use `{}`=1 only when you intentionally need raw secret-file contents. \ + Safe templates such as .env.example, .env.sample, and .env.template are allowed.", + path, + cmd_name_display, + core::sensitive_paths::SENSITIVE_ENV_OVERRIDE + ); + timer.track( + &format!("proxy {} (sensitive env refused)", cmd_name_display), + &format!("contextcrawler proxy {}", cmd_name_display), + "", + "", + ); + std::process::exit(126); + } + } + if cli.verbose > 0 { eprintln!( "Proxy mode: {} {}", diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 6203400f70..60e6dbcea4 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -1,6 +1,6 @@ //! Compares two files and shows only the changed lines. -use crate::core::tracking; +use crate::core::{sensitive_paths, tracking}; use anyhow::Result; use std::fs; use std::path::Path; @@ -9,6 +9,9 @@ use std::path::Path; pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); + sensitive_paths::ensure_not_sensitive_env_path(file1, "contextcrawler diff")?; + sensitive_paths::ensure_not_sensitive_env_path(file2, "contextcrawler diff")?; + if verbose > 0 { eprintln!("Comparing: {} vs {}", file1.display(), file2.display()); } diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 51888ef273..c9fba814ef 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -2,6 +2,7 @@ use crate::core::config; use crate::core::runner; +use crate::core::sensitive_paths; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{check_forbidden_rg_args, secure_rg_command}; @@ -28,6 +29,8 @@ pub fn run( ) -> Result { let timer = tracking::TimedExecution::start(); + sensitive_paths::ensure_not_sensitive_env_path(std::path::Path::new(path), "contextcrawler grep")?; + if verbose > 0 { eprintln!("grep: '{}' in {}", pattern, path); } diff --git a/src/cmds/system/local_llm.rs b/src/cmds/system/local_llm.rs index 20ac7c18f5..69555bdc54 100644 --- a/src/cmds/system/local_llm.rs +++ b/src/cmds/system/local_llm.rs @@ -6,9 +6,12 @@ use std::fs; use std::path::Path; use crate::core::filter::Language; +use crate::core::sensitive_paths; /// Heuristic-based code summarizer - no external model needed pub fn run(file: &Path, _model: &str, _force_download: bool, verbose: u8) -> Result<()> { + sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler smart")?; + if verbose > 0 { eprintln!("Analyzing: {}", file.display()); } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index e060602ebb..f0c029ea45 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,6 +1,6 @@ //! Deduplicates repeated log lines and shows counts instead. -use crate::core::tracking; +use crate::core::{sensitive_paths, tracking}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; @@ -24,6 +24,8 @@ lazy_static! { pub fn run_file(file: &Path, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); + sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler log")?; + if verbose > 0 { eprintln!("Analyzing log: {}", file.display()); } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 791d7c5ea3..39d51bfab9 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -4,6 +4,7 @@ use crate::cmds::system::intent as intent_extractor; use crate::cmds::system::json_cmd; use crate::core::config; use crate::core::filter::{self, FilterLevel, Language}; +use crate::core::sensitive_paths; use crate::core::tracking; use crate::core::utils::format_tokens; use anyhow::{Context, Result}; @@ -24,6 +25,8 @@ pub fn run( ) -> Result<()> { let timer = tracking::TimedExecution::start(); + sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler read")?; + if verbose > 0 { eprintln!("Reading: {} (filter: {})", file.display(), level); } diff --git a/src/core/mod.rs b/src/core/mod.rs index e72f780248..2b91664343 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,6 +9,7 @@ pub mod filter; pub mod runner; pub mod output_summary; pub mod secret_redact; +pub mod sensitive_paths; pub mod stream; pub mod tee; pub mod telemetry; diff --git a/src/core/sensitive_paths.rs b/src/core/sensitive_paths.rs new file mode 100644 index 0000000000..32caa1413e --- /dev/null +++ b/src/core/sensitive_paths.rs @@ -0,0 +1,82 @@ +//! Shared guards for file paths that commonly contain local secrets. + +use anyhow::{bail, Result}; +use std::ffi::OsStr; +use std::path::Path; + +pub const SENSITIVE_ENV_OVERRIDE: &str = "CONTEXTCRAWLER_ALLOW_SENSITIVE_ENV_READ"; + +pub fn is_sensitive_env_path(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(OsStr::to_str) else { + return false; + }; + + if !is_dotenv_secret_name(name) { + return false; + } + + !is_documented_safe_dotenv_name(name) +} + +pub fn ensure_not_sensitive_env_path(path: &Path, surface: &str) -> Result<()> { + if is_sensitive_env_path(path) { + bail!( + "contextcrawler: refusing to read sensitive env file `{}` via {}. \ + Use `{}`=1 only when you intentionally need raw secret-file contents. \ + Safe templates such as .env.example, .env.sample, and .env.template are allowed.", + path.display(), + surface, + SENSITIVE_ENV_OVERRIDE + ); + } + Ok(()) +} + +pub fn sensitive_env_override_enabled() -> bool { + std::env::var(SENSITIVE_ENV_OVERRIDE).as_deref() == Ok("1") +} + +fn is_dotenv_secret_name(name: &str) -> bool { + name == ".env" || name.starts_with(".env.") +} + +fn is_documented_safe_dotenv_name(name: &str) -> bool { + name == ".env.example" + || name == ".env.sample" + || name == ".env.template" + || name.ends_with(".example") + || name.ends_with(".sample") + || name.ends_with(".template") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_secret_dotenv_names() { + for path in [".env", ".env.local", ".env.production", "dir/.env.test"] { + assert!(is_sensitive_env_path(Path::new(path)), "{path}"); + } + } + + #[test] + fn allows_documented_template_names() { + for path in [ + ".env.example", + ".env.sample", + ".env.template", + ".env.production.example", + "dir/.env.local.template", + ] { + assert!(!is_sensitive_env_path(Path::new(path)), "{path}"); + } + } + + #[test] + fn ignores_non_dotenv_names() { + for path in [".envrc", "env", "README.env", "dotenv"] { + assert!(!is_sensitive_env_path(Path::new(path)), "{path}"); + } + } +} diff --git a/tests/system_hardening.rs b/tests/system_hardening.rs index 8ab3206fba..7cee2ccd7b 100644 --- a/tests/system_hardening.rs +++ b/tests/system_hardening.rs @@ -23,6 +23,13 @@ fn unique_dir(tag: &str) -> PathBuf { std::env::temp_dir().join(format!("ctxc-g5-{}-{}-{}", tag, pid, ts)) } +fn contextcrawler_cmd() -> Command { + let mut cmd = Command::new(binary_path()); + cmd.env("CONTEXTCRAWLER_TEST_MODE", "1"); + cmd.env_remove("CONTEXTCRAWLER_ALLOW_SENSITIVE_ENV_READ"); + cmd +} + /// `ls` of a directory that contains an entry named `-la` must succeed and /// show the dash-prefixed entry — the `--` boundary keeps `ls` from parsing /// any operand as an option. @@ -33,10 +40,9 @@ fn ls_lists_dash_prefixed_entry() { fs::create_dir_all(root.join("-la")).expect("create -la subdir"); fs::write(root.join("normal.txt"), "hi").expect("write normal.txt"); - let out = Command::new(binary_path()) + let out = contextcrawler_cmd() .arg("ls") .arg(&root) - .env("CONTEXTCRAWLER_TEST_MODE", "1") .output() .expect("spawn contextcrawler ls"); @@ -79,10 +85,9 @@ fn tree_handles_dash_prefixed_path() { let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("-la")).expect("create -la subdir"); - let out = Command::new(binary_path()) + let out = contextcrawler_cmd() .arg("tree") .arg(&root) - .env("CONTEXTCRAWLER_TEST_MODE", "1") .output() .expect("spawn contextcrawler tree"); @@ -116,8 +121,7 @@ fn grep_pattern_shaped_like_pre_flag_is_not_executed() { let root = unique_dir("grep-pre"); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).expect("create grep-pre dir"); - fs::write(root.join("haystack.txt"), "nothing interesting here\n") - .expect("write haystack.txt"); + fs::write(root.join("haystack.txt"), "nothing interesting here\n").expect("write haystack.txt"); // If `--pre` were honoured, rg would exec this script per file. let marker = root.join("pwned.marker"); @@ -135,11 +139,10 @@ fn grep_pattern_shaped_like_pre_flag_is_not_executed() { fs::set_permissions(&script, perms).unwrap(); } - let out = Command::new(binary_path()) + let out = contextcrawler_cmd() .arg("grep") .arg(format!("--pre={}", script.display())) .arg(&root) - .env("CONTEXTCRAWLER_TEST_MODE", "1") .output() .expect("spawn contextcrawler grep"); @@ -168,11 +171,10 @@ fn grep_path_starting_with_dash_is_treated_as_path() { fs::create_dir_all(&dash_dir).expect("create -dashdir"); fs::write(dash_dir.join("file.txt"), "findme_token\n").expect("write file.txt"); - let out = Command::new(binary_path()) + let out = contextcrawler_cmd() .arg("grep") .arg("findme_token") .arg(&dash_dir) - .env("CONTEXTCRAWLER_TEST_MODE", "1") .output() .expect("spawn contextcrawler grep"); @@ -201,9 +203,8 @@ fn read_no_files_reads_piped_stdin() { let input: String = (1..=100).map(|i| format!("{}\n", i)).collect(); - let mut child = Command::new(binary_path()) + let mut child = contextcrawler_cmd() .args(["read", "--tail-lines", "20"]) - .env("CONTEXTCRAWLER_TEST_MODE", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -226,10 +227,163 @@ fn read_no_files_reads_piped_stdin() { "read with no files + piped stdin should exit 0; stderr:\n{}", String::from_utf8_lossy(&out.stderr), ); - assert!(stdout.contains("100"), "should show last lines, got:\n{}", stdout); + assert!( + stdout.contains("100"), + "should show last lines, got:\n{}", + stdout + ); assert!( !stdout.contains("a value is required"), "must not emit the clap missing-arg error, got:\n{}", stdout, ); } + +#[test] +fn read_refuses_dotenv_secret_without_echoing_value() { + let root = unique_dir("dotenv-read"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create dotenv-read dir"); + fs::write(root.join(".env"), "PASSWORD=demo-only\n").expect("write .env"); + + let out = contextcrawler_cmd() + .arg("read") + .arg(".env") + .current_dir(&root) + .output() + .expect("spawn contextcrawler read"); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + assert_ne!(out.status.code(), Some(0), "read .env must fail"); + assert!( + combined.contains("refusing to read sensitive env file"), + "expected clear refusal, got:\n{}", + combined + ); + assert!( + !combined.contains("demo-only"), + "refusal must not echo secret values, got:\n{}", + combined + ); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn grep_refuses_dotenv_secret_without_echoing_value() { + let root = unique_dir("dotenv-grep"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create dotenv-grep dir"); + fs::write(root.join(".env"), "PASSWORD=demo-only\n").expect("write .env"); + + let out = contextcrawler_cmd() + .args(["grep", "PASSWORD", ".env"]) + .current_dir(&root) + .output() + .expect("spawn contextcrawler grep"); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + assert_ne!(out.status.code(), Some(0), "grep .env must fail"); + assert!( + combined.contains("refusing to read sensitive env file"), + "expected clear refusal, got:\n{}", + combined + ); + assert!( + !combined.contains("demo-only"), + "refusal must not echo secret values, got:\n{}", + combined + ); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn proxy_grep_refuses_dotenv_secret_without_echoing_value() { + let root = unique_dir("dotenv-proxy-grep"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create dotenv-proxy-grep dir"); + fs::write(root.join(".env"), "PASSWORD=demo-only\n").expect("write .env"); + + let out = contextcrawler_cmd() + .args(["proxy", "grep", "PASSWORD", ".env"]) + .current_dir(&root) + .output() + .expect("spawn contextcrawler proxy grep"); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + assert_eq!(out.status.code(), Some(126), "proxy refusal exit code"); + assert!( + combined.contains("refusing to proxy sensitive env file read"), + "expected clear proxy refusal, got:\n{}", + combined + ); + assert!( + !combined.contains("demo-only"), + "refusal must not echo secret values, got:\n{}", + combined + ); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn documented_dotenv_templates_are_allowed() { + let root = unique_dir("dotenv-templates"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create dotenv-templates dir"); + + for name in [".env.example", ".env.sample", ".env.template"] { + fs::write(root.join(name), "PASSWORD=demo-only\n").expect("write template"); + + let read = contextcrawler_cmd() + .arg("read") + .arg(name) + .current_dir(&root) + .output() + .expect("spawn contextcrawler read template"); + assert_eq!( + read.status.code(), + Some(0), + "read {name} should be allowed; stderr:\n{}", + String::from_utf8_lossy(&read.stderr) + ); + assert!( + String::from_utf8_lossy(&read.stdout).contains("demo-only"), + "read {name} should show template content" + ); + + let grep = contextcrawler_cmd() + .args(["grep", "PASSWORD", name]) + .current_dir(&root) + .output() + .expect("spawn contextcrawler grep template"); + assert_eq!( + grep.status.code(), + Some(0), + "grep {name} should be allowed; stderr:\n{}", + String::from_utf8_lossy(&grep.stderr) + ); + assert!( + String::from_utf8_lossy(&grep.stdout).contains("demo-only"), + "grep {name} should show template content" + ); + } + + let _ = fs::remove_dir_all(&root); +}