residoo 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 residoo 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,185 @@
1
+ # residoo
2
+
3
+ **Find secrets leaking through your AI coding agent's session history.**
4
+
5
+ Every time Claude Code, Cursor, or a similar tool reads a file, runs a command, or
6
+ browses a page on your behalf, it writes a transcript of the whole session to disk —
7
+ including the contents of whatever it touched. If that ever included a `.env` file,
8
+ a config with a real key in it, or a login token captured during testing, that
9
+ credential is now sitting in plaintext, indefinitely, in a place almost nobody
10
+ thinks to check.
11
+
12
+ residoo scans those transcripts and tells you what's in them.
13
+
14
+ ```
15
+ $ residoo scan
16
+
17
+ ⚠ 17 potential secrets found across 3 files
18
+ 87 files scanned (1.2 GB) · sources: claude-code
19
+ oldest match ~8d old · most recent ~0d old
20
+
21
+ 16 [high] AWS Access Key ID (1 distinct value, re-exposed 15× across tool output)
22
+ 1 [high] Private key block
23
+
24
+ Values are redacted in this report — first/last 4 characters only. Nothing scanned
25
+ here left your machine; residoo makes no network calls.
26
+ ```
27
+
28
+ ## Why this, and not a git secret scanner
29
+
30
+ Tools like `gitleaks` and `trufflehog` are excellent at what they do — and what
31
+ they do is scan **commits**. That's a different, well-covered space. Nobody was
32
+ looking at the **conversation transcripts** these agents leave behind, which
33
+ contain a superset of everything a commit does: not just code, but file
34
+ contents, terminal output, and whatever got pasted into a prompt.
35
+
36
+ ## What it does
37
+
38
+ - Scans your local AI-agent session transcripts for high-confidence secret
39
+ patterns (cloud provider keys, private key blocks, OAuth/API tokens,
40
+ database connection strings, and more — see `src/patterns.js`).
41
+ - Redacts everything in its own output. You get a shape and a first/last-4
42
+ preview, never the real value — including in `--json` mode.
43
+ - Tells you how many **distinct** secrets it found versus how many times one
44
+ got echoed back across tool calls, so the headline number reflects real
45
+ exposure, not repetition.
46
+ - Flags likely placeholder/example matches (an HTML form's
47
+ `placeholder="AKIA..."` hint, a doc's example key) separately from real
48
+ findings, rather than either hiding them or inflating the count with them.
49
+
50
+ ## Sealing what it finds
51
+
52
+ Finding a leaked key in a transcript raises the obvious next question: *now
53
+ what?* `--seal` is the answer:
54
+
55
+ ```bash
56
+ residoo scan --seal
57
+ ```
58
+
59
+ Every transcript that carried a finding is encrypted into a local vault
60
+ directory — AES-256-GCM, key derived from your passphrase with scrypt, streamed
61
+ (an 800MB transcript never touches memory whole). The vault's manifest — the
62
+ mapping from numbered blobs back to real paths — is itself encrypted, so the
63
+ vault doesn't advertise what's inside it even by name. **Originals are never
64
+ touched**: once you've verified a restore works (`residoo unseal <vault> --restore
65
+ 0001.sealed --out /tmp/check` — verified byte-identical via a recorded SHA-256),
66
+ deleting the plaintext is your decision, made by you, not by this tool.
67
+
68
+ Optionally, `--upload-cloudroam` (with `CLOUDROAM_API_KEY`, `--connector`,
69
+ `--bucket`) copies the sealed vault to [CloudRoam](https://cloudroam.io) for
70
+ durable, cross-cloud storage. **This is the only feature in residoo that touches
71
+ the network, it never runs unless you pass the flag, and only ciphertext is
72
+ transmitted** — the vault is sealed before upload code ever executes.
73
+
74
+ ## What it does not do
75
+
76
+ - **No network calls in the default path — and none at all unless you
77
+ explicitly pass `--upload-cloudroam`.** A secret scanner that phones home is
78
+ not a tool you should trust with your secrets. Verify this yourself: the one
79
+ `fetch` call in the codebase is in `src/sealvault.js`, reachable only behind
80
+ that flag, and sends only encrypted bytes.
81
+ - **Nothing destructive, ever.** Scanning is read-only. Sealing creates *new*
82
+ files and modifies or deletes nothing — not even the plaintext it just
83
+ encrypted a copy of. That last step is deliberately left to a human.
84
+ - **No telemetry, no analytics, no update-check ping.**
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ npx residoo scan
90
+ ```
91
+
92
+ or install it properly:
93
+
94
+ ```bash
95
+ npm install -g residoo
96
+ residoo scan
97
+ ```
98
+
99
+ Requires Node.js 18+. Zero runtime dependencies — check `package.json`.
100
+
101
+ ## Usage
102
+
103
+ ```
104
+ residoo scan [options]
105
+
106
+ --json machine-readable output (full detail, still redacted)
107
+ --include-noisy also run broad, false-positive-prone rules
108
+ --include-suppressed also show matches that looked like placeholder/example text
109
+ --fail-on-find exit code 1 if anything is found (for CI)
110
+ --no-color disable ANSI colour
111
+
112
+ --seal encrypt every transcript with findings into a local vault
113
+ --vault-dir <dir> vault location (default ./residoo-vault-<stamp>)
114
+ --upload-cloudroam also upload the sealed vault (needs CLOUDROAM_API_KEY,
115
+ --connector <id>, --bucket <name>; ciphertext only)
116
+
117
+ residoo unseal <vault-dir> list a vault's contents
118
+ residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
119
+ ```
120
+
121
+ The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
122
+ prompt. There is no recovery if you lose it — that is the point of the design,
123
+ so pick one you keep.
124
+
125
+ ## Sources supported today
126
+
127
+ **Claude Code** (`~/.claude/projects/**/*.jsonl`) — verified against real,
128
+ populated transcript directories while building this.
129
+
130
+ Cursor, GitHub Copilot, and Windsurf all keep local session history too, and
131
+ support for them is very much wanted — but shipping a scanner that checks a
132
+ guessed path and reports "all clear" when it simply didn't know where to
133
+ look is worse than not supporting a tool at all. If you use one of these and
134
+ want to add a verified adapter, see below — it's a small, self-contained
135
+ file.
136
+
137
+ ## Adding a source
138
+
139
+ A source is a small object with four methods: `id()`, `label()`,
140
+ `available()`, `files()`, and `readLines(file)`. `src/sources/claude-code.js`
141
+ is the reference implementation — copy it, point it at the real local
142
+ storage path for your tool, and open a PR. Two contracts scan.js actually
143
+ depends on, worth getting right rather than guessing from a quick skim:
144
+
145
+ - **`files()`** is a generator yielding `{ file, mtimeMs, sizeBytes, broken }`.
146
+ Set `broken: true` (other fields can be omitted) for an entry that looked
147
+ like it should be scannable but wasn't — a dangling symlink is the main
148
+ case. Don't just `continue` past it inside the generator: an early version
149
+ of the Claude Code source did exactly that, and a real, non-hypothetical
150
+ case (a project directory relocated via a symlink whose target no longer
151
+ exists) went completely invisible — not in the scan count, not in any
152
+ warning, nothing. Surfacing it as `broken` is what lets scan.js report it
153
+ instead.
154
+ - **`readLines(file)`** is `async`, returning `{ lines, status, bytesRead }`.
155
+ `status` is `"complete"`, `"partial"` (some real lines WERE read before a
156
+ failure partway through — return them, don't discard real content because
157
+ the rest of the file didn't finish cleanly), `"too-large"`, or `"failed"`.
158
+ Whatever you return in `lines` for a non-"complete" status still gets
159
+ scanned normally.
160
+
161
+ Please verify the path actually exists and holds real content before
162
+ submitting — see the note above on why
163
+ that matters here specifically.
164
+
165
+ ## A known limitation, stated plainly
166
+
167
+ Shape-based detection can't tell a real secret from a realistic-looking
168
+ example in a fetched web page or a piece of documentation your agent read
169
+ aloud back to you. The `--include-suppressed`/placeholder-context heuristic
170
+ catches the common UI-hint case, not every case. Treat every finding as a
171
+ lead to check, not a certainty — the same is true of every tool in this
172
+ category, including the well-established ones.
173
+
174
+ ## License
175
+
176
+ MIT. See `LICENSE`.
177
+
178
+ ---
179
+
180
+ Built and maintained by the team behind [CloudRoam](https://cloudroam.io) —
181
+ client-side encrypted, cross-cloud backup. residoo has no dependency on
182
+ CloudRoam and never will need one to be useful; if a scan turns up something
183
+ you want stored somewhere durable and encrypted going forward, that's the
184
+ kind of problem CloudRoam solves, but it's an entirely separate choice from
185
+ running this tool.
package/SECURITY.md ADDED
@@ -0,0 +1,75 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ If you find a security issue in residoo — including "this finding shouldn't
6
+ have been suppressed," "this output leaked more than it should have," or
7
+ anything in the redaction logic — please report it privately rather than as
8
+ a public issue. Open a [GitHub Security Advisory](../../security/advisories/new)
9
+ on this repository, or email the maintainer listed in `package.json`.
10
+
11
+ Please include:
12
+ - What you ran and what you expected vs. what happened
13
+ - Whether real secret material was involved (if so, a redacted/synthetic
14
+ reproduction is preferred over the real value)
15
+
16
+ You'll get an acknowledgment within a few days. There's no bug bounty —
17
+ this is a small open-source tool, not a funded program — but every report
18
+ gets read and taken seriously, and credited in the fix unless you'd rather
19
+ stay anonymous.
20
+
21
+ ## What's already been checked, and how
22
+
23
+ This isn't a claim taken on faith. Every property below was tested, not
24
+ just asserted — see the git history for the actual commands run:
25
+
26
+ - **No network calls in the scan path.** Grepped for every network-capable
27
+ primitive (`http`, `https`, `fetch`, `child_process`, etc.) across the
28
+ scanning code. The codebase's single `fetch` lives in `src/sealvault.js`,
29
+ is reachable only behind the explicit `--upload-cloudroam` flag, and
30
+ transmits ciphertext only — the vault is fully sealed before that code
31
+ can run.
32
+ - **Scanning is read-only.** Grepped for every filesystem write/delete
33
+ primitive in the scan path. Sealing (`--seal`) writes NEW files into a
34
+ vault directory it creates; nothing in the codebase modifies or deletes
35
+ an existing file, including the plaintext originals a seal just encrypted —
36
+ removing those is deliberately left to the human.
37
+ - **Output can't leak more than it shows.** The one raw matched value is
38
+ used in exactly two places: an in-memory dedup count (never serialized)
39
+ and the redaction function. Verified with a crafted input containing a
40
+ raw ANSI escape sequence that a real terminal would execute — confirmed
41
+ it rendered live (a working clear-screen) before the fix, confirmed it
42
+ doesn't after.
43
+ - **Not vulnerable to regex denial-of-service.** Every pattern checked
44
+ against the nested-quantifier shape behind real, dated CVEs in adjacent
45
+ tooling (e.g. CVE-2026-0621, a ReDoS in Anthropic's own MCP SDK from
46
+ catastrophic backtracking on an exploded template pattern). Also stress-
47
+ tested directly against multi-megabyte adversarial inputs.
48
+ - **No supply-chain surface.** Zero runtime dependencies, zero
49
+ pre/post-install lifecycle scripts — check `package.json` yourself,
50
+ there's nothing to hide behind a `postinstall` hook.
51
+
52
+ ## Verifying you have the real thing
53
+
54
+ Fake clones of security tools are a real, active pattern — not a
55
+ hypothetical. In the last year alone: a self-propagating npm worm that
56
+ typosquatted common package names and injected malicious config into AI
57
+ coding tools' own settings files; a fake installer for a well-known AI
58
+ agent tuned to rank highly in AI-assisted search results; and a campaign
59
+ that cloned roughly 10,000 GitHub repositories with fabricated commit
60
+ history to smuggle malware behind a README download link. A tool whose
61
+ entire premise is "trust me with what I find in your secrets" is exactly
62
+ the kind of thing worth impersonating.
63
+
64
+ - The only npm package is **`residoo`**, published from **this** GitHub
65
+ repository via CI, not uploaded by hand from a maintainer's laptop.
66
+ - The canonical repository is **`github.com/dandovdub/residoo`** — the one
67
+ named in this package's own `repository` field, which npm's provenance
68
+ attestation cryptographically ties each release to. A GitHub account named
69
+ "residoo" exists and is NOT this project. If you found residoo through a
70
+ link, a blog post, or a search result rather than directly on npm, check
71
+ the provenance badge on the npm page — it names the exact repo and
72
+ workflow that built the release.
73
+ - Nothing here needs a postinstall script, a config change to another
74
+ tool, or elevated permissions. If a "residoo" you found asks for any of
75
+ those, it isn't this project.
package/bin/residoo.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { main } = require("../src/cli");
5
+
6
+ main(process.argv).then(
7
+ (code) => { process.exitCode = code; },
8
+ (err) => {
9
+ // Reaching here means a genuine bug, not an expected failure mode — every
10
+ // expected failure (an unreadable file, no sources, bad args) is caught
11
+ // inside main()/scan() and turned into a result or an exit code, never a
12
+ // throw. This is the backstop for whatever that didn't anticipate.
13
+ //
14
+ // err isn't guaranteed to be an Error instance — `err && err.message`
15
+ // silently prints "undefined" for a bare string/object rejection, which
16
+ // would defeat the one job this handler has. String(err) works for both.
17
+ process.stderr.write(`residoo crashed unexpectedly: ${err instanceof Error ? err.message : String(err)}\n`);
18
+ process.exitCode = 1;
19
+ }
20
+ );
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "residoo",
3
+ "version": "0.1.0",
4
+ "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
+ "license": "MIT",
6
+ "author": "CloudRoam (https://cloudroam.io)",
7
+ "repository": { "type": "git", "url": "git+https://github.com/dandovdub/residoo.git" },
8
+ "homepage": "https://github.com/dandovdub/residoo#readme",
9
+ "bugs": { "url": "https://github.com/dandovdub/residoo/issues" },
10
+ "bin": { "residoo": "bin/residoo.js" },
11
+ "main": "src/cli.js",
12
+ "engines": { "node": ">=18" },
13
+ "scripts": { "test": "node tests/smoke.js" },
14
+ "files": ["bin", "src", "README.md", "SECURITY.md", "LICENSE"],
15
+ "keywords": [
16
+ "security", "secrets", "secret-scanning", "ai-agent", "claude-code",
17
+ "cursor", "copilot", "mcp", "privacy", "cli", "encryption"
18
+ ]
19
+ }
package/src/cli.js ADDED
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const { availableSources, ALL_SOURCES } = require("./sources");
5
+ const { scan, emptyResult } = require("./scan");
6
+ const { render, renderJson } = require("./report");
7
+
8
+ const HELP = `residoo — find secrets leaking through your AI agent's session history
9
+
10
+ Coding agents (Claude Code, Cursor, Copilot, ...) write everything you do
11
+ to a local transcript, including file contents your prompts touch — which
12
+ means real credentials sitting in plaintext on disk, indefinitely, in a
13
+ place nobody thinks to check. residoo scans those transcripts for them.
14
+
15
+ Scanning makes NO network calls and changes nothing on disk. Findings are
16
+ redacted in every output format. Sealing (--seal) writes NEW encrypted
17
+ files only — it never modifies or deletes anything that already exists.
18
+
19
+ Usage:
20
+ residoo scan [options]
21
+ residoo unseal <vault-dir> [--restore <n> --out <path>]
22
+
23
+ Scan options:
24
+ --json machine-readable output (full detail, still redacted)
25
+ --include-noisy also run broad, false-positive-prone rules
26
+ --include-suppressed also show matches that looked like placeholder/example text
27
+ --fail-on-find exit code 1 if anything is found (for CI)
28
+ --no-color disable ANSI colour
29
+
30
+ Seal options (used with scan):
31
+ --seal after scanning, encrypt every transcript that carried a
32
+ finding into a local vault directory (AES-256-GCM,
33
+ passphrase-derived key; originals are left untouched)
34
+ --vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
35
+ --upload-cloudroam ALSO upload the sealed vault to CloudRoam. This is the
36
+ only residoo feature that touches the network, it is
37
+ off unless you pass it, and only ciphertext is sent.
38
+ Needs CLOUDROAM_API_KEY (env) plus:
39
+ --connector <id> CloudRoam connector id for the destination
40
+ --bucket <name> destination bucket
41
+ --prefix <p> optional key prefix inside the bucket
42
+
43
+ Unseal:
44
+ residoo unseal <vault-dir> list the vault's contents
45
+ residoo unseal <vault-dir> --restore 0001.sealed --out file.jsonl
46
+ restore one entry, verified
47
+ byte-identical via its
48
+ recorded SHA-256
49
+
50
+ The passphrase is read from RESIDOO_PASSPHRASE, or prompted (hidden) on a TTY.
51
+
52
+ Sources checked on this machine: ${ALL_SOURCES.map((s) => s.label()).join(", ")}
53
+ `;
54
+
55
+ function argValue(args, flag) {
56
+ const i = args.indexOf(flag);
57
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
58
+ }
59
+
60
+ async function getPassphrase({ confirmNew }) {
61
+ const { promptHidden } = require("./prompt");
62
+ const p1 = await promptHidden("Vault passphrase (input hidden): ");
63
+ if (!p1 || p1.length < 8) throw new Error("Passphrase must be at least 8 characters.");
64
+ if (confirmNew && !process.env.RESIDOO_PASSPHRASE) {
65
+ const p2 = await promptHidden("Repeat passphrase: ");
66
+ if (p1 !== p2) throw new Error("Passphrases did not match.");
67
+ }
68
+ return p1;
69
+ }
70
+
71
+ async function runSeal(result, args) {
72
+ const { sealFindings, uploadVaultToCloudRoam } = require("./sealvault");
73
+
74
+ const filesWithFindings = [...new Set(result.findings.map((f) => f.file))];
75
+ if (filesWithFindings.length === 0) {
76
+ process.stdout.write("Nothing to seal — no findings.\n");
77
+ return 0;
78
+ }
79
+
80
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
81
+ const vaultDir = argValue(args, "--vault-dir") || path.resolve(`residoo-vault-${stamp}`);
82
+ const passphrase = await getPassphrase({ confirmNew: true });
83
+
84
+ process.stdout.write(`\nSealing ${filesWithFindings.length} file(s) with findings into ${vaultDir}\n`);
85
+ const { entries } = await sealFindings({
86
+ files: filesWithFindings, vaultDir, passphrase,
87
+ log: (s) => process.stdout.write(s + "\n"),
88
+ });
89
+ const totalPlain = entries.reduce((s, e) => s + e.plainBytes, 0);
90
+ const totalSealed = entries.reduce((s, e) => s + e.sealedBytes, 0);
91
+ process.stdout.write(
92
+ `\nSealed ${entries.length} file(s): ${(totalPlain / 1024 / 1024).toFixed(1)}MB plain -> ` +
93
+ `${(totalSealed / 1024 / 1024).toFixed(1)}MB encrypted.\n` +
94
+ `Originals were NOT touched. Once you've verified a restore works\n` +
95
+ `(residoo unseal ${path.basename(vaultDir)} --restore 0001.sealed --out /tmp/check), removing the\n` +
96
+ `plaintext originals is your call — residoo never deletes anything itself.\n`
97
+ );
98
+
99
+ if (args.includes("--upload-cloudroam")) {
100
+ const apiKey = process.env.CLOUDROAM_API_KEY;
101
+ const connectorId = argValue(args, "--connector");
102
+ const bucket = argValue(args, "--bucket");
103
+ if (!apiKey || !connectorId || !bucket) {
104
+ process.stderr.write("--upload-cloudroam needs CLOUDROAM_API_KEY (env), --connector and --bucket.\n");
105
+ return 2;
106
+ }
107
+ process.stdout.write(`\nUploading sealed vault to CloudRoam (${bucket}) — ciphertext only:\n`);
108
+ const uploaded = await uploadVaultToCloudRoam({
109
+ vaultDir,
110
+ baseUrl: process.env.CLOUDROAM_BASE_URL || "https://cloudroam.io",
111
+ apiKey, connectorId, bucket,
112
+ prefix: argValue(args, "--prefix") || "",
113
+ log: (s) => process.stdout.write(s + "\n"),
114
+ });
115
+ process.stdout.write(`Uploaded ${uploaded.length} object(s). The local vault remains at ${vaultDir}.\n`);
116
+ }
117
+ return 0;
118
+ }
119
+
120
+ async function runUnseal(args) {
121
+ const { openManifest, restoreEntry } = require("./sealvault");
122
+ const vaultDir = args[1];
123
+ if (!vaultDir) { process.stderr.write("usage: residoo unseal <vault-dir> [--restore <n> --out <path>]\n"); return 2; }
124
+
125
+ const passphrase = await getPassphrase({ confirmNew: false });
126
+ let manifest;
127
+ try {
128
+ manifest = openManifest(vaultDir, passphrase);
129
+ } catch {
130
+ process.stderr.write("Could not open vault — wrong passphrase, or the vault is corrupted.\n");
131
+ return 1;
132
+ }
133
+
134
+ const restoreName = argValue(args, "--restore");
135
+ if (!restoreName) {
136
+ process.stdout.write(`Vault contents (${manifest.entries.length} sealed file(s)):\n`);
137
+ for (const e of manifest.entries) {
138
+ process.stdout.write(` ${e.n} ${(e.plainBytes / 1024 / 1024).toFixed(1).padStart(8)}MB ${e.origPath}\n`);
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ const entry = manifest.entries.find((e) => e.n === restoreName);
144
+ if (!entry) { process.stderr.write(`No entry "${restoreName}" in this vault.\n`); return 2; }
145
+ const out = argValue(args, "--out");
146
+ if (!out) { process.stderr.write("--restore needs --out <path>.\n"); return 2; }
147
+
148
+ const { ok, plainBytes } = await restoreEntry(vaultDir, entry, out, passphrase);
149
+ if (ok) {
150
+ process.stdout.write(`Restored ${entry.n} -> ${out} (${(plainBytes / 1024 / 1024).toFixed(1)}MB), ` +
151
+ `verified byte-identical to the original (SHA-256 match).\n`);
152
+ return 0;
153
+ }
154
+ process.stderr.write(`Restored, but verification FAILED — content does not match what was sealed. Do not trust this copy.\n`);
155
+ return 1;
156
+ }
157
+
158
+ async function main(argv) {
159
+ const args = argv.slice(2);
160
+ if (args.includes("-h") || args.includes("--help") || args.length === 0) {
161
+ process.stdout.write(HELP);
162
+ return 0;
163
+ }
164
+
165
+ const cmd = args[0];
166
+ if (cmd === "unseal") return runUnseal(args);
167
+ if (cmd !== "scan") {
168
+ process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
169
+ return 2;
170
+ }
171
+
172
+ const wantsJson = args.includes("--json");
173
+ const includeNoisy = args.includes("--include-noisy");
174
+ const includeSuppressed = args.includes("--include-suppressed");
175
+ const failOnFind = args.includes("--fail-on-find");
176
+ // Passed through explicitly to render() rather than mutating
177
+ // process.env.NO_COLOR — main() is an exported function a host process can
178
+ // legitimately call more than once (a wrapper CLI, a test runner), and a
179
+ // mutated env var would leak past this one invocation and silently kill
180
+ // color for a later call that never asked for that.
181
+ const noColor = args.includes("--no-color");
182
+
183
+ const sources = availableSources();
184
+ if (sources.length === 0) {
185
+ const empty = emptyResult();
186
+ if (wantsJson) {
187
+ // A --json caller (CI, a script piping into jq) must always get valid JSON
188
+ // on stdout, even on the "nothing to scan" path — a plain-text message on
189
+ // stderr with exit 0 silently breaks that contract.
190
+ process.stdout.write(renderJson(empty) + "\n");
191
+ } else {
192
+ process.stderr.write(
193
+ "No known transcript sources found on this machine.\n" +
194
+ `Checked: ${ALL_SOURCES.map((s) => s.label()).join(", ")}.\n`
195
+ );
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ const result = await scan({ sources, includeNoisy, includeSuppressed });
201
+ process.stdout.write((wantsJson ? renderJson(result) : render(result, { noColor })) + "\n");
202
+
203
+ if (args.includes("--seal")) {
204
+ const sealExit = await runSeal(result, args);
205
+ if (sealExit !== 0) return sealExit;
206
+ }
207
+
208
+ return failOnFind && result.findings.length > 0 ? 1 : 0;
209
+ }
210
+
211
+ module.exports = { main };
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Detection rules for residoo.
5
+ *
6
+ * Every rule is high-confidence by design: a security tool that cries wolf gets
7
+ * uninstalled. Broad, noisy patterns (bare "password=" style matches) are
8
+ * deliberately left out of the default set rather than included and caveated —
9
+ * see NOISY_PATTERNS below if you want them anyway via --include-noisy.
10
+ *
11
+ * `confidence: "high"` = the shape is specific enough that a match is almost
12
+ * certainly real (a vendor-prefixed token format). `confidence: "medium"` =
13
+ * shape-based, occasionally a placeholder or test fixture.
14
+ */
15
+
16
+ const PATTERNS = [
17
+ { id: "aws_access_key_id", label: "AWS Access Key ID", confidence: "high",
18
+ re: /\bAKIA[0-9A-Z]{16}\b/g },
19
+ { id: "aws_session_token", label: "AWS Temporary Access Key ID", confidence: "high",
20
+ re: /\bASIA[0-9A-Z]{16}\b/g },
21
+ { id: "private_key_block", label: "Private key block", confidence: "high",
22
+ re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
23
+ { id: "github_pat", label: "GitHub personal access token", confidence: "high",
24
+ re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
25
+ { id: "gitlab_pat", label: "GitLab personal access token", confidence: "high",
26
+ re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
27
+ { id: "slack_token", label: "Slack token", confidence: "high",
28
+ re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
29
+ { id: "stripe_key", label: "Stripe API key", confidence: "high",
30
+ re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
31
+ // The negative lookahead keeps this rule and anthropic_key mutually exclusive —
32
+ // without it, "sk-ant-..." matches BOTH patterns and gets reported twice under
33
+ // two different (one wrong) provider labels. Verified: both regexes independently
34
+ // matched a synthetic sk-ant- key before this fix.
35
+ { id: "openai_key", label: "OpenAI API key", confidence: "high",
36
+ re: /\bsk-(?!ant-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
37
+ { id: "anthropic_key", label: "Anthropic API key", confidence: "high",
38
+ re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
39
+ { id: "google_api_key", label: "Google / Firebase API key", confidence: "high",
40
+ re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
41
+ { id: "npm_token", label: "npm access token", confidence: "high",
42
+ re: /\bnpm_[A-Za-z0-9]{36}\b/g },
43
+ { id: "sendgrid_key", label: "SendGrid API key", confidence: "high",
44
+ re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
45
+ { id: "twilio_key", label: "Twilio API key", confidence: "high",
46
+ re: /\bSK[a-f0-9]{32}\b/g },
47
+ { id: "jwt", label: "JWT-shaped token", confidence: "medium",
48
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
49
+ { id: "connection_string_with_password", label: "Database connection string with embedded password", confidence: "high",
50
+ re: /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s:@\/]+:[^\s@\/]{3,}@[^\s\/]+/g },
51
+ { id: "bearer_header", label: "Authorization: Bearer header with a real-looking token", confidence: "medium",
52
+ re: /\bauthorization["']?\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._-]{16,}/gi },
53
+ { id: "refresh_token_field", label: "refresh_token field", confidence: "medium",
54
+ re: /"refresh_token"\s*:\s*"[^"\s]{20,}"/gi },
55
+ { id: "access_token_field", label: "access_token field", confidence: "medium",
56
+ re: /"access_token"\s*:\s*"[^"\s]{20,}"/gi },
57
+ ];
58
+
59
+ /**
60
+ * Broader, shape-based patterns that catch more but false-positive more often —
61
+ * a bare `password = "..."` line is frequently a placeholder, a variable name,
62
+ * or documentation. Opt-in only, never part of the headline count.
63
+ */
64
+ const NOISY_PATTERNS = [
65
+ { id: "generic_password_assignment", label: "password / pwd assignment", confidence: "low",
66
+ re: /\b(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{6,}["']?/gi },
67
+ { id: "generic_secret_assignment", label: "generic secret / apikey assignment", confidence: "low",
68
+ re: /\b(api[_-]?key|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-\/+=]{12,}["']?/gi },
69
+ ];
70
+
71
+ /**
72
+ * Strip C0 control characters (0x00-0x1F) and DEL (0x7F) — this is where ANSI
73
+ * escape sequences live. Two of the rules above (connection strings, the
74
+ * *_token_field rules) match against a negated character class that excludes
75
+ * whitespace and quotes but NOT control bytes, so a crafted or malformed
76
+ * transcript line could otherwise put a raw terminal-control sequence into
77
+ * this tool's own report output. Verified: an unsanitized preview containing
78
+ * "\x1b[2J" actually clears the screen when printed. Applied here, at the
79
+ * one place raw matched text turns into displayable text, rather than left
80
+ * to every call site to remember.
81
+ */
82
+ // A plain regex, not a manual code-point loop: control characters (0x00-0x1F,
83
+ // 0x7F) are single UTF-16 units that never overlap a surrogate-pair half
84
+ // (those live at 0xD800-0xDFFF), so stripping them by regex can't split or
85
+ // corrupt a multi-unit character — no code-point-aware iteration needed here.
86
+ function stripControlChars(s) { return s.replace(/[\x00-\x1f\x7f]/g, ""); }
87
+
88
+ /** Mask a matched value for display: never print secret material to a terminal. */
89
+ function redact(value) {
90
+ const v = stripControlChars(String(value));
91
+ // Split by code point (Array.from, not .slice/.length) — several rules
92
+ // match via a negated character class that doesn't exclude non-ASCII, so a
93
+ // matched value CAN contain an astral character (surrogate pair)
94
+ // straddling a UTF-16 cut point. .slice(0,4) on the raw string can then
95
+ // return one half of a pair, rendering as a broken glyph.
96
+ const cps = Array.from(v);
97
+ // Every number in this function's OUTPUT — the count included — must come
98
+ // from the same stripped, code-point-split value the preview itself is
99
+ // built from. An earlier version reported String(value).length (the raw,
100
+ // pre-strip, UTF-16-unit count) here: whenever a match actually contained
101
+ // stripped control bytes, or an astral character, the parenthetical count
102
+ // visibly didn't match what the preview showed — the exact kind of
103
+ // internal inconsistency this function exists to avoid.
104
+ if (cps.length === 0) return "";
105
+ if (cps.length <= 10) return "*".repeat(cps.length);
106
+ return cps.slice(0, 4).join("") + "…" + cps.slice(-4).join("") + ` (${cps.length} chars)`;
107
+ }
108
+
109
+ module.exports = { PATTERNS, NOISY_PATTERNS, redact };