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
package/dist/rules.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
function vendorTomlPath() {
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
return join(here, "..", "vendor", "gitleaks.toml");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Gitleaks regexes are Go RE2 syntax, which differs from JS RegExp in two
|
|
11
|
+
* ways that show up repeatedly in this ruleset:
|
|
12
|
+
*
|
|
13
|
+
* 1. Named capture groups use Python/Go syntax (?P<name>...) instead of
|
|
14
|
+
* JS's (?<name>...).
|
|
15
|
+
* 2. RE2 supports inline flag toggles ((?i), (?i:...), (?-i:...)) that
|
|
16
|
+
* apply case-insensitivity from that point to the end of the enclosing
|
|
17
|
+
* group. JS has no equivalent scoped toggle, so scoped groups are
|
|
18
|
+
* flattened to plain non-capturing groups, and any remaining bare (?i)
|
|
19
|
+
* marker is approximated by making the *entire* pattern case-insensitive
|
|
20
|
+
* via the `i` flag — looser than RE2's scoping, but keeps the rule
|
|
21
|
+
* working rather than dropping it. Documented parity gap.
|
|
22
|
+
*/
|
|
23
|
+
function translatePattern(raw) {
|
|
24
|
+
let pattern = raw.replace(/\(\?P</g, "(?<");
|
|
25
|
+
let flags = "g";
|
|
26
|
+
pattern = pattern.replace(/\(\?-?i:/g, "(?:");
|
|
27
|
+
if (pattern.includes("(?i)")) {
|
|
28
|
+
pattern = pattern.split("(?i)").join("");
|
|
29
|
+
flags += "i";
|
|
30
|
+
}
|
|
31
|
+
return { pattern, flags };
|
|
32
|
+
}
|
|
33
|
+
function toRegex(raw) {
|
|
34
|
+
const { pattern, flags } = translatePattern(raw);
|
|
35
|
+
return new RegExp(pattern, flags);
|
|
36
|
+
}
|
|
37
|
+
function collectAllowlistRegexes(allowlists) {
|
|
38
|
+
const regexes = [];
|
|
39
|
+
for (const allowlist of allowlists ?? []) {
|
|
40
|
+
for (const raw of allowlist?.regexes ?? []) {
|
|
41
|
+
try {
|
|
42
|
+
regexes.push(toRegex(String(raw)));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// skip a malformed/incompatible allowlist regex rather than fail the whole rule
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return regexes;
|
|
50
|
+
}
|
|
51
|
+
let cached;
|
|
52
|
+
export function loadRuleSet() {
|
|
53
|
+
if (cached)
|
|
54
|
+
return cached;
|
|
55
|
+
const raw = readFileSync(vendorTomlPath(), "utf8");
|
|
56
|
+
const doc = parse(raw);
|
|
57
|
+
const globalAllowlistRegexes = collectAllowlistRegexes(doc.allowlist ? [doc.allowlist] : []);
|
|
58
|
+
const globalStopwords = (doc.allowlist?.stopwords ?? []).map((w) => String(w).toLowerCase());
|
|
59
|
+
const rules = [];
|
|
60
|
+
const skipped = [];
|
|
61
|
+
for (const r of doc.rules ?? []) {
|
|
62
|
+
const id = typeof r.id === "string" ? r.id : "(unknown)";
|
|
63
|
+
if (typeof r.regex !== "string") {
|
|
64
|
+
skipped.push({ id, reason: "no regex field" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
rules.push({
|
|
69
|
+
id,
|
|
70
|
+
description: typeof r.description === "string" ? r.description : "",
|
|
71
|
+
regex: toRegex(r.regex),
|
|
72
|
+
keywords: (r.keywords ?? []).map((k) => String(k).toLowerCase()),
|
|
73
|
+
entropy: typeof r.entropy === "number" ? r.entropy : undefined,
|
|
74
|
+
allowlistRegexes: collectAllowlistRegexes(r.allowlists),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
skipped.push({ id, reason: err.message });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
cached = { rules, skipped, globalAllowlistRegexes, globalStopwords };
|
|
82
|
+
return cached;
|
|
83
|
+
}
|
|
84
|
+
export function shannonEntropy(input) {
|
|
85
|
+
if (!input)
|
|
86
|
+
return 0;
|
|
87
|
+
const counts = new Map();
|
|
88
|
+
for (const ch of input)
|
|
89
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
90
|
+
let entropy = 0;
|
|
91
|
+
for (const count of counts.values()) {
|
|
92
|
+
const p = count / input.length;
|
|
93
|
+
entropy -= p * Math.log2(p);
|
|
94
|
+
}
|
|
95
|
+
return entropy;
|
|
96
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function claudeProjectsDir() {
|
|
5
|
+
return join(homedir(), ".claude", "projects");
|
|
6
|
+
}
|
|
7
|
+
export function projectKeyForCwd(cwd) {
|
|
8
|
+
return cwd.replace(/\//g, "-");
|
|
9
|
+
}
|
|
10
|
+
function safeReaddir(dir) {
|
|
11
|
+
try {
|
|
12
|
+
return readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function listSessions(opts = {}) {
|
|
19
|
+
const root = claudeProjectsDir();
|
|
20
|
+
const projectKeys = opts.cwdOnly
|
|
21
|
+
? [projectKeyForCwd(process.cwd())]
|
|
22
|
+
: safeReaddir(root);
|
|
23
|
+
const sessions = [];
|
|
24
|
+
for (const projectKey of projectKeys) {
|
|
25
|
+
const projectDir = join(root, projectKey);
|
|
26
|
+
for (const entry of safeReaddir(projectDir)) {
|
|
27
|
+
if (!entry.endsWith(".jsonl"))
|
|
28
|
+
continue;
|
|
29
|
+
const path = join(projectDir, entry);
|
|
30
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
31
|
+
sessions.push({
|
|
32
|
+
path,
|
|
33
|
+
projectKey,
|
|
34
|
+
sessionId: entry.replace(/\.jsonl$/, ""),
|
|
35
|
+
mtimeMs,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return sessions.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
40
|
+
}
|
|
41
|
+
export function findSession(idOrPrefix) {
|
|
42
|
+
const sessions = listSessions();
|
|
43
|
+
return (sessions.find((s) => s.sessionId === idOrPrefix) ??
|
|
44
|
+
sessions.find((s) => s.sessionId.startsWith(idOrPrefix)));
|
|
45
|
+
}
|
|
46
|
+
export function readSessionLines(session) {
|
|
47
|
+
const raw = readFileSync(session.path, "utf8");
|
|
48
|
+
const hadTrailingNewline = raw.endsWith("\n");
|
|
49
|
+
const lines = raw.split("\n");
|
|
50
|
+
return hadTrailingNewline ? lines.slice(0, -1) : lines;
|
|
51
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as clack from "@clack/prompts";
|
|
2
|
+
import { maskPreview } from "./redact.js";
|
|
3
|
+
export function banner(action, text) {
|
|
4
|
+
clack[action](text);
|
|
5
|
+
}
|
|
6
|
+
export function withSpinner(label, fn) {
|
|
7
|
+
const s = clack.spinner();
|
|
8
|
+
s.start(label);
|
|
9
|
+
try {
|
|
10
|
+
const result = fn();
|
|
11
|
+
s.stop(label);
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
s.stop(`${label} (failed)`);
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function printFindings(findings) {
|
|
20
|
+
for (const f of findings) {
|
|
21
|
+
clack.log.warn(`${maskPreview(f.secret)} rule=${f.ruleId} line=${f.startLine}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function printNoFindings() {
|
|
25
|
+
clack.log.success("No secrets found.");
|
|
26
|
+
}
|
|
27
|
+
export async function confirmRedaction(count) {
|
|
28
|
+
const result = await clack.confirm({
|
|
29
|
+
message: `Redact ${count} finding(s) in place? A .bak backup will be written first.`,
|
|
30
|
+
initialValue: false,
|
|
31
|
+
});
|
|
32
|
+
if (clack.isCancel(result))
|
|
33
|
+
return false;
|
|
34
|
+
return result;
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-scrub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Find and remove exposed secrets from Claude Code session history, plus a live hook to catch them before they land in context.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-scrub": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"test": "tsx test/redact.test.ts && tsx test/gitleaks.test.ts && tsx test/localDetect.test.ts",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@clack/prompts": "^1.7.0",
|
|
19
|
+
"smol-toml": "^1.7.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.14.0",
|
|
23
|
+
"tsx": "^4.19.0",
|
|
24
|
+
"typescript": "^5.6.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"claude-code",
|
|
28
|
+
"security",
|
|
29
|
+
"secrets",
|
|
30
|
+
"gitleaks",
|
|
31
|
+
"redaction",
|
|
32
|
+
"plugin"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"vendor",
|
|
38
|
+
".claude-plugin",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v8.30.1
|
package/vendor/NOTICE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
`gitleaks.toml` in this directory is vendored from the [gitleaks](https://github.com/gitleaks/gitleaks) project, pinned to release `v8.30.1`.
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2019 Zachary Rice
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Full license: https://github.com/gitleaks/gitleaks/blob/v8.30.1/LICENSE
|
|
10
|
+
|
|
11
|
+
`cc-scrub` is not affiliated with or endorsed by the gitleaks project. This file is redistributed unmodified except for the regex-dialect translation applied at load time in `src/rules.ts` (JS RegExp doesn't support all Go RE2 syntax) — the vendored `.toml` itself is untouched.
|