Skip to content

Commit 30ac0ad

Browse files
authored
feat: pre-commit secrets scanner (v3.5.0) (#123)
1 parent d40ec75 commit 30ac0ad

41 files changed

Lines changed: 4072 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Pre-commit secrets scanner** — a zero-network, local scanner over the staged diff's added lines, detecting AWS/GCP/Azure/GitHub/GitLab/Slack/Stripe/OpenAI/Anthropic tokens, RSA/OpenSSH/EC/PGP private-key headers, JWTs, and high-entropy literals (Shannon entropy, tunable threshold). Non-blocking in-app: an orange badge in the commit area opens a findings modal (redacted excerpts only, per-finding dismiss, per-pattern ignore), and committing with active findings shows a confirm the user can always bypass. Extensible per-repo via `.gitwandrc` `secrets.patterns[]` / `secrets.ignore[]`. Dual-implemented (Rust `regex` crate for the desktop app, a pure TypeScript mirror in `@gitwand/core` for `pnpm dev:web` and the CLI) and locked in sync by a parity test. Also ships `gitwand scan [--json] [--strict]` in the CLI and an opt-in, blocking pre-commit hook installer (Settings → Hooks) that shells out to it — always bypassable with `git commit --no-verify`.
13+
1014
## [3.4.0] - 2026-07-08
1115

1216
### Added

apps/desktop/dev-server.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,84 @@ async function handleRequest(req, res) {
14631463
}
14641464
}
14651465

