seisin 0.4.0 → 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.
- package/README.md +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1366 -529
package/src/review.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Three questions, and the second is the one nothing else can answer.
|
|
11
11
|
*
|
|
12
|
-
* 1. What is a role being
|
|
12
|
+
* 1. What is a role being denied, over and over?
|
|
13
13
|
* Forty denials on one directory is not an agent misbehaving. It is a
|
|
14
14
|
* policy that is wrong, and today it reads as forty tidy amber lines
|
|
15
15
|
* nobody adds up.
|
|
16
16
|
*
|
|
17
17
|
* 2. What was granted and never used?
|
|
18
|
-
* Every
|
|
18
|
+
* Every allowlist only ever grows, everywhere, and the reason is
|
|
19
19
|
* always the same: nobody can prove a line is dead, so the safe move is to
|
|
20
20
|
* leave it. Here the log can prove it. This is the only direction that
|
|
21
21
|
* makes a policy smaller.
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
* means nothing without "…in how long".
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
|
-
import { covers,
|
|
31
|
+
import { covers, standingOf } from "./owners.js";
|
|
32
|
+
import { kindsOf } from "./kinds.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* One reading of the log against the policy.
|
|
@@ -103,11 +104,11 @@ function findings(config, entries, minDenials) {
|
|
|
103
104
|
* that refusal is the whole of the second finding.
|
|
104
105
|
*
|
|
105
106
|
* "Never used" is read off `allowed` lines, and **only the hook writes those**.
|
|
106
|
-
* The kernel reports what it
|
|
107
|
+
* The kernel reports what it denied; it has nothing to say about what went
|
|
107
108
|
* through. So a log with no hook behind it holds denials and nothing else,
|
|
108
109
|
* every grant falls through as unused, and this section reports that the whole
|
|
109
110
|
* policy is dead — under a heading calling itself the only evidence anyone
|
|
110
|
-
* will ever have for making a
|
|
111
|
+
* will ever have for making a policy smaller.
|
|
111
112
|
*
|
|
112
113
|
* Measured on a real repository with the hook not installed: every write
|
|
113
114
|
* permission of every role listed as never used, while those roles were
|
|
@@ -122,18 +123,27 @@ function findings(config, entries, minDenials) {
|
|
|
122
123
|
* than full — a consumer that has never heard of `unusedKnowable` then reports
|
|
123
124
|
* nothing instead of everything, which is the direction this has to fail in.
|
|
124
125
|
*/
|
|
125
|
-
|
|
126
|
+
// A disputed line is the parent saying the verdict did not match the policy
|
|
127
|
+
// — often a forged line. It is not evidence of legitimate use, so it neither
|
|
128
|
+
// makes the window knowable nor marks a territory used.
|
|
129
|
+
const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
|
|
126
130
|
|
|
127
131
|
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
128
|
-
// hold a credential for the one
|
|
132
|
+
// hold a credential for the one run a month that needs it — but a folder a
|
|
129
133
|
// role owns and has never written to in the whole log is a line nobody would
|
|
130
134
|
// miss, and the only evidence anyone will ever have for removing it.
|
|
135
|
+
// Straight to the line's own role, and each (role, target) once: walking
|
|
136
|
+
// every role's globs for every line to keep only one role's was the cost.
|
|
131
137
|
const used = new Set();
|
|
138
|
+
const seen = new Set();
|
|
132
139
|
for (const e of entries) {
|
|
133
|
-
if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
|
|
141
|
+
if (!Object.prototype.hasOwnProperty.call(config.roles, e.role)) continue;
|
|
142
|
+
const k = `${e.role}\u0000${e.target}`;
|
|
143
|
+
if (seen.has(k)) continue;
|
|
144
|
+
seen.add(k);
|
|
145
|
+
for (const glob of config.roles[e.role].writes)
|
|
146
|
+
if (covers(glob, e.target)) used.add(`${e.role} ${glob}`);
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
const unused = [];
|
|
@@ -144,14 +154,27 @@ function findings(config, entries, minDenials) {
|
|
|
144
154
|
|
|
145
155
|
/* ── 3. what nobody owns ────────────────────────────────────────────── */
|
|
146
156
|
|
|
157
|
+
// The same classification the console and `seisin_causes` use, so the word
|
|
158
|
+
// means one thing everywhere. A key directory is closed on purpose, a port is
|
|
159
|
+
// not a path, a protected file is closed to every role and a path outside
|
|
160
|
+
// the repository is outside every territory: none of them is a hole in the
|
|
161
|
+
// map. This used to skip keys and ports and count the rest, protected ones
|
|
162
|
+
// included, while the console counted all four — one word, two numbers.
|
|
163
|
+
//
|
|
164
|
+
// Each row also says what kind of thing it is (kinds.js), by directory and
|
|
165
|
+
// kind together: `.git` holding a lock and a folder holding a source file
|
|
166
|
+
// are different answers, and only `territory` is a hole somebody has to fill.
|
|
167
|
+
const standOf = standingOf(config);
|
|
168
|
+
const loose = entries.filter((e) => e.target && standOf(e).kind === "unowned");
|
|
169
|
+
const natures = kindsOf(loose.map((e) => e.target), { keyDirs: config.keyDirs ?? [] });
|
|
147
170
|
const unowned = new Map();
|
|
148
|
-
for (const e of
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
unowned.set(
|
|
171
|
+
for (const e of loose) {
|
|
172
|
+
const { kind, hint } = natures.get(e.target);
|
|
173
|
+
const where = dirOf(e.target);
|
|
174
|
+
const k = `${kind}\u0000${where}`;
|
|
175
|
+
const row = unowned.get(k) ?? { where, times: 0, kind, hint };
|
|
176
|
+
row.times++;
|
|
177
|
+
unowned.set(k, row);
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
return {
|
|
@@ -160,9 +183,7 @@ function findings(config, entries, minDenials) {
|
|
|
160
183
|
connects,
|
|
161
184
|
unused,
|
|
162
185
|
unusedKnowable: knowable,
|
|
163
|
-
unowned: [...unowned.
|
|
164
|
-
.map(([where, times]) => ({ where, times }))
|
|
165
|
-
.sort((a, b) => b.times - a.times),
|
|
186
|
+
unowned: [...unowned.values()].sort((a, b) => b.times - a.times),
|
|
166
187
|
};
|
|
167
188
|
}
|
|
168
189
|
|
package/src/rundir.js
CHANGED
|
@@ -25,6 +25,7 @@ import { tmpdir } from "node:os";
|
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
26
26
|
import { realOrSelf } from "./grants.js";
|
|
27
27
|
import { channelName } from "./spool.js";
|
|
28
|
+
import { alive } from "./log.js";
|
|
28
29
|
|
|
29
30
|
export const RUNS_NAME = "snr";
|
|
30
31
|
|
|
@@ -48,11 +49,6 @@ export function runsRoot(base = tmpdir()) {
|
|
|
48
49
|
return root;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/** Is the process that owns a run directory still alive? */
|
|
52
|
-
function alive(pid) {
|
|
53
|
-
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
/**
|
|
57
53
|
* Removes the directories of runs whose process is gone.
|
|
58
54
|
*
|
|
@@ -66,6 +62,8 @@ function sweep(root) {
|
|
|
66
62
|
const dir = join(root, name);
|
|
67
63
|
let pid;
|
|
68
64
|
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
65
|
+
// Only a process the kernel says is gone: a directory kept one sweep too
|
|
66
|
+
// long costs disk, one removed early takes a live run's socket and keys.
|
|
69
67
|
if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
|
|
70
68
|
}
|
|
71
69
|
}
|
|
@@ -87,7 +85,7 @@ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
|
87
85
|
dir,
|
|
88
86
|
root,
|
|
89
87
|
// A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
|
|
90
|
-
// socket inside the
|
|
88
|
+
// socket inside the sandbox (spool.js).
|
|
91
89
|
sock: join(dir, channelName()),
|
|
92
90
|
keys: join(dir, "keys"),
|
|
93
91
|
/**
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()) {
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
258
|
+
}
|
|
259
|
+
for (const r of walked ?? [root]) walk(r);
|
|
173
260
|
|
|
174
|
-
|
|
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
|
+
|