Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4e0757f
feat: add config file, SARIF output, format selection, and pre-commit…
pixincreate Jul 30, 2026
b6b977c
docs: add architecture section to README with Mermaid diagrams and da…
pixincreate Jul 30, 2026
d2078f6
docs(changelog): add config, SARIF, format, and architecture entries
pixincreate Jul 30, 2026
7601286
refactor: extract hook lifecycle from lib.rs into hooks.rs
pixincreate Jul 31, 2026
97ad337
docs: add SVG architecture diagram, replace mermaid sections in README
pixincreate Jul 31, 2026
ef5a836
refactor: split configuration module
pixincreate Aug 1, 2026
28897e8
feat: apply configuration during scans
pixincreate Aug 1, 2026
5c23f11
feat: add redacted SARIF reporting
pixincreate Aug 1, 2026
6fff24a
fix: isolate action config discovery
pixincreate Aug 1, 2026
3fab24d
test: restore baseline regression coverage
pixincreate Aug 1, 2026
9ccfbfa
docs: document scan configuration options
pixincreate Aug 1, 2026
a7c8b68
style: use descriptive config names
pixincreate Aug 2, 2026
474e8d7
style: use descriptive SARIF names
pixincreate Aug 2, 2026
44a4490
fix: prevent repository config from bypassing pre-push scan
pixincreate Aug 2, 2026
1c6a09c
fix: ignore repository detector overrides in trusted scans
pixincreate Aug 2, 2026
8592a75
refactor: type CLI validation errors
pixincreate Aug 2, 2026
f93cfa9
refactor: type detector initialization errors
pixincreate Aug 2, 2026
cd1ecb7
refactor: type configuration errors
pixincreate Aug 2, 2026
f3bb7c9
refactor: type scanner errors
pixincreate Aug 2, 2026
7c50019
refactor: type baseline errors
pixincreate Aug 2, 2026
1776372
refactor: type hook lifecycle errors
pixincreate Aug 2, 2026
fe3b902
style: clarify report identifiers
pixincreate Aug 2, 2026
558006f
refactor: compose typed CLI runtime errors
pixincreate Aug 2, 2026
3561e18
docs: record typed error API break
pixincreate Aug 2, 2026
69f9135
docs: replace architecture svg with mermaid diagrams
pixincreate Aug 2, 2026
09fa084
docs: update architecture changelog entry
pixincreate Aug 2, 2026
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
12 changes: 10 additions & 2 deletions .github/actions/keywatch-scan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ inputs:
description: 'Deprecated: verbose scanner output is disabled to avoid logging matched secrets'
required: false
default: 'false'
config:
description: 'Path to an explicit trusted .keywatch.toml config file'
required: false
default: ''

