seisin 0.4.2 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/src/scan.js CHANGED
@@ -17,9 +17,10 @@
17
17
  * than no scanner: it converts a real risk into a wall of noise someone learns
18
18
  * to skip. Three things were wrong and all three are handled below.
19
19
  */
20
- import { readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
21
- import { join, relative, sep } from "node:path";
20
+ import { readdirSync, readFileSync, statSync, realpathSync, existsSync } from "node:fs";
21
+ import { join, relative, sep, dirname } from "node:path";
22
22
  import { covers } from "./owners.js";
23
+ import { toWritePath } from "./grants.js";
23
24
 
24
25
  /**
25
26
  * A match is `certain` when the shape alone is enough — these strings are
@@ -65,6 +66,29 @@ const PLACEHOLDER = /^(?:changeme|change_me|xxx+|your[-_a-z]*|example|placeholde
65
66
  * finding repeated five times, and three keys belonging to a company that is
66
67
  * not the one running the scan.
67
68
  */
69
+ /**
70
+ * A password inside a URL — `postgres://app:hunter2@db/prod`.
71
+ *
72
+ * The commonest way a database credential sits in a repo, and no issued shape
73
+ * covers it: `.env` with a DATABASE_URL scanned clean. Certain, because a URL
74
+ * with a password in it is a credential whatever the password is, unless the
75
+ * password is plainly a stand-in (`password`, `${DB_PASS}`, `<pass>`, `xxx`).
76
+ */
77
+ export const URL_CREDENTIAL = /\b[a-z][a-z0-9+.-]*:\/\/([^\s:@\/'"]+):([^\s@\/'"]+)@[^\s\/'"]+/i;
78
+ const PASSWORD_STANDIN = /^(?:pass(?:word)?|passwd|pwd|pw|secret|user|username)$|[{}$<>%]/i;
79
+
80
+ /**
81
+ * Files that usually hold secrets, by their name alone.
82
+ *
83
+ * Listed, never read for this — a name is not proof, and the list says so.
84
+ * A `.env` of `DEBUG=1` is a false alarm; a `.env` nobody declared that holds
85
+ * the production database is the most common credential there is, and a scan
86
+ * that only knows issued shapes calls it clean. `.example`, `.sample`,
87
+ * `.template` and `.dist` are the checked-in stand-ins and are left out.
88
+ */
89
+ export const SECRET_NAMES = /^(?:\.env(?:\..+)?|.+\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)|\.netrc|\.pgpass|credentials\.json|service-account.*\.json)$/i;
90
+ const STAND_IN_FILE = /\.(?:example|sample|template|dist|tmpl)$|\.(?:example|sample|template)\./i;
91
+
68
92
  export const DEFAULT_IGNORE = [
69
93
  "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**",
70
94
  "**/vendor/**", "**/__pycache__/**", "**/.venv/**", "**/venv/**", "**/coverage/**",
@@ -74,7 +98,7 @@ export const DEFAULT_IGNORE = [
74
98
  "**/*.min.js", "**/*.min.css", "**/*-lock.json", "**/*.lock",
75
99
  ];
76
100
 
77
- const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|bz2|xz|mp[34]|mov|wav|woff2?|ttf|otf|eot|so|dylib|a|o|class|jar|wasm|sqlite3?|db)$/i;
101
+ const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|bz2|xz|mp[34]|mov|wav|woff2?|ttf|otf|eot|so|dylib|a|o|class|jar|wasm|sqlite3?|db|pyc|pyo)$/i;
78
102
  const MAX_BYTES = 512 * 1024;
79
103
  // Written as an escape, not as the byte itself. A literal NUL in the source
80
104
  // makes git classify this file as binary, and a file with no readable diff
@@ -88,17 +112,66 @@ const NUL = "\u0000";
88
112
  * `protectedDirs` are the declared key directories: a hit inside one is the
89
113
  * system working, not a finding. `ignore` adds to DEFAULT_IGNORE rather than
90
114
  * replacing it, so a user's list stays short.
