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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ 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 `~`

### Changed

- Reports redact matched text by default; `--show-secrets` opts into raw values
- Reports summarise exclusions as a count plus a sample instead of listing every path

### Fixed

- `--output` files are created readable only by their owner
- 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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ key-watch verify-integrity
- `scan --git-history` - Scan git history (`git log -p`) for committed secrets
- `scan --staged [path...]` - Scan only the added lines of the staged diff (run from inside the repository; paths narrow the diff as git pathspecs); findings keep real file paths and line numbers, so `--baseline` and `--exclude` compose. Files git renders as binary (e.g. `-diff` in `.gitattributes`) are listed in `excluded_files` rather than scanned. Note: a multi-line secret added across separate commits can span hunks the diff scan never sees together — the pre-push whole-tree scan remains the backstop for that case
- `scan --output <path>` - Save report to file
- `scan --verbose` - Print full JSON output
- `scan --verbose` - Print full JSON output (matched text is redacted; see `--show-secrets`)
- `scan --show-secrets` - Include raw matched text in reports. Off by default: reports are routinely written to files or uploaded as CI artifacts, and `--output` files are created with owner-only permissions
- `scan --exclude <patterns>` - Comma-separated glob patterns to exclude
- `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
Expand Down
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ pub struct ScanArgs {
#[arg(short, long, default_value_t = false)]
pub verbose: bool,

/// Include raw matched text in reports (default: redacted)
#[arg(long, default_value_t = false)]
pub show_secrets: bool,

/// Paths to exclude from scanning (comma-separated, supports glob patterns)
#[arg(long)]
pub exclude: Option<String>,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ fn emit(line: &str) -> Result<(), RunCliError> {

fn run_scan_command(args: &ScanArgs) -> Result<(), RunCliError> {
let start = Instant::now();
report::set_show_secrets(args.show_secrets);

// Resolve the baseline like config: an explicit --baseline wins, otherwise
// discover .keywatch-baseline.json in the scanned tree. --update-baseline
Expand Down
54 changes: 52 additions & 2 deletions src/report.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};

/// Whether reports may contain raw matched text. Off unless the operator asks
/// for it: a report is routinely written to a file or uploaded as a CI
/// artifact, which would turn the scanner into an exfiltration channel.
static SHOW_SECRETS: AtomicBool = AtomicBool::new(false);

pub fn set_show_secrets(show: bool) {
SHOW_SECRETS.store(show, Ordering::Relaxed);
}

/// Keeps enough to identify a finding without reproducing the credential:
/// the first four characters and the length.
pub fn redact(matched: &str) -> String {
let visible: String = matched.chars().take(4).collect();
format!("{visible}... ({} chars, redacted)", matched.chars().count())
}

fn serialize_matched_content<S>(matched: &str, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if SHOW_SECRETS.load(Ordering::Relaxed) {
serializer.serialize_str(matched)
} else {
serializer.serialize_str(&redact(matched))
}
}
use std::{fmt, str::FromStr};
mod sarif;

Expand Down Expand Up @@ -105,6 +133,7 @@ pub struct Finding {
pub line_number: usize,
pub finding_type: String,
pub severity: Severity,
#[serde(serialize_with = "serialize_matched_content")]
pub matched_content: String,
pub plugin_name: String,
}
Expand All @@ -117,13 +146,34 @@ pub struct ScanMetadata {
pub suppressed_by_baseline: usize,
}

/// How many paths an exclusion removed, and a bounded sample of them.
///
/// The full list is not reported: excluding `target/**` in a Rust repository
/// produced 46,310 entries and a 4.6 MB report for 61 scanned files.
#[derive(Serialize, Clone, Default)]
pub struct ExcludedSummary {
pub count: usize,
pub sample: Vec<String>,
}

impl ExcludedSummary {
const SAMPLE_LIMIT: usize = 20;

pub fn from_paths(paths: &[String]) -> Self {
Self {
count: paths.len(),
sample: paths.iter().take(Self::SAMPLE_LIMIT).cloned().collect(),
}
}
}

#[derive(Serialize)]
pub struct Report {
pub status: ScanStatus,
pub findings: Vec<Finding>,
pub files_scanned: usize,
pub total_lines: usize,
pub excluded_files: Vec<String>,
pub excluded: ExcludedSummary,
pub suppressed_by_baseline: usize,
pub scan_time: String,
}
Expand All @@ -143,7 +193,7 @@ pub fn create_report(
findings,
files_scanned: metadata.files_scanned,
total_lines: metadata.total_lines,
excluded_files: metadata.excluded_files,
excluded: ExcludedSummary::from_paths(&metadata.excluded_files),
suppressed_by_baseline: metadata.suppressed_by_baseline,
scan_time,
};
Expand Down
14 changes: 12 additions & 2 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,21 @@ pub fn display_path(path: &Path) -> String {
}
}

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

/// Writes a report file readable only by its owner.
///
/// `File::create` uses 0666 & ~umask, i.e. world-readable by default, and a
/// report can carry matched text when `--show-secrets` is set.
pub fn write_to_file(path: &str, content: &str) -> Result<()> {
let mut file = File::create(path)?;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
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 @@ -10,6 +10,7 @@ fn test_stdin_with_path_validation_returns_typed_error() {
staged: false,
output: None,
verbose: false,
show_secrets: false,
exclude: None,
exit_mode: ExitMode::Strict,
baseline: None,
Expand Down Expand Up @@ -44,6 +45,7 @@ fn test_staged_with_stdin_validation_returns_typed_error() {
staged: true,
output: None,
verbose: false,
show_secrets: false,
exclude: None,
exit_mode: ExitMode::Strict,
baseline: None,
Expand Down Expand Up @@ -74,6 +76,7 @@ fn test_staged_with_git_history_validation_returns_typed_error() {
staged: true,
output: None,
verbose: false,
show_secrets: false,
exclude: None,
exit_mode: ExitMode::Strict,
baseline: None,
Expand Down Expand Up @@ -104,6 +107,7 @@ fn test_staged_allows_zero_or_many_paths() {
staged: true,
output: None,
verbose: false,
show_secrets: false,
exclude: None,
exit_mode: ExitMode::Strict,
baseline: None,
Expand Down
30 changes: 28 additions & 2 deletions tests/exit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,12 @@ fn test_scan_verbose_output() {

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("AKIAIOSFODNN7EXAMPLE"),
"Verbose output should contain secret info"
!stdout.contains("AKIAIOSFODNN7EXAMPLE"),
"reports must redact matched text by default, got:\n{stdout}"
);
assert!(
stdout.contains("redacted") && stdout.contains("AWS Access Key"),
"the finding must still be identifiable, got:\n{stdout}"
);
assert!(
stdout.contains("\"status\": \"FAIL\""),
Expand Down Expand Up @@ -344,3 +348,25 @@ fn test_init_command() {
"Should contain kw alias"
);
}

#[test]
fn test_show_secrets_opts_into_raw_matched_content() {
let dir = setup_scan_dir("show_secrets", true);
let file = dir.join("leak.txt");
fs::write(&file, "aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n").expect("write");

let output = Command::new(env!("CARGO_BIN_EXE_key-watch"))
.args(["scan"])
.arg(&file)
.args(["--verbose", "--show-secrets"])
.output()
.expect("run key-watch");
let stdout = String::from_utf8_lossy(&output.stdout);

assert!(
stdout.contains("AKIAIOSFODNN7EXAMPLE"),
"--show-secrets must include raw matched text, got:\n{stdout}"
);

let _ = fs::remove_dir_all(&dir);
}
15 changes: 11 additions & 4 deletions tests/report_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ fn test_create_report() {
assert_eq!(json["status"], "PASS");
assert_eq!(json["files_scanned"], 5);
assert_eq!(json["total_lines"], 100);
assert_eq!(json["excluded_files"], serde_json::json!([]));
assert_eq!(json["excluded"]["count"], 0);
assert_eq!(json["scan_time"], "0.5s");
}

Expand All @@ -51,7 +51,11 @@ fn test_report_with_findings() {

assert_eq!(json["status"], "FAIL");
assert_eq!(json["findings"][0]["finding_type"], "AWS Key");
assert_eq!(json["findings"][0]["matched_content"], "AKIATESTKEY");
// Matched text is redacted unless --show-secrets is passed.
assert_eq!(
json["findings"][0]["matched_content"],
"AKIA... (11 chars, redacted)"
);
}

#[test]
Expand All @@ -76,11 +80,14 @@ fn test_create_report_includes_excluded_files_and_plugin_metadata() {
let json = parse_json(&report);

assert_eq!(
json["excluded_files"],
json["excluded"]["sample"],
serde_json::json!(["ignored.log", "vendor/secrets.txt"])
);
assert_eq!(json["findings"][0]["plugin_name"], "TokenDetector");
assert_eq!(json["findings"][0]["matched_content"], "tok_test_123");
assert_eq!(
json["findings"][0]["matched_content"],
"tok_... (12 chars, redacted)"
);
assert_eq!(json["total_lines"], 80);
}

Expand Down
Loading