diff --git a/docs/cli.md b/docs/cli.md index ca92176..9c0ce83 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,6 +32,9 @@ vulp [--json] # non-interactive (scripts/AI) commands refuse to touch listed repos (exit 1) unless **`--force`** is passed. Matching is by display label or dir basename, case-insensitive. The protection check runs before `--dry-run`, so a dry run also reveals it. +- **Secret scan**: `sync --action commit-push` refuses to commit staged + changes that look like credentials (see Git operations); override with + `--allow-secrets`. - **`VULP_AGENT`** (env var, any non-empty value): every activity-log entry written while it's set gets a `· via agent` marker in its detail, so human and agent actions are distinguishable in `vulp activity` and the TUI feed. @@ -68,6 +71,12 @@ vulp release --tag v1.2.0 [--notes "..."] # git tag + push + provider r `commit-push` requires `-m/--message`. `sync` exits 1 if any target failed (per-repo progress on stderr). +Before every `commit-push` commit, the staged diff is scanned for secret +shapes (GitHub/GitLab tokens, AWS access keys, Discord token assignments, +private key blocks). A hit blocks that repo's commit and unstages the +changes; pass `--allow-secrets` to override. The same scan guards the TUI +sync flow. Matched values are never echoed back. + ## Todos ``` diff --git a/src/cli.rs b/src/cli.rs index fa4a94c..3a1a5d8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -65,6 +65,8 @@ pub enum Commands { #[arg(long, default_value = "pull")] action: String, /// Commit message (required for commit-push) #[arg(short, long)] message: Option, + /// Commit even when staged changes look like secrets + #[arg(long)] allow_secrets: bool, }, /// Tag a release and publish it on the provider Release { diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index f9d37a6..3d230ec 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -30,8 +30,8 @@ pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Re workspace::list(cfg, json, dirty, behind, ahead, lang, project), Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Scan => workspace::scan_cmd(cfg, json), - Commands::Sync { repos, all, project, action, message } => - workspace::sync(cfg, guard, repos, all, project, &action, message), + Commands::Sync { repos, all, project, action, message, allow_secrets } => + workspace::sync(cfg, guard, repos, all, project, &action, message, allow_secrets), Commands::Release { repo, tag, notes } => remote::release(cfg, guard, &repo, &tag, ¬es).await, Commands::Stats { period } => workspace::stats(cfg, json, &period), diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 718d6a6..43440a9 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -88,10 +88,11 @@ pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] pub fn sync( cfg: &AppConfig, guard: super::Guard, repos: Vec, all: bool, project: Option, - action: &str, message: Option, + action: &str, message: Option, allow_secrets: bool, ) -> Result<()> { use crate::activity::ActivityKind; use crate::util::git::{git_run, git_run_ok}; @@ -150,10 +151,19 @@ pub fn sync( } "commit_push" => { git_run(&r.path, &["add", "-A"]); - git_run(&r.path, &["commit", "-m", &msg]); - let ok = git_run_ok(&r.path, &["push"]); - if ok { log_activity(ActivityKind::Push, &r.name, format!("vulp sync (commit+push: {msg})")); } - ok + let hits = if allow_secrets { vec![] } else { crate::secrets::scan_staged(&r.path) }; + if !hits.is_empty() { + eprintln!(" SECRETS in staged changes — commit blocked:"); + for h in &hits { eprintln!(" {} — {}", h.file, h.pattern); } + eprintln!(" changes unstaged; re-run with --allow-secrets to override"); + git_run_ok(&r.path, &["reset", "-q"]); + false + } else { + git_run(&r.path, &["commit", "-m", &msg]); + let ok = git_run_ok(&r.path, &["push"]); + if ok { log_activity(ActivityKind::Push, &r.name, format!("vulp sync (commit+push: {msg})")); } + ok + } } "push" => { let ok = git_run_ok(&r.path, &["push"]); diff --git a/src/hub/actions.rs b/src/hub/actions.rs index a0f2723..aa40db0 100644 --- a/src/hub/actions.rs +++ b/src/hub/actions.rs @@ -42,8 +42,16 @@ pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize], action: &st "pull" => run_logged(&r.path, &["pull"], crate::activity::ActivityKind::Pull, &r.name), "commit_push" => { git_run(&r.path, &["add", "-A"]); - git_run(&r.path, &["commit", "-m", commit_msg]); - run_logged(&r.path, &["push"], crate::activity::ActivityKind::Push, &r.name); + let hits = crate::secrets::scan_staged(&r.path); + if !hits.is_empty() { + println!(" ✗ SECRETS in staged changes — commit blocked:"); + for h in &hits { println!(" {} — {}", h.file, h.pattern); } + println!(" changes unstaged; fix them (or override via CLI --allow-secrets)"); + crate::util::git::git_run_ok(&r.path, &["reset", "-q"]); + } else { + git_run(&r.path, &["commit", "-m", commit_msg]); + run_logged(&r.path, &["push"], crate::activity::ActivityKind::Push, &r.name); + } } _ => run_logged(&r.path, &["push"], crate::activity::ActivityKind::Push, &r.name), } diff --git a/src/main.rs b/src/main.rs index cfd988d..e34bffb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod profile; mod projects; mod providers; mod release_info; +mod secrets; mod status; mod todos; mod tui; diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..34818d1 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,96 @@ +//! Staged-changes secret scanning, run before every vulp-driven commit+push +//! (TUI sync flow and `vulp sync --action commit-push`). Matches the added +//! lines of `git diff --cached` against known credential shapes; a hit +//! blocks the commit (CLI override: `--allow-secrets`). Hand-rolled +//! matchers — the shapes are simple prefixes, not worth a regex dependency. + +use std::path::Path; + +/// One detected secret: which file and which credential shape. The matched +/// text itself is deliberately not carried anywhere — reporting a secret +/// must not re-leak it into terminals or logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretHit { + pub file: String, + pub pattern: &'static str, +} + +/// Scans the staged diff of `repo` (added lines only). Returns no hits when +/// git itself fails — sync's own git calls will surface that error instead. +pub fn scan_staged(repo: &Path) -> Vec { + let out = std::process::Command::new("git") + .arg("-C").arg(repo) + .args(["diff", "--cached", "--no-color", "--unified=0"]) + .output(); + let Ok(out) = out else { return vec![] }; + scan_diff(&String::from_utf8_lossy(&out.stdout)) +} + +/// Scans unified-diff text, reporting each (file, pattern) pair once. +pub fn scan_diff(diff: &str) -> Vec { + let mut hits: Vec = Vec::new(); + let mut file = String::from("?"); + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("+++ b/") { + file = rest.to_string(); + continue; + } + if line.starts_with("+++") { continue; } // "+++ /dev/null" + let Some(added) = line.strip_prefix('+') else { continue }; + if let Some(pattern) = line_secret(added) { + let hit = SecretHit { file: file.clone(), pattern }; + if !hits.contains(&hit) { hits.push(hit); } + } + } + hits +} + +/// The credential shape found in `line`, if any. +fn line_secret(line: &str) -> Option<&'static str> { + if token_after_prefix(line, "ghp_", 20) { return Some("GitHub token (ghp_…)"); } + if token_after_prefix(line, "github_pat_", 20) { return Some("GitHub fine-grained token"); } + if token_after_prefix(line, "glpat-", 10) + || token_after_prefix(line, "glpat_", 10) { return Some("GitLab token (glpat…)"); } + if aws_access_key(line) { return Some("AWS access key (AKIA…)"); } + if discord_token_assignment(line) { return Some("Discord token assignment"); } + if line.contains("-----BEGIN") && line.contains("PRIVATE KEY") { + return Some("private key block"); + } + None +} + +/// Whether `prefix` occurs followed by at least `min_tail` token characters +/// — filters out prose/code that merely mentions the prefix. +fn token_after_prefix(line: &str, prefix: &str, min_tail: usize) -> bool { + let mut search = 0; + while let Some(i) = line[search..].find(prefix) { + let after = &line[search + i + prefix.len()..]; + let tail = after.chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .count(); + if tail >= min_tail { return true; } + search += i + prefix.len(); + } + false +} + +/// `AKIA` followed by 16 uppercase alphanumerics (AWS access key ID shape). +fn aws_access_key(line: &str) -> bool { + let mut search = 0; + while let Some(i) = line[search..].find("AKIA") { + let abs = search + i; + let tail = line[abs + 4..].chars() + .take_while(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + .count(); + if tail >= 16 { return true; } + search = abs + 4; + } + false +} + +/// A `DISCORD_TOKEN = ` style assignment (any spacing around `=`). +fn discord_token_assignment(line: &str) -> bool { + line.find("DISCORD_TOKEN") + .map(|i| line[i + "DISCORD_TOKEN".len()..].trim_start().starts_with('=')) + .unwrap_or(false) +}