Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4a0ce6
docs: add v3.5.0 secrets-scanner implementation plan
Jul 8, 2026
068c8f6
feat(core): add secrets block to .gitwandrc config schema
Jul 8, 2026
449af70
feat(core): add built-in secret pattern catalog
Jul 8, 2026
011427b
feat(core): implement pure secrets scanner (regex + entropy + redaction)
Jul 8, 2026
863be82
feat(desktop): add Rust secrets scanning engine (regex crate)
Jul 8, 2026
63bcc95
feat(desktop): add scan_secrets Tauri command over staged diff
Jul 8, 2026
7f9b25c
feat(desktop): wire scanSecrets IPC wrapper + dev-server route
Jul 8, 2026
7ff01e1
test(desktop): parity coverage for scan_secrets (Rust ↔ dev-server)
Jul 8, 2026
ee1c713
feat(desktop): add secrets-scanner settings (useSettings + SettingsPa…
Jul 8, 2026
7fbd91e
feat(desktop): add useSecretsScanner composable
Jul 8, 2026
24ba81a
feat(desktop): secrets findings modal + orange commit-area badge
Jul 8, 2026
41179e2
feat(desktop): surface secrets findings in commit flow (badge + confirm)
Jul 8, 2026
3b9014d
feat(cli): add gitwand scan command for staged secrets
Jul 8, 2026
512f6ce
feat(desktop): opt-in secrets pre-commit hook installer in Settings >…
Jul 8, 2026
05ad423
docs(changelog): add secrets scanner Unreleased entry + i18n sync
Jul 8, 2026
5dc570d
fix(secrets-scanner): apply default generated-file ignore globs (D5)
Jul 8, 2026
913a4d0
fix(secrets-scanner): confirm + honest scope labeling for "Ignore"
Jul 8, 2026
2f689a0
fix(secrets-scanner): drop .bm-btn ancestor selector in modal CSS
Jul 8, 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **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`.

## [3.4.0] - 2026-07-08

### Added
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,84 @@ async function handleRequest(req, res) {
}
}

// POST /api/scan-secrets { cwd, config }
// v3.5.0 — secrets scanner: extracts the staged diff's added lines with the SAME
// header/hunk state machine as the Rust extractor (see
// apps/desktop/src-tauri/src/commands/secrets.rs `extract_staged_added_lines`), then
// scans them with the pure `@gitwand/core` engine (the same one mirrored in Rust). This
// is the real dev-server route required by the project rule ("every #[tauri::command] gets
// a real dev-server route, not a stub") and is locked in sync with Rust by the parity test.
if (url.pathname === "/api/scan-secrets" && req.method === "POST") {
try {
const { cwd, config } = await readBody(req);
if (!cwd || typeof cwd !== "string") {
return jsonResponse(req, res, { error: "cwd must be a non-empty string" }, 400);
}
if (!config || typeof config !== "object") {
return jsonResponse(req, res, { error: "config must be an object" }, 400);
}

if (!config.enabled) {
return jsonResponse(req, res, []);
}

const resolvedCwd = resolve(cwd);
let stdout;
try {
stdout = await gitSpawn(["diff", "--cached", "--unified=0", "--no-color", "--", "."], resolvedCwd);
} catch {
stdout = "";
}

const files = [];
let currentPath = null;
let currentLines = [];
let newLineNo = 0;

const flush = () => {
if (currentPath !== null && currentLines.length > 0) {
files.push({ path: currentPath, addedLines: currentLines });
}
currentPath = null;
currentLines = [];
};

for (const line of stdout.split("\n")) {
if (line.startsWith("diff --git ")) {
flush();
} else if (line.startsWith("+++ ")) {
const rest = line.slice("+++ ".length);
currentPath = rest === "/dev/null" ? null : rest.replace(/^b\//, "");
} else if (line.startsWith("@@")) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
newLineNo = match ? parseInt(match[1], 10) : 0;
} else if (line.startsWith("+") && !line.startsWith("+++")) {
// A content added line starts with a single `+`; the `+++` file header is handled
// above by parsing state, never by this check alone (AGENTS.md diff-parsing gotcha).
if (currentPath !== null) {
currentLines.push({ line: newLineNo, text: line.slice(1) });
}
newLineNo++;
}
// `-`-prefixed removed lines are irrelevant for the added-lines-only scan surface.
}
flush();

const scanConfig = {
enabled: !!config.enabled,
extraPatterns: Array.isArray(config.extraPatterns) ? config.extraPatterns : [],
ignore: Array.isArray(config.ignore) ? config.ignore : [],
entropyThreshold: typeof config.entropyThreshold === "number" ? config.entropyThreshold : 0,
};

const { scanSecrets } = await import("@gitwand/core");
const findings = scanSecrets(files, scanConfig);
return jsonResponse(req, res, findings);
} catch (err) {
return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500);
}
}

// GET /api/git-get-user?cwd=<path>
if (url.pathname === "/api/git-get-user" && req.method === "GET") {
const cwd = url.searchParams.get("cwd");
Expand Down Expand Up @@ -6195,6 +6273,7 @@ server.listen(PORT, "127.0.0.1", () => {
console.log(` GET /api/list-dir?path=<path>`);
console.log(` GET /api/git-status?cwd=<path>`);
console.log(` GET /api/git-diff?cwd=<path>&path=<file>&staged=<bool>`);
console.log(` POST /api/scan-secrets { cwd, config }`);
console.log(` GET /api/git-log?cwd=<path>&count=<n>&all=<bool>`);
console.log(` GET /api/gh-list-prs?cwd=<path>&state=<state>`);
console.log(` GET /api/gh-pr-count?cwd=<path>&state=<state>`);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ tauri-plugin-aptabase = "1.0.0"
# Anonymous install ID generation (v4 random UUID, stored on first launch).
uuid = { version = "1", features = ["v4"] }

# Secrets scanner (v3.5.0) — linear-time regex engine (no catastrophic
# backtracking), a security plus since it also compiles user-supplied
# patterns from `.gitwandrc`. Mirrored by a pure-RegExp TS engine in
# @gitwand/core; see commands/secrets.rs.
regex = "1"

# ─── Release profile (P2.4) ──────────────────────────────────
#
# Profile tuné pour la build de production (vs defaults Cargo conservateurs).
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src-tauri/examples/parity_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
use gitwand_desktop_lib::{
git_branches_parity, git_commit_submodule_changes_parity, git_log_parity,
git_stash_list_parity, git_status_libgit2_parity, git_status_parity,
git_submodule_branches_parity,
git_submodule_branches_parity, scan_secrets_parity,
};
use serde_json::{json, Value};
use std::io::{self, Read};
Expand All @@ -45,7 +45,7 @@ fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("usage: parity-probe <command>");
eprintln!("commands: git-status, git-status-fast, git-log, git-branches, git-stash-list, git-submodule-branches, git-commit-submodule-changes");
eprintln!("commands: git-status, git-status-fast, git-log, git-branches, git-stash-list, git-submodule-branches, git-commit-submodule-changes, scan-secrets");
return ExitCode::from(2);
}

Expand Down Expand Up @@ -145,6 +145,14 @@ fn main() -> ExitCode {
};
to_json(git_commit_submodule_changes_parity(cwd))
}
"scan-secrets" => {
let cwd = match must_str("cwd") {
Ok(v) => v,
Err(code) => return code,
};
let config = input.get("config").cloned().unwrap_or_else(|| json!({}));
to_json(scan_secrets_parity(cwd, config))
}
other => {
eprintln!("unknown command: {}", other);
return ExitCode::from(2);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ pub(crate) mod network;
pub(crate) mod ops;
pub(crate) mod read;
pub(crate) mod scratch;
pub(crate) mod secrets;
pub(crate) mod terminal;
pub(crate) mod workspace;
Loading
Loading