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 +21 -0
- package/README.md +185 -0
- package/SECURITY.md +75 -0
- package/bin/residoo.js +20 -0
- package/package.json +19 -0
- package/src/cli.js +211 -0
- package/src/patterns.js +109 -0
- package/src/prompt.js +30 -0
- package/src/report.js +123 -0
- package/src/scan.js +163 -0
- package/src/sealcrypto.js +149 -0
- package/src/sealvault.js +111 -0
- package/src/sources/claude-code.js +180 -0
- package/src/sources/index.js +23 -0
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 };
|
package/src/patterns.js
ADDED
|
@@ -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 };
|