Sprint B.7: secret scan before commit-push (TUI + CLI)

- src/secrets.rs: scans added lines of the staged diff for GitHub/GitLab
  token shapes, AWS access keys, Discord token assignments, private key
  blocks; prefix+tail-length matching (no regex dep), matched values never
  echoed
- a hit blocks the commit and unstages (git reset); CLI override
  --allow-secrets; TUI sync flow shares the same scan
- verified: block+unstage, override commits, prose mentions don't trigger
This commit is contained in:
dualfroz committed 2026-07-06 15:47:32 +02:00
1 parent 8119cdeba7
commit c0b282335d
7 files changed
+135 -9

No files matched your search

+9
View File
@@ -32,6 +32,9 @@ vulp <command> [--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 <repo> --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
```
+2
View File
@@ -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<String>,
/// Commit even when staged changes look like secrets
#[arg(long)] allow_secrets: bool,
},
/// Tag a release and publish it on the provider
Release {
+2 -2
View File
@@ -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, &notes).await,
Commands::Stats { period } => workspace::stats(cfg, json, &period),
+15 -5
View File
@@ -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<String>, all: bool, project: Option<String>,
action: &str, message: Option<String>,
action: &str, message: Option<String>, 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"]);
+10 -2
View File
@@ -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),
}
+1
View File
@@ -21,6 +21,7 @@ mod profile;
mod projects;
mod providers;
mod release_info;
mod secrets;
mod status;
mod todos;
mod tui;
+96
View File
@@ -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<SecretHit> {
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<SecretHit> {
let mut hits: Vec<SecretHit> = 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 = <value>` 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)
}