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/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
+ }
@@ -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
@@ -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.