Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Support for the `pre-commit` framework (https://pre-commit.com).
#
# Separate from `injection-scanner install-hook`, which writes a plain git hook
# for repositories that do not use the framework. Teams that do use it manage
# hooks declaratively and would have this one overwritten by `pre-commit
# install`, so both entry points have to exist.
- id: injection-scanner
name: Scan for prompt injection
description: >
Detects prompt-injection patterns in agent-facing files — CLAUDE.md, skill
files, MCP manifests, RAG documents.
entry: injection-scanner check
args: ["--fail-on", "high"]
language: rust
# Only agent-facing text. The framework passes matched filenames as arguments,
# so this is the same widening the walker does for a directory scan.
types_or: [markdown, yaml, json, toml, text, html]
pass_filenames: true
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,53 @@ injection-scanner check ./untrusted-skills --no-suppress
Rule of thumb: **suppression is for your own repository; `--no-suppress` is for everyone else's
content.**

## Pre-commit Hook

```bash
$ injection-scanner install-hook
Installed pre-commit hook at .git/hooks/pre-commit.
Staged files are scanned before each commit; High and above block it.
Bypass once with `git commit --no-verify`.

$ git commit -m "update spec"
./docs/CLAUDE.md
:3 CRITICAL Attempts to override agent instructions (PI001)

Commit blocked: prompt-injection patterns at high or above.
Explain a finding with: injection-scanner explain <PI0XX>
Commit anyway with: git commit --no-verify
```

**60ms** on a 40-file repository, against the 200ms budget.

Three things it gets right that a naive hook does not:

- **Scans staged content, not the working tree.** A partially staged file is
judged on what is actually about to be committed — otherwise you could stage a
clean version, leave the payload unstaged, and pass.
- **Reports repository paths.** It scans a staging copy under a temp directory,
but findings name `./docs/CLAUDE.md`, not a `/tmp` path that no longer exists
by the time you read it.
- **Never replaces a hook it did not write.** A pre-commit hook is often the only
thing between a repository and a committed secret. It refuses and tells you
about `--force`.

`--fail-on` defaults to `high` here rather than `low`, so MEDIUM heuristics
inform without blocking. Change it with
`install-hook --fail-on critical`.

Using the [pre-commit framework](https://pre-commit.com) instead? This repo ships
a `.pre-commit-hooks.yaml`, because `pre-commit install` would overwrite the hook
above:

```yaml
repos:
- repo: https://github.com/UnityInFlow/injection-scanner
rev: v0.0.3
hooks:
- id: injection-scanner
```

## Choosing What Fails the Build

```bash
Expand Down
156 changes: 156 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ enum Commands {
#[arg(long, value_enum, ignore_case = true, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
/// Install a git pre-commit hook that scans staged files
InstallHook {
/// Repository to install into (default: the current one)
#[arg(long, value_name = "DIR")]
repo: Option<PathBuf>,
/// Severity at or above which a commit is blocked
#[arg(long, value_enum, ignore_case = true, default_value_t = FailOn::High)]
fail_on: FailOn,
/// Overwrite an existing hook
#[arg(long)]
force: bool,
},
/// Show everything known about one pattern
Explain {
/// Pattern id, e.g. PI001 (case-insensitive)
Expand Down Expand Up @@ -490,6 +502,51 @@ fn main() -> Result<()> {
}
}

Commands::InstallHook {
repo,
fail_on,
force,
} => {
let root = repo.unwrap_or_else(|| PathBuf::from("."));
let hooks = git_hooks_dir(&root)?;
let hook = hooks.join("pre-commit");

if hook.exists() && !force {
let existing = fs::read_to_string(&hook).unwrap_or_default();
if existing.contains(HOOK_MARKER) {
println!("Hook already installed at {}.", hook.display());
println!("Re-run with --force to update it.");
return Ok(());
}
// Never silently replace someone else's hook. A pre-commit hook
// is often the only thing standing between a repository and a
// committed secret.
anyhow::bail!(
"{} already exists and was not written by this tool.\n\
Inspect it, then re-run with --force to replace it.",
hook.display()
);
}

fs::create_dir_all(&hooks)
.with_context(|| format!("Failed to create {}", hooks.display()))?;
fs::write(&hook, hook_script(fail_on))
.with_context(|| format!("Failed to write {}", hook.display()))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755))
.with_context(|| format!("Failed to make {} executable", hook.display()))?;
}

println!("Installed pre-commit hook at {}.", hook.display());
println!(
"Staged files are scanned before each commit; {fail_on:?} and above block it."
);
println!("Bypass once with `git commit --no-verify`.");
}

Commands::Explain { id, patterns } => {
let categories = load_graded(patterns.as_deref())?;
let wanted = id.to_uppercase();
Expand Down Expand Up @@ -571,3 +628,102 @@ fn load_graded(patterns: Option<&std::path::Path>) -> Result<Vec<GradedRule>> {
rules.sort_by(|a, b| a.id.cmp(&b.id));
Ok(rules)
}

/// Marks a hook as ours, so an update can tell "mine, older" from "someone
/// else's, do not touch".
const HOOK_MARKER: &str = "injection-scanner:install-hook";

/// Where git wants hooks for this repository.
///
/// Reads `core.hooksPath` and the real `.git` location from git itself rather
/// than assuming `.git/hooks`. Both assumptions break on a worktree — where
/// `.git` is a FILE pointing elsewhere — and on any repository that has
/// configured a shared hooks directory.
fn git_hooks_dir(root: &std::path::Path) -> Result<PathBuf> {
let run = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|s| !s.is_empty())
};

let common = run(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
.context("Not a git repository (or git is not on PATH)")?;

Ok(match run(&["config", "--get", "core.hooksPath"]) {
Some(configured) => {
let path = PathBuf::from(&configured);
if path.is_absolute() {
path
} else {
root.join(path)
}
}
None => PathBuf::from(common).join("hooks"),
})
}

/// The hook script.
///
/// Scans only what is STAGED, and scans the staged content rather than the
/// working tree — `git stash`-free and correct when a file is partially staged.
/// Without that, a developer could stage a clean version, leave a payload
/// unstaged, and have the hook pass on text that is not what gets committed.
fn hook_script(fail_on: FailOn) -> String {
let bar = format!("{fail_on:?}").to_lowercase();
format!(
r##"#!/bin/sh
# {HOOK_MARKER}
#
# Scans staged content for prompt-injection patterns before each commit.
# Regenerate with: injection-scanner install-hook --force
#
# Bypass once with: git commit --no-verify
set -eu

if ! command -v injection-scanner >/dev/null 2>&1; then
echo "injection-scanner not on PATH; skipping the injection scan." >&2
exit 0
fi

# Added, copied and modified paths only. A deleted file has nothing to scan, and
# a rename is caught under its new path.
staged=$(git diff --cached --name-only --diff-filter=ACMR)
[ -n "$staged" ] || exit 0

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

printf '%s\n' "$staged" | while IFS= read -r path; do
[ -n "$path" ] || continue
mkdir -p "$tmp/$(dirname "$path")"
# The STAGED blob, not the working tree. A partially staged file has to be
# judged on what is actually about to be committed — otherwise a developer
# can stage a clean version, leave a payload unstaged, and pass.
git show ":$path" > "$tmp/$path" 2>/dev/null || true
done

# Run from inside the staging copy so findings are reported at ./path, the path
# the developer recognises, rather than at some /tmp/tmp.XXXX prefix they cannot
# act on.
status=0
( cd "$tmp" && injection-scanner check . --fail-on {bar} --no-ignore ) || status=$?

if [ "$status" -eq 1 ]; then
echo "" >&2
echo "Commit blocked: prompt-injection patterns at {bar} or above." >&2
echo "Explain a finding with: injection-scanner explain <PI0XX>" >&2
echo "Commit anyway with: git commit --no-verify" >&2
exit 1
fi

exit 0
"##
)
}
Loading