sealkeep 0.11.5 → 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.
- package/CHANGELOG.md +53 -0
- package/CONTROL_PLANE.md +2 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/chunk-store.js +104 -82
- package/dist/src/cli.js +150 -5
- package/dist/src/control-plane/auth.d.ts +9 -0
- package/dist/src/control-plane/auth.js +19 -2
- package/dist/src/control-plane/server.js +47 -0
- package/dist/src/control-plane/store.d.ts +32 -0
- package/dist/src/control-plane/store.js +33 -0
- package/dist/src/daemon.js +2 -0
- package/dist/src/errors.d.ts +1 -1
- package/dist/src/leakscan.d.ts +101 -9
- package/dist/src/leakscan.js +481 -23
- package/dist/src/local-api.js +16 -33
- package/dist/src/machine-settings.d.ts +26 -0
- package/dist/src/machine-settings.js +23 -0
- package/dist/src/queue.d.ts +11 -1
- package/dist/src/queue.js +2 -2
- package/dist/src/stream-to-cloud.d.ts +2 -0
- package/dist/src/stream-to-cloud.js +80 -59
- package/dist/src/types.d.ts +18 -0
- package/dist/src/vault.js +38 -8
- package/docs/README.md +21 -0
- package/docs/secret-scanning.md +81 -0
- package/package.json +3 -2
- package/web/app.js +24 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,59 @@
|
|
|
3
3
|
Notable changes, by published version. Sealkeep is pre-1.0: minor versions
|
|
4
4
|
may change behavior, and say so here when they do.
|
|
5
5
|
|
|
6
|
+
## 0.11.7 — 2026-09-26 — secret scanning and faster hooks
|
|
7
|
+
|
|
8
|
+
- **Session hooks return after saving the archive request.** Queue-wide
|
|
9
|
+
reconciliation, sealing, indexing, and cloud upload continue in the
|
|
10
|
+
background, keeping large queues from exceeding the agent's hook timeout.
|
|
11
|
+
|
|
12
|
+
- **Local dashboard bootstrap tickets require owner proof.** Browser-supplied
|
|
13
|
+
Fetch Metadata headers no longer mint a bearer ticket; the approved UI
|
|
14
|
+
bootstrap flow remains available.
|
|
15
|
+
|
|
16
|
+
- **New seals can be checked for pasted credentials before they are encrypted
|
|
17
|
+
or uploaded.** The check is off until you turn it on. `sealkeep scan policy
|
|
18
|
+
record` stores findings with the archive. `block-high` refuses the seal
|
|
19
|
+
when a high-confidence finding exists, and it does that for automatic,
|
|
20
|
+
manual, MCP, and streamed seals. Medium findings, including the entropy
|
|
21
|
+
detector, never block. A transcript that changes while it is being scanned
|
|
22
|
+
is not sealed. Other scanner failures stop the seal when the policy is
|
|
23
|
+
`block-high`, or when `sealkeep scan policy fail-closed on` is set.
|
|
24
|
+
Otherwise the seal continues and does not claim it was scanned.
|
|
25
|
+
|
|
26
|
+
- **What is stored, and what can leave the machine.** The archive record keeps
|
|
27
|
+
the detector kind, severity, count, and line numbers. It does not keep the
|
|
28
|
+
matched text or the masked preview. `sealkeep scan` on this machine still
|
|
29
|
+
prints the masked preview. An organization rollup is sent only after
|
|
30
|
+
`sealkeep scan policy rollup on` and `sealkeep scan policy plane <url>`.
|
|
31
|
+
That request contains kind, severity, and count. It does not contain
|
|
32
|
+
transcript text, matched values, paths, or line numbers. The control plane
|
|
33
|
+
keeps those rollups for 90 days. Consent is per machine and is not copied
|
|
34
|
+
when a policy file is imported.
|
|
35
|
+
|
|
36
|
+
- **Patterns and policy can be shared without sharing consent.** Built-in
|
|
37
|
+
detectors stay fixed. `sealkeep scan pattern add <kind> <high|medium> <regex>`
|
|
38
|
+
adds a local detector. High custom findings block only under `block-high`.
|
|
39
|
+
A pattern must compile, must not match the empty string, must include four
|
|
40
|
+
consecutive letters or digits, and must not nest unbounded quantifiers.
|
|
41
|
+
`sealkeep scan policy export` and `import` move the mode, the fail-closed
|
|
42
|
+
flag, and the patterns. Each machine still chooses its own rollup consent
|
|
43
|
+
and control-plane URL. `sealkeep scan policy pull` copies the organization
|
|
44
|
+
policy from that URL and leaves consent unchanged.
|
|
45
|
+
|
|
46
|
+
The detectors are fixed patterns plus an entropy check. They do not prove
|
|
47
|
+
that a transcript contains no secret. A streamed or chunked seal uploads
|
|
48
|
+
the scanned copy, then deletes that copy even if later setup fails. A copy
|
|
49
|
+
left behind by a crash is removed on the next daemon start once its
|
|
50
|
+
process is gone.
|
|
51
|
+
|
|
52
|
+
## 0.11.6 — 2026-09-25 — reconnect and Recovery Kit limits are separate
|
|
53
|
+
|
|
54
|
+
- After a machine reconnects successfully, the Recovery Kit page says so even
|
|
55
|
+
if a later cloud check is paused by a service limit. It shows when that
|
|
56
|
+
check can be retried and no longer asks for another reconnect that cannot
|
|
57
|
+
lift the limit. The local recovery phrase and existing vault are unchanged.
|
|
58
|
+
|
|
6
59
|
## 0.11.5 — 2026-09-23 — compacting is safe, and a lost destination says so
|
|
7
60
|
|
|
8
61
|
- **An agent that has just been compacted is told where the work stands.**
|
package/CONTROL_PLANE.md
CHANGED
|
@@ -20,6 +20,8 @@ Endpoints:
|
|
|
20
20
|
- `POST /v1/archives/leases`, `POST /v1/archives/:id/complete`
|
|
21
21
|
- `PUT /v1/archives/:id/manifest`, `GET /v1/archives`
|
|
22
22
|
- `GET /v1/audit`
|
|
23
|
+
- `GET` and `PUT /v1/secret-scan/policy` — mode, fail-closed, and custom patterns. No transcript content.
|
|
24
|
+
- `POST` and `GET /v1/secret-scan/rollups` — detector kind, severity, and count only. Kept for 90 days. Line numbers, paths, and matched text are rejected.
|
|
23
25
|
|
|
24
26
|
## Authentication
|
|
25
27
|
|
package/dist/src/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditAction = "archive.create" | "index.tokens" | "index.merge" | "archive.restore" | "archive.restore_local" | "archive.sealed_copy_download" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "archive.copy_delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve" | "storage.gdrive_connect" | "storage.target_connect" | "storage.targets_update" | "phrase.reveal" | "recovery.codes_created" | "bridge.share" | "project.sharing" | "project.draft";
|
|
1
|
+
export type AuditAction = "archive.create" | "index.tokens" | "index.merge" | "archive.restore" | "archive.restore_local" | "archive.sealed_copy_download" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "archive.copy_delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve" | "storage.gdrive_connect" | "storage.target_connect" | "storage.targets_update" | "phrase.reveal" | "recovery.codes_created" | "bridge.share" | "project.sharing" | "project.draft" | "secret_scan.rollup";
|
|
2
2
|
export type LocalAuditEvent = {
|
|
3
3
|
id: string;
|
|
4
4
|
at: string;
|
package/dist/src/chunk-store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrap
|
|
|
13
13
|
import { frameObject } from "./cloud.js";
|
|
14
14
|
import { aeadCipher, aeadDecipher } from "../packages/sealkeep-crypto/src/aead.js";
|
|
15
15
|
import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
|
|
16
|
+
import { assertScanCoversSeal, deliverOrgRollup, discardScanSnapshot, scanBeforeSeal } from "./leakscan.js";
|
|
16
17
|
import { oneChunk } from "./byte-stream.js";
|
|
17
18
|
import { paceBackgroundByteStream } from "./background-bandwidth.js";
|
|
18
19
|
const SEAL_SUITE = "chacha20-poly1305";
|
|
@@ -165,93 +166,101 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
|
|
|
165
166
|
const source = await stat(absolute).catch(() => null);
|
|
166
167
|
if (!source || !source.isFile())
|
|
167
168
|
fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
169
|
+
const sealScan = await scanBeforeSeal(dataDir, absolute, options.signal, undefined, source.size, { snapshot: true });
|
|
170
|
+
try {
|
|
171
|
+
const secretScan = sealScan?.metadata;
|
|
172
|
+
const plaintextPath = sealScan?.snapshotPath ?? absolute;
|
|
173
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
174
|
+
const totalBytes = source.size;
|
|
175
|
+
// Same override the local seal path honours, for the same reason: proving the
|
|
176
|
+
// streamed layout works should not cost tens of megabytes of disk per run.
|
|
177
|
+
const chunkOverride = options.chunkBytes;
|
|
178
|
+
const chunkBytes = typeof chunkOverride === "number" && Number.isInteger(chunkOverride) && chunkOverride > 0
|
|
179
|
+
? chunkOverride
|
|
180
|
+
: DEFAULT_CHUNK_BYTES;
|
|
181
|
+
const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
|
|
182
|
+
// Journal triage, chunk-layout edition. Same drain-the-stack shape as the
|
|
183
|
+
// streaming path: resume the newest matching journal or abandon it with the
|
|
184
|
+
// reason and look again.
|
|
185
|
+
for (;;) {
|
|
186
|
+
const journal = await findSpoolForSource(dataDir, absolute);
|
|
187
|
+
if (!journal)
|
|
188
|
+
break;
|
|
189
|
+
if (journal.providerState !== null && !isChunkFolderState(journal.providerState)) {
|
|
190
|
+
// A single-object streaming journal for this source belongs to the other
|
|
191
|
+
// pipeline; leave it alone and let that pipeline (or --fresh) decide.
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const abandon = async (reason) => {
|
|
195
|
+
await abandonFolderJournal(dataDir, client, journal, reason);
|
|
196
|
+
};
|
|
197
|
+
if (options.resume === false) {
|
|
198
|
+
await abandon("a fresh start was requested (--fresh)");
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (journal.provider !== remoteStorage.provider || journal.bucket !== remoteStorage.bucket || journal.source.totalBytes !== totalBytes) {
|
|
202
|
+
await abandon("the source file or the storage target is no longer the one the journal describes");
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
|
|
206
|
+
try {
|
|
207
|
+
return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, plaintextPath, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield, secretScan, sealScan }, journal);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
|
|
211
|
+
throw error;
|
|
212
|
+
await abandon(error.message);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await lock.release();
|
|
217
|
+
}
|
|
199
218
|
}
|
|
200
|
-
|
|
219
|
+
// Fresh seal.
|
|
220
|
+
const archiveId = randomUUID();
|
|
221
|
+
const createdAt = new Date(options.now ?? Date.now()).toISOString();
|
|
222
|
+
// Sealed vault ⇒ unreadable bucket, everywhere. Sealing the CONTENT while
|
|
223
|
+
// labelling it project/date/session.jsonl would hand every storage provider
|
|
224
|
+
// the story of the work; the readable hierarchy is the dashboard's job,
|
|
225
|
+
// built from local records — and from the encrypted identity in each
|
|
226
|
+
// folder's sidecar when only the bucket survives. `remoteNaming:
|
|
227
|
+
// "readable"` in config.json is the explicit opt-out for people who want
|
|
228
|
+
// their own bucket browsable and accept what that says.
|
|
229
|
+
// Managed vaults skip the naming machinery entirely: the folder is the bare
|
|
230
|
+
// archive id, so the plane's references (`<id>.chunk-000000`) carry nothing
|
|
231
|
+
// but the grouping it could always see. Own buckets keep the hashed (or
|
|
232
|
+
// opted-in readable) project/date/session hierarchy.
|
|
233
|
+
const naming = config.remoteNaming ?? "hashed";
|
|
234
|
+
const folder = remoteStorage.provider === "vaultline" ? archiveId : chunkFolderFor({
|
|
235
|
+
configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
|
|
236
|
+
naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
|
|
237
|
+
});
|
|
238
|
+
// Members of this project are wrapped in here, and nowhere else.
|
|
239
|
+
const recipients = [...configuredRecipients(config, phrase, { project: options.project ?? undefined, projectKey: options.projectKey ?? undefined }), ...(options.teamRecipients ?? [])];
|
|
240
|
+
const archiveKey = randomBytes(KEY_BYTES);
|
|
241
|
+
const noncePrefix = randomBytes(4);
|
|
242
|
+
const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
|
|
201
243
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
await createSpool(dataDir, {
|
|
245
|
+
archiveId, suite: SEAL_SUITE, provider: remoteStorage.provider, bucket: remoteStorage.bucket, objectKey: folder,
|
|
246
|
+
source: { path: absolute, totalBytes, chunkBytes },
|
|
247
|
+
wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
|
|
248
|
+
noncePrefixB64: noncePrefix.toString("base64"),
|
|
249
|
+
nextChunkIndex: 0,
|
|
250
|
+
consumedPrefixSha256: createHash("sha256").digest("hex"),
|
|
251
|
+
chunkHeaders: [],
|
|
252
|
+
hashState: { plaintext: "", stored: "" },
|
|
253
|
+
providerState: { kind: "chunk-folder", folder, uploaded: 0 }
|
|
254
|
+
}, { now: options.now });
|
|
255
|
+
return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, plaintextPath, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield, secretScan, sealScan }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
|
|
209
256
|
}
|
|
210
257
|
finally {
|
|
258
|
+
zeroize(archiveKey);
|
|
211
259
|
await lock.release();
|
|
212
260
|
}
|
|
213
261
|
}
|
|
214
|
-
// Fresh seal.
|
|
215
|
-
const archiveId = randomUUID();
|
|
216
|
-
const createdAt = new Date(options.now ?? Date.now()).toISOString();
|
|
217
|
-
// Sealed vault ⇒ unreadable bucket, everywhere. Sealing the CONTENT while
|
|
218
|
-
// labelling it project/date/session.jsonl would hand every storage provider
|
|
219
|
-
// the story of the work; the readable hierarchy is the dashboard's job,
|
|
220
|
-
// built from local records — and from the encrypted identity in each
|
|
221
|
-
// folder's sidecar when only the bucket survives. `remoteNaming:
|
|
222
|
-
// "readable"` in config.json is the explicit opt-out for people who want
|
|
223
|
-
// their own bucket browsable and accept what that says.
|
|
224
|
-
// Managed vaults skip the naming machinery entirely: the folder is the bare
|
|
225
|
-
// archive id, so the plane's references (`<id>.chunk-000000`) carry nothing
|
|
226
|
-
// but the grouping it could always see. Own buckets keep the hashed (or
|
|
227
|
-
// opted-in readable) project/date/session hierarchy.
|
|
228
|
-
const naming = config.remoteNaming ?? "hashed";
|
|
229
|
-
const folder = remoteStorage.provider === "vaultline" ? archiveId : chunkFolderFor({
|
|
230
|
-
configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
|
|
231
|
-
naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
|
|
232
|
-
});
|
|
233
|
-
// Members of this project are wrapped in here, and nowhere else.
|
|
234
|
-
const recipients = [...configuredRecipients(config, phrase, { project: options.project ?? undefined, projectKey: options.projectKey ?? undefined }), ...(options.teamRecipients ?? [])];
|
|
235
|
-
const archiveKey = randomBytes(KEY_BYTES);
|
|
236
|
-
const noncePrefix = randomBytes(4);
|
|
237
|
-
const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
|
|
238
|
-
try {
|
|
239
|
-
await createSpool(dataDir, {
|
|
240
|
-
archiveId, suite: SEAL_SUITE, provider: remoteStorage.provider, bucket: remoteStorage.bucket, objectKey: folder,
|
|
241
|
-
source: { path: absolute, totalBytes, chunkBytes },
|
|
242
|
-
wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
|
|
243
|
-
noncePrefixB64: noncePrefix.toString("base64"),
|
|
244
|
-
nextChunkIndex: 0,
|
|
245
|
-
consumedPrefixSha256: createHash("sha256").digest("hex"),
|
|
246
|
-
chunkHeaders: [],
|
|
247
|
-
hashState: { plaintext: "", stored: "" },
|
|
248
|
-
providerState: { kind: "chunk-folder", folder, uploaded: 0 }
|
|
249
|
-
}, { now: options.now });
|
|
250
|
-
return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
|
|
251
|
-
}
|
|
252
262
|
finally {
|
|
253
|
-
|
|
254
|
-
await lock.release();
|
|
263
|
+
await discardScanSnapshot(sealScan);
|
|
255
264
|
}
|
|
256
265
|
}
|
|
257
266
|
function isChunkFolderState(state) {
|
|
@@ -369,7 +378,7 @@ async function runChunkSeal(ctx, args) {
|
|
|
369
378
|
pending.set(index, task);
|
|
370
379
|
}
|
|
371
380
|
};
|
|
372
|
-
const sealed = await sealChunksToSink({ path: ctx.
|
|
381
|
+
const sealed = await sealChunksToSink({ path: ctx.plaintextPath, end: ctx.totalBytes }, sink, {
|
|
373
382
|
recipients, archiveId: args.archiveId, adapter: { agent: ctx.agent, version: ADAPTER_VERSION },
|
|
374
383
|
suite: SEAL_SUITE, chunkBytes: ctx.chunkBytes, archiveKey: args.archiveKey, noncePrefix: args.noncePrefix,
|
|
375
384
|
hashers, createdAt: args.createdAt,
|
|
@@ -419,6 +428,17 @@ async function runChunkSeal(ctx, args) {
|
|
|
419
428
|
await recordAudit(ctx.dataDir, "upload.verify", "denied", { archiveId: args.archiveId, objectKey: args.folder, problems: problems.length });
|
|
420
429
|
fail("ciphertext_integrity_failed", `Refusing to record the chunked archive as durable: ${problems.join("; ")}. The journal was kept; re-running resumes what is already there.`, { archiveId: args.archiveId, objectKey: args.folder });
|
|
421
430
|
}
|
|
431
|
+
try {
|
|
432
|
+
assertScanCoversSeal(ctx.sealScan, sealed.originalSha256, ctx.absolute);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
for (let index = 0; index < envelope.chunks.length; index += 1) {
|
|
436
|
+
await ctx.client.deleteObject(`${args.folder}/${chunkObjectName(index)}`).catch(() => undefined);
|
|
437
|
+
}
|
|
438
|
+
await ctx.client.deleteObject(`${args.folder}/${ENVELOPE_OBJECT}`).catch(() => undefined);
|
|
439
|
+
await shredSpool(ctx.dataDir, args.archiveId);
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
422
442
|
const now = new Date(ctx.now ?? Date.now()).toISOString();
|
|
423
443
|
const remote = {
|
|
424
444
|
provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder,
|
|
@@ -436,7 +456,8 @@ async function runChunkSeal(ctx, args) {
|
|
|
436
456
|
envelope,
|
|
437
457
|
objectPath: join(config.storage.root, `${args.archiveId}.skarchive`),
|
|
438
458
|
remote,
|
|
439
|
-
offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder }
|
|
459
|
+
offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder },
|
|
460
|
+
...(ctx.secretScan ? { secretScan: ctx.secretScan } : {})
|
|
440
461
|
};
|
|
441
462
|
const record = await mutateArchiveRecord(config.storage.root, args.archiveId, (current) => {
|
|
442
463
|
if (!isV2(current))
|
|
@@ -448,7 +469,8 @@ async function runChunkSeal(ctx, args) {
|
|
|
448
469
|
const { remote: _remote, copies: _copies, ...completedFacts } = completed;
|
|
449
470
|
return appendArchiveCopy({ ...current, ...completedFacts }, remote, { makePrimary: true });
|
|
450
471
|
}, { initial: completed });
|
|
451
|
-
await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks });
|
|
472
|
+
await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks, secretFindings: ctx.secretScan?.findings.reduce((total, item) => total + item.count, 0) ?? null });
|
|
473
|
+
await deliverOrgRollup(ctx.dataDir, args.archiveId, ctx.secretScan);
|
|
452
474
|
await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: args.archiveId, objectKey: args.folder, bytes: sealed.storedBytes, provider: ctx.remoteStorage.provider, chunked: true });
|
|
453
475
|
await shredSpool(ctx.dataDir, args.archiveId);
|
|
454
476
|
// The tokens collected during the seal become the archive's index entry —
|
package/dist/src/cli.js
CHANGED
|
@@ -39,7 +39,7 @@ import { connectGdrive } from "./providers/gdrive.js";
|
|
|
39
39
|
import { resolveRecoveryPhrase } from "./secrets.js";
|
|
40
40
|
import { amber, bold, blue, bytes, callout, command as cmd, dim, divider, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table } from "./ui.js";
|
|
41
41
|
import { envVar, PREFIX as ENV_PREFIX } from "./env.js";
|
|
42
|
-
import { automaticTranscriptIsEnabled, readLocalSettings } from "./machine-settings.js";
|
|
42
|
+
import { automaticTranscriptIsEnabled, readLocalSettings, writeLocalSettings } from "./machine-settings.js";
|
|
43
43
|
import { normalizeDarwinServicePolicy, prepareLegacyDarwinManagerHandoff } from "./darwin-service-policy.js";
|
|
44
44
|
const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--ui-port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password", "--kind", "--text", "--channel", "--reword", "--mode", "--session", "--mirror", "--interval-ms", "--from-seq", "--from-byte", "--max-gb", "--priority", "--projects", "--id", "--email", "--code", "--passcode"]);
|
|
45
45
|
function take(args, flag, fallback) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; }
|
|
@@ -205,7 +205,7 @@ function usage() {
|
|
|
205
205
|
["archive <file>", "seal one file into the vault by hand"],
|
|
206
206
|
["archive <file> --stream", "seal straight into your bucket, no local ciphertext"],
|
|
207
207
|
["archive <file> --stream --fresh", "abandon an interrupted streamed upload and start over"],
|
|
208
|
-
["scan <path>", "find pasted secrets
|
|
208
|
+
["scan <path> · scan policy off|record|block-high", "find pasted secrets; record or block them when a session is sealed"],
|
|
209
209
|
["list", "browse archives"],
|
|
210
210
|
["search <query>", "search metadata, or contents with --content"],
|
|
211
211
|
["why <commit>", "the session — and the reasoning — behind a git commit"],
|
|
@@ -1462,9 +1462,150 @@ async function main() {
|
|
|
1462
1462
|
* machine — see leakscan.ts for why it cannot.
|
|
1463
1463
|
*/
|
|
1464
1464
|
if (command === "scan") {
|
|
1465
|
+
const scanArgs = positionals(args);
|
|
1466
|
+
if (scanArgs[0] === "policy") {
|
|
1467
|
+
const { sharedScanPolicy, validateCustomPattern } = await import("./leakscan.js");
|
|
1468
|
+
const settings = await readLocalSettings(dataDir);
|
|
1469
|
+
const requested = scanArgs[1];
|
|
1470
|
+
if (!requested) {
|
|
1471
|
+
print(`\n Seal-time secret scan: ${bold(settings.secretScanPolicy)} ${dim("· machine-local")}`);
|
|
1472
|
+
print(` Fail closed on scanner errors: ${bold(settings.secretScanFailClosed ? "on" : "off")} ${dim("· block-high always fails closed")}`);
|
|
1473
|
+
print(` Organization rollup: ${bold(settings.secretScanOrgRollup ? "on" : "off")} ${dim(settings.secretScanControlPlaneUrl ? `· ${settings.secretScanControlPlaneUrl}` : "· no control plane configured")}`);
|
|
1474
|
+
print(` Custom patterns: ${bold(String(settings.secretScanPatterns.length))}`);
|
|
1475
|
+
print(` ${dim("Modes: off · record · block-high. Shared files carry mode, fail-closed, and patterns. Consent stays on this machine.")}\n`);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (requested === "off" || requested === "record" || requested === "block-high") {
|
|
1479
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanPolicy: requested });
|
|
1480
|
+
print(`\n ${mark.ok()} Seal-time secret scan set to ${bold(requested)} ${dim("· every new local, MCP, and streamed seal")}\n`);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (requested === "fail-closed") {
|
|
1484
|
+
const value = scanArgs[2];
|
|
1485
|
+
if (value !== "on" && value !== "off")
|
|
1486
|
+
fail("invalid_argument", "Usage: sealkeep scan policy fail-closed on|off");
|
|
1487
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanFailClosed: value === "on" });
|
|
1488
|
+
print(`\n ${mark.ok()} Scanner errors ${bold(value === "on" ? "fail closed" : "do not block")} ${dim("· block-high still fails closed")}\n`);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (requested === "rollup") {
|
|
1492
|
+
const value = scanArgs[2];
|
|
1493
|
+
if (value !== "on" && value !== "off")
|
|
1494
|
+
fail("invalid_argument", "Usage: sealkeep scan policy rollup on|off");
|
|
1495
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanOrgRollup: value === "on" });
|
|
1496
|
+
print(`\n ${mark.ok()} Organization rollup ${bold(value)} ${dim("· kinds, severities, and counts only; line numbers stay here")}\n`);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (requested === "plane") {
|
|
1500
|
+
const value = scanArgs[2];
|
|
1501
|
+
if (!value || value === "off") {
|
|
1502
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanControlPlaneUrl: null });
|
|
1503
|
+
print(`\n ${mark.ok()} Control plane cleared. Rollups stay on this machine.\n`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
let origin;
|
|
1507
|
+
try {
|
|
1508
|
+
origin = new URL(value).origin;
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
fail("invalid_argument", "Usage: sealkeep scan policy plane <https://control-plane> | off");
|
|
1512
|
+
}
|
|
1513
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanControlPlaneUrl: origin });
|
|
1514
|
+
print(`\n ${mark.ok()} Control plane set to ${bold(origin)}\n`);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (requested === "export") {
|
|
1518
|
+
print(JSON.stringify(sharedScanPolicy(settings), null, 2));
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (requested === "import") {
|
|
1522
|
+
const file = required(scanArgs[2], "Usage: sealkeep scan policy import <file>");
|
|
1523
|
+
const { readFile } = await import("node:fs/promises");
|
|
1524
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1525
|
+
if (parsed.secretScanPolicy !== "off" && parsed.secretScanPolicy !== "record" && parsed.secretScanPolicy !== "block-high") {
|
|
1526
|
+
fail("invalid_argument", "Policy file needs secretScanPolicy of off, record, or block-high.");
|
|
1527
|
+
}
|
|
1528
|
+
const patterns = Array.isArray(parsed.secretScanPatterns) ? parsed.secretScanPatterns.map((pattern) => validateCustomPattern(pattern)) : [];
|
|
1529
|
+
await writeLocalSettings(dataDir, {
|
|
1530
|
+
...settings,
|
|
1531
|
+
secretScanPolicy: parsed.secretScanPolicy,
|
|
1532
|
+
secretScanFailClosed: parsed.secretScanFailClosed === true,
|
|
1533
|
+
secretScanPatterns: patterns
|
|
1534
|
+
});
|
|
1535
|
+
print(`\n ${mark.ok()} Imported scan policy ${bold(parsed.secretScanPolicy)} with ${patterns.length} custom pattern${patterns.length === 1 ? "" : "s"}. ${dim("Rollup consent was left as it was.")}\n`);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (requested === "pull") {
|
|
1539
|
+
if (!settings.secretScanControlPlaneUrl)
|
|
1540
|
+
fail("invalid_argument", "Set a control plane first: sealkeep scan policy plane <url>");
|
|
1541
|
+
const { readControlPlaneDevice } = await import("./leakscan.js");
|
|
1542
|
+
const { signedControlPlaneFetch } = await import("./control-plane/auth.js");
|
|
1543
|
+
const device = await readControlPlaneDevice(dataDir);
|
|
1544
|
+
if (!device)
|
|
1545
|
+
fail("unauthorized", "This machine has no control-plane device key. Import one before pulling a policy.");
|
|
1546
|
+
const response = await signedControlPlaneFetch({
|
|
1547
|
+
origin: settings.secretScanControlPlaneUrl,
|
|
1548
|
+
method: "GET",
|
|
1549
|
+
path: "/v1/secret-scan/policy",
|
|
1550
|
+
deviceId: device.deviceId,
|
|
1551
|
+
privateKey: device.privateKeyPem
|
|
1552
|
+
});
|
|
1553
|
+
if (!response.ok)
|
|
1554
|
+
fail("cloud_account_unavailable", `Control plane returned ${response.status}`);
|
|
1555
|
+
const parsed = await response.json();
|
|
1556
|
+
if (parsed.mode !== "off" && parsed.mode !== "record" && parsed.mode !== "block-high")
|
|
1557
|
+
fail("invalid_argument", "Control plane policy was not usable.");
|
|
1558
|
+
const patterns = Array.isArray(parsed.patterns) ? parsed.patterns.map((pattern) => validateCustomPattern(pattern)) : [];
|
|
1559
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanPolicy: parsed.mode, secretScanFailClosed: parsed.failClosed === true, secretScanPatterns: patterns });
|
|
1560
|
+
print(`\n ${mark.ok()} Pulled scan policy ${bold(parsed.mode)}. ${dim("This machine's rollup consent did not change.")}\n`);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
fail("invalid_argument", "Usage: sealkeep scan policy [off|record|block-high|fail-closed on|off|rollup on|off|plane <url>|export|import <file>|pull]");
|
|
1564
|
+
}
|
|
1565
|
+
if (scanArgs[0] === "pattern") {
|
|
1566
|
+
const { validateCustomPattern } = await import("./leakscan.js");
|
|
1567
|
+
const settings = await readLocalSettings(dataDir);
|
|
1568
|
+
const action = scanArgs[1];
|
|
1569
|
+
if (action === "list" || !action) {
|
|
1570
|
+
if (json) {
|
|
1571
|
+
print(JSON.stringify(settings.secretScanPatterns, null, 2));
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
print(`\n${heading("Custom secret patterns")}`);
|
|
1575
|
+
print(table(settings.secretScanPatterns, [
|
|
1576
|
+
{ header: "kind", get: (pattern) => pattern.kind },
|
|
1577
|
+
{ header: "severity", get: (pattern) => pattern.severity },
|
|
1578
|
+
{ header: "pattern", get: (pattern) => dim(pattern.source) }
|
|
1579
|
+
], "None. Built-in detectors still run when scanning is on."));
|
|
1580
|
+
print("");
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (action === "remove") {
|
|
1584
|
+
const kind = required(scanArgs[2], "Usage: sealkeep scan pattern remove <kind>");
|
|
1585
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanPatterns: settings.secretScanPatterns.filter((pattern) => pattern.kind !== kind) });
|
|
1586
|
+
print(`\n ${mark.ok()} Removed ${bold(kind)}\n`);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (action === "add") {
|
|
1590
|
+
const kind = required(scanArgs[2], "Usage: sealkeep scan pattern add <kind> <high|medium> <regex>");
|
|
1591
|
+
const severity = scanArgs[3];
|
|
1592
|
+
const source = scanArgs[4];
|
|
1593
|
+
if ((severity !== "high" && severity !== "medium") || !source)
|
|
1594
|
+
fail("invalid_argument", "Usage: sealkeep scan pattern add <kind> <high|medium> <regex>");
|
|
1595
|
+
const pattern = validateCustomPattern({ kind, severity, source });
|
|
1596
|
+
if (settings.secretScanPatterns.some((existing) => existing.kind === pattern.kind))
|
|
1597
|
+
fail("invalid_argument", `A pattern named ${pattern.kind} already exists.`);
|
|
1598
|
+
if (settings.secretScanPatterns.length >= 32)
|
|
1599
|
+
fail("invalid_argument", "This machine already has 32 custom patterns.");
|
|
1600
|
+
await writeLocalSettings(dataDir, { ...settings, secretScanPatterns: [...settings.secretScanPatterns, pattern] });
|
|
1601
|
+
print(`\n ${mark.ok()} Added ${bold(pattern.kind)} as ${pattern.severity}. ${dim("High findings block only when the policy is block-high.")}\n`);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
fail("invalid_argument", "Usage: sealkeep scan pattern list|add|remove");
|
|
1605
|
+
}
|
|
1465
1606
|
const { scanPath } = await import("./leakscan.js");
|
|
1466
|
-
const target = required(
|
|
1467
|
-
const report = await scanPath(target);
|
|
1607
|
+
const target = required(scanArgs[0], "Usage: sealkeep scan <file-or-directory> [--json]");
|
|
1608
|
+
const report = await scanPath(target, undefined, (await readLocalSettings(dataDir)).secretScanPatterns);
|
|
1468
1609
|
if (json) {
|
|
1469
1610
|
print(JSON.stringify(report, null, 2));
|
|
1470
1611
|
}
|
|
@@ -2225,7 +2366,11 @@ async function main() {
|
|
|
2225
2366
|
const event = await hookEventFromStdin(agent, raw);
|
|
2226
2367
|
const local = await readLocalSettings(dataDir);
|
|
2227
2368
|
if (automaticTranscriptIsEnabled(local, agent, event.sourcePath)) {
|
|
2228
|
-
|
|
2369
|
+
// Persist the archive intent and return to the agent immediately.
|
|
2370
|
+
// Queue-wide supersession, sealing, indexing and cloud upload belong
|
|
2371
|
+
// to the daemon; doing the queue scan here made SessionEnd exceed the
|
|
2372
|
+
// agent's three-second hook budget on mature vaults.
|
|
2373
|
+
await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId }, { deferSupersede: true });
|
|
2229
2374
|
}
|
|
2230
2375
|
}
|
|
2231
2376
|
catch {
|
|
@@ -46,6 +46,15 @@ export declare function signRequest(input: {
|
|
|
46
46
|
timestamp?: string;
|
|
47
47
|
nonce: string;
|
|
48
48
|
}): Record<string, string>;
|
|
49
|
+
/** One signed control-plane call. Unsigned fetches are not a client of this plane. */
|
|
50
|
+
export declare function signedControlPlaneFetch(input: {
|
|
51
|
+
origin: string;
|
|
52
|
+
method: "GET" | "POST" | "PUT";
|
|
53
|
+
path: string;
|
|
54
|
+
deviceId: string;
|
|
55
|
+
privateKey: KeyObject | string;
|
|
56
|
+
body?: string;
|
|
57
|
+
}): Promise<Response>;
|
|
49
58
|
export type AuthFailure = {
|
|
50
59
|
ok: false;
|
|
51
60
|
status: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, createPublicKey, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, randomUUID, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
|
|
2
2
|
/**
|
|
3
3
|
* Device authentication for the control plane.
|
|
4
4
|
*
|
|
@@ -49,7 +49,7 @@ export function signingString(method, path, timestamp, nonce, body, prefix = SIG
|
|
|
49
49
|
export function signRequest(input) {
|
|
50
50
|
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
51
51
|
const message = signingString(input.method, input.path, timestamp, input.nonce, input.body ?? "");
|
|
52
|
-
const key = typeof input.privateKey === "string" ?
|
|
52
|
+
const key = typeof input.privateKey === "string" ? createPrivateKey(input.privateKey) : input.privateKey;
|
|
53
53
|
return {
|
|
54
54
|
[SENDS.device]: input.deviceId,
|
|
55
55
|
[SENDS.timestamp]: timestamp,
|
|
@@ -57,6 +57,23 @@ export function signRequest(input) {
|
|
|
57
57
|
[SENDS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/** One signed control-plane call. Unsigned fetches are not a client of this plane. */
|
|
61
|
+
export async function signedControlPlaneFetch(input) {
|
|
62
|
+
const body = input.body ?? "";
|
|
63
|
+
const headers = {
|
|
64
|
+
...signRequest({
|
|
65
|
+
method: input.method,
|
|
66
|
+
path: input.path,
|
|
67
|
+
deviceId: input.deviceId,
|
|
68
|
+
privateKey: input.privateKey,
|
|
69
|
+
body,
|
|
70
|
+
nonce: randomUUID()
|
|
71
|
+
})
|
|
72
|
+
};
|
|
73
|
+
if (body)
|
|
74
|
+
headers["content-type"] = "application/json";
|
|
75
|
+
return fetch(new URL(input.path, input.origin), { method: input.method, headers, body: body || undefined });
|
|
76
|
+
}
|
|
60
77
|
export class NonceCache {
|
|
61
78
|
windowMs;
|
|
62
79
|
limit;
|
|
@@ -29,6 +29,25 @@ const completeSchema = z.object({
|
|
|
29
29
|
// The manifest is opaque ciphertext. A strict schema is what stops a future caller
|
|
30
30
|
// from "temporarily" posting plaintext transcript fields to this endpoint.
|
|
31
31
|
const manifestSchema = z.object({ ciphertext: z.string().min(1).max(256 * 1024) }).strict();
|
|
32
|
+
const rollupSchema = z.object({
|
|
33
|
+
version: z.literal(1),
|
|
34
|
+
archiveId: z.string().uuid(),
|
|
35
|
+
scannedAt: z.string().datetime(),
|
|
36
|
+
counts: z.array(z.object({
|
|
37
|
+
kind: z.string().regex(/^[a-z][a-z0-9-]{1,40}$/),
|
|
38
|
+
severity: z.enum(["high", "medium"]),
|
|
39
|
+
count: z.number().int().positive().max(10_000)
|
|
40
|
+
})).max(64)
|
|
41
|
+
}).strict();
|
|
42
|
+
const scanPolicySchema = z.object({
|
|
43
|
+
mode: z.enum(["off", "record", "block-high"]),
|
|
44
|
+
failClosed: z.boolean(),
|
|
45
|
+
patterns: z.array(z.object({
|
|
46
|
+
kind: z.string().regex(/^[a-z][a-z0-9-]{1,40}$/),
|
|
47
|
+
severity: z.enum(["high", "medium"]),
|
|
48
|
+
source: z.string().min(4).max(200)
|
|
49
|
+
})).max(32)
|
|
50
|
+
}).strict();
|
|
32
51
|
const enrollSchema = z.object({
|
|
33
52
|
accountId: z.string().uuid(),
|
|
34
53
|
enrollmentToken: z.string().min(8).max(200),
|
|
@@ -255,6 +274,34 @@ export function createControlPlaneServer(options = {}) {
|
|
|
255
274
|
status = 200;
|
|
256
275
|
return json(response, 200, store.archivesFor(device.accountId).map(({ archiveId, bytes, objectKey, durableAt }) => ({ archiveId, bytes, objectKey, durableAt: durableAt ?? null })));
|
|
257
276
|
}
|
|
277
|
+
if (method === "GET" && url.pathname === "/v1/secret-scan/policy") {
|
|
278
|
+
status = 200;
|
|
279
|
+
const policy = store.secretScanPolicy(device.accountId);
|
|
280
|
+
return json(response, 200, policy
|
|
281
|
+
? { version: 1, mode: policy.mode, failClosed: policy.failClosed, patterns: policy.patterns, updatedAt: policy.updatedAt }
|
|
282
|
+
: { version: 1, mode: "off", failClosed: false, patterns: [], updatedAt: null });
|
|
283
|
+
}
|
|
284
|
+
if (method === "PUT" && url.pathname === "/v1/secret-scan/policy") {
|
|
285
|
+
const input = parse(scanPolicySchema, body);
|
|
286
|
+
const saved = store.setSecretScanPolicy({ accountId: device.accountId, updatedAt: new Date(now()).toISOString(), mode: input.mode, failClosed: input.failClosed, patterns: input.patterns });
|
|
287
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "secret-scan.policy", outcome: "allowed", detail: { mode: saved.mode, patterns: saved.patterns.length } });
|
|
288
|
+
status = 200;
|
|
289
|
+
return json(response, 200, { version: 1, mode: saved.mode, failClosed: saved.failClosed, patterns: saved.patterns, updatedAt: saved.updatedAt });
|
|
290
|
+
}
|
|
291
|
+
if (method === "POST" && url.pathname === "/v1/secret-scan/rollups") {
|
|
292
|
+
const input = parse(rollupSchema, body);
|
|
293
|
+
const saved = store.addSecretScanRollup({
|
|
294
|
+
id: randomUUID(), accountId: device.accountId, deviceId: device.id, archiveId: input.archiveId,
|
|
295
|
+
scannedAt: input.scannedAt, receivedAt: new Date(now()).toISOString(), counts: input.counts
|
|
296
|
+
}, now());
|
|
297
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "secret-scan.rollup", outcome: "allowed", detail: { archiveId: input.archiveId, counts: input.counts.reduce((total, item) => total + item.count, 0) } });
|
|
298
|
+
status = 201;
|
|
299
|
+
return json(response, 201, { id: saved.id, archiveId: saved.archiveId, counts: saved.counts });
|
|
300
|
+
}
|
|
301
|
+
if (method === "GET" && url.pathname === "/v1/secret-scan/rollups") {
|
|
302
|
+
status = 200;
|
|
303
|
+
return json(response, 200, store.secretScanRollups(device.accountId).map(({ archiveId, scannedAt, receivedAt, counts }) => ({ archiveId, scannedAt, receivedAt, counts })));
|
|
304
|
+
}
|
|
258
305
|
if (method === "GET" && url.pathname === "/v1/audit") {
|
|
259
306
|
status = 200;
|
|
260
307
|
return json(response, 200, store.auditEvents().filter((event) => !event.accountId || event.accountId === device.accountId).slice(-200));
|