115
+ *
116
+ * `limit` caps the `review` lines and the links, never the `certain` ones.
117
+ * It used to cap all of them together, and the walk stopped at the cap: 500
118
+ * ordinary `PASSWORD =` lines early in the tree hid a `ghp_` token after them,
119
+ * `certain` came back empty and a CI job gated on it exited 0. A certain hit is
120
+ * the finding the command exists for, so the walk always finishes and every
121
+ * one of them is kept; what the cap drops is counted in `omitted`.
122
+ *
123
+ * ── Nested checkouts: pruned inside a repo, walked outside one ──
124
+ * When the root is inside a git repository, a directory below it with its own
125
+ * `.git` (a clone, a submodule, a worktree, an agent's work copy) is somebody
126
+ * else's repository, and is skipped whole: counted in `skipped.nested`, named
127
+ * in `nestedPaths`. Measured on a real tree: two such directories held 207k
128
+ * files, and scanning them took 145 s and 413 MB for findings that belong to
129
+ * somebody else's policy.
130
+ *
131
+ * When the root is NOT a repository, the same rule made the command blind: a
132
+ * folder that holds projects (a workspace) is nothing BUT nested
133
+ * checkouts, so every one of them was pruned, the scan read almost nothing
134
+ * and exited 0 with "nothing credential-shaped". There is no "somebody else"
135
+ * there — the checkouts are the tree — so they are walked. A tree of work
136
+ * copies that is too big to walk goes in `[scan] ignore`.
137
+ *
138
+ * Either way a pruned checkout is still readable by every role; the renderer
139
+ * says so in yellow and says how to scan it, so a pruned tree never reads as
140
+ * a clean one.
141
+ *
142
+ * `.gitignore` is deliberately NOT honoured (no `git ls-files`): the files a
143
+ * repository ignores are precisely where credentials sit — `.env`, a local
144
+ * config — and every role can read them all the same. Skipping them would
145
+ * make the command quiet about the case it was written for.
91
146
  */
92
- export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
93
- const safe = protectedDirs.map((d) => join(root, d));
147
+ export function scan(root, protectedDirs = [], ignore = [], limit = 500, { roots = null } = {}) {
148
+ // The real root, so a symlink's destination is compared like with like: on
149
+ // macOS a tree under /tmp is really under /private/tmp, and every link
150
+ // inside it pointed "out of the repo" until both sides were resolved.
151
+ try { root = realpathSync.native(root); } catch { /* scanned as written */ }
152
+ // `.secrets/` and `.secrets` are one directory. With the slash kept, the
153
+ // prefix test below never matched and the protected directory was scanned.
154
+ const safe = protectedDirs.map((d) => join(root, d.replace(/\/+$/, "") || "."));
94
155
  const patterns = [...DEFAULT_IGNORE, ...ignore];
95
156
  const hits = [];
96
- const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0 };
157
+ const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0, nested: 0 };
158
+ const nestedPaths = [];
159
+ // With `roots`, only those directories are walked, and a checkout inside one
160
+ // is another repository — pruned, unless it is itself one of the roots.
161
+ const walked = roots ? roots.map((r) => { try { return realpathSync.native(r); } catch { return r; } }) : null;
162
+ const prune = walked ? true : insideRepo(root);
163
+ let capped = 0; // review lines and links counted against `limit`
164
+ let omitted = 0; // … and the ones dropped because of it
165
+ const keep = (hit) => {
166
+ if (hit.level === "certain") return hits.push(hit);
167
+ if (capped >= limit) return omitted++;
168
+ capped++;
169
+ hits.push(hit);
170
+ };
97
171
 
98
172
  const isIgnored = (rel) => patterns.some((p) => covers(p, rel));
99
173
 
