seisin 0.4.2 → 0.6.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/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one key provider and reports what it printed, as JSON on stdout.
|
|
3
|
+
*
|
|
4
|
+
* A separate process because `spawnSync` cannot do two things a provider
|
|
5
|
+
* needs, and `resolveRef` is synchronous by contract (keys.js):
|
|
6
|
+
*
|
|
7
|
+
* - **Take the answer when the provider exits.** `spawnSync` returns when
|
|
8
|
+
* the child has exited AND every copy of its stdout is closed. A provider
|
|
9
|
+
* that printed the value, exited 0 and left a background process holding
|
|
10
|
+
* the pipe made seisin wait the whole timeout and then say the provider
|
|
11
|
+
* "did not answer". Here the answer is what was read by the time the
|
|
12
|
+
* provider exited, plus a short drain for bytes still in the pipe.
|
|
13
|
+
* - **Stop it for good.** A timeout is SIGKILL, not SIGTERM: a provider that
|
|
14
|
+
* ignored SIGTERM held the run forever. And it goes to every descendant
|
|
15
|
+
* too, so a child the provider was waiting on dies with it instead of
|
|
16
|
+
* living on. Found by walking `ps`, not by a process group of its own: a
|
|
17
|
+
* new session would take the provider's terminal away, and a pinentry
|
|
18
|
+
* needs it. A provider that exits on its own keeps whatever it left
|
|
19
|
+
* running: an agent it starts (gpg-agent, an op daemon) is its business.
|
|
20
|
+
*
|
|
21
|
+
* stderr is read and counted, never kept: a provider may print the value
|
|
22
|
+
* there, and nothing downstream shows it.
|
|
23
|
+
*
|
|
24
|
+
* argv: <timeoutMs> <maxBytes> <drainMs> <command> [args…]
|
|
25
|
+
*/
|
|
26
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
const [timeoutMs, maxBytes, drainMs] = process.argv.slice(2, 5).map(Number);
|
|
29
|
+
const argv = process.argv.slice(5);
|
|
30
|
+
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0, errBytes = 0, timedOut = false, tooBig = false, done = false;
|
|
33
|
+
|
|
34
|
+
function finish(result) {
|
|
35
|
+
if (done) return;
|
|
36
|
+
done = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const out = tooBig ? "" : Buffer.concat(chunks).toString("base64");
|
|
39
|
+
process.stdout.write(JSON.stringify({ ...result, out, errBytes, timedOut, tooBig }), () => process.exit(0));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The provider and every process below it. */
|
|
43
|
+
function tree(root) {
|
|
44
|
+
const kids = new Map();
|
|
45
|
+
try {
|
|
46
|
+
for (const line of execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }).split("\n")) {
|
|
47
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
48
|
+
if (pid && ppid) kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
const all = [root];
|
|
52
|
+
for (let i = 0; i < all.length; i++) all.push(...(kids.get(all[i]) ?? []));
|
|
53
|
+
return all;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
|
|
57
|
+
const kill = () => {
|
|
58
|
+
if (!child.pid) return;
|
|
59
|
+
// Stopped first, so nothing forks between the listing and the kill.
|
|
60
|
+
const pids = tree(child.pid);
|
|
61
|
+
for (const pid of pids) try { process.kill(pid, "SIGSTOP"); } catch {}
|
|
62
|
+
for (const pid of tree(child.pid)) if (!pids.includes(pid)) pids.push(pid);
|
|
63
|
+
for (const pid of pids) try { process.kill(pid, "SIGKILL"); } catch {}
|
|
64
|
+
};
|
|
65
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
66
|
+
|
|
67
|
+
child.stdout.on("data", (c) => {
|
|
68
|
+
size += c.length;
|
|
69
|
+
if (size > maxBytes) { tooBig = true; chunks.length = 0; kill(); return; }
|
|
70
|
+
if (!tooBig) chunks.push(c);
|
|
71
|
+
});
|
|
72
|
+
child.stderr.on("data", (c) => { errBytes += c.length; });
|
|
73
|
+
child.on("error", (e) => finish({ error: e.code ?? e.message }));
|
|
74
|
+
child.on("exit", (status, signal) => {
|
|
75
|
+
// What the provider wrote before exiting may still be in the pipe. Wait for
|
|
76
|
+
// the end of it, but not for a descendant that kept the pipe open.
|
|
77
|
+
const drained = () => finish({ status, signal });
|
|
78
|
+
if (child.stdout.readableEnded) return drained();
|
|
79
|
+
child.stdout.once("end", drained);
|
|
80
|
+
setTimeout(drained, drainMs).unref();
|
|
81
|
+
});
|
package/src/redact.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* README spends its time avoiding.
|
|
21
21
|
*/
|
|
22
22
|
import { Transform } from "node:stream";
|
|
23
|
+
import { StringDecoder } from "node:string_decoder";
|
|
23
24
|
|
|
24
25
|
/** Reads the values this role may read. Unreadable or huge files are skipped. */
|
|
25
26
|
export function secretsOf(settings, readFile) {
|
|
@@ -49,34 +50,66 @@ export function secretsOf(settings, readFile) {
|
|
|
49
50
|
* It carries the tail of each chunk forward, because a value split across a
|
|
50
51
|
* buffer boundary would otherwise sail through — which is the one case a
|
|
51
52
|
* naive implementation always gets wrong and never notices.
|
|
53
|
+
*
|
|
54
|
+
* Bytes are decoded with a StringDecoder that lives as long as the stream. A
|
|
55
|
+
* chunk boundary falls wherever the pipe put it, including inside a multibyte
|
|
56
|
+
* character: decoding each chunk on its own turned `año` into `a��o`, and a
|
|
57
|
+
* secret with an `ñ` in it split that way was never seen whole, so it was
|
|
58
|
+
* never masked. The same care applies to the cut below, which must not leave
|
|
59
|
+
* half of a surrogate pair on either side.
|
|
52
60
|
*/
|
|
53
61
|
export function redactor(secrets, label = "redacted") {
|
|
62
|
+
// Embedders use this export directly; neither order nor empty values may
|
|
63
|
+
// decide how much of a split credential is retained between chunks.
|
|
64
|
+
secrets = [...new Set(secrets.filter((s) => typeof s === "string" && s.length > 0))]
|
|
65
|
+
.sort((a, b) => b.length - a.length);
|
|
54
66
|
if (secrets.length === 0) return null;
|
|
55
67
|
const longest = secrets[0].length;
|
|
68
|
+
const decoder = new StringDecoder("utf8");
|
|
56
69
|
let tail = "";
|
|
57
70
|
|
|
58
71
|
return new Transform({
|
|
59
72
|
transform(chunk, _enc, done) {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
// Retain original input, not replacement text. Replacing a shorter
|
|
74
|
+
// credential before a longer one finishes destroys their shared prefix;
|
|
75
|
+
// carrying a marker forward also lets another secret replace its label.
|
|
76
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
77
|
+
const buffered = tail + text;
|
|
78
|
+
let cut = Math.max(0, buffered.length - longest + 1);
|
|
79
|
+
if (cut > 0 && isHighSurrogate(buffered.charCodeAt(cut - 1))) cut--;
|
|
80
|
+
const result = maskPrefix(buffered, cut, secrets, label);
|
|
81
|
+
if (result.output) this.push(result.output);
|
|
82
|
+
tail = result.tail;
|
|
68
83
|
done();
|
|
69
84
|
},
|
|
70
85
|
flush(done) {
|
|
71
|
-
|
|
86
|
+
const text = tail + decoder.end();
|
|
87
|
+
const result = maskPrefix(text, text.length, secrets, label);
|
|
88
|
+
if (result.output) this.push(result.output);
|
|
72
89
|
tail = "";
|
|
73
90
|
done();
|
|
74
91
|
},
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
|
|
96
|
+
|
|
97
|
+
/** Only match starts with enough input to decide the longest credential. */
|
|
98
|
+
function maskPrefix(text, cut, secrets, label) {
|
|
99
|
+
const next = secrets.map((s) => text.indexOf(s));
|
|
100
|
+
let from = 0, output = "";
|
|
101
|
+
while (from < cut) {
|
|
102
|
+
let at = Infinity, chosen = -1;
|
|
103
|
+
for (let i = 0; i < next.length; i++) {
|
|
104
|
+
if (next[i] >= 0 && next[i] < at) { at = next[i]; chosen = i; }
|
|
105
|
+
}
|
|
106
|
+
if (at >= cut) { output += text.slice(from, cut); from = cut; break; }
|
|
107
|
+
output += text.slice(from, at) + `‹${label}›`;
|
|
108
|
+
from = at + secrets[chosen].length;
|
|
109
|
+
// Keep each search moving forward. In particular, a value absent from
|
|
110
|
+
// this buffer is not searched again for every occurrence of another.
|
|
111
|
+
for (let i = 0; i < next.length; i++)
|
|
112
|
+
if (next[i] >= 0 && next[i] < from) next[i] = text.indexOf(secrets[i], from);
|
|
113
|
+
}
|
|
114
|
+
return { output, tail: text.slice(from) };
|
|
82
115
|
}
|
package/src/render.js
CHANGED
|
@@ -67,8 +67,16 @@ export function collapseSidecars(paths) {
|
|
|
67
67
|
return out;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The `check` report, as text. Takes the object `inspect()` returns.
|
|
72
|
+
*
|
|
73
|
+
* The map and one line per warning first; the paragraphs behind `verbose`.
|
|
74
|
+
* On a three-role repo the full report ran to forty lines of explanation under
|
|
75
|
+
* a six-line map, and the one warning a person could act on sat in the middle.
|
|
76
|
+
* Nothing is dropped: the protected paths, each warning's reason and the
|
|
77
|
+
* standing limits are one flag away, and the footer says how many.
|
|
78
|
+
*/
|
|
79
|
+
export function renderReport(report, { verbose = true } = {}) {
|
|
72
80
|
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
73
81
|
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
74
82
|
|
|
@@ -78,6 +86,8 @@ export function renderReport(report) {
|
|
|
78
86
|
lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
|
|
79
87
|
// Only when present: absent is the default, and a "never_writes nothing" line
|
|
80
88
|
// on every role is noise that teaches the reader to skip the one that matters.
|
|
89
|
+
if (r.private?.length)
|
|
90
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}private${C.off} ${r.private.join(" ")} ${C.dim}(only this role reads or writes it)${C.off}\n`);
|
|
81
91
|
if (r.neverWrites?.length)
|
|
82
92
|
lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
|
|
83
93
|
if (r.trustd)
|
|
@@ -88,6 +98,12 @@ export function renderReport(report) {
|
|
|
88
98
|
lines.push(` ${" ".repeat(width)} ${C.yellow}mcp${C.off} ${r.mcp.length ? r.mcp.join(" ") : "none"} ${C.dim}(declared; the launcher enforces it)${C.off}\n`);
|
|
89
99
|
if (r.localPorts?.length)
|
|
90
100
|
lines.push(` ${" ".repeat(width)} ${C.yellow}reach${C.off} localhost ${r.localPorts.join(" ")} ${C.dim}(local_ports, through the proxy)${C.off}\n`);
|
|
101
|
+
if (r.reads?.length)
|
|
102
|
+
lines.push(` ${" ".repeat(width)} ${C.blue}reads${C.off} ${r.reads.join(" ")}\n`);
|
|
103
|
+
if (r.toolchain?.length)
|
|
104
|
+
lines.push(` ${" ".repeat(width)} ${C.blue}tools${C.off} ${r.toolchain.join(" ")}\n`);
|
|
105
|
+
if (r.verify)
|
|
106
|
+
lines.push(` ${" ".repeat(width)} ${C.blue}verify${C.off} ${r.verify.join(" ")}\n`);
|
|
91
107
|
lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
|
|
92
108
|
}
|
|
93
109
|
|
|
@@ -101,10 +117,10 @@ export function renderReport(report) {
|
|
|
101
117
|
// What the kernel refuses inside a territory, and why. Shown because each one
|
|
102
118
|
// takes something from a territory as written; capped because a monorepo has
|
|
103
119
|
// a hooks directory per package and the reader needs the shape, not all 200.
|
|
104
|
-
if (report.protected?.length) {
|
|
120
|
+
if (report.protected?.length && verbose) {
|
|
105
121
|
const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
|
|
106
122
|
const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
|
|
107
|
-
lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory,
|
|
123
|
+
lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, denied anyway: something outside the sandbox runs or reads it${C.off}\n`);
|
|
108
124
|
const LIMIT = 12;
|
|
109
125
|
for (const e of report.protected.slice(0, LIMIT)) {
|
|
110
126
|
const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
|
|
@@ -115,22 +131,39 @@ export function renderReport(report) {
|
|
|
115
131
|
lines.push("\n");
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
if (!verbose) {
|
|
135
|
+
for (const w of report.warnings) lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
136
|
+
if (report.warnings.length) lines.push("\n");
|
|
137
|
+
const more = [
|
|
138
|
+
report.protected?.length ? `${report.protected.length} protected path(s)` : "",
|
|
139
|
+
report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n`);
|
|
142
|
+
lines.push(limitsLine(report), "\n");
|
|
143
|
+
return lines.join("");
|
|
144
|
+
}
|
|
145
|
+
|
|
118
146
|
for (const w of report.warnings) {
|
|
119
147
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
120
148
|
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
|
149
|
+
// A list is a list: one entry per line, not a sentence of twenty paths.
|
|
150
|
+
for (const item of w.items ?? []) lines.push(` ${C.dim}${item}${C.off}\n`);
|
|
121
151
|
lines.push("\n");
|
|
122
152
|
}
|
|
123
153
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (l.detail) lines.push(` ${C.dim}${l.detail}${C.off}\n`);
|
|
129
|
-
lines.push("\n");
|
|
130
|
-
}
|
|
154
|
+
// The standing limits are the same in every repo, so they are one line here
|
|
155
|
+
// and the paragraphs are `seisin help limits` — six of them closed every
|
|
156
|
+
// verbose report, under the part that was about this repo.
|
|
157
|
+
lines.push(limitsLine(report), "\n");
|
|
131
158
|
return lines.join("");
|
|
132
159
|
}
|
|
133
160
|
|
|
161
|
+
/** Where the standing limits are, or nothing when the report carries none. */
|
|
162
|
+
function limitsLine(report) {
|
|
163
|
+
const n = report.limits?.length ?? 0;
|
|
164
|
+
return n ? ` ${C.dim}${n} standing limit(s) apply to every repo: seisin help limits${C.off}\n` : "";
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
/* ── one verdict ──────────────────────────────────────────────────────── */
|
|
135
168
|
|
|
136
169
|
/** The `explain` answer, as text. Takes what `owners.explain()` returns. */
|
|
@@ -149,6 +182,19 @@ export function renderVerdict(role, action, target, verdict) {
|
|
|
149
182
|
* people to stop reading the colour.
|
|
150
183
|
*/
|
|
151
184
|
export function renderEntry(e) {
|
|
185
|
+
if (e.event === "canary") {
|
|
186
|
+
const mark = e.confined ? `${C.green}canary passed${C.off}` : `${C.red}canary failed${C.off}`;
|
|
187
|
+
const why = e.reasons?.length ? ` ${C.dim}${e.reasons.join("; ")}${C.off}` : "";
|
|
188
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off}${why}\n`;
|
|
189
|
+
}
|
|
190
|
+
if (e.event === "log-dropped") {
|
|
191
|
+
const mark = `${C.yellow}log dropped${C.off}`;
|
|
192
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${C.dim}${e.batches} batch(es) of system log messages lost — kernel denials may be missing${C.off}\n`;
|
|
193
|
+
}
|
|
194
|
+
if (e.event === "key") {
|
|
195
|
+
const mark = e.ok ? `${C.green}key resolved${C.off}` : `${C.red}key failed${C.off}`;
|
|
196
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${safe(e.key)} ${C.dim}${e.mode}${C.off}\n`;
|
|
197
|
+
}
|
|
152
198
|
const mark =
|
|
153
199
|
e.verdict === "denied" ? `${C.yellow}denied ${C.off}`
|
|
154
200
|
: e.verdict === "observed" ? `${C.dim}seen ${C.off}`
|
|
@@ -177,16 +223,40 @@ export function renderScan(result, keyDirs) {
|
|
|
177
223
|
const { certain, review, skipped, truncated } = result;
|
|
178
224
|
const links = result.links ?? [];
|
|
179
225
|
const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
|
|
180
|
-
const lines = [`\n ${C.dim}protected: ${where}${C.off}\n
|
|
226
|
+
const lines = [`\n ${C.dim}protected: ${where}${C.off}\n`];
|
|
227
|
+
// Scoped to the territories: say where it looked, and how to look everywhere.
|
|
228
|
+
if (result.scope) {
|
|
229
|
+
const shown = result.scope.slice(0, 10).map(safe).join(", ");
|
|
230
|
+
const more = result.scope.length > 10 ? ` and ${result.scope.length - 10} more` : "";
|
|
231
|
+
lines.push(` ${C.dim}scanned: where the territories are — ${shown}${more}. ${C.b}seisin scan --all${C.off}${C.dim} walks the whole folder${C.off}\n`);
|
|
232
|
+
}
|
|
233
|
+
lines.push("\n");
|
|
181
234
|
|
|
182
|
-
|
|
183
|
-
|
|
235
|
+
// A pruned checkout is part of what a clean result means, so it is said
|
|
236
|
+
// even when there is nothing else to say — in yellow, not dim: every role
|
|
237
|
+
// can still read it, and a dim line under a green verdict read as clean.
|
|
238
|
+
const paths = result.nestedPaths ?? [];
|
|
239
|
+
let nested = "";
|
|
240
|
+
if (skipped.nested) {
|
|
241
|
+
nested = ` ${C.yellow}not scanned: ${skipped.nested} nested checkout(s)${C.off} — another repository each; every role can still read them. Scan one with ${C.b}cd <dir> && seisin scan${C.off}\n`;
|
|
242
|
+
for (const p of paths.slice(0, 15)) nested += ` ${safe(p)}\n`;
|
|
243
|
+
if (paths.length > 15) nested += ` ${C.dim}… and ${paths.length - 15} more${C.off}\n`;
|
|
244
|
+
nested += "\n";
|
|
245
|
+
}
|
|
246
|
+
const named = result.named ?? [];
|
|
247
|
+
if (certain.length === 0 && review.length === 0 && links.length === 0 && named.length === 0) {
|
|
248
|
+
lines.push(skipped.nested
|
|
249
|
+
? ` nothing credential-shaped in what was scanned ${C.dim}(outside the declared directories)${C.off}\n\n`
|
|
250
|
+
: ` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`, nested);
|
|
184
251
|
return lines.join("");
|
|
185
252
|
}
|
|
186
253
|
|
|
187
254
|
if (certain.length) {
|
|
188
|
-
lines.push(` ${C.red}${certain.length} credential(s)${C.off} —
|
|
189
|
-
for (const h of certain)
|
|
255
|
+
lines.push(` ${C.red}${certain.length} credential(s)${C.off} — issued shapes, or a password in a URL: a credential, not a word that mentions one\n\n`);
|
|
256
|
+
for (const h of certain) {
|
|
257
|
+
const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
|
|
258
|
+
lines.push(` ${C.b}${safe(h.file)}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}${whose}\n`);
|
|
259
|
+
}
|
|
190
260
|
lines.push("\n");
|
|
191
261
|
}
|
|
192
262
|
|
|
@@ -200,6 +270,18 @@ export function renderScan(result, keyDirs) {
|
|
|
200
270
|
lines.push("\n");
|
|
201
271
|
}
|
|
202
272
|
|
|
273
|
+
// By name, not by content, and said that way: a .env of DEBUG=1 is on this
|
|
274
|
+
// list too. What it is for is the .env nobody declared.
|
|
275
|
+
if (named.length) {
|
|
276
|
+
lines.push(` ${C.yellow}${named.length} file(s) that usually hold secrets${C.off} — by their name, not certain; every role reads them\n\n`);
|
|
277
|
+
for (const h of named.slice(0, 15)) {
|
|
278
|
+
const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
|
|
279
|
+
lines.push(` ${C.b}${safe(h.file)}${C.off}${whose}\n`);
|
|
280
|
+
}
|
|
281
|
+
if (named.length > 15) lines.push(` ${C.dim}… and ${named.length - 15} more${C.off}\n`);
|
|
282
|
+
lines.push("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
203
285
|
// Links are their own finding, not a line with a secret-shaped name in it.
|
|
204
286
|
// What is reported is that the link leaves the repo — the destination is
|
|
205
287
|
// named and never opened, because the point is that it exists.
|
|
@@ -217,7 +299,9 @@ export function renderScan(result, keyDirs) {
|
|
|
217
299
|
if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
|
|
218
300
|
if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
|
|
219
301
|
if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
|
|
220
|
-
|
|
302
|
+
// Only the ones to review are capped; a certain hit is always listed above.
|
|
303
|
+
if (truncated) lines.push(` ${C.yellow}${result.omitted ?? "more"} more to review were not listed — the cap applies to review, never to certain hits${C.off}\n`);
|
|
304
|
+
if (nested) lines.push(nested);
|
|
221
305
|
lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
|
|
222
306
|
return lines.join("");
|
|
223
307
|
}
|