sealkeep 0.11.6 → 0.11.7

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.
@@ -14,23 +14,410 @@
14
14
  * the same bytes always produce the same findings, which is what lets the
15
15
  * exit code gate a script.
16
16
  *
17
- * Phase 2 — integration points, deliberately not implemented here:
17
+ * Seal-time scanning runs on every new archive — local, delta, MCP, and both
18
+ * streamed layouts — when the machine policy is `record` or `block-high`.
19
+ * `off` (the default) does not scan. Medium findings stay advisory.
20
+ * `block-high` refuses the seal before encryption or upload. A transcript
21
+ * that changes during the scan is always refused. Any other scanner failure
22
+ * refuses the seal when `block-high` is on or `secretScanFailClosed` is set;
23
+ * otherwise the seal continues and records no scan, so an empty finding list
24
+ * always means the bytes were actually read.
18
25
  *
19
- * (a) Worker seal path: worker.ts calls scanFileStreaming(job.sourcePath)
20
- * just before sealing and stores the findings — kind, severity, line,
21
- * masked preview — as metadata beside the archive record, so "which
22
- * archives hold live credentials?" is answerable without opening any
23
- * of them.
24
- * (b) Org rollup: the control plane receives kind/severity/counts only —
25
- * never content, never lines, never previews. Because only this machine
26
- * ever holds plaintext, an org can learn *that* a credential was sealed
27
- * and *what sort*, but the credential itself cannot travel.
26
+ * Archive metadata stores detector kind, severity, count, and line numbers.
27
+ * Matched text and previews stay in the live `sealkeep scan` report only.
28
+ * An organization rollup, sent only with per-machine consent, carries kind,
29
+ * severity, and count. It never carries transcript text, matched values,
30
+ * paths, or line numbers.
28
31
  */
29
- import { createReadStream } from "node:fs";
30
- import { readdir, stat } from "node:fs/promises";
32
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
33
+ import { createReadStream, createWriteStream } from "node:fs";
34
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
35
+ import { once } from "node:events";
31
36
  import { join } from "node:path";
32
37
  import { createInterface } from "node:readline";
