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.
- package/.claude-plugin/plugin.json +18 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/clean.js +51 -0
- package/dist/cli.js +178 -0
- package/dist/detect.js +18 -0
- package/dist/gitleaks.js +71 -0
- package/dist/hooks/post-tool-use-redact.js +38 -0
- package/dist/localDetect.js +75 -0
- package/dist/protect.js +67 -0
- package/dist/redact.js +54 -0
- package/dist/rules.js +96 -0
- package/dist/sessions.js +51 -0
- package/dist/ui.js +35 -0
- package/package.json +42 -0
- package/vendor/GITLEAKS_VERSION +1 -0
- package/vendor/NOTICE.md +11 -0
- package/vendor/gitleaks.toml +3209 -0
|
@@ -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
|
+
}
|
package/dist/gitleaks.js
ADDED
|
@@ -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
|
+
}
|
package/dist/protect.js
ADDED
|
@@ -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
|
+
}
|