cc-scrub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "cc-scrub",
3
+ "description": "Find, redact, and prevent secrets leaking into local Claude Code session transcripts.",
4
+ "version": "0.1.0",
5
+ "hooks": {
6
+ "PostToolUse": [
7
+ {
8
+ "matcher": "Read|Bash|Grep|Glob",
9
+ "hooks": [
10
+ {
11
+ "type": "command",
12
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/post-tool-use-redact.js\""
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cc-scrub contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # cc-scrub
2
+
3
+ Find, redact, and prevent secrets leaking into local Claude Code session transcripts.
4
+
5
+ Claude Code stores every session as a `.jsonl` file under `~/.claude/projects/`. If you ever pasted an API key, token, or credential into a session — or Claude read a file/ran a command that echoed one back — it's sitting in that transcript in plaintext. `cc-scrub` finds it, lets you redact it in place without breaking Claude Code's ability to resume the session, and can install a live hook that catches secrets before they ever land in context.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx cc-scrub scan # scan every local session for secrets
11
+ npx cc-scrub clean <id> # preview redactions for one session
12
+ npx cc-scrub clean <id> --yes # redact in place (writes a .bak first)
13
+ npx cc-scrub protect # install a live redaction hook going forward
14
+ ```
15
+
16
+ No required external dependencies. If [gitleaks](https://github.com/gitleaks/gitleaks#installing) is on your `PATH`, `cc-scrub` uses it; otherwise it automatically falls back to a bundled in-process ruleset (see "Detection engines" below). Run `cc-scrub doctor` to see which one is active.
17
+
18
+ ## Usage
19
+
20
+ | Command | What it does |
21
+ |---|---|
22
+ | `cc-scrub list` | List local sessions, newest first |
23
+ | `cc-scrub scan [session-id]` | Scan one session, or all of them if omitted. Exit code `1` if anything is found |
24
+ | `cc-scrub clean <session-id>` | Dry run — show what would be redacted |
25
+ | `cc-scrub clean <session-id> --yes` | Redact in place. Always backs up to `<session>.jsonl.bak` first, never overwrites an existing backup |
26
+ | `cc-scrub doctor` | Show which detection engine is active and how many rules loaded |
27
+ | `cc-scrub protect` | Add a `PostToolUse` hook to `~/.claude/settings.json` that redacts secrets from tool output before Claude sees it |
28
+ | `cc-scrub unprotect` | Remove that hook |
29
+
30
+ Add `--engine auto\|gitleaks\|local` to `scan`/`clean` to control which engine runs (default `auto`: gitleaks binary if installed, else the bundled ruleset). Flag values can be passed as `--engine local` or `--engine=local`.
31
+
32
+ ## Install as a plugin
33
+
34
+ If you install `cc-scrub` via the Claude Code plugin system instead of npm, the bundled `.claude-plugin/plugin.json` registers the same `PostToolUse` redaction hook automatically — no need to run `protect` separately.
35
+
36
+ ## Detection engines
37
+
38
+ `cc-scrub` ships two interchangeable engines rather than committing to just one:
39
+
40
+ - **`gitleaks` (binary)** — shells out to the real [gitleaks](https://github.com/gitleaks/gitleaks) CLI when it's on `PATH`. Full fidelity: this is the actual upstream engine, regex dialect and all.
41
+ - **`local` (bundled, in-process)** — `vendor/gitleaks.toml` is gitleaks' own public MIT-licensed ruleset (pinned to the version this package was built against), vendored directly into the repo. `src/rules.ts` parses it and `src/localDetect.ts` runs it line-by-line in plain TypeScript: keyword pre-filtering, regex matching, Shannon-entropy thresholding, and per-rule/global allowlist filtering — no subprocess, no temp files, no external binary required. `cc-scrub doctor` reports how many rules loaded (221 of 222 at last check; the one skip is a path-only rule with no content regex, correctly inapplicable here).
42
+
43
+ `--engine auto` (the default) prefers the gitleaks binary when present and falls back to `local` otherwise — including inside the live `protect` hook, which no longer goes fully silent just because gitleaks isn't installed.
44
+
45
+ **Why not just ship `local` as the only engine?** Two RE2-to-JS regex dialect differences had to be translated (Go/Python-style named groups `(?P<name>...)` → JS's `(?<name>...)`, and RE2's scoped inline case-insensitivity toggle `(?i)`/`(?i:...)`, which JS has no equivalent for — approximated by making the whole pattern case-insensitive instead of just the scoped portion). That's a real, if small, parity gap against the actual gitleaks binary, so `auto` prefers the binary when it's available and treats `local` as the (very close, but not byte-for-byte identical) fallback.
46
+
47
+ RE2 also guarantees linear-time matching by construction; JS's backtracking regex engine doesn't. Since the vendored ruleset was written for RE2, `local` caps scanned lines at 10,000 characters (real secrets are always far shorter) to bound worst-case regex cost on adversarial input — this is checked, not theoretical (see `test/localDetect.test.ts`), and skipped lines are reported to stderr rather than silently dropped.
48
+
49
+ ## How it works
50
+
51
+ - **Retroactive clean**: scans a session file with the selected engine, groups findings by line, and replaces only the exact matched secret substring with `[REDACTED:<rule-id>]` — line count and per-line JSON validity are verified before and after every write. If a redaction would corrupt a line's JSON, `cc-scrub` refuses to write anything and tells you which line.
52
+ - **Live hook**: on `PostToolUse` for `Read`/`Bash`/`Grep`/`Glob`, scans the tool's output with the same `auto` engine selection and, if it finds anything, replaces the output via the hook's `updatedToolOutput` field before Claude ever sees it.
53
+
54
+ ## Limitations
55
+
56
+ - **`local` engine gaps vs. the real gitleaks binary**: line-by-line matching only (no multi-line secrets, e.g. PEM key blocks spanning several lines), and the regex-dialect approximations noted above. See "Detection engines."
57
+ - **Doesn't scan `settings.json`.** Only session transcripts are covered. Secrets embedded in your Bash permission allowlist or other config are out of scope.
58
+ - **Structured tool output is flattened.** If a tool's response is a structured object rather than a plain string, the live hook normalizes it to a JSON string before scanning/redacting — Claude receives a redacted string instead of the original shape.
59
+ - **Already-sent data can't be recalled.** This is local cleanup only. If a secret was already sent to a model provider's API, `cc-scrub` cannot undo that — rotate the credential.
60
+
61
+ ## Roadmap
62
+
63
+ Keeping `vendor/gitleaks.toml` in sync with upstream gitleaks releases is currently a manual re-pin (bump the version in the download step and re-verify against `test/localDetect.test.ts`) — automating that check is a natural next step. Closing the remaining regex-dialect gaps (proper scoped case-insensitivity, multi-line matching) would bring `local` to full parity with the binary, but isn't blocking anything today since `auto` already prefers the binary whenever it's present.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ npm install
69
+ npm run build # tsc -> dist/
70
+ npm test # tsx test/*.test.ts — degrades gracefully if gitleaks isn't installed
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
package/dist/clean.js ADDED
@@ -0,0 +1,51 @@
1
+ import { constants, copyFileSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { detectFile } from "./detect.js";
3
+ import { groupFindingsByLine, redactLines } from "./redact.js";
4
+ import { readSessionLines } from "./sessions.js";
5
+ export function backupPathFor(sessionPath) {
6
+ return `${sessionPath}.bak`;
7
+ }
8
+ export function ensureBackup(sessionPath) {
9
+ const path = backupPathFor(sessionPath);
10
+ try {
11
+ copyFileSync(sessionPath, path, constants.COPYFILE_EXCL);
12
+ return { created: true, path };
13
+ }
14
+ catch {
15
+ return { created: false, path };
16
+ }
17
+ }
18
+ export function planClean(session, engine = "auto") {
19
+ const { findings, engineUsed } = detectFile(session.path, engine);
20
+ const lines = readSessionLines(session);
21
+ const { lines: redactedLines, redactedCount, failures } = redactLines(lines, groupFindingsByLine(findings));
22
+ return { lines: redactedLines, redactedCount, failures, findings, engineUsed };
23
+ }
24
+ export function applyClean(session, opts) {
25
+ const plan = planClean(session, opts.engine ?? "auto");
26
+ if (!opts.yes || plan.redactedCount === 0 || plan.failures.length > 0) {
27
+ return { ...plan, written: false };
28
+ }
29
+ const originalLines = readSessionLines(session);
30
+ if (plan.lines.length !== originalLines.length) {
31
+ throw new Error(`refusing to write ${session.path}: redacted line count (${plan.lines.length}) != original (${originalLines.length})`);
32
+ }
33
+ const { created, path: backupPath } = ensureBackup(session.path);
34
+ void created;
35
+ const raw = readFileSync(session.path, "utf8");
36
+ const hadTrailingNewline = raw.endsWith("\n");
37
+ const output = plan.lines.join("\n") + (hadTrailingNewline ? "\n" : "");
38
+ const tmpPath = `${session.path}.tmp`;
39
+ writeFileSync(tmpPath, output, "utf8");
40
+ renameSync(tmpPath, session.path);
41
+ const verifyLines = readSessionLines(session);
42
+ if (verifyLines.length !== originalLines.length) {
43
+ throw new Error(`post-write verification failed for ${session.path}: line count changed after write`);
44
+ }
45
+ for (const line of verifyLines) {
46
+ if (!line)
47
+ continue;
48
+ JSON.parse(line);
49
+ }
50
+ return { ...plan, written: true, backupPath };
51
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ import { applyClean, planClean } from "./clean.js";
3
+ import { detectFile } from "./detect.js";
4
+ import { GitleaksNotFoundError, isGitleaksAvailable } from "./gitleaks.js";
5
+ import { loadRuleSet } from "./rules.js";
6
+ import { addHook, readSettings, removeHook, settingsPath, writeSettings } from "./protect.js";
7
+ import { findSession, listSessions } from "./sessions.js";
8
+ import { banner, confirmRedaction, printFindings, printNoFindings, withSpinner } from "./ui.js";
9
+ const VALUE_FLAGS = new Set(["engine"]);
10
+ function parseArgs(argv) {
11
+ const [command = "", ...rest] = argv;
12
+ const positional = [];
13
+ const flags = {};
14
+ for (let i = 0; i < rest.length; i++) {
15
+ const arg = rest[i];
16
+ if (!arg.startsWith("--")) {
17
+ positional.push(arg);
18
+ continue;
19
+ }
20
+ const [key, inlineValue] = arg.slice(2).split("=");
21
+ if (inlineValue !== undefined) {
22
+ flags[key] = inlineValue;
23
+ }
24
+ else if (VALUE_FLAGS.has(key) && rest[i + 1] && !rest[i + 1].startsWith("--")) {
25
+ flags[key] = rest[i + 1];
26
+ i += 1;
27
+ }
28
+ else {
29
+ flags[key] = true;
30
+ }
31
+ }
32
+ return { command, positional, flags };
33
+ }
34
+ function engineFromFlags(flags) {
35
+ const value = flags.engine;
36
+ if (value === "local" || value === "gitleaks")
37
+ return value;
38
+ return "auto";
39
+ }
40
+ function usage() {
41
+ return `cc-scrub — find and remove exposed secrets from Claude Code sessions
42
+
43
+ Usage:
44
+ cc-scrub list list local sessions (newest first)
45
+ cc-scrub scan [session-id] scan one session, or all sessions if omitted
46
+ cc-scrub clean <session-id> preview redactions (dry run)
47
+ cc-scrub clean <session-id> --yes redact in place (writes a .bak backup first)
48
+ cc-scrub protect install a live PostToolUse redaction hook
49
+ cc-scrub unprotect remove the hook
50
+ cc-scrub doctor check detection engine status
51
+
52
+ --engine auto|gitleaks|local auto (default): gitleaks binary if installed, else the
53
+ bundled in-process ruleset. Force one explicitly with
54
+ --engine gitleaks or --engine local.`;
55
+ }
56
+ async function main() {
57
+ const { command, positional, flags } = parseArgs(process.argv.slice(2));
58
+ const engine = engineFromFlags(flags);
59
+ try {
60
+ switch (command) {
61
+ case "list": {
62
+ const sessions = listSessions();
63
+ if (sessions.length === 0) {
64
+ console.log("No sessions found.");
65
+ break;
66
+ }
67
+ for (const s of sessions) {
68
+ console.log(`${s.sessionId} (${s.projectKey})`);
69
+ }
70
+ break;
71
+ }
72
+ case "scan": {
73
+ const target = positional[0];
74
+ const sessions = target ? [findSession(target)].filter((s) => s !== undefined) : listSessions();
75
+ if (sessions.length === 0) {
76
+ console.log("No matching sessions.");
77
+ break;
78
+ }
79
+ banner("intro", `cc-scrub: scanning ${sessions.length} session(s)`);
80
+ let anyFound = false;
81
+ let usedEngine;
82
+ for (const session of sessions) {
83
+ const { findings, engineUsed } = withSpinner(session.sessionId, () => detectFile(session.path, engine));
84
+ usedEngine = engineUsed;
85
+ if (findings.length === 0) {
86
+ printNoFindings();
87
+ }
88
+ else {
89
+ anyFound = true;
90
+ printFindings(findings);
91
+ }
92
+ }
93
+ banner("outro", (anyFound ? "Findings above — run `cc-scrub clean <session-id>`." : "All clear.") +
94
+ (usedEngine ? ` (engine: ${usedEngine})` : ""));
95
+ process.exitCode = anyFound ? 1 : 0;
96
+ break;
97
+ }
98
+ case "clean": {
99
+ const sessionId = positional[0];
100
+ if (!sessionId) {
101
+ console.error("Usage: cc-scrub clean <session-id> [--yes] [--engine auto|gitleaks|local]");
102
+ process.exitCode = 1;
103
+ break;
104
+ }
105
+ const session = findSession(sessionId);
106
+ if (!session) {
107
+ console.error(`No session found matching "${sessionId}"`);
108
+ process.exitCode = 1;
109
+ break;
110
+ }
111
+ banner("intro", `cc-scrub: cleaning ${session.sessionId}`);
112
+ const plan = withSpinner("scanning", () => planClean(session, engine));
113
+ if (plan.redactedCount === 0) {
114
+ printNoFindings();
115
+ banner("outro", `Nothing to do. (engine: ${plan.engineUsed})`);
116
+ break;
117
+ }
118
+ printFindings(plan.findings);
119
+ for (const failure of plan.failures) {
120
+ console.error(` line ${failure.lineNumber}: ${failure.reason}`);
121
+ }
122
+ if (plan.failures.length > 0) {
123
+ banner("outro", "Refusing to write — some lines failed the JSON safety check above.");
124
+ process.exitCode = 1;
125
+ break;
126
+ }
127
+ const yes = flags.yes === true || (await confirmRedaction(plan.redactedCount));
128
+ if (!yes) {
129
+ banner("outro", "Dry run only. Re-run with --yes to write changes.");
130
+ break;
131
+ }
132
+ const result = applyClean(session, { yes: true, engine });
133
+ banner("outro", result.written
134
+ ? `Redacted ${result.redactedCount} finding(s) (engine: ${result.engineUsed}). Backup: ${result.backupPath}`
135
+ : "No changes written.");
136
+ break;
137
+ }
138
+ case "doctor": {
139
+ if (isGitleaksAvailable()) {
140
+ console.log("gitleaks binary: found (preferred engine when --engine=auto)");
141
+ }
142
+ else {
143
+ console.log("gitleaks binary: NOT found. Install: https://github.com/gitleaks/gitleaks#installing");
144
+ }
145
+ const { rules, skipped } = loadRuleSet();
146
+ console.log(`local ruleset: ${rules.length} rule(s) loaded, ${skipped.length} skipped (vendor/gitleaks.toml)`);
147
+ console.log(isGitleaksAvailable()
148
+ ? "protect: will use gitleaks; falls back to the local ruleset automatically if gitleaks is ever removed."
149
+ : "protect: will use the local ruleset (gitleaks not installed).");
150
+ break;
151
+ }
152
+ case "protect": {
153
+ const settings = addHook(readSettings());
154
+ writeSettings(settings);
155
+ console.log(`Live redaction hook installed in ${settingsPath()}`);
156
+ break;
157
+ }
158
+ case "unprotect": {
159
+ const settings = removeHook(readSettings());
160
+ writeSettings(settings);
161
+ console.log(`Live redaction hook removed from ${settingsPath()}`);
162
+ break;
163
+ }
164
+ default:
165
+ console.log(usage());
166
+ process.exitCode = command ? 1 : 0;
167
+ }
168
+ }
169
+ catch (err) {
170
+ if (err instanceof GitleaksNotFoundError) {
171
+ console.error(err.message);
172
+ process.exitCode = 2;
173
+ return;
174
+ }
175
+ throw err;
176
+ }
177
+ }
178
+ main();
package/dist/detect.js ADDED
@@ -0,0 +1,18 @@
1
+ import { GitleaksNotFoundError, isGitleaksAvailable, scanFile, scanText } from "./gitleaks.js";
2
+ import { scanFileLocal, scanTextLocal } from "./localDetect.js";
3
+ export function detectFile(filePath, engine = "auto") {
4
+ if (engine !== "local" && isGitleaksAvailable()) {
5
+ return { findings: scanFile(filePath), engineUsed: "gitleaks" };
6
+ }
7
+ if (engine === "gitleaks")
8
+ throw new GitleaksNotFoundError();
9
+ return { findings: scanFileLocal(filePath), engineUsed: "local" };
10
+ }
11
+ export function detectText(text, engine = "auto") {
12
+ if (engine !== "local" && isGitleaksAvailable()) {
13
+ return { findings: scanText(text), engineUsed: "gitleaks" };
14
+ }
15
+ if (engine === "gitleaks")
16
+ throw new GitleaksNotFoundError();
17
+ return { findings: scanTextLocal(text), engineUsed: "local" };
18
+ }
@@ -0,0 +1,71 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ export class GitleaksNotFoundError extends Error {
7
+ constructor() {
8
+ super("gitleaks not found on PATH. Install: https://github.com/gitleaks/gitleaks#installing");
9
+ this.name = "GitleaksNotFoundError";
10
+ }
11
+ }
12
+ export function isGitleaksAvailable() {
13
+ const probe = spawnSync("gitleaks", ["version"]);
14
+ return !probe.error;
15
+ }
16
+ export function parseGitleaksReport(json) {
17
+ if (!Array.isArray(json))
18
+ return [];
19
+ return json.map((f) => ({
20
+ ruleId: f.RuleID,
21
+ secret: f.Secret,
22
+ startLine: f.StartLine,
23
+ endLine: f.EndLine,
24
+ description: f.Description,
25
+ }));
26
+ }
27
+ function reportPath() {
28
+ return join(tmpdir(), `cc-scrub-${randomUUID()}.json`);
29
+ }
30
+ function readAndCleanupReport(path) {
31
+ if (!existsSync(path))
32
+ return [];
33
+ const raw = readFileSync(path, "utf8").trim();
34
+ unlinkSync(path);
35
+ if (!raw)
36
+ return [];
37
+ return parseGitleaksReport(JSON.parse(raw));
38
+ }
39
+ export function scanFile(filePath) {
40
+ const outPath = reportPath();
41
+ const result = spawnSync("gitleaks", [
42
+ "detect",
43
+ "--no-git",
44
+ "--source",
45
+ filePath,
46
+ "--report-format",
47
+ "json",
48
+ "--report-path",
49
+ outPath,
50
+ "--exit-code",
51
+ "0",
52
+ ]);
53
+ if (result.error) {
54
+ if (result.error.code === "ENOENT") {
55
+ throw new GitleaksNotFoundError();
56
+ }
57
+ throw result.error;
58
+ }
59
+ return readAndCleanupReport(outPath);
60
+ }
61
+ export function scanText(text) {
62
+ const outPath = reportPath();
63
+ const result = spawnSync("gitleaks", ["stdin", "--report-format", "json", "--report-path", outPath, "--exit-code", "0"], { input: text });
64
+ if (result.error) {
65
+ if (result.error.code === "ENOENT") {
66
+ throw new GitleaksNotFoundError();
67
+ }
68
+ throw result.error;
69
+ }
70
+ return readAndCleanupReport(outPath);
71
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { detectText } from "../detect.js";
4
+ function extractText(toolResponse) {
5
+ return typeof toolResponse === "string" ? toolResponse : JSON.stringify(toolResponse);
6
+ }
7
+ function main() {
8
+ try {
9
+ const raw = readFileSync(0, "utf8");
10
+ const payload = JSON.parse(raw);
11
+ const text = extractText(payload.tool_response);
12
+ // "auto": prefers the gitleaks binary when installed, otherwise falls back to the
13
+ // bundled in-process ruleset — this hook no longer goes fully silent just because
14
+ // gitleaks isn't on PATH.
15
+ const { findings } = detectText(text, "auto");
16
+ if (findings.length === 0) {
17
+ process.exit(0);
18
+ }
19
+ let redacted = text;
20
+ for (const finding of findings) {
21
+ if (!finding.secret)
22
+ continue;
23
+ redacted = redacted.split(finding.secret).join(`[REDACTED:${finding.ruleId}]`);
24
+ }
25
+ process.stdout.write(JSON.stringify({
26
+ hookSpecificOutput: {
27
+ hookEventName: "PostToolUse",
28
+ updatedToolOutput: redacted,
29
+ },
30
+ }));
31
+ process.exit(0);
32
+ }
33
+ catch {
34
+ // never break the tool call on a hook bug — fail open, silently
35
+ process.exit(0);
36
+ }
37
+ }
38
+ main();
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { loadRuleSet, shannonEntropy } from "./rules.js";
3
+ function passesAllowlist(secret, ruleAllowlist, globalAllowlist, globalStopwords) {
4
+ const lower = secret.toLowerCase();
5
+ for (const word of globalStopwords) {
6
+ if (lower.includes(word))
7
+ return false;
8
+ }
9
+ for (const re of ruleAllowlist) {
10
+ re.lastIndex = 0;
11
+ if (re.test(secret))
12
+ return false;
13
+ }
14
+ for (const re of globalAllowlist) {
15
+ re.lastIndex = 0;
16
+ if (re.test(secret))
17
+ return false;
18
+ }
19
+ return true;
20
+ }
21
+ // Gitleaks' regexes are authored for RE2 (Go), which guarantees linear-time
22
+ // matching by construction. JS RegExp uses backtracking, so some of these
23
+ // patterns could in principle be coaxed into catastrophic backtracking on a
24
+ // pathological line — and the live hook runs this on every Read/Bash/Grep/
25
+ // Glob result. Real secrets are always far shorter than this, so capping
26
+ // line length bounds worst-case cost regardless of which rule is at risk.
27
+ const MAX_LINE_LENGTH = 10_000;
28
+ /**
29
+ * In-process alternative to shelling out to the gitleaks binary. Runs the
30
+ * vendored gitleaks ruleset (vendor/gitleaks.toml) against text line by
31
+ * line. Known parity gaps vs. the real binary: line-scoped matching only
32
+ * (no multi-line secrets like PEM blocks), lines over MAX_LINE_LENGTH are
33
+ * skipped (see above), and a handful of rules using RE2 scoped inline-flag
34
+ * groups are translated approximately (see rules.ts).
35
+ */
36
+ export function scanTextLocal(text) {
37
+ const { rules, globalAllowlistRegexes, globalStopwords } = loadRuleSet();
38
+ const lines = text.split("\n");
39
+ const findings = [];
40
+ lines.forEach((line, idx) => {
41
+ const lineNumber = idx + 1;
42
+ if (line.length > MAX_LINE_LENGTH) {
43
+ console.error(`cc-scrub: skipping line ${lineNumber} (${line.length} chars) for local-engine scanning — exceeds ${MAX_LINE_LENGTH} char cap`);
44
+ return;
45
+ }
46
+ const lowerLine = line.toLowerCase();
47
+ for (const rule of rules) {
48
+ if (rule.keywords.length > 0 && !rule.keywords.some((k) => lowerLine.includes(k))) {
49
+ continue;
50
+ }
51
+ rule.regex.lastIndex = 0;
52
+ let match;
53
+ while ((match = rule.regex.exec(line))) {
54
+ const secret = match[1] ?? match[0];
55
+ const passesEntropy = rule.entropy === undefined || shannonEntropy(secret) >= rule.entropy;
56
+ const passesAllow = passesAllowlist(secret, rule.allowlistRegexes, globalAllowlistRegexes, globalStopwords);
57
+ if (passesEntropy && passesAllow) {
58
+ findings.push({
59
+ ruleId: rule.id,
60
+ secret,
61
+ startLine: lineNumber,
62
+ endLine: lineNumber,
63
+ description: rule.description,
64
+ });
65
+ }
66
+ if (match.index === rule.regex.lastIndex)
67
+ rule.regex.lastIndex += 1; // guard zero-length match loop
68
+ }
69
+ }
70
+ });
71
+ return findings;
72
+ }
73
+ export function scanFileLocal(filePath) {
74
+ return scanTextLocal(readFileSync(filePath, "utf8"));
75
+ }
@@ -0,0 +1,67 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ const HOOK_MARKER = "post-tool-use-redact.js";
6
+ export function settingsPath() {
7
+ return join(homedir(), ".claude", "settings.json");
8
+ }
9
+ export function hookScriptPath() {
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ return join(here, "hooks", "post-tool-use-redact.js");
12
+ }
13
+ export function hookCommand() {
14
+ return `node "${hookScriptPath()}"`;
15
+ }
16
+ export function readSettings() {
17
+ const path = settingsPath();
18
+ if (!existsSync(path))
19
+ return {};
20
+ try {
21
+ return JSON.parse(readFileSync(path, "utf8"));
22
+ }
23
+ catch (err) {
24
+ // Do NOT fall back to {} here: writeSettings() would then overwrite the
25
+ // user's real settings.json with just our hook entry, destroying
26
+ // everything else in it. Fail loudly instead.
27
+ throw new Error(`refusing to modify ${path}: it exists but failed to parse as JSON (${err.message}). Fix or back it up before retrying.`);
28
+ }
29
+ }
30
+ function isOurHook(entry) {
31
+ return Boolean(entry?.hooks?.some((h) => String(h.command).includes(HOOK_MARKER)));
32
+ }
33
+ export function addHook(settings) {
34
+ const next = { ...settings };
35
+ next.hooks = { ...(next.hooks ?? {}) };
36
+ const existing = next.hooks.PostToolUse ?? [];
37
+ if (existing.some(isOurHook)) {
38
+ next.hooks.PostToolUse = existing;
39
+ return next;
40
+ }
41
+ next.hooks.PostToolUse = [
42
+ ...existing,
43
+ {
44
+ matcher: "Read|Bash|Grep|Glob",
45
+ hooks: [{ type: "command", command: hookCommand() }],
46
+ },
47
+ ];
48
+ return next;
49
+ }
50
+ export function removeHook(settings) {
51
+ if (!settings.hooks?.PostToolUse)
52
+ return settings;
53
+ const next = { ...settings, hooks: { ...settings.hooks } };
54
+ const filtered = next.hooks.PostToolUse.filter((entry) => !isOurHook(entry));
55
+ if (filtered.length > 0) {
56
+ next.hooks.PostToolUse = filtered;
57
+ }
58
+ else {
59
+ delete next.hooks.PostToolUse;
60
+ if (Object.keys(next.hooks).length === 0)
61
+ delete next.hooks;
62
+ }
63
+ return next;
64
+ }
65
+ export function writeSettings(settings) {
66
+ writeFileSync(settingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf8");
67
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,54 @@
1
+ export function maskPreview(secret) {
2
+ if (secret.length <= 4)
3
+ return "*".repeat(secret.length);
4
+ const stars = Math.min(secret.length - 4, 20);
5
+ return secret.slice(0, 4) + "*".repeat(stars);
6
+ }
7
+ export function groupFindingsByLine(findings) {
8
+ const byLine = new Map();
9
+ for (const finding of findings) {
10
+ const list = byLine.get(finding.startLine) ?? [];
11
+ list.push(finding);
12
+ byLine.set(finding.startLine, list);
13
+ }
14
+ return byLine;
15
+ }
16
+ export function redactLine(line, findings) {
17
+ let candidate = line;
18
+ for (const finding of findings) {
19
+ if (!finding.secret || !candidate.includes(finding.secret))
20
+ continue;
21
+ candidate = candidate.split(finding.secret).join(`[REDACTED:${finding.ruleId}]`);
22
+ }
23
+ if (candidate === line)
24
+ return { line, changed: false };
25
+ try {
26
+ JSON.parse(line);
27
+ JSON.parse(candidate);
28
+ }
29
+ catch {
30
+ return {
31
+ line,
32
+ changed: false,
33
+ error: "redaction would produce invalid JSON — refusing to modify this line",
34
+ };
35
+ }
36
+ return { line: candidate, changed: true };
37
+ }
38
+ export function redactLines(lines, findingsByLine) {
39
+ const failures = [];
40
+ let redactedCount = 0;
41
+ const result = lines.map((line, idx) => {
42
+ const lineNumber = idx + 1;
43
+ const findings = findingsByLine.get(lineNumber);
44
+ if (!findings || !line)
45
+ return line;
46
+ const { line: outLine, changed, error } = redactLine(line, findings);
47
+ if (error)
48
+ failures.push({ lineNumber, reason: error });
49
+ if (changed)
50
+ redactedCount += findings.length;
51
+ return outLine;
52
+ });
53
+ return { lines: result, redactedCount, failures };
54
+ }