Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .keywatch-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,13 @@
"finding_type": "Private Key Content",
"matched_content_hash": "43531827dd6142886cfeb10207f046021a4eb6c575828583ad2cb20d9430c72f",
"plugin_name": "PrivateKeyDetector"
},
{
"file_path": "./tests/scanner_tests.rs",
"line_number": 2047,
"finding_type": "AWS Access Key",
"matched_content_hash": "357b7fb7890985d4c94a43012d1f7aefe25757f36d8810388357993bb38bd8e7",
"plugin_name": "AWSKeyDetector"
}
]
}
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ All notable changes to this project will be documented in this file.
- Config discovery searches parent directories up to the repository root
- Hook messages abbreviate the home directory as `~`

### Added

- `--prune-baseline` rewrites the baseline from current findings, dropping entries for deleted files and rotated credentials

### Changed

- Reports redact matched text by default; `--show-secrets` opts into raw values
Expand All @@ -42,7 +46,7 @@ All notable changes to this project will be documented in this file.
- The baseline file is no longer scanned as input to itself
- `GenericKeyValueDetector` and `RandomString` no longer flag code identifiers (`let payment_method_token = card_token`, snake_case serde attributes)
- `PasswordDetector` no longer flags `$PWD:`
- Piping output to a closed reader no longer panics
- Piping output to a closed reader no longer panics, including `hook install` and `init`

### Performance

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ key-watch verify-integrity
- `scan --exit-mode <mode>` - Exit behavior: `always` (always pass), `critical` (fail on HIGH/CRITICAL only), `strict` (fail on any finding, default)
- `scan --baseline <path>` - Suppress known findings from a previous scan. Without this flag, a `.keywatch-baseline.json` is discovered automatically by walking up from the scan target (bounded at the repository root or home directory), so hook scans pick up a committed repo baseline with no configuration
- `scan --no-baseline-discovery` - Ignore a discovered baseline (an explicit `--baseline` still loads)
- `scan --prune-baseline` - With `--update-baseline`, rebuild the baseline from current findings instead of merging, dropping stale entries
- `scan --update-baseline` - Update the baseline with current findings; creates `.keywatch-baseline.json` when no baseline exists. The baseline stores fingerprints (path + finding type + SHA-256 of the match + detector), never secrets, and is meant to be committed. The `update-baseline` workflow can regenerate it via a reviewable pull request
- `hook install <pre-commit|pre-push> [--global]` - Install a git hook
- `hook uninstall <pre-commit|pre-push> [--global]` - Remove a git hook
Expand Down
34 changes: 4 additions & 30 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ pub struct ScanArgs {
#[arg(long)]
pub update_baseline: bool,

/// Rewrite the baseline from the current findings, dropping stale entries
#[arg(long, default_value_t = false)]
pub prune_baseline: bool,

/// Path to .keywatch.toml config file
#[arg(long)]
pub config: Option<String>,
Expand Down Expand Up @@ -265,17 +269,6 @@ pub enum Shell {
Posix,
}

impl Shell {
pub fn as_str(&self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
Self::Posix => "posix",
}
}
}

#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum ExitMode {
Always,
Expand All @@ -288,22 +281,3 @@ pub enum OutputFormat {
Json,
Sarif,
}

impl OutputFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Sarif => "sarif",
}
}
}

impl ExitMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Always => "always",
Self::Critical => "critical",
Self::Strict => "strict",
}
}
}
9 changes: 0 additions & 9 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,6 @@ impl KeywatchConfig {
Ok(())
}

/// Load from an explicit path. When `path` is `None`, discovery starts in
/// the current working directory.
/// Returns `None` when no config file is found (config is optional).
pub fn load(path: Option<&str>) -> Result<Option<Self>, ConfigError> {
let cwd =
std::env::current_dir().map_err(|source| ConfigError::CurrentDirectory { source })?;
Self::load_for_paths_at_cwd(path, &[], &cwd)
}

/// Load config with scan-path-aware discovery.
///
/// Resolution order:
Expand Down
20 changes: 10 additions & 10 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,21 @@ pub fn install_hook(args: &HookInstallArgs) -> Result<(), HookError> {
})?;

if install_target.configured_global_path {
println!(
let _ = utils::emit_line(&format!(
"Configured git --global core.hooksPath to {}",
utils::display_path(&install_target.hooks_dir)
);
));
}

println!(
let _ = utils::emit_line(&format!(
"Installed {} {hook_type_str} hook at {}",
scope_label(install_target.is_global),
utils::display_path(&install_target.path)
);
println!(
));
let _ = utils::emit_line(&format!(
"The hook will run automatically during git {}.",
hook_type_str.replace('-', " ")
);
));

Ok(())
}
Expand All @@ -151,10 +151,10 @@ pub fn uninstall_hook(args: &HookUninstallArgs) -> Result<(), HookError> {

let scope = scope_label(install_target.is_global);
if !install_target.path.exists() {
println!(
let _ = utils::emit_line(&format!(
"No {scope} {hook_type_str} hook found at {}",
utils::display_path(&install_target.path)
);
));
return Ok(());
}

Expand All @@ -169,10 +169,10 @@ pub fn uninstall_hook(args: &HookUninstallArgs) -> Result<(), HookError> {
source,
})?;

println!(
let _ = utils::emit_line(&format!(
"Removed {scope} {hook_type_str} hook at {}",
utils::display_path(&install_target.path)
);
));