outputs:
findings-count:
Expand Down Expand Up @@ -133,6 +137,7 @@ runs:
INPUT_EXIT_MODE: ${{ inputs.exit-mode }}
INPUT_OUTPUT: ${{ inputs.output }}
INPUT_VERBOSE: ${{ inputs.verbose }}
INPUT_CONFIG: ${{ inputs.config }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -205,7 +210,7 @@ runs:
if ((${#extra_args[@]})); then
for arg in "${extra_args[@]}"; do
case "$arg" in
--verbose|--verbose=*|-v*|--format|--format=*|-f|-f*|--output|--output=*|-o|-o*|--exit-mode|--exit-mode=*)
--verbose|--verbose=*|-v*|--format|--format=*|-f|-f*|--output|--output=*|-o|-o*|--exit-mode|--exit-mode=*|--config|--config=*|--no-config-discovery|--no-config-discovery=*)
echo "ERROR: '$arg' is managed by action inputs and cannot be passed through args" >&2
exit 1
;;
Expand All @@ -224,8 +229,11 @@ runs:
mkdir -p "$(dirname -- "$report_path")"
fi

keywatch_args=(scan)
keywatch_args=(scan --no-config-discovery)
keywatch_args+=(--exit-mode "$INPUT_EXIT_MODE")
if [ -n "$INPUT_CONFIG" ]; then
keywatch_args+=(--config "$INPUT_CONFIG")
fi
if ((${#extra_args[@]})); then
keywatch_args+=("${extra_args[@]}")
fi
Expand Down
19 changes: 19 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- id: keywatch-scan
name: KeyWatch scan
description: Scan staged files for secrets, API keys, tokens, and credentials
entry: key-watch scan
language: rust
files: ''
pass_filenames: true
verbose: false
args: ['--exit-mode=strict']

- id: keywatch-scan-system
name: KeyWatch scan (system)
description: Scan staged files for secrets, API keys, tokens, and credentials
entry: key-watch scan
language: system
files: ''
pass_filenames: true
verbose: false
args: ['--exit-mode=strict']
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Breaking Changes

- Public Rust APIs now return module-local typed errors instead of `String` or boxed errors. This affects CLI validation, baseline, configuration, detector initialization, scanner, hook, and `run_cli()` return types and requires a major-version release.

### Added

- **CRITICAL severity support** — findings can now be scored as Critical, High, Medium, or Low
Expand All @@ -19,9 +23,15 @@ All notable changes to this project will be documented in this file.
- **GitHub Action** — composite action (`action.yml`) for CI/CD integration
- **Docker support** — multi-stage Dockerfile with `--locked` flag, stripped binary, non-root user, and git installed for `--git-history` scanning and hook installation
- `.dockerignore` for optimized Docker builds
- **Config file support** — `.keywatch.toml` with custom rules, detector overrides, and exclude patterns
- **SARIF 2.1.0 output** — `--format sarif` enables GitHub Code Scanning and SARIF viewer integration
- **`--config` CLI flag** — specify a custom path to `.keywatch.toml`
- **Pre-commit `language: system`** — generated hooks use `language: system` for faster execution

### Changed

- `get_severity_counts()` now returns 4-tuple (Critical, High, Medium, Low) instead of 3-tuple
- `run_scan()` accepts optional `config` parameter for merging user configuration
- Simplified distribution to a single shipped binary: `key-watch`
- Git hook installation now supports first-class global hooks via `core.hooksPath`
- Installation guidance is now cargo-first, with manual GitHub Releases setup documented step by step
Expand Down Expand Up @@ -49,6 +59,10 @@ All notable changes to this project will be documented in this file.
- `scripts/install.sh` in favor of documented `cargo install` and manual release-binary setup
- ~1650 lines of redundant context-based detectors; kept only prefix-based detectors plus GenericKeyValueDetector

### Documentation

- README architecture documentation now uses two inline GitHub-compatible Mermaid diagrams for the system overview and detection pipeline, alongside the layer-by-layer overview and core data type reference

## [1.1.0] - 2026-05-05

### Added
Expand Down
118 changes: 118 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ key-watch verify-integrity
## Options

- `scan <path>...` - Scan one or more files or directories
- `scan --config <path>` - Load configuration from an explicit `.keywatch.toml` path
- `scan --no-config-discovery` - Ignore discovered repository config unless `--config` is explicit
- `scan --format <json|sarif>` - Choose the report format written to stdout or the output file
- `scan --stdin` - Read content from stdin instead of files
- `scan --git-history` - Scan git history (`git log -p`) for committed secrets
- `scan --output <path>` - Save report to file
Expand Down Expand Up @@ -191,6 +194,121 @@ password = 'known-test-password' # keywatch:ignore
- KeyWatch refuses to overwrite a non-KeyWatch global hook file
- KeyWatch also refuses to remove a non-KeyWatch global hook file

## Architecture

### System Overview

```mermaid
flowchart TD
CLI["key-watch CLI"]
Scan["scan command"]
Hooks["hook install / uninstall"]
Setup["init / verify-integrity"]

Sources["Scan sources<br/>files, directories, stdin, or git history"]
BuiltIns["detectors.toml<br/>built-in rules"]
UserConfig[".keywatch.toml or --config<br/>custom rules, overrides, excludes"]
Detectors["Merged detector set"]
Pipeline["Detection pipeline"]
Findings["Findings + ScanMetadata"]
BaselineAction{"Baseline action"}
BaselineFilter["Filter known findings<br/>--baseline"]
BaselineUpdate["Write updated baseline<br/>--update-baseline"]
BaselineFile["Baseline JSON + exit"]
Report["JSON or SARIF 2.1.0 report"]
Destination["stdout or --output<br/>summary + exit code"]

HookTargets["Git hook targets<br/>local or global"]
PreCommit["pre-commit<br/>scan staged files"]
PrePush["pre-push<br/>check policy, then scan repository"]

CLI --> Scan
CLI --> Hooks
CLI --> Setup

Hooks --> HookTargets
HookTargets --> PreCommit
HookTargets --> PrePush
PreCommit --> Scan
PrePush --> Scan

Scan --> Sources
Scan --> BuiltIns
Scan --> UserConfig
BuiltIns --> Detectors
UserConfig --> Detectors
Sources --> Pipeline
Detectors --> Pipeline
Pipeline --> Findings
Findings --> BaselineAction
BaselineAction -->|none| Report
BaselineAction -->|filter| BaselineFilter
BaselineAction -->|update| BaselineUpdate
BaselineFilter --> Report
BaselineUpdate --> BaselineFile
Report --> Destination
```

### Architecture Overview

KeyWatch is organized into five layers. Data flows top to bottom: input sources and configuration feed the detection pipeline, findings pass through post-processing, and results are serialized to stdout or a file.

1. **Input** — the CLI accepts files, directories, stdin, or git history (`--git-history`). Flags control exclusion (`--exclude`), baselineing (`--baseline`, `--update-baseline`), output format (`--format`), config path (`--config`), and exit behavior (`--exit-mode`).
2. **Configuration** — `detectors.toml` ships with the binary and holds the built-in rules. An optional `.keywatch.toml` adds custom rules, per-detector severity/enable overrides, and exclude patterns. Configuration merges — it never replaces defaults.
3. **Detection pipeline** — six stages run per file: collect files (recursive walk, skipping `.git` and binary files), apply exclude globs, pre-filter by keyword (fast path that avoids regex on irrelevant files), match regexes (single-line and multiline `(?s)`), gate on Shannon entropy, and apply allowlists plus inline `keywatch:ignore` suppression. Files are scanned in parallel with rayon.
4. **Post-processing** — an optional baseline filter suppresses findings already recorded in the baseline file, keyed by a salted SHA-256 fingerprint of the matched content.
5. **Output** — findings serialize as JSON or SARIF 2.1.0 and are written to stdout or an output file, followed by a severity summary and an exit code derived from the exit mode.

### Detection Pipeline

```mermaid
flowchart TD
Start["scan command"]
Config["Load optional configuration"]
Detectors["Initialize built-in and custom detectors"]
Mode{"Input mode"}

Paths["Files or directories"]
Stdin["stdin stream"]
History["git log patch stream"]

Collect["Collect targets<br/>recursive walk, skip symlinks and .git"]
Dedupe["Sort and deduplicate targets"]
Exclude["Apply CLI and config exclude globs"]
Read["Read text files<br/>skip binary and non-UTF-8 content"]
Parallel["Scan files in parallel with rayon"]
Stream["Scan stream in overlapping chunks"]

Keyword["Keyword pre-filter"]
Regex["Line and multiline regex matching"]
Entropy["Entropy threshold"]
Suppress["Detector allowlist + inline suppression"]
Emit["Emit Finding"]
Result["Return findings + metadata"]

Start --> Config --> Detectors --> Mode
Mode -->|paths| Paths
Mode -->|stdin| Stdin
Mode -->|git history| History

Paths --> Collect --> Dedupe --> Exclude --> Read --> Parallel
Stdin --> Stream
History --> Stream

Parallel --> Keyword
Stream --> Keyword
Keyword --> Regex --> Entropy --> Suppress --> Emit --> Result
```

### Core Data Types

- **Detector** — a named rule: regex, finding type, severity, optional keywords for pre-filtering, an entropy threshold, and an allowlist.
- **Finding** — one detected secret: file path, line number, finding type, severity, matched content, and the detector that produced it.
- **Severity** — `Critical`, `High`, `Medium`, `Low`.
- **KeywatchConfig** — parsed `.keywatch.toml`: custom rules, per-detector overrides, and exclude patterns.
- **Baseline** — versioned collection of fingerprint entries; filters out already-known findings.
- **ScanMetadata** — files scanned, total lines, and excluded files, reported alongside findings.

## Development

```sh
Expand Down
10 changes: 8 additions & 2 deletions scripts/validate-keywatch-action.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ScanScenario(NamedTuple):
expected_stderr: tuple[str, ...] = ()
expected_summary: tuple[str, ...] = ()
preseed_report: bool = False
config: str = ""


def run_blocks(text: str) -> list[str]:
Expand Down Expand Up @@ -74,10 +75,12 @@ def write_keywatch_stub(bin_dir: Path) -> Path:

def run_scan_scenarios(scan_block: str) -> None:
scenarios = (
ScanScenario("glob-expands", "scan/*.txt", "", 0, "valid", 0, ("exit_code=0", "findings_count=2"), ("scan/match.txt",), ("scan/*.txt", "--verbose")),
ScanScenario("glob-expands", "scan/*.txt", "", 0, "valid", 0, ("exit_code=0", "findings_count=2"), ("--no-config-discovery", "scan/match.txt"), ("scan/*.txt", "--verbose")),
ScanScenario("explicit-config", ".", "", 0, "valid", 0, expected_capture=("--no-config-discovery\n", "--config\ntrusted.toml\n"), config="trusted.toml"),
ScanScenario("semicolon-literal", "literal;touch_pwned", "", 0, "valid", 0, expected_capture=("literal;touch_pwned",)),
ScanScenario("path-option-is-literal", "--verbose", "", 0, "valid", 0, expected_capture=("--\n--verbose\n",)),
ScanScenario("format-long-value-rejected", ".", "--format sarif", 0, "valid", 1, expected_stderr=("managed by action inputs",)),
ScanScenario("config-passthrough-rejected", ".", "--config .keywatch.toml", 0, "valid", 1, expected_stderr=("managed by action inputs",)),
ScanScenario("verbose-long-value-rejected", ".", "--verbose=true", 0, "valid", 1, expected_stderr=("managed by action inputs",)),
ScanScenario("verbose-compact-short-rejected", ".", "-vv", 0, "valid", 1, expected_stderr=("managed by action inputs",)),
ScanScenario("verbose-mode-long-allowed", ".", "--verbose-mode", 0, "valid", 0, expected_capture=("--verbose-mode",)),
Expand Down Expand Up @@ -119,6 +122,7 @@ def run_scan_scenarios(scan_block: str) -> None:
"INPUT_EXIT_MODE": "strict",
"INPUT_OUTPUT": "",
"INPUT_VERBOSE": "false",
"INPUT_CONFIG": scenario.config,
"KEYWATCH_CAPTURE": str(workspace / "capture"),
"KEYWATCH_STUB_EXIT": str(scenario.scanner_exit),
"KEYWATCH_REPORT_MODE": scenario.report_mode,
Expand Down Expand Up @@ -185,7 +189,8 @@ def main() -> int:
require(fragment not in shell, f"forbidden shell fragment remains: {fragment}")

require("${{ inputs." not in shell, "inputs must be routed through step env, not run blocks")
require("keywatch_args=(scan)" in shell, "scanner argv must be built with a Bash array")
require("keywatch_args=(scan --no-config-discovery)" in shell, "Action scans must disable untrusted config discovery")
require("keywatch_args+=(--config \"$INPUT_CONFIG\")" in shell, "explicit trusted config input must be supported")
require("read -r -a paths" in shell, "paths input must be parsed without shell evaluation")
require("compgen -G \"$path_token\"" in shell, "path globs must expand without shell evaluation")
require("expanded_paths+=(\"$path_token\")" in shell, "unmatched globs and metachar literals must stay literal")
Expand All @@ -194,6 +199,7 @@ def main() -> int:
require("managed by action inputs" in shell, "args must not override verbose/output/exit-mode inputs")
require("--verbose|--verbose=*|-v*" in shell, "all verbose arg forms must be rejected")
require("--format|--format=*|-f|-f*" in shell, "all format arg forms must be rejected")
require("--config|--config=*|--no-config-discovery|--no-config-discovery=*" in shell, "config discovery controls must be managed by the Action")
require("verbose output is disabled" in shell, "verbose mode must not log matched secrets")
require("false|0|no|off|\"\")" in shell, "verbose=false must be an explicit non-verbose case")
require("asset_arch=\"aarch64\"" in shell, "Darwin ARM64 must map to aarch64 release assets")
Expand Down
Loading