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:
1 parent
8119cdeba7
commit
c0b282335d
7 files changed
+135
-9
No files matched your search
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ mod profile;
|
||||
mod projects;
|
||||
mod providers;
|
||||
mod release_info;
|
||||
mod secrets;
|
||||
mod status;
|
||||
mod todos;
|
||||
mod tui;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user