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/src/prompt.js ADDED
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+
3
+ const readline = require("readline");
4
+ const { Writable } = require("stream");
5
+
6
+ /**
7
+ * Hidden passphrase prompt — echoes nothing while typing. Falls back to the
8
+ * RESIDOO_PASSPHRASE env var for scripted/CI use, and refuses to prompt when
9
+ * stdin isn't a TTY (a scanner hanging silently in a pipeline waiting for
10
+ * input nobody can see is worse than failing with instructions).
11
+ */
12
+ function promptHidden(question) {
13
+ return new Promise((resolve, reject) => {
14
+ if (process.env.RESIDOO_PASSPHRASE) { resolve(process.env.RESIDOO_PASSPHRASE); return; }
15
+ if (!process.stdin.isTTY) {
16
+ reject(new Error("No TTY for a passphrase prompt. Set RESIDOO_PASSPHRASE in the environment for non-interactive use."));
17
+ return;
18
+ }
19
+ const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
20
+ const rl = readline.createInterface({ input: process.stdin, output: muted, terminal: true });
21
+ process.stderr.write(question);
22
+ rl.question("", (answer) => {
23
+ rl.close();
24
+ process.stderr.write("\n");
25
+ resolve(answer);
26
+ });
27
+ });
28
+ }
29
+
30
+ module.exports = { promptHidden };
package/src/report.js ADDED
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+
5
+ // Minimal raw ANSI — no chalk, no deps. A security tool asking you to trust
6
+ // a pile of third-party packages before it's even scanned anything is a bad
7
+ // first impression; residoo ships with zero runtime dependencies.
8
+ const c = {
9
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
10
+ red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", cyan: "\x1b[36m",
11
+ };
12
+ // Read fresh on every call, not once at require() time — a module-level
13
+ // const would freeze whatever the environment was at require() time, before
14
+ // cli.js has even parsed argv. `forceNoColor` is how cli.js's --no-color
15
+ // flag actually reaches this function: as an explicit per-call argument, not
16
+ // by mutating process.env.NO_COLOR. `main()` is an exported function, not
17
+ // only a one-shot CLI entrypoint — a mutated env var would leak into any
18
+ // later call in the same process (a test runner, a wrapper CLI reusing it)
19
+ // and silently disable color for calls that never asked for that.
20
+ function supportsColor(forceNoColor) {
21
+ return !forceNoColor && process.stdout.isTTY && process.env.NO_COLOR === undefined;
22
+ }
23
+ function makePaint(forceNoColor) {
24
+ return (code, s) => (supportsColor(forceNoColor) ? `${code}${s}${c.reset}` : s);
25
+ }
26
+
27
+ function ageDays(mtimeMs) {
28
+ return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86400000));
29
+ }
30
+
31
+ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false } = {}) {
32
+ const paint = makePaint(noColor);
33
+ const lines = [];
34
+ const push = (s = "") => lines.push(s);
35
+
36
+ const suppressedNote = suppressedCount > 0
37
+ ? paint(c.dim, ` (${suppressedCount} more matched but looked like placeholder/example text — see --include-suppressed)`)
38
+ : "";
39
+ // Surfaced, not silent: a file that couldn't be (fully) read was not fully
40
+ // scanned, and a report must not read as "checked and found nothing" for
41
+ // it. `unreadableFiles` holds { file: <basename>, reason } — basenames
42
+ // only, deliberately: the full path can itself carry a username or a
43
+ // project-name-derived directory slug, which is exactly the kind of thing
44
+ // every other line in this report is careful to redact down from.
45
+ const unreadableNote = unreadableFiles.length > 0
46
+ ? paint(c.yellow, `⚠ ${unreadableFiles.length} file(s) not fully scanned — see --json for which and why.`)
47
+ : null;
48
+
49
+ if (findings.length === 0) {
50
+ push(paint(c.green + c.bold, "✓ No exposed secrets found") +
51
+ ` — ${filesScanned} file${filesScanned === 1 ? "" : "s"} scanned across ${sourcesScanned.join(", ") || "no sources"}.` +
52
+ suppressedNote);
53
+ if (unreadableNote) push(unreadableNote);
54
+ return lines.join("\n");
55
+ }
56
+
57
+ // Group by rule for the headline counts.
58
+ const byRule = new Map();
59
+ for (const f of findings) {
60
+ if (!byRule.has(f.ruleId)) byRule.set(f.ruleId, { label: f.label, confidence: f.confidence, items: [] });
61
+ byRule.get(f.ruleId).items.push(f);
62
+ }
63
+ const byFile = new Map();
64
+ for (const f of findings) byFile.set(f.file, (byFile.get(f.file) || 0) + 1);
65
+ const oldest = findings.reduce((a, b) => (b.fileMTimeMs < a ? b.fileMTimeMs : a), Date.now());
66
+ const newest = findings.reduce((a, b) => (b.fileMTimeMs > a ? b.fileMTimeMs : a), 0);
67
+
68
+ push();
69
+ push(paint(c.red + c.bold, `⚠ ${findings.length} potential secret${findings.length === 1 ? "" : "s"} found`) +
70
+ ` across ${byFile.size} file${byFile.size === 1 ? "" : "s"}`);
71
+ push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
72
+ push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
73
+ if (unreadableNote) push(unreadableNote);
74
+ push();
75
+
76
+ const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
77
+ for (const [ruleId, { label, confidence, items }] of sorted) {
78
+ const tag = confidence === "high" ? paint(c.red, "high") : confidence === "medium" ? paint(c.yellow, "med ") : paint(c.dim, "low ");
79
+ const distinct = distinctCounts[ruleId];
80
+ const distinctNote = distinct && distinct !== items.length
81
+ ? paint(c.dim, ` (${distinct} distinct value${distinct === 1 ? "" : "s"}, re-exposed ${items.length - distinct}× across tool output)`)
82
+ : "";
83
+ push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}`);
84
+ }
85
+
86
+ push();
87
+ push(paint(c.bold, "By file:"));
88
+ const fileRows = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
89
+ for (const [file, count] of fileRows) {
90
+ push(` ${String(count).padStart(4)} ${paint(c.cyan, path.basename(file))}`);
91
+ }
92
+ if (byFile.size > fileRows.length) push(paint(c.dim, ` … and ${byFile.size - fileRows.length} more file(s)`));
93
+
94
+ push();
95
+ push(paint(c.dim, "Values are redacted in this report — first/last 4 characters only. Nothing scanned"));
96
+ push(paint(c.dim, "here left your machine; residoo makes no network calls. Run with --json for full detail."));
97
+
98
+ return lines.join("\n");
99
+ }
100
+
101
+ function renderJson(result) {
102
+ return JSON.stringify(
103
+ {
104
+ summary: {
105
+ findingCount: result.findings.length,
106
+ filesScanned: result.filesScanned,
107
+ filesWithFindings: new Set(result.findings.map((f) => f.file)).size,
108
+ sourcesScanned: result.sourcesScanned,
109
+ bytesScanned: result.bytesScanned,
110
+ suppressedCount: result.suppressedCount || 0,
111
+ unreadableFiles: result.unreadableFiles || [],
112
+ },
113
+ findings: result.findings.map((f) => ({
114
+ rule: f.ruleId, label: f.label, confidence: f.confidence,
115
+ source: f.source, file: f.relFile, line: f.line, preview: f.preview,
116
+ })),
117
+ },
118
+ null,
119
+ 2
120
+ );
121
+ }
122
+
123
+ module.exports = { render, renderJson };
package/src/scan.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
+
6
+ /**
7
+ * Text immediately before a match that strongly suggests "this is an example
8
+ * or a UI hint," not a real credential — verified against residoo's own
9
+ * first real run, which flagged HTML `placeholder="AKIA..."` attributes in
10
+ * an unrelated codebase's connector form (a UI hint showing the expected
11
+ * key SHAPE) as if they were leaked keys. Suppressed by default, reported
12
+ * separately rather than silently dropped, and re-includable with
13
+ * --include-suppressed — a scanner that hides its own uncertainty is worse
14
+ * than one that shows it.
15
+ */
16
+ const SUPPRESS_CONTEXT_RE = /(placeholder|example|sample|dummy|<REDACTED>|xxxxxxxx|your[_-]?(api[_-]?)?key|EXAMPLE)/i;
17
+ const CONTEXT_WINDOW = 40;
18
+
19
+ /** Matches every finding's own `relFile` convention — never the full path. See SECURITY.md. */
20
+ function safeName(file) { return path.basename(file); }
21
+
22
+ /**
23
+ * Scan every transcript from every available source.
24
+ *
25
+ * Matches raw text lines directly rather than parsing each line as JSON and
26
+ * walking specific fields — transcript schemas vary by tool and change over
27
+ * time, but a leaked key looks the same either way. This is also exactly the
28
+ * method verified against a real, populated transcript directory while this
29
+ * tool was built, so it's a known-working default rather than a redesign.
30
+ *
31
+ * Returns { findings, filesScanned, sourcesScanned, bytesScanned,
32
+ * suppressedCount, distinctCounts, unreadableFiles }. `findings` never
33
+ * contains the raw matched secret — only a redacted preview — because a
34
+ * security tool's own report output is itself a place secrets could leak
35
+ * from (a screenshot, a copied terminal log, a CI artifact). Same reasoning
36
+ * is why `unreadableFiles` holds basenames only, not full paths — an
37
+ * absolute path can itself carry a username or a project name the rest of
38
+ * this report is careful never to print.
39
+ */
40
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null } = {}) {
41
+ const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
42
+ const findings = [];
43
+ let suppressedCount = 0;
44
+ let filesScanned = 0;
45
+ let bytesScanned = 0;
46
+ const sourcesScanned = [];
47
+ const unreadableFiles = [];
48
+ // Raw values live ONLY in this in-process Set, for counting how many
49
+ // DISTINCT secrets exist vs. how many times one got echoed back across
50
+ // tool calls (a token re-surfacing in every screenshot/read_page during a
51
+ // browser-testing run is one leak, not ten) — never written to a report,
52
+ // never leaves this function.
53
+ const distinctByRule = new Map();
54
+
55
+ const matchLine = (line, file, relFile, lineNo, mtimeMs) => {
56
+ for (const rule of rules) {
57
+ rule.re.lastIndex = 0; // rules are reused across files; reset global regex state
58
+ let m;
59
+ while ((m = rule.re.exec(line)) !== null) {
60
+ const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
61
+ const looksLikePlaceholder = SUPPRESS_CONTEXT_RE.test(before);
62
+ if (looksLikePlaceholder && !includeSuppressed) {
63
+ suppressedCount++;
64
+ } else {
65
+ if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
66
+ distinctByRule.get(rule.id).add(m[0]);
67
+ findings.push({
68
+ ruleId: rule.id,
69
+ label: rule.label,
70
+ confidence: looksLikePlaceholder ? "low" : rule.confidence,
71
+ suppressedReason: looksLikePlaceholder ? "placeholder-like context" : null,
72
+ source: relFile.source,
73
+ file, relFile: relFile.name,
74
+ line: lineNo,
75
+ preview: redact(m[0]),
76
+ fileMTimeMs: mtimeMs,
77
+ });
78
+ }
79
+ if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
80
+ }
81
+ }
82
+ };
83
+
84
+ for (const source of sources) {
85
+ let sourceScannedAnything = false;
86
+
87
+ for (const entry of source.files()) {
88
+ if (onProgress) onProgress({ source: source.id(), file: entry.file });
89
+
90
+ // files() itself can now report an entry it couldn't resolve at all —
91
+ // chiefly a dangling symlink. Surfaced the same way an unreadable file
92
+ // is: visibly, never silently dropped inside the walk.
93
+ if (entry.broken) {
94
+ unreadableFiles.push({ file: safeName(entry.file), reason: "could not be resolved" });
95
+ continue;
96
+ }
97
+ const { file, mtimeMs, sizeBytes } = entry;
98
+
99
+ // Any unexpected throw here (a source's readLines behaving outside its
100
+ // documented contract, a future bug) must not take down the rest of
101
+ // the scan and discard every finding already collected from other
102
+ // files — one bad file degrading to "unreadable" is the correct
103
+ // failure mode; the whole run crashing is not.
104
+ let result;
105
+ try {
106
+ result = await source.readLines(file);
107
+ } catch (err) {
108
+ unreadableFiles.push({ file: safeName(file), reason: "unexpected error" });
109
+ continue;
110
+ }
111
+
112
+ const { lines, status, bytesRead } = result;
113
+ if (status === "failed") {
114
+ unreadableFiles.push({ file: safeName(file), reason: "could not be read" });
115
+ continue;
116
+ }
117
+ if (status === "too-large") {
118
+ unreadableFiles.push({ file: safeName(file), reason: "too large to scan" });
119
+ continue;
120
+ }
121
+ // "partial" means the read failed partway through, but real lines WERE
122
+ // captured before that — those lines get scanned normally below (a
123
+ // secret in the part that succeeded is still a real finding), and the
124
+ // file is ALSO flagged so the user knows it wasn't fully checked.
125
+ if (status === "partial") {
126
+ unreadableFiles.push({ file: safeName(file), reason: "only partially read" });
127
+ }
128
+
129
+ sourceScannedAnything = true;
130
+ filesScanned++;
131
+ // Actual bytes streamed, not the pre-read stat() snapshot — matters
132
+ // for a file Claude Code is actively appending to mid-scan, where the
133
+ // two can genuinely differ.
134
+ bytesScanned += bytesRead || sizeBytes || 0;
135
+
136
+ const relFile = { name: safeName(file), source: source.id() };
137
+ for (let i = 0; i < lines.length; i++) {
138
+ if (lines[i]) matchLine(lines[i], file, relFile, i + 1, mtimeMs);
139
+ }
140
+ }
141
+
142
+ if (sourceScannedAnything) sourcesScanned.push(source.id());
143
+ }
144
+
145
+ const distinctCounts = {};
146
+ for (const [ruleId, set] of distinctByRule) distinctCounts[ruleId] = set.size;
147
+ return { findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount, distinctCounts, unreadableFiles };
148
+ }
149
+
150
+ /**
151
+ * The shape of a scan() result with nothing in it — exported so callers with
152
+ * a "nothing to scan" path (no sources on this machine) can reuse the exact
153
+ * result shape instead of hand-typing a duplicate literal that has to be
154
+ * remembered and kept in sync every time a new field is added here.
155
+ */
156
+ function emptyResult() {
157
+ return {
158
+ findings: [], filesScanned: 0, sourcesScanned: [], bytesScanned: 0,
159
+ suppressedCount: 0, distinctCounts: {}, unreadableFiles: [],
160
+ };
161
+ }
162
+
163
+ module.exports = { scan, emptyResult };
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const crypto = require("crypto");
5
+ const zlib = require("zlib");
6
+ const { pipeline } = require("stream/promises");
7
+ const { Transform } = require("stream");
8
+
9
+ /**
10
+ * Streaming seal/unseal for transcript files.
11
+ *
12
+ * AES-256-GCM + scrypt — the same primitives validated in the memvault spike,
13
+ * with both of that spike's hard-won lessons applied from the start:
14
+ * - everything is Buffers end to end (a .toString("utf-8") round-trip on
15
+ * binary silently corrupts it — verified, not theoretical);
16
+ * - scrypt at N=2^15/r=8 needs ~32MB, exactly Node's DEFAULT maxmem, so
17
+ * maxmem must be raised explicitly or the very first real run throws
18
+ * ERR_CRYPTO_INVALID_SCRYPT_PARAMS (also verified the hard way).
19
+ *
20
+ * And one lesson from residoo itself: transcripts run to 800MB+, past V8's
21
+ * ~512M-char single-string ceiling, so seal/unseal are stream pipelines —
22
+ * no step ever materializes the whole file.
23
+ *
24
+ * Container format (one sealed file):
25
+ * [4-byte BE header length][JSON header][ciphertext...][16-byte GCM tag]
26
+ * The header is PLAINTEXT and deliberately minimal — salt, iv, version.
27
+ * Anything sensitive (original path, plaintext hash) lives in the vault's
28
+ * separate manifest, which is itself sealed: a sealed blob that leaks its
29
+ * own origin path in cleartext would undermine the point of sealing it.
30
+ */
31
+
32
+ const MAGIC = 1;
33
+ const SCRYPT = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
34
+
35
+ function deriveKey(passphrase, salt) {
36
+ return crypto.scryptSync(passphrase, salt, 32, SCRYPT);
37
+ }
38
+
39
+ /**
40
+ * Seal srcPath -> destPath, streaming (read -> gzip -> encrypt -> write).
41
+ * Returns { plainSha256, plainBytes, sealedBytes } — the plaintext hash is
42
+ * computed on the fly from the same bytes that get sealed, so the caller can
43
+ * later prove an unsealed copy is byte-identical to what went in.
44
+ */
45
+ async function sealFile(srcPath, destPath, passphrase) {
46
+ const salt = crypto.randomBytes(16);
47
+ const iv = crypto.randomBytes(12);
48
+ const key = deriveKey(passphrase, salt);
49
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
50
+ const hash = crypto.createHash("sha256");
51
+ let plainBytes = 0;
52
+
53
+ const header = Buffer.from(JSON.stringify({
54
+ v: MAGIC, salt: salt.toString("base64"), iv: iv.toString("base64"), gzip: true,
55
+ }), "utf-8");
56
+ const lenBuf = Buffer.alloc(4);
57
+ lenBuf.writeUInt32BE(header.length);
58
+
59
+ const out = fs.createWriteStream(destPath);
60
+ out.write(lenBuf);
61
+ out.write(header);
62
+
63
+ const tap = new Transform({
64
+ transform(chunk, _enc, cb) { plainBytes += chunk.length; hash.update(chunk); cb(null, chunk); },
65
+ });
66
+
67
+ await pipeline(fs.createReadStream(srcPath), tap, zlib.createGzip(), cipher, out, { end: false });
68
+ // GCM's auth tag only exists after the cipher finishes — appended last, read
69
+ // back first by unsealFile below.
70
+ const tag = cipher.getAuthTag();
71
+ await new Promise((resolve, reject) => out.end(tag, (err) => (err ? reject(err) : resolve())));
72
+
73
+ const sealedBytes = fs.statSync(destPath).size;
74
+ return { plainSha256: hash.digest("hex"), plainBytes, sealedBytes };
75
+ }
76
+
77
+ /** Unseal sealedPath -> destPath, streaming. Throws on wrong passphrase or any tampering (GCM tag). */
78
+ async function unsealFile(sealedPath, destPath, passphrase) {
79
+ const fd = fs.openSync(sealedPath, "r");
80
+ let headerLen, header, dataStart, dataEnd, tag;
81
+ try {
82
+ const size = fs.fstatSync(fd).size;
83
+ const lenBuf = Buffer.alloc(4);
84
+ fs.readSync(fd, lenBuf, 0, 4, 0);
85
+ headerLen = lenBuf.readUInt32BE(0);
86
+ if (headerLen <= 0 || headerLen > 4096) throw new Error("not a residoo sealed file (bad header)");
87
+ const headerBuf = Buffer.alloc(headerLen);
88
+ fs.readSync(fd, headerBuf, 0, headerLen, 4);
89
+ header = JSON.parse(headerBuf.toString("utf-8"));
90
+ dataStart = 4 + headerLen;
91
+ dataEnd = size - 16; // GCM tag
92
+ if (dataEnd < dataStart) throw new Error("sealed file truncated");
93
+ tag = Buffer.alloc(16);
94
+ fs.readSync(fd, tag, 0, 16, dataEnd);
95
+ } finally {
96
+ fs.closeSync(fd);
97
+ }
98
+
99
+ const key = deriveKey(passphrase, Buffer.from(header.salt, "base64"));
100
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(header.iv, "base64"));
101
+ decipher.setAuthTag(tag);
102
+
103
+ const hash = crypto.createHash("sha256");
104
+ let plainBytes = 0;
105
+ const tap = new Transform({
106
+ transform(chunk, _enc, cb) { plainBytes += chunk.length; hash.update(chunk); cb(null, chunk); },
107
+ });
108
+
109
+ await pipeline(
110
+ fs.createReadStream(sealedPath, { start: dataStart, end: dataEnd - 1 }),
111
+ decipher,
112
+ zlib.createGunzip(),
113
+ tap,
114
+ fs.createWriteStream(destPath)
115
+ );
116
+ return { plainSha256: hash.digest("hex"), plainBytes };
117
+ }
118
+
119
+ /** Seal a small in-memory Buffer (the vault manifest) into the same container format. */
120
+ function sealBuffer(buf, passphrase) {
121
+ const salt = crypto.randomBytes(16);
122
+ const iv = crypto.randomBytes(12);
123
+ const key = deriveKey(passphrase, salt);
124
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
125
+ const gz = zlib.gzipSync(buf);
126
+ const ct = Buffer.concat([cipher.update(gz), cipher.final()]);
127
+ const tag = cipher.getAuthTag();
128
+ const header = Buffer.from(JSON.stringify({
129
+ v: MAGIC, salt: salt.toString("base64"), iv: iv.toString("base64"), gzip: true,
130
+ }), "utf-8");
131
+ const lenBuf = Buffer.alloc(4);
132
+ lenBuf.writeUInt32BE(header.length);
133
+ return Buffer.concat([lenBuf, header, ct, tag]);
134
+ }
135
+
136
+ /** Inverse of sealBuffer. Returns the plaintext Buffer; throws on wrong passphrase/tampering. */
137
+ function unsealBuffer(sealed, passphrase) {
138
+ const headerLen = sealed.readUInt32BE(0);
139
+ const header = JSON.parse(sealed.slice(4, 4 + headerLen).toString("utf-8"));
140
+ const tag = sealed.slice(sealed.length - 16);
141
+ const ct = sealed.slice(4 + headerLen, sealed.length - 16);
142
+ const key = deriveKey(passphrase, Buffer.from(header.salt, "base64"));
143
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(header.iv, "base64"));
144
+ decipher.setAuthTag(tag);
145
+ const gz = Buffer.concat([decipher.update(ct), decipher.final()]);
146
+ return zlib.gunzipSync(gz);
147
+ }
148
+
149
+ module.exports = { sealFile, unsealFile, sealBuffer, unsealBuffer };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { sealFile, sealBuffer, unsealBuffer, unsealFile } = require("./sealcrypto");
6
+
7
+ /**
8
+ * Seal every file that carried findings into an encrypted vault directory.
9
+ *
10
+ * Principles, in order of importance:
11
+ * - NEVER touches the originals. Seal creates new files only; deleting the
12
+ * plaintext afterwards is the user's own, separate, informed decision.
13
+ * (residoo's core promise is that scanning is read-only — sealing writes
14
+ * NEW files and nothing else.)
15
+ * - The vault leaks nothing in plaintext. Sealed blobs are numbered
16
+ * (0001.sealed…), and the mapping back to real paths + plaintext hashes
17
+ * lives in manifest.sealed — itself encrypted. A vault that names its
18
+ * own contents would defeat the point of uploading it anywhere.
19
+ * - Streaming throughout: transcripts run to 800MB+; nothing here ever
20
+ * holds a whole file in memory.
21
+ *
22
+ * Vault layout:
23
+ * residoo-vault-<stamp>/
24
+ * 0001.sealed … encrypted+gzipped transcript files
25
+ * manifest.sealed encrypted JSON: [{n, origPath, plainSha256, plainBytes, sealedBytes}]
26
+ * README.txt plaintext instructions (no sensitive content)
27
+ */
28
+ async function sealFindings({ files, vaultDir, passphrase, log = () => {} }) {
29
+ fs.mkdirSync(vaultDir, { recursive: true });
30
+ const entries = [];
31
+ let n = 0;
32
+ for (const file of files) {
33
+ n++;
34
+ const name = String(n).padStart(4, "0") + ".sealed";
35
+ const dest = path.join(vaultDir, name);
36
+ log(` sealing ${path.basename(file)} …`);
37
+ const { plainSha256, plainBytes, sealedBytes } = await sealFile(file, dest, passphrase);
38
+ entries.push({ n: name, origPath: file, plainSha256, plainBytes, sealedBytes });
39
+ log(` -> ${name} (${(plainBytes / 1024 / 1024).toFixed(1)}MB plain -> ${(sealedBytes / 1024 / 1024).toFixed(1)}MB sealed)`);
40
+ }
41
+
42
+ const manifest = { v: 1, entries };
43
+ fs.writeFileSync(path.join(vaultDir, "manifest.sealed"), sealBuffer(Buffer.from(JSON.stringify(manifest, null, 2), "utf-8"), passphrase));
44
+ fs.writeFileSync(path.join(vaultDir, "README.txt"),
45
+ "residoo sealed vault\n" +
46
+ "====================\n\n" +
47
+ "Files here are AES-256-GCM encrypted (scrypt-derived key). Without the\n" +
48
+ "passphrase they are unreadable, including by residoo's authors.\n\n" +
49
+ "To list contents: residoo unseal <vault-dir> (prompts for passphrase)\n" +
50
+ "To restore a file: residoo unseal <vault-dir> --restore <n> --out <path>\n\n" +
51
+ "manifest.sealed maps the numbered blobs back to their original paths and\n" +
52
+ "records a SHA-256 of each original, so a restore can be verified as\n" +
53
+ "byte-identical. The manifest is encrypted for the same reason the files\n" +
54
+ "are: even the NAMES of what is in here are nobody else's business.\n"
55
+ );
56
+ return { entries, vaultDir };
57
+ }
58
+
59
+ /** Decrypt and return the vault's manifest. Throws on wrong passphrase. */
60
+ function openManifest(vaultDir, passphrase) {
61
+ const sealed = fs.readFileSync(path.join(vaultDir, "manifest.sealed"));
62
+ return JSON.parse(unsealBuffer(sealed, passphrase).toString("utf-8"));
63
+ }
64
+
65
+ /** Restore one numbered entry to outPath and verify it against the recorded hash. */
66
+ async function restoreEntry(vaultDir, entry, outPath, passphrase) {
67
+ const { plainSha256, plainBytes } = await unsealFile(path.join(vaultDir, entry.n), outPath, passphrase);
68
+ const ok = plainSha256 === entry.plainSha256 && plainBytes === entry.plainBytes;
69
+ return { ok, plainSha256, plainBytes };
70
+ }
71
+
72
+ /**
73
+ * Upload a vault to CloudRoam — the ONLY code path in residoo that touches
74
+ * the network, and it never runs unless the user passed --upload-cloudroam
75
+ * explicitly. Uses CloudRoam's raw-body streaming endpoint
76
+ * (POST /api/files/upload-stream?bucket&key + X-Connector-Id), so this stays
77
+ * zero-dependency. Only ciphertext leaves the machine: the vault's files are
78
+ * already sealed before this function is ever called, and the manifest that
79
+ * names them is sealed too.
80
+ */
81
+ async function uploadVaultToCloudRoam({ vaultDir, baseUrl, apiKey, connectorId, bucket, prefix, log = () => {} }) {
82
+ const files = fs.readdirSync(vaultDir).filter((f) => f.endsWith(".sealed") || f === "README.txt");
83
+ const base = baseUrl.replace(/\/+$/, "");
84
+ const uploaded = [];
85
+ for (const f of files) {
86
+ const full = path.join(vaultDir, f);
87
+ const size = fs.statSync(full).size;
88
+ const key = (prefix ? prefix.replace(/\/+$/, "") + "/" : "") + path.basename(vaultDir) + "/" + f;
89
+ const q = new URLSearchParams({ bucket, key });
90
+ log(` uploading ${f} (${(size / 1024 / 1024).toFixed(1)}MB) …`);
91
+ const res = await fetch(`${base}/api/files/upload-stream?${q}`, {
92
+ method: "POST",
93
+ headers: {
94
+ Authorization: "Bearer " + apiKey,
95
+ "X-Connector-Id": connectorId,
96
+ "Content-Type": "application/octet-stream",
97
+ "Content-Length": String(size),
98
+ },
99
+ body: fs.createReadStream(full),
100
+ duplex: "half", // required by Node's fetch for a streaming request body
101
+ });
102
+ if (!res.ok) {
103
+ const text = await res.text().catch(() => "");
104
+ throw new Error(`upload of ${f} failed: HTTP ${res.status} ${text.slice(0, 200)}`);
105
+ }
106
+ uploaded.push(key);
107
+ }
108
+ return uploaded;
109
+ }
110
+
111
+ module.exports = { sealFindings, openManifest, restoreEntry, uploadVaultToCloudRoam };