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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ All notable changes to this project will be documented in this file.

### Fixed

- Config is not trusted from a world-writable directory, so a `.keywatch.toml` dropped in `/tmp` cannot weaken scans beneath it
- `KEYWATCH_CONFIG_PATH` is ignored in trusted mode when it points inside the tree being scanned
- Baseline suppression reports how many findings it hid, instead of applying silently
- `CreditCardDetector` requires an issuer prefix and a valid Luhn checksum, instead of matching any 13-16 digit run
- `HighEntropyDetector` could never fire (its 4.0 threshold is the ceiling for hex) and now runs, restricted to lines naming a credential
- PKCS#8 private key headers (`BEGIN PRIVATE KEY`, `BEGIN ENCRYPTED PRIVATE KEY`) are detected
Expand Down
25 changes: 25 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ pub(crate) fn find_file_upwards(
} else {
cwd.join(search_dir)
};
// Drop "." components so discovered paths read as "/repo/.keywatch.toml"
// rather than "/repo/./.keywatch.toml" when reported to the user.
let search_dir: PathBuf = search_dir
.components()
.filter(|component| !matches!(component, std::path::Component::CurDir))
.collect();

let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
Expand All @@ -264,6 +270,12 @@ pub(crate) fn find_file_upwards(
.find(|candidate_path| candidate_path.exists())
.and_then(|candidate_path| candidate_path.to_str().map(str::to_string));
if found.is_some() {
// A world-writable directory is not a trust boundary: on a shared
// host any user could drop a config into /tmp and weaken every
// scan beneath it.
if is_world_writable(dir) {
return None;
}
return found;
}
if dir.join(".git").exists() || home.as_deref() == Some(dir) {
Expand All @@ -272,3 +284,16 @@ pub(crate) fn find_file_upwards(
}
None
}

#[cfg(unix)]
fn is_world_writable(dir: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(dir)
.map(|metadata| metadata.permissions().mode() & 0o002 != 0)
.unwrap_or(false)
}

#[cfg(not(unix))]
fn is_world_writable(_dir: &Path) -> bool {
false
}
30 changes: 30 additions & 0 deletions src/config/tests/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,33 @@ fn test_load_none_uses_supplied_cwd() {
.collect();
assert!(names.contains(&"CwdRule".to_string()));
}

#[test]
fn test_config_in_world_writable_directory_is_ignored() {
// On a shared host any user can drop a config into a world-writable
// directory; trusting it would let them disable detectors for everyone
// scanning beneath it.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
write_file(
&dir,
".keywatch.toml",
&minimal_rule_toml("HostileRule", r"\\bX\\b", "LOW"),
);
let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
perms.set_mode(0o777);
std::fs::set_permissions(dir.path(), perms).unwrap();
let scan_file = dir.path().join("secrets.txt");
std::fs::write(&scan_file, "content").unwrap();

let paths = vec![scan_file.to_str().unwrap().to_string()];
assert!(
KeywatchConfig::load_for_paths(None, &paths)
.unwrap()
.is_none(),
"config from a world-writable directory must not be trusted"
);
}
}
18 changes: 18 additions & 0 deletions src/detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,29 @@ struct DetectorConfig {
validate: Option<String>,
}

/// True when `path` sits inside `dir`, i.e. the scanned repository supplied
/// it rather than the operator.
fn is_within(path: &std::path::Path, dir: &std::path::Path) -> bool {
match (fs::canonicalize(path), fs::canonicalize(dir)) {
(Ok(path), Ok(dir)) => path.starts_with(dir),
_ => false,
}
}

fn find_detectors_config(include_repository_config: bool) -> Option<std::path::PathBuf> {
std::env::var("KEYWATCH_CONFIG_PATH")
.map(std::path::PathBuf::from)
.ok()
.filter(|path| path.exists())
// KEYWATCH_CONFIG_PATH is an operator channel. A repository can reach
// it through .envrc/direnv or a devcontainer, so in trusted mode an
// value pointing back into the tree being scanned is ignored.
.filter(|path| {
include_repository_config
|| std::env::current_dir()
.map(|cwd| !is_within(path, &cwd))
.unwrap_or(true)
})
.or_else(|| {
if !include_repository_config {
return None;
Expand Down
28 changes: 4 additions & 24 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::sync::LazyLock;

mod error;
pub use error::HookError;
Expand All @@ -20,30 +19,11 @@ fn shell_escape(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\"'\"'"))
}

/// The user's home directory, resolved once per process.
static HOME_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
});

/// `"global"` or `"local"`, for messages describing a hook's scope.
const fn scope_label(is_global: bool) -> &'static str {
if is_global { "global" } else { "local" }
}

/// Renders a path for terminal output, abbreviating the home directory as `~`.
fn display_path(path: &Path) -> String {
match HOME_DIR
.as_deref()
.and_then(|home| path.strip_prefix(home).ok())
{
Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Some(rest) => format!("~/{}", rest.display()),
None => path.display().to_string(),
}
}