33
- import { fail } from "./errors.js";
38
+ import { Worker } from "node:worker_threads";
39
+ import { recordAudit } from "./audit.js";
40
+ import { signedControlPlaneFetch } from "./control-plane/auth.js";
41
+ import { fail, isSealkeepError } from "./errors.js";
42
+ import { readLocalSettings } from "./machine-settings.js";
43
+ const BUILTIN_KINDS = new Set([
44
+ "aws-access-key-id", "aws-secret-access-key", "github-token", "google-api-key",
45
+ "slack-token", "private-key", "jwt", "high-entropy-value"
46
+ ]);
47
+ const KIND_PATTERN = /^[a-z][a-z0-9-]{1,40}$/;
48
+ function literalRun(source) {
49
+ let best = 0;
50
+ let run = 0;
51
+ let escaped = false;
52
+ let inClass = false;
53
+ for (const character of source) {
54
+ if (escaped) {
55
+ escaped = false;
56
+ if (!inClass && /[A-Za-z0-9]/.test(character)) {
57
+ run += 1;
58
+ best = Math.max(best, run);
59
+ }
60
+ else
61
+ run = 0;
62
+ continue;
63
+ }
64
+ if (character === "\\") {
65
+ escaped = true;
66
+ continue;
67
+ }
68
+ if (character === "[") {
69
+ inClass = true;
70
+ run = 0;
71
+ continue;
72
+ }
73
+ if (character === "]" && inClass) {
74
+ inClass = false;
75
+ run = 0;
76
+ continue;
77
+ }
78
+ if (inClass || !/[A-Za-z0-9]/.test(character)) {
79
+ run = 0;
80
+ continue;
81
+ }
82
+ run += 1;
83
+ best = Math.max(best, run);
84
+ }
85
+ return best;
86
+ }
87
+ /**
88
+ * Custom patterns are a bounded subset of regular expressions. Alternation,
89
+ * unbounded quantifiers, lookaround, and backreferences are rejected because
90
+ * JavaScript's matcher can take exponential time on them. A fixed bound such
91
+ * as `{8}` or `{1,16}` is allowed.
92
+ */
93
+ function assertBoundedPattern(source) {
94
+ let escaped = false;
95
+ let inClass = false;
96
+ for (let index = 0; index < source.length; index += 1) {
97
+ const character = source[index];
98
+ if (escaped) {
99
+ escaped = false;
100
+ continue;
101
+ }
102
+ if (character === "\\") {
103
+ const next = source[index + 1];
104
+ if (next && /[1-9]/.test(next))
105
+ fail("invalid_argument", "Custom patterns cannot use backreferences.");
106
+ escaped = true;
107
+ continue;
108
+ }
109
+ if (character === "[") {
110
+ inClass = true;
111
+ continue;
112
+ }
113
+ if (character === "]" && inClass) {
114
+ inClass = false;
115
+ continue;
116
+ }
117
+ if (inClass)
118
+ continue;
119
+ if (character === "|")
120
+ fail("invalid_argument", "Custom patterns cannot use alternation.");
121
+ if (character === "*" || character === "+" || character === "?") {
122
+ fail("invalid_argument", "Custom patterns cannot use unbounded quantifiers. Use a fixed bound such as {1,16}.");
123
+ }
124
+ if (source.startsWith("(?", index) && source[index + 2] !== ":") {
125
+ fail("invalid_argument", "Custom patterns cannot use lookaround or other special groups.");
126
+ }
127
+ if (character === "{") {
128
+ const close = source.indexOf("}", index);
129
+ const body = close > index ? source.slice(index + 1, close) : "";
130
+ const match = /^(\d+)(?:,(\d+))?$/.exec(body);
131
+ if (!match || (match[2] === undefined && body.includes(","))) {
132
+ fail("invalid_argument", "Custom patterns cannot use an open-ended quantifier.");
133
+ }
134
+ const low = Number(match[1]);
135
+ const high = match[2] === undefined ? low : Number(match[2]);
136
+ if (high < low || high - low > 32 || high > 200) {
137
+ fail("invalid_argument", "A custom quantifier must be a closed range no wider than 32 and no longer than 200.");
138
+ }
139
+ index = close;
140
+ }
141
+ }
142
+ }
143
+ /** Reject patterns that match everything, fail to compile, or can backtrack without bound. */
144
+ export function validateCustomPattern(input) {
145
+ const kind = input.kind.trim();
146
+ const source = input.source;
147
+ if (!KIND_PATTERN.test(kind) || BUILTIN_KINDS.has(kind)) {
148
+ fail("invalid_argument", "A custom pattern kind must be 2–41 lowercase letters, digits, or hyphens, and must not reuse a built-in detector name.");
149
+ }
150
+ if (input.severity !== "high" && input.severity !== "medium")
151
+ fail("invalid_argument", "Pattern severity must be high or medium.");
152
+ if (source.length < 4 || source.length > 200)
153
+ fail("invalid_argument", "A custom pattern must be between 4 and 200 characters.");
154
+ assertBoundedPattern(source);
155
+ if (literalRun(source) < 4)
156
+ fail("invalid_argument", "A custom pattern needs at least four consecutive letters or digits so it cannot match ordinary text.");
157
+ let compiled;
158
+ try {
159
+ compiled = new RegExp(source, "d");
160
+ }
161
+ catch {
162
+ fail("invalid_argument", "That pattern is not a valid regular expression.");
163
+ }
164
+ if (compiled.test(""))
165
+ fail("invalid_argument", "A custom pattern must not match the empty string.");
166
+ return { kind, severity: input.severity, source };
167
+ }
168
+ const CUSTOM_LINE_MS = 50;
169
+ /** Worker startup is once per scan and can be slow on a busy machine. It is not the per-line deadline. */
170
+ const CUSTOM_START_MS = 5_000;
171
+ function openCustomMatcher(patterns) {
172
+ const worker = new Worker(`
173
+ import { parentPort } from "node:worker_threads";
174
+ const compiled = [];
175
+ parentPort.on("message", (message) => {
176
+ if (message.patterns) {
177
+ compiled.length = 0;
178
+ for (const pattern of message.patterns) compiled.push({ ...pattern, expression: new RegExp(pattern.source, "dg") });
179
+ parentPort.postMessage({ ready: true });
180
+ return;
181
+ }
182
+ const found = [];
183
+ for (const pattern of compiled) {
184
+ for (const match of message.line.matchAll(pattern.expression)) {
185
+ const span = match.indices && match.indices[0];
186
+ if (span) found.push({ kind: pattern.kind, severity: pattern.severity, start: span[0], end: span[1] });
187
+ }
188
+ }
189
+ parentPort.postMessage(found);
190
+ });
191
+ `, { eval: true, execArgv: [] });
192
+ worker.postMessage({ patterns });
193
+ return worker;
194
+ }
195
+ /** Runs custom detectors off the seal thread and stops them if one line runs long. */
196
+ export async function matchCustomLine(line, patterns, timeoutMs = CUSTOM_LINE_MS) {
197
+ if (patterns.length === 0)
198
+ return [];
199
+ const worker = openCustomMatcher(patterns);
200
+ try {
201
+ return await new Promise((resolve, reject) => {
202
+ let timer = setTimeout(() => reject(new Error("custom pattern timed out")), CUSTOM_START_MS);
203
+ const onMessage = (value) => {
204
+ if (!Array.isArray(value)) {
205
+ clearTimeout(timer);
206
+ timer = setTimeout(() => reject(new Error("custom pattern timed out")), timeoutMs);
207
+ worker.postMessage({ line });
208
+ return;
209
+ }
210
+ clearTimeout(timer);
211
+ worker.off("message", onMessage);
212
+ resolve(value);
213
+ };
214
+ worker.on("message", onMessage);
215
+ worker.once("error", (error) => { clearTimeout(timer); reject(error); });
216
+ });
217
+ }
218
+ finally {
219
+ await worker.terminate();
220
+ }
221
+ }
222
+ function compileCustom(patterns) {
223
+ return patterns.map((pattern) => {
224
+ const checked = validateCustomPattern(pattern);
225
+ return { kind: checked.kind, pattern: new RegExp(checked.source, "dg"), severity: checked.severity };
226
+ });
227
+ }
228
+ export function findingsMetadata(findings, policy) {
229
+ const groups = new Map();
230
+ for (const finding of findings) {
231
+ const key = `${finding.kind}\0${finding.severity}`;
232
+ const group = groups.get(key) ?? { kind: finding.kind, severity: finding.severity, count: 0, lines: [] };
233
+ group.count += 1;
234
+ if (group.lines.length < 32 && !group.lines.includes(finding.line))
235
+ group.lines.push(finding.line);
236
+ groups.set(key, group);
237
+ }
238
+ return { version: 1, scannedAt: new Date().toISOString(), policy, findings: [...groups.values()] };
239
+ }
240
+ export function orgRollupBody(archiveId, scan) {
241
+ return {
242
+ version: 1,
243
+ archiveId,
244
+ scannedAt: scan.scannedAt,
245
+ counts: scan.findings.map(({ kind, severity, count }) => ({ kind, severity, count }))
246
+ };
247
+ }
248
+ /** Removes a scanned snapshot. Safe to call when the scan did not take one. */
249
+ export async function discardScanSnapshot(scan) {
250
+ if (scan?.snapshotPath)
251
+ await rm(scan.snapshotPath, { force: true });
252
+ }
253
+ const snapshotName = /^[0-9a-f]+\.(\d+)\.snap$/;
254
+ function processAlive(pid) {
255
+ if (!Number.isInteger(pid) || pid <= 0)
256
+ return false;
257
+ try {
258
+ process.kill(pid, 0);
259
+ return true;
260
+ }
261
+ catch (error) {
262
+ return error.code === "EPERM";
263
+ }
264
+ }
265
+ /**
266
+ * A crash after the snapshot is written leaves plaintext in scan-snapshots.
267
+ * The name records the creating pid. Startup and the next scanned seal remove
268
+ * a file only when that process is gone, so a live seal keeps its copy.
269
+ */
270
+ export async function sweepStaleScanSnapshots(dataDir) {
271
+ const names = await readdir(join(dataDir, "scan-snapshots")).catch(() => []);
272
+ for (const name of names) {
273
+ const match = snapshotName.exec(name);
274
+ if (!match)
275
+ continue;
276
+ const pid = Number(match[1]);
277
+ if (pid === process.pid || processAlive(pid))
278
+ continue;
279
+ await rm(join(dataDir, "scan-snapshots", name), { force: true }).catch(() => undefined);
280
+ }
281
+ }
282
+ /**
283
+ * Set by tests to change the file after the scan has accepted it and before
284
+ * the seal reads it again. Production leaves this unset.
285
+ */
286
+ export const sealScanTestHooks = {};
287
+ /**
288
+ * Scan before encryption or upload. Returns metadata and the digest of the
289
+ * exact byte range the seal will read, or undefined when the policy is off
290
+ * or a fail-open scanner error skipped it. Throws before any archive exists
291
+ * when the seal must not proceed.
292
+ */
293
+ export async function scanBeforeSeal(dataDir, path, signal, hooks, byteLength, options) {
294
+ const settings = await readLocalSettings(dataDir);
295
+ if (settings.secretScanPolicy === "off")
296
+ return undefined;
297
+ const policy = settings.secretScanPolicy;
298
+ signal?.throwIfAborted();
299
+ const before = await stat(path);
300
+ const end = byteLength ?? before.size;
301
+ const snapshotDir = join(dataDir, "scan-snapshots");
302
+ if (options?.snapshot)
303
+ await sweepStaleScanSnapshots(dataDir);
304
+ const snapshotPath = options?.snapshot ? join(snapshotDir, `${randomBytes(9).toString("hex")}.${process.pid}.snap`) : undefined;
305
+ let snapshot;
306
+ if (snapshotPath) {
307
+ await mkdir(snapshotDir, { recursive: true, mode: 0o700 });
308
+ snapshot = createWriteStream(snapshotPath, { mode: 0o600 });
309
+ }
310
+ const discard = async () => {
311
+ snapshot?.destroy();
312
+ if (snapshotPath)
313
+ await rm(snapshotPath, { force: true });
314
+ };
315
+ let scanned;
316
+ try {
317
+ scanned = await scanByteRange(path, end, settings.secretScanPatterns, signal, snapshot);
318
+ if (snapshot) {
319
+ snapshot.end();
320
+ await once(snapshot, "finish");
321
+ }
322
+ await hooks?.afterRead?.();
323
+ }
324
+ catch (error) {
325
+ await discard();
326
+ if (isSealkeepError(error) || (error instanceof Error && error.name === "AbortError"))
327
+ throw error;
328
+ if (policy === "block-high" || settings.secretScanFailClosed) {
329
+ fail("secret_scan_failed", "Seal stopped: the secret scan could not finish, and this machine fails closed.", { sourcePath: path });
330
+ }
331
+ return undefined;
332
+ }
333
+ try {
334
+ signal?.throwIfAborted();
335
+ const after = await stat(path).catch(() => fail("source_unreadable", "Transcript disappeared while secret scanning.", { sourcePath: path }));
336
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino) {
337
+ fail("source_unreadable", "Transcript changed while secret scanning; retry the seal after the agent pauses.", { sourcePath: path });
338
+ }
339
+ const high = scanned.findings.filter((finding) => finding.severity === "high");
340
+ if (policy === "block-high" && high.length > 0) {
341
+ const kinds = [...new Set(high.map((finding) => finding.kind))];
342
+ fail("secret_scan_blocked", `Seal blocked: secret scan found ${high.length} high-confidence finding${high.length === 1 ? "" : "s"} (${kinds.slice(0, 5).join(", ")}). Review with “sealkeep scan <transcript>” and remove or rotate any real credentials.`, { count: high.length, kinds });
343
+ }
344
+ await sealScanTestHooks.afterAccepted?.(path);
345
+ return { metadata: findingsMetadata(scanned.findings, policy), sha256: scanned.sha256, ...(snapshotPath ? { snapshotPath } : {}) };
346
+ }
347
+ catch (error) {
348
+ await discard();
349
+ throw error;
350
+ }
351
+ }
352
+ /** Refuse to publish a seal whose plaintext digest is not the digest that was scanned. */
353
+ export function assertScanCoversSeal(scan, sealedSha256, sourcePath) {
354
+ if (!scan)
355
+ return;
356
+ const left = Buffer.from(scan.sha256, "hex");
357
+ const right = Buffer.from(sealedSha256, "hex");
358
+ if (left.length !== right.length || !timingSafeEqual(left, right)) {
359
+ fail("source_unreadable", "Transcript changed after the secret scan; retry the seal after the agent pauses.", { sourcePath });
360
+ }
361
+ }
362
+ /** @deprecated Use scanBeforeSeal. Kept so older call sites keep blocking. */
363
+ export async function assertSecretScanPasses(dataDir, path, signal) {
364
+ await scanBeforeSeal(dataDir, path, signal);
365
+ }
366
+ const controlPlaneDevicePath = (dataDir) => join(dataDir, "control-plane-device.json");
367
+ export async function readControlPlaneDevice(dataDir) {
368
+ try {
369
+ const parsed = JSON.parse(await readFile(controlPlaneDevicePath(dataDir), "utf8"));
370
+ if (typeof parsed.deviceId !== "string" || typeof parsed.privateKeyPem !== "string")
371
+ return null;
372
+ return { deviceId: parsed.deviceId, privateKeyPem: parsed.privateKeyPem };
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
378
+ export async function writeControlPlaneDevice(dataDir, device) {
379
+ await writeFile(controlPlaneDevicePath(dataDir), JSON.stringify(device) + "\n", { mode: 0o600 });
380
+ }
381
+ /** Best-effort delivery after a durable seal. A network failure never rolls the archive back. */
382
+ export async function deliverOrgRollup(dataDir, archiveId, scan) {
383
+ if (!scan)
384
+ return;
385
+ const settings = await readLocalSettings(dataDir);
386
+ if (!settings.secretScanOrgRollup || !settings.secretScanControlPlaneUrl)
387
+ return;
388
+ const body = orgRollupBody(archiveId, scan);
389
+ const device = await readControlPlaneDevice(dataDir);
390
+ const counts = body.counts.reduce((total, item) => total + item.count, 0);
391
+ if (!device) {
392
+ await recordAudit(dataDir, "secret_scan.rollup", "denied", { archiveId, status: 401, counts }).catch(() => undefined);
393
+ return;
394
+ }
395
+ try {
396
+ const response = await signedControlPlaneFetch({
397
+ origin: settings.secretScanControlPlaneUrl,
398
+ method: "POST",
399
+ path: "/v1/secret-scan/rollups",
400
+ deviceId: device.deviceId,
401
+ privateKey: device.privateKeyPem,
402
+ body: JSON.stringify(body)
403
+ });
404
+ if (!response.ok) {
405
+ await recordAudit(dataDir, "secret_scan.rollup", "denied", { archiveId, status: response.status, counts }).catch(() => undefined);
406
+ }
407
+ }
408
+ catch {
409
+ await recordAudit(dataDir, "secret_scan.rollup", "denied", { archiveId, status: 0, counts }).catch(() => undefined);
410
+ }
411
+ }
412
+ /** What may move between machines. Consent and the control-plane URL stay local. */
413
+ export function sharedScanPolicy(settings) {
414
+ return {
415
+ version: 1,
416
+ secretScanPolicy: settings.secretScanPolicy,
417
+ secretScanFailClosed: settings.secretScanFailClosed,
418
+ secretScanPatterns: settings.secretScanPatterns.map((pattern) => validateCustomPattern(pattern))
419
+ };
420
+ }
34
421
  /**
35
422
  * First four characters, then only the length. A leak report that repeats the
36
423
  * leak is a second leak: findings travel further than the transcript did —
@@ -122,10 +509,10 @@ const BENIGN_VALUE_SHAPES = [
122
509
  /^AAAA/, // base64 of leading zero bytes: well-known placeholders and ssh key headers
123
510
  /^(?:vlt|vault)_/i // Sealkeep's own refs (vlt_…, vault_ref ids), which are public by design
124
511
  ];
125
- function scanLine(line, lineNumber) {
512
+ function scanLine(line, lineNumber, extra = []) {
126
513
  const findings = [];
127
514
  const claimed = [];
128
- for (const detector of PATTERN_DETECTORS) {
515
+ for (const detector of [...PATTERN_DETECTORS, ...extra]) {
129
516
  for (const match of line.matchAll(detector.pattern)) {
130
517
  const span = match.indices?.[detector.group ?? 0];
131
518
  if (!span)
@@ -134,7 +521,7 @@ function scanLine(line, lineNumber) {
134
521
  if (detector.validate && !detector.validate(secret))
135
522
  continue;
136
523
  claimed.push(span);
137
- findings.push({ kind: detector.kind, line: lineNumber, column: span[0] + 1, preview: maskSecret(secret), severity: "high" });
524
+ findings.push({ kind: detector.kind, line: lineNumber, column: span[0] + 1, preview: maskSecret(secret), severity: detector.severity ?? "high" });
138
525
  }
139
526
  }
140
527
  for (const match of line.matchAll(GENERIC_ASSIGNMENT)) {
@@ -153,8 +540,9 @@ function scanLine(line, lineNumber) {
153
540
  }
154
541
  return findings.sort((a, b) => a.column - b.column || a.kind.localeCompare(b.kind));
155
542
  }
156
- export function scanText(text) {
157
- return text.split(/\r?\n/).flatMap((line, index) => scanLine(line, index + 1));
543
+ export function scanText(text, patterns = []) {
544
+ const extra = patterns.length ? compileCustom(patterns) : [];
545
+ return text.split(/\r?\n/).flatMap((line, index) => scanLine(line, index + 1, extra));
158
546
  }
159
547
  /**
160
548
  * Line-by-line over a read stream: memory is bounded by the longest single
@@ -162,7 +550,75 @@ export function scanText(text) {
162
550
  * a small one. An unreadable input rejects — a scanner that swallows a read
163
551
  * error would be reporting "clean" about bytes it never saw.
164
552
  */
165
- export async function scanFileStreaming(path, onFinding) {
553
+ /** Hash and scan exactly `[0, end)`, the range a seal will read. */
554
+ async function scanByteRange(path, end, patterns, signal, snapshot) {
555
+ const hash = createHash("sha256");
556
+ const decoder = new TextDecoder("utf8");
557
+ const findings = [];
558
+ let pending = "";
559
+ let lineNumber = 0;
560
+ const matcher = patterns.length > 0 ? openCustomMatcher(patterns) : null;
561
+ const consume = async (line) => {
562
+ lineNumber += 1;
563
+ for (const finding of scanLine(line, lineNumber))
564
+ findings.push(finding);
565
+ if (!matcher)
566
+ return;
567
+ const custom = await new Promise((resolve, reject) => {
568
+ const timer = setTimeout(() => reject(new Error("custom pattern timed out")), CUSTOM_LINE_MS);
569
+ const onMessage = (value) => {
570
+ if (!Array.isArray(value))
571
+ return;
572
+ clearTimeout(timer);
573
+ matcher.off("message", onMessage);
574
+ resolve(value);
575
+ };
576
+ matcher.on("message", onMessage);
577
+ matcher.postMessage({ line });
578
+ });
579
+ for (const hit of custom) {
580
+ const secret = line.slice(hit.start, hit.end);
581
+ findings.push({ kind: hit.kind, line: lineNumber, column: hit.start + 1, preview: maskSecret(secret), severity: hit.severity });
582
+ }
583
+ };
584
+ try {
585
+ if (matcher) {
586
+ await new Promise((resolve, reject) => {
587
+ const timer = setTimeout(() => reject(new Error("custom pattern timed out")), CUSTOM_START_MS);
588
+ matcher.once("message", () => { clearTimeout(timer); resolve(); });
589
+ matcher.once("error", (error) => { clearTimeout(timer); reject(error); });
590
+ });
591
+ }
592
+ if (end > 0) {
593
+ const input = createReadStream(path, { start: 0, end: end - 1, signal });
594
+ for await (const chunk of input) {
595
+ signal?.throwIfAborted();
596
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
597
+ hash.update(bytes);
598
+ if (snapshot && !snapshot.write(bytes))
599
+ await once(snapshot, "drain");
600
+ pending += decoder.decode(bytes, { stream: true });
601
+ let newline = pending.indexOf("\n");
602
+ while (newline >= 0) {
603
+ let line = pending.slice(0, newline);
604
+ pending = pending.slice(newline + 1);
605
+ if (line.endsWith("\r"))
606
+ line = line.slice(0, -1);
607
+ await consume(line);
608
+ newline = pending.indexOf("\n");
609
+ }
610
+ }
611
+ }
612
+ pending += decoder.decode();
613
+ if (pending.length > 0)
614
+ await consume(pending);
615
+ return { findings, sha256: hash.digest("hex") };
616
+ }
617
+ finally {
618
+ await matcher?.terminate();
619
+ }
620
+ }
621
+ export async function scanFileStreaming(path, onFinding, extra = []) {
166
622
  const input = createReadStream(path, { encoding: "utf8" });
167
623
  const lines = createInterface({ input, crlfDelay: Infinity });
168
624
  const findings = [];
@@ -170,7 +626,7 @@ export async function scanFileStreaming(path, onFinding) {
170
626
  try {
171
627
  for await (const line of lines) {
172
628
  lineNumber += 1;
173
- for (const finding of scanLine(line, lineNumber)) {
629
+ for (const finding of scanLine(line, lineNumber, extra)) {
174
630
  findings.push(finding);
175
631
  onFinding?.(finding);
176
632
  }
@@ -205,16 +661,18 @@ async function jsonlFiles(dir) {
205
661
  * over a "medium" suspicion is exactly the cry-wolf behaviour the severity
206
662
  * split exists to avoid.
207
663
  */
208
- export async function scanPath(target, onFinding) {
664
+ export async function scanPath(target, onFinding, patterns = []) {
209
665
  const info = await stat(target).catch(() => fail("source_unreadable", `Cannot read ${target}`));
210
666
  const files = info.isDirectory() ? await jsonlFiles(target) : [target];
211
667
  const findings = [];
212
668
  for (const file of files) {
213
- await scanFileStreaming(file, (finding) => {
669
+ const info = await stat(file);
670
+ const scanned = await scanByteRange(file, info.size, patterns);
671
+ for (const finding of scanned.findings) {
214
672
  const placed = { ...finding, file };
215
673
  findings.push(placed);
216
674
  onFinding?.(placed);
217
- });
675
+ }
218
676
  }
219
677
  const high = findings.filter((finding) => finding.severity === "high").length;
220
678
  const medium = findings.length - high;
@@ -335,7 +335,6 @@ const setupServiceSchema = z.object({
335
335
  const policyCanReclaim = (policy) => policy === "archive-and-reclaim" || policy === "manual-approval";
336
336
  const webRoot = findWebRoot();
337
337
  const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml" };
338
- const BOOTSTRAP_REDIRECT_PARAM = "__sealkeep_bootstrap";
339
338
  /**
340
339
  * Serves the companion dashboard from the same origin as the API, so the page can
341
340
  * authenticate with the local token without any cross-origin exception.
@@ -955,9 +954,8 @@ function json(response, status, value, headers = {}) {
955
954
  });
956
955
  response.end(JSON.stringify(value));
957
956
  }
958
- /** A direct address-bar navigation is the only unauthenticated request that
959
- * may receive a browser bootstrap page. Fetch Metadata keeps a cross-site
960
- * page from silently navigating a victim into an authenticated local UI. */
957
+ /** Browser CSRF classification only. These headers are client-supplied, so a
958
+ * match must never mint a bearer ticket. Another local account can set them. */
961
959
  function isDirectDashboardNavigation(request) {
962
960
  const site = request.headers["sec-fetch-site"];
963
961
  const mode = request.headers["sec-fetch-mode"];
@@ -3252,36 +3250,21 @@ export function createLocalApiServer(dataDir, token, options = {}) {
3252
3250
  }
3253
3251
  // The UI shell is static and holds no vault data, so it loads before
3254
3252
  // authentication; it then presents the token on every /v1 call below.
3253
+ // Fetch Metadata may describe a browser navigation, including one typed
3254
+ // into the address bar. That classification is CSRF-only: any local
3255
+ // process can set the same headers, so this response never carries a
3256
+ // bootstrap ticket. `sealkeep ui` obtains a ticket with the owner-only
3257
+ // HMAC proof. A second browser waits for a tab that already holds the
3258
+ // bearer to approve it.
3255
3259
  if (method === "GET" && !url.pathname.startsWith("/v1/")) {
3256
- // A person typing the stable localhost origin into a second browser
3257
- // performs a genuine top-level navigation. Give that navigation the
3258
- // same short-lived fragment ticket as `sealkeep ui`; no command or
3259
- // bearer copy is needed. A one-shot marker prevents the bootstrap from
3260
- // looping when the browser follows it, and require Fetch Metadata headers so cross-site embeds and
3261
- // drive-by navigations cannot mint a local API credential. This also
3262
- // covers a fresh install, whose landing page is the setup wizard.
3263
- const directUiPath = url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/setup.html";
3264
- if (directUiPath && !url.searchParams.has(BOOTSTRAP_REDIRECT_PARAM) && isDirectDashboardNavigation(request)) {
3265
- const { ticket } = newBootstrapTicket();
3266
- const targetPath = url.pathname === "/" ? await landingPage(dataDir) : url.pathname;
3267
- // The HTTP server cannot see the requested fragment. A redirect with
3268
- // #ticket would erase an incoming recovery/project deep link. The
3269
- // same-origin script retains a validated view before navigating to
3270
- // the marked shell. Both attributes contain server-generated values:
3271
- // a base64url ticket and one of the three fixed dashboard paths.
3272
- const target = `${targetPath}?${BOOTSTRAP_REDIRECT_PARAM}=1`;
3273
- response.writeHead(200, {
3274
- "content-type": "text/html; charset=utf-8",
3275
- "cache-control": "no-store",
3276
- "x-content-type-options": "nosniff",
3277
- "cross-origin-opener-policy": "same-origin",
3278
- "x-frame-options": "DENY",
3279
- "content-security-policy": "default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'",
3280
- });
3281
- response.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Opening Sealkeep</title><script src="/bootstrap.js" defer></script></head><body data-bootstrap-ticket="${ticket}" data-bootstrap-target="${target}"><p>Opening Sealkeep securely…</p><noscript>Enable JavaScript to open this local dashboard.</noscript></body></html>`);
3282
- return;
3283
- }
3284
- return serveStatic(url.pathname === "/" ? await landingPage(dataDir) : url.pathname, response);
3260
+ const pathname = url.pathname === "/" ? await landingPage(dataDir) : url.pathname;
3261
+ // Passing this check only means a browser would call the request a
3262
+ // top-level visit. It used to embed a bearer ticket. A local process
3263
+ // can set the same headers, so the visit receives the unsigned shell.
3264
+ if ((url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/setup.html") && isDirectDashboardNavigation(request)) {
3265
+ return serveStatic(pathname, response);
3266
+ }
3267
+ return serveStatic(pathname, response);
3285
3268
  }
3286
3269
  if (!authorized(request, token))
3287
3270
  return json(response, 401, { error: { code: "unauthorized", message: "A local API bearer token is required" } }, { "www-authenticate": "Bearer" });
@@ -8,6 +8,32 @@ export type LocalSettings = {
8
8
  watchedPaths: string[];
9
9
  exclusions: string[];
10
10
  reclaimEnabled: boolean;
11
+ /**
12
+ * Seal-time secret scan. `off` skips it. `record` stores findings and does
13
+ * not block. `block-high` refuses the seal when a high-confidence finding
14
+ * exists. Medium findings stay advisory in every mode.
15
+ */
16
+ secretScanPolicy: "off" | "record" | "block-high";
17
+ /**
18
+ * When the scanner cannot finish, refuse the seal. `block-high` always
19
+ * fails closed even if this is false. A transcript that changes during the
20
+ * scan is always refused.
21
+ */
22
+ secretScanFailClosed: boolean;
23
+ /**
24
+ * Per-machine consent to send aggregate kind/severity/count rollups to the
25
+ * configured control plane. Default off. Line numbers and matched text never
26
+ * travel, and this flag is not part of a shared policy file.
27
+ */
28
+ secretScanOrgRollup: boolean;
29
+ /** Control plane origin for policy pull and rollup delivery. Null sends nothing. */
30
+ secretScanControlPlaneUrl: string | null;
31
+ /** Extra detectors. High ones block under `block-high`; medium ones never do. */
32
+ secretScanPatterns: Array<{
33
+ kind: string;
34
+ severity: "high" | "medium";
35
+ source: string;
36
+ }>;
11
37
  trashStrategy: "auto" | TrashResult["strategy"];
12
38
  schedule: {
13
39
  intervalMinutes: number;