100
- (function walk(dir) {
101
- if (hits.length >= limit) return;
174
+ function walk(dir) {
102
175
  let entries;
103
176
  try {
104
177
  entries = readdirSync(dir, { withFileTypes: true });
@@ -106,7 +179,6 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
106
179
  return; // an unreadable directory is a permission, not a finding
107
180
  }
108
181
  for (const e of entries) {
109
- if (hits.length >= limit) return;
110
182
  const full = join(dir, e.name);
111
183
  const rel = relative(root, full);
112
184
 
@@ -129,19 +201,27 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
129
201
  if (e.isSymbolicLink()) {
130
202
  let real;
131
203
  try {
132
- real = realpathSync(full);
204
+ real = realpathSync.native(full);
133
205
  } catch {
134
206
  continue; // a broken link has nothing to leak
135
207
  }
136
208
  if (!real.startsWith(root + sep) && real !== root)
137
- hits.push({
138
- file: rel, line: 0, level: "link", shape: real,
139
- });
209
+ keep({ file: rel, line: 0, level: "link", shape: real });
140
210
  continue;
141
211
  }
142
212
 
143
- if (e.isDirectory()) { walk(full); continue; }
144
- if (!e.isFile() || SKIP_EXT.test(e.name)) continue;
213
+ if (e.isDirectory()) {
214
+ if (prune && existsSync(join(full, ".git"))) {
215
+ if (walked?.includes(full)) continue; // walked on its own
216
+ skipped.nested++; nestedPaths.push(rel); continue;
217
+ }
218
+ walk(full);
219
+ continue;
220
+ }
221
+ if (!e.isFile()) continue;
222
+ if (SECRET_NAMES.test(e.name) && !STAND_IN_FILE.test(e.name))
223
+ keep({ file: rel, line: 0, level: "named", shape: "usually holds secrets, by its name" });
224
+ if (SKIP_EXT.test(e.name)) continue;
145
225
 
146
226
  let text;
147
227
  try {
@@ -153,8 +233,14 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
153
233
  if (text.includes(NUL)) continue; // binary
154
234
 
155
235
  const lines = text.split(/\r?\n/);
156
- for (let i = 0; i < lines.length && hits.length < limit; i++) {
236
+ for (let i = 0; i < lines.length; i++) {
157
237
  const line = lines[i];
238
+ const url = URL_CREDENTIAL.exec(line);
239
+ if (url) {
240
+ const pw = url[2];
241
+ if (PASSWORD_STANDIN.test(pw) || PLACEHOLDER.test(pw) || /^[x*]+$/i.test(pw)) skipped.placeholder++;
242
+ else { keep({ file: rel, line: i + 1, shape: "password in a URL", level: "certain" }); continue; }
243
+ }
158
244
  for (const [level, shape, re] of SHAPES) {
159
245
  const m = re.exec(line);
160
246
  if (!m) continue;
@@ -164,12 +250,84 @@ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
164
250
  if (REFERENCE.test(line)) { skipped.reference++; break; }
165
251
  if (m[1] && PLACEHOLDER.test(m[1])) { skipped.placeholder++; break; }
166
252
  }
167
- hits.push({ file: rel, line: i + 1, shape, level });
253
+ keep({ file: rel, line: i + 1, shape, level });
168
254
  break; // one finding per line; the first shape is the most specific
169
255
  }
170
256
  }
171
257
  }
172
- })(root);
258
+ }
259
+ for (const r of walked ?? [root]) walk(r);
173
260
 
174
- return { hits, skipped, truncated: hits.length >= limit };
261
+ // A file with a certain finding is already listed with its line; naming it
262
+ // again as "usually holds secrets" says less about the same file.
263
+ const sure = new Set(hits.filter((h) => h.level === "certain").map((h) => h.file));
264
+ for (let i = hits.length - 1; i >= 0; i--) if (hits[i].level === "named" && sure.has(hits[i].file)) hits.splice(i, 1);
265
+
266
+ return { hits, skipped, nestedPaths, truncated: omitted > 0, omitted };
267
+ }
268
+
269
+ /**
270
+ * Whether `dir` is inside a git checkout: a `.git` (directory, or the file a
271
+ * worktree or submodule has) at `dir` or at any directory above it. Read from
272
+ * the filesystem, not from `git`, so a machine without git scans the same way.
273
+ */
274
+ export function insideRepo(dir) {
275
+ for (let d = dir; ; d = dirname(d)) {
276
+ if (existsSync(join(d, ".git"))) return true;
277
+ if (dirname(d) === d) return false;
278
+ }
175
279
  }
280
+
281
+ /**
282
+ * What `seisin scan` walks when the policy sits in a folder that is not a
283
+ * repository — the repositories its territories live in, whole.
284
+ *
285
+ * A policy at the root of one repository scans that repository, and that is
286
+ * right: a `.env` at its root is outside every territory and every role reads
287
+ * it. A policy at the root of a folder that holds many repositories (a
288
+ * workspace, a monorepo of clones) is a different shape. Walking the whole
289
+ * folder read everything the person running it ever kept there — archives, experiments,
290
+ * copies — measured at 155 s and 1,137 certain hits on one such folder, most of
291
+ * them in directories no agent works in. The question the command answers is
292
+ * "which credentials will an agent come across", and the agents work in their
293
+ * territories: so the scan follows the policy, and moves with it when the
294
+ * folder is reorganised. `--all` still walks everything.
295
+ *
296
+ * Each territory is widened to the repository that contains it, because a
297
+ * credential at a repository's root is read by an agent working anywhere in it.
298
+ * A territory outside any repository is walked as itself. Territories outside
299
+ * the policy's folder are left out: they are another folder's to scan.
300
+ */
301
+ export function territoryRoots(config) {
302
+ const root = config.root;
303
+ const inside = (p) => p === root || p.startsWith(root + sep);
304
+ const found = new Set();
305
+ for (const role of Object.values(config.roles)) {
306
+ for (const glob of role.writes ?? []) {
307
+ let p;
308
+ try { p = join(root, toWritePath(glob)); } catch { continue; }
309
+ if (!inside(p)) continue;
310
+ found.add(repositoryOf(p, root));
311
+ }
312
+ }
313
+ // A plain folder inside another root is walked already; a checkout is not
314
+ // (the walk prunes checkouts), so it stays a root of its own.
315
+ const all = [...found];
316
+ return all.filter((r) => r === root || existsSync(join(r, ".git")) ||
317
+ !all.some((o) => o !== r && (o === root || r.startsWith(o + sep))));
318
+ }
319
+
320
+ /** The nearest checkout at or above `p`, stopping below `root`; else the territory itself. */
321
+ function repositoryOf(p, root) {
322
+ let d = p;
323
+ while (d !== root && !isDir(d)) d = dirname(d); // a file, or not created yet
324
+ const territory = d;
325
+ for (let at = d; at !== root && at.startsWith(root + sep); at = dirname(at))
326
+ if (existsSync(join(at, ".git"))) return at;
327
+ return territory;
328
+ }
329
+
330
+ function isDir(p) {
331
+ try { return statSync(p).isDirectory(); } catch { return false; }
332
+ }
333
+