fn build_repo_section(allowed: Option<&str>, blocked: Option<&str>) -> String {
let escaped_allowed = allowed.map(shell_escape);
let escaped_blocked = blocked.map(shell_escape);
Expand Down Expand Up @@ -144,14 +124,14 @@ pub fn install_hook(args: &HookInstallArgs) -> Result<(), HookError> {
if install_target.configured_global_path {
println!(
"Configured git --global core.hooksPath to {}",
display_path(&install_target.hooks_dir)
utils::display_path(&install_target.hooks_dir)
);
}

println!(
"Installed {} {hook_type_str} hook at {}",
scope_label(install_target.is_global),
display_path(&install_target.path)
utils::display_path(&install_target.path)
);
println!(
"The hook will run automatically during git {}.",
Expand All @@ -173,7 +153,7 @@ pub fn uninstall_hook(args: &HookUninstallArgs) -> Result<(), HookError> {
if !install_target.path.exists() {
println!(
"No {scope} {hook_type_str} hook found at {}",
display_path(&install_target.path)
utils::display_path(&install_target.path)
);
return Ok(());
}
Expand All @@ -191,7 +171,7 @@ pub fn uninstall_hook(args: &HookUninstallArgs) -> Result<(), HookError> {

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

Ok(())
Expand Down
15 changes: 15 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ 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))?;
let before = findings.len();
findings = baseline.filter_findings(findings);
scan_metadata.suppressed_by_baseline = before - findings.len();
}

if args.update_baseline {
Expand All @@ -105,6 +108,7 @@ fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
elapsed.as_secs(),
elapsed.subsec_millis() / 100
);
let suppressed = scan_metadata.suppressed_by_baseline;
let severity_counts = report::get_severity_counts(&findings);
let exit_code = calculate_exit_code(&findings, &args.exit_mode);
let findings_count = findings.len();
Expand All @@ -114,6 +118,17 @@ fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
}
.map_err(|source| RunCliError::ReportSerialize { source })?;

if suppressed > 0 && !args.verbose {
emit(&format!(
"Suppressed {} finding(s) via {}",
suppressed,
args.baseline
.as_deref()
.map(|path| utils::display_path(std::path::Path::new(path)))
.unwrap_or_else(|| "baseline".to_string())
))?;
}

let summary = match findings_count {
_ if args.verbose => report_out.clone(),
0 => "No secrets found.".to_string(),
Expand Down
5 changes: 4 additions & 1 deletion src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,12 @@ pub struct Finding {
pub plugin_name: String,
}

#[derive(Serialize, Clone)]
#[derive(Serialize, Clone, Default)]
pub struct ScanMetadata {
pub files_scanned: usize,
pub total_lines: usize,
pub excluded_files: Vec<String>,
pub suppressed_by_baseline: usize,
}

#[derive(Serialize)]
Expand All @@ -123,6 +124,7 @@ pub struct Report {
pub files_scanned: usize,
pub total_lines: usize,
pub excluded_files: Vec<String>,
pub suppressed_by_baseline: usize,
pub scan_time: String,
}

Expand All @@ -142,6 +144,7 @@ pub fn create_report(
files_scanned: metadata.files_scanned,
total_lines: metadata.total_lines,
excluded_files: metadata.excluded_files,
suppressed_by_baseline: metadata.suppressed_by_baseline,
scan_time,
};

Expand Down
4 changes: 4 additions & 0 deletions src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ pub fn run_scan(
files_scanned: 1,
total_lines,
excluded_files: Vec::new(),
suppressed_by_baseline: 0,
};

return Ok((findings, metadata));
Expand Down Expand Up @@ -465,6 +466,7 @@ pub fn run_scan(
files_scanned: 1,
total_lines,
excluded_files: Vec::new(),
suppressed_by_baseline: 0,
};

return Ok((findings, metadata));
Expand Down Expand Up @@ -573,6 +575,7 @@ pub fn run_scan(
files_scanned,
total_lines,
excluded_files,
suppressed_by_baseline: 0,
};

Ok((findings, metadata))
Expand Down Expand Up @@ -831,6 +834,7 @@ fn scan_staged_diff<ReaderType: BufRead>(
files_scanned: scanned_files.len(),
total_lines,
excluded_files,
suppressed_by_baseline: 0,
};

Ok(StagedScan {
Expand Down
22 changes: 22 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

/// The user's home directory.
static HOME_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
});

/// Renders a path for terminal output, abbreviating the home directory as `~`.
pub fn display_path(path: &Path) -> String {
match HOME_DIR
.as_deref()
.and_then(|home| path.strip_prefix(home).ok())
{
Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Some(rest) => format!("~/{}", rest.display()),
None => path.display().to_string(),
}
}

use std::fs::File;
use std::io::{Result, Write};

Expand Down
5 changes: 5 additions & 0 deletions tests/report_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fn test_create_report() {
files_scanned: 5,
total_lines: 100,
excluded_files: vec![],
suppressed_by_baseline: 0,
};

let report = create_report(findings, metadata, "0.5s".to_string())
Expand Down Expand Up @@ -41,6 +42,7 @@ fn test_report_with_findings() {
files_scanned: 1,
total_lines: 50,
excluded_files: vec![],
suppressed_by_baseline: 0,
};

let report = create_report(findings, metadata, "0.1s".to_string())
Expand All @@ -66,6 +68,7 @@ fn test_create_report_includes_excluded_files_and_plugin_metadata() {
files_scanned: 2,
total_lines: 80,
excluded_files: vec!["ignored.log".to_string(), "vendor/secrets.txt".to_string()],
suppressed_by_baseline: 0,
};

let report = create_report(findings, metadata, "1.2s".to_string())
Expand Down Expand Up @@ -96,6 +99,7 @@ fn test_create_sarif_report_uses_camel_case_fields_and_hides_matched_content() {
files_scanned: 1,
total_lines: 12,
excluded_files: vec![],
suppressed_by_baseline: 0,
};

let sarif = create_sarif_report(findings, metadata, "2026-08-01T00:00:00Z".to_string())
Expand Down Expand Up @@ -188,6 +192,7 @@ fn test_create_sarif_report_maps_all_severities_to_expected_levels() {
files_scanned: 4,
total_lines: 4,
excluded_files: vec![],
suppressed_by_baseline: 0,
};

let sarif = create_sarif_report(findings, metadata, "2026-08-01T00:00:00Z".to_string())
Expand Down
Loading