Ok(())
}
Expand Down
37 changes: 20 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ pub fn run_cli() -> Result<(), RunCliError> {
hooks::uninstall_hook(&uninstall_args).map_err(Into::into)
}
},
Command::Init { shell } => {
print_shell_init(&shell);
Ok(())
}
Command::Init { shell } => print_shell_init(&shell),
Command::VerifyIntegrity => verify_binary_integrity(),
}
}
Expand All @@ -50,13 +47,7 @@ pub fn run_cli() -> Result<(), RunCliError> {
/// reader to stop listening, so it is reported as success; anything else is a
/// real I/O failure and is propagated.
fn emit(line: &str) -> Result<(), RunCliError> {
use std::io::Write;

let mut stdout = std::io::stdout().lock();
match writeln!(stdout, "{line}") {
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
other => other.map_err(|source| RunCliError::WriteOutput { source }),
}
utils::emit_line(line).map_err(|source| RunCliError::WriteOutput { source })
}

fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
Expand Down Expand Up @@ -84,8 +75,14 @@ fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
let (mut findings, scan_metadata) = scanner::run_scan(args, config.as_ref())?;

let mut scan_metadata = scan_metadata;
if let Some(ref baseline_path) = args.baseline {
let baseline = baseline::Baseline::load(std::path::Path::new(baseline_path))?;
// Pruning rewrites the baseline from what the scan actually found, so the
// existing entries must not filter those findings away first.
let prune = args.prune_baseline && args.update_baseline;
let mut loaded_baseline = match args.baseline.as_deref() {
Some(path) => Some(baseline::Baseline::load(std::path::Path::new(path))?),
None => None,
};
if let Some(baseline) = &loaded_baseline.as_ref().filter(|_| !prune) {
let before = findings.len();
findings = baseline.filter_findings(findings);
scan_metadata.suppressed_by_baseline = before - findings.len();
Expand All @@ -96,8 +93,14 @@ fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
.baseline
.as_ref()
.ok_or(RunCliError::MissingBaselineForUpdate)?;
let mut baseline = baseline::Baseline::load(std::path::Path::new(baseline_path))?;
baseline.update_with_findings(&findings);
let baseline = loaded_baseline
.as_mut()
.ok_or(RunCliError::MissingBaselineForUpdate)?;
if prune {
*baseline = baseline::Baseline::from_findings(&findings);
} else {
baseline.update_with_findings(&findings);
}
baseline.save(std::path::Path::new(baseline_path))?;
emit(&format!("Baseline updated: {baseline_path}"))?;
return Ok(());
Expand Down Expand Up @@ -152,15 +155,15 @@ fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
std::process::exit(exit_code);
}

fn print_shell_init(shell: &Shell) {
fn print_shell_init(shell: &Shell) -> Result<(), RunCliError> {
let script = match shell {
Shell::Fish => "alias keywatch 'key-watch'\nalias kw 'key-watch'\n",
Shell::Bash | Shell::Zsh | Shell::Posix => {
"alias keywatch='key-watch'\nalias kw='key-watch'\n"
}
};

print!("{script}");
emit(script.trim_end())
}

fn verify_binary_integrity() -> Result<(), RunCliError> {
Expand Down
8 changes: 7 additions & 1 deletion src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,13 @@ fn is_baseline_file(path: &str, baseline: Option<&PathBuf>) -> bool {
let Some(baseline) = baseline else {
return false;
};
fs::canonicalize(path).is_ok_and(|candidate| candidate == *baseline)
// Compare file names before paying for a realpath(2) on every scanned
// file: only a handful can possibly be the baseline.
let candidate = Path::new(path);
if candidate.file_name() != baseline.file_name() {
return false;
}
fs::canonicalize(candidate).is_ok_and(|candidate| candidate == *baseline)
}

fn parse_hunk_new_start(header: &str) -> usize {
Expand Down
15 changes: 15 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ static HOME_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
.map(PathBuf::from)
});

/// Writes a line to stdout.
///
/// `println!` panics if stdout is closed, which happens routinely when output
/// is piped (`key-watch hook install | head`). A closed pipe is a normal way
/// for a reader to stop listening, so it is reported as success.
pub fn emit_line(line: &str) -> std::io::Result<()> {
use std::io::Write;

let mut stdout = std::io::stdout().lock();
match writeln!(stdout, "{line}") {
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
other => other,
}
}

/// Renders a path for terminal output, abbreviating the home directory as `~`.
pub fn display_path(path: &Path) -> String {
match HOME_DIR
Expand Down
4 changes: 4 additions & 0 deletions tests/cli_validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fn test_stdin_with_path_validation_returns_typed_error() {
exit_mode: ExitMode::Strict,
baseline: None,
update_baseline: false,
prune_baseline: false,
no_baseline_discovery: true,
config: None,
no_config_discovery: false,
Expand Down Expand Up @@ -50,6 +51,7 @@ fn test_staged_with_stdin_validation_returns_typed_error() {
exit_mode: ExitMode::Strict,
baseline: None,
update_baseline: false,
prune_baseline: false,
no_baseline_discovery: true,
config: None,
no_config_discovery: false,
Expand Down Expand Up @@ -81,6 +83,7 @@ fn test_staged_with_git_history_validation_returns_typed_error() {
exit_mode: ExitMode::Strict,
baseline: None,
update_baseline: false,
prune_baseline: false,
no_baseline_discovery: true,
config: None,
no_config_discovery: false,
Expand Down Expand Up @@ -112,6 +115,7 @@ fn test_staged_allows_zero_or_many_paths() {
exit_mode: ExitMode::Strict,
baseline: None,
update_baseline: false,
prune_baseline: false,
no_baseline_discovery: true,
config: None,
no_config_discovery: false,
Expand Down
Loading