1466+
// POST /api/scan-secrets { cwd, config }
1467+
// v3.5.0 — secrets scanner: extracts the staged diff's added lines with the SAME
1468+
// header/hunk state machine as the Rust extractor (see
1469+
// apps/desktop/src-tauri/src/commands/secrets.rs `extract_staged_added_lines`), then
1470+
// scans them with the pure `@gitwand/core` engine (the same one mirrored in Rust). This
1471+
// is the real dev-server route required by the project rule ("every #[tauri::command] gets
1472+
// a real dev-server route, not a stub") and is locked in sync with Rust by the parity test.
1473+
if (url.pathname === "/api/scan-secrets" && req.method === "POST") {
1474+
try {
1475+
const { cwd, config } = await readBody(req);
1476+
if (!cwd || typeof cwd !== "string") {
1477+
return jsonResponse(req, res, { error: "cwd must be a non-empty string" }, 400);
1478+
}
1479+
if (!config || typeof config !== "object") {
1480+
return jsonResponse(req, res, { error: "config must be an object" }, 400);
1481+
}
1482+
1483+
if (!config.enabled) {
1484+
return jsonResponse(req, res, []);
1485+
}
1486+
1487+
const resolvedCwd = resolve(cwd);
1488+
let stdout;
1489+
try {
1490+
stdout = await gitSpawn(["diff", "--cached", "--unified=0", "--no-color", "--", "."], resolvedCwd);
1491+
} catch {
1492+
stdout = "";
1493+
}
1494+
1495+
const files = [];
1496+
let currentPath = null;
1497+
let currentLines = [];
1498+
let newLineNo = 0;
1499+
1500+
const flush = () => {
1501+
if (currentPath !== null && currentLines.length > 0) {
1502+
files.push({ path: currentPath, addedLines: currentLines });
1503+
}
1504+
currentPath = null;
1505+
currentLines = [];
1506+
};
1507+
1508+
for (const line of stdout.split("\n")) {
1509+
if (line.startsWith("diff --git ")) {
1510+
flush();
1511+
} else if (line.startsWith("+++ ")) {
1512+
const rest = line.slice("+++ ".length);
1513+
currentPath = rest === "/dev/null" ? null : rest.replace(/^b\//, "");
1514+
} else if (line.startsWith("@@")) {
1515+
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1516+
newLineNo = match ? parseInt(match[1], 10) : 0;
1517+
} else if (line.startsWith("+") && !line.startsWith("+++")) {
1518+
// A content added line starts with a single `+`; the `+++` file header is handled
1519+
// above by parsing state, never by this check alone (AGENTS.md diff-parsing gotcha).
1520+
if (currentPath !== null) {
1521+
currentLines.push({ line: newLineNo, text: line.slice(1) });
1522+
}
1523+
newLineNo++;
1524+
}
1525+
// `-`-prefixed removed lines are irrelevant for the added-lines-only scan surface.
1526+
}
1527+
flush();
1528+
1529+
const scanConfig = {
1530+
enabled: !!config.enabled,
1531+
extraPatterns: Array.isArray(config.extraPatterns) ? config.extraPatterns : [],
1532+
ignore: Array.isArray(config.ignore) ? config.ignore : [],
1533+
entropyThreshold: typeof config.entropyThreshold === "number" ? config.entropyThreshold : 0,
1534+
};
1535+
1536+
const { scanSecrets } = await import("@gitwand/core");
1537+
const findings = scanSecrets(files, scanConfig);
1538+
return jsonResponse(req, res, findings);
1539+
} catch (err) {
1540+
return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500);
1541+
}
1542+
}
1543+
14661544
// GET /api/git-get-user?cwd=<path>
14671545
if (url.pathname === "/api/git-get-user" && req.method === "GET") {
14681546
const cwd = url.searchParams.get("cwd");
@@ -6195,6 +6273,7 @@ server.listen(PORT, "127.0.0.1", () => {
61956273
console.log(` GET /api/list-dir?path=<path>`);
61966274
console.log(` GET /api/git-status?cwd=<path>`);
61976275
console.log(` GET /api/git-diff?cwd=<path>&path=<file>&staged=<bool>`);
6276+
console.log(` POST /api/scan-secrets { cwd, config }`);
61986277
console.log(` GET /api/git-log?cwd=<path>&count=<n>&all=<bool>`);
61996278
console.log(` GET /api/gh-list-prs?cwd=<path>&state=<state>`);
62006279
console.log(` GET /api/gh-pr-count?cwd=<path>&state=<state>`);

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ tauri-plugin-aptabase = "1.0.0"
8888
# Anonymous install ID generation (v4 random UUID, stored on first launch).
8989
uuid = { version = "1", features = ["v4"] }
9090

91+
# Secrets scanner (v3.5.0) — linear-time regex engine (no catastrophic
92+
# backtracking), a security plus since it also compiles user-supplied
93+
# patterns from `.gitwandrc`. Mirrored by a pure-RegExp TS engine in
94+
# @gitwand/core; see commands/secrets.rs.
95+
regex = "1"
96+
9197
# ─── Release profile (P2.4) ──────────────────────────────────
9298
#
9399
# Profile tuné pour la build de production (vs defaults Cargo conservateurs).

apps/desktop/src-tauri/examples/parity_probe.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
use gitwand_desktop_lib::{
3636
git_branches_parity, git_commit_submodule_changes_parity, git_log_parity,
3737
git_stash_list_parity, git_status_libgit2_parity, git_status_parity,
38-
git_submodule_branches_parity,
38+
git_submodule_branches_parity, scan_secrets_parity,
3939
};
4040
use serde_json::{json, Value};
4141
use std::io::{self, Read};
@@ -45,7 +45,7 @@ fn main() -> ExitCode {
4545
let args: Vec<String> = std::env::args().collect();
4646
if args.len() < 2 {
4747
eprintln!("usage: parity-probe <command>");
48-
eprintln!("commands: git-status, git-status-fast, git-log, git-branches, git-stash-list, git-submodule-branches, git-commit-submodule-changes");
48+
eprintln!("commands: git-status, git-status-fast, git-log, git-branches, git-stash-list, git-submodule-branches, git-commit-submodule-changes, scan-secrets");
4949
return ExitCode::from(2);
5050
}
5151

@@ -145,6 +145,14 @@ fn main() -> ExitCode {
145145
};
146146
to_json(git_commit_submodule_changes_parity(cwd))
147147
}
148+
"scan-secrets" => {
149+
let cwd = match must_str("cwd") {
150+
Ok(v) => v,
151+
Err(code) => return code,
152+
};
153+
let config = input.get("config").cloned().unwrap_or_else(|| json!({}));
154+
to_json(scan_secrets_parity(cwd, config))
155+
}
148156
other => {
149157
eprintln!("unknown command: {}", other);
150158
return ExitCode::from(2);

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ pub(crate) mod network;
1212
pub(crate) mod ops;
1313
pub(crate) mod read;
1414
pub(crate) mod scratch;
15+
pub(crate) mod secrets;
1516
pub(crate) mod terminal;
1617
pub(crate) mod workspace;

0 commit comments

Comments
 (0)