failproofai 1.0.4-beta.2 → 1.0.4-beta.6
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +5 -5
- package/.next/standalone/.next/server/app/audit/page.js +3 -3
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +4 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +4 -4
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +3 -3
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +2 -2
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1lh7m5d._.js → [externals]__0v1hz15._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1rqkg_y._.js → [externals]__1nb206a._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_08w6xzm._.js → _0otft92._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
- package/.next/standalone/.next/server/chunks/_1vslkoe._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0-4ja6q._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__00cr3xu._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__04px77e._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__190won8._.js → [root-of-the-server]__0dxf928._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0s740oi._.js → [root-of-the-server]__0egr5s1._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01wy8d-._.js → [root-of-the-server]__0i0l1di._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0n_lxhg._.js → [root-of-the-server]__0kokc8z._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ctpynv._.js → [root-of-the-server]__0n2txhk._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__0r3c1_0._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__17xnxlx._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__1ckpmkk._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00-s7h8._.js → [root-of-the-server]__1fvfw59._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ui5ur8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_02b3i98._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/{_0oq1dh6._.js → _04ng_6x._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1u8-lu2._.js → _07t3jol._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0h_douw._.js → _0s-77v5._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1_ozgif._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +4 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +22 -22
- package/.next/standalone/.next/static/chunks/{1pb1oztsbwcss.js → 07c6l1lq5_69b.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0o-hh5_turzlz.css → 0jpd8dv2vk930.css} +1 -1
- package/.next/standalone/.next/static/chunks/{32spub4wqjem-.js → 12bsgz8czvcnn.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3m4upvybtrexd.js → 1gn08w2pgqh09.js} +1 -1
- package/.next/standalone/.next/static/chunks/{010bv1w6j171t.js → 1xgq22tri86an.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2aquitk72k2op.js → 20xkiqzvfkuvm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2bi_1y0a_smt7.js → 2a84ug32tzik2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2ej3b8gk5ittu.js → 32m2ryfankf_2.js} +1 -1
- package/.next/standalone/.next/static/chunks/3ioub_lpen-sq.js +1 -0
- package/.next/standalone/.next/static/chunks/{0wz8yftk18ts2.js → 3jxd133cw2vm3.js} +1 -1
- package/.next/standalone/.next/static/chunks/44ncu88eq6z71.js +1 -0
- package/.next/standalone/app/actions/get-leaks.ts +5 -1
- package/.next/standalone/app/actions/update-scheduled-audit.ts +4 -56
- package/.next/standalone/app/api/auth/status/route.ts +3 -2
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +29 -2
- package/.next/standalone/app/audit/_components/leak-section.tsx +15 -2
- package/.next/standalone/app/audit/audit-styles.css +18 -0
- package/.next/standalone/app/settings/page.tsx +1 -1
- package/.next/standalone/app/settings/settings-client.tsx +13 -39
- package/.next/standalone/lib/auth/api-server-client.ts +6 -6
- package/.next/standalone/lib/codex-sessions.ts +20 -4
- package/.next/standalone/lib/copilot-sessions.ts +32 -1
- package/.next/standalone/lib/cursor-sessions.ts +62 -5
- package/.next/standalone/lib/sqlite-reader.ts +78 -1
- package/.next/standalone/package.json +10 -10
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +1 -1
- package/dist/cli.mjs +1191 -1130
- package/dist/worker.mjs +198 -359
- package/lib/auth/api-server-client.ts +6 -6
- package/lib/codex-sessions.ts +20 -4
- package/lib/copilot-sessions.ts +32 -1
- package/lib/cursor-sessions.ts +62 -5
- package/lib/sqlite-reader.ts +78 -1
- package/package.json +10 -10
- package/pi-extension/index.ts +11 -4
- package/src/audit/cache.ts +6 -0
- package/src/audit/cli-adapters/claude.ts +13 -0
- package/src/audit/cli-login.ts +4 -1
- package/src/audit/cli.ts +67 -51
- package/src/audit/harm-report.ts +12 -6
- package/src/audit/index.ts +151 -8
- package/src/audit/leak-fingerprint.ts +2 -4
- package/src/audit/leak-record.ts +13 -9
- package/src/audit/leak-scan.ts +20 -44
- package/src/audit/leak-store.ts +5 -2
- package/src/audit/machine-store.ts +8 -9
- package/src/audit/macos-notifier.ts +2 -2
- package/src/audit/redact-example.ts +62 -5
- package/src/audit/report-harm.ts +31 -46
- package/src/audit/schedule-cli.ts +17 -33
- package/src/audit/types.ts +11 -0
- package/src/hooks/configure-wizard.ts +28 -0
- package/src/hooks/fp-config.ts +29 -54
- package/src/hooks/handler.ts +1 -59
- package/src/hooks/integrations.ts +4 -1
- package/src/hooks/notice.ts +23 -154
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +0 -23
- package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +0 -1
- package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +0 -1
- package/src/audit/leak-notice.ts +0 -161
- /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_ssgManifest.js +0 -0
package/src/audit/cli.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the dashboard to view it.
|
|
4
4
|
*
|
|
5
5
|
* failproofai audit Scan, then launch the dashboard at /audit.
|
|
6
|
-
* failproofai audit --schedule Put scans on a timer
|
|
6
|
+
* failproofai audit --schedule Put local scans and notifications on a timer.
|
|
7
7
|
* failproofai audit --no-schedule Stop the timer.
|
|
8
8
|
* failproofai audit --status What this machine is scheduled to do.
|
|
9
9
|
* failproofai audit -h, --help Show usage.
|
|
@@ -37,8 +37,9 @@ import { openWhenReady } from "./open-browser";
|
|
|
37
37
|
import { describeOutcome, reportHarm } from "./report-harm";
|
|
38
38
|
import { notifyDesktop } from "./desktop-notify";
|
|
39
39
|
import { macNotifierInstalled, pruneMacNotifyQueue, queueMacNotification } from "./macos-notifier";
|
|
40
|
-
import {
|
|
40
|
+
import { desktopLeakNotice, type DesktopLeakEmailState } from "../hooks/notice";
|
|
41
41
|
import { readConfig } from "../hooks/fp-config";
|
|
42
|
+
import { readAuth } from "../../lib/auth/auth-store";
|
|
42
43
|
import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM, helpScreen, helpOptsFor } from "../hooks/tui";
|
|
43
44
|
import { version } from "../../package.json";
|
|
44
45
|
|
|
@@ -96,32 +97,36 @@ export function helpText(): string {
|
|
|
96
97
|
{
|
|
97
98
|
command: "audit",
|
|
98
99
|
version,
|
|
99
|
-
tagline: "
|
|
100
|
+
tagline: "find credentials your agents leaked into their own transcripts",
|
|
100
101
|
sections: [
|
|
101
102
|
{
|
|
102
103
|
label: "usage",
|
|
103
104
|
entries: [
|
|
104
|
-
["(bare)", `Scan your session history, then open http://localhost:${DASHBOARD_PORT}/audit`],
|
|
105
|
-
["--schedule [days]", "Scan on a timer and
|
|
105
|
+
["(bare)", `Scan your session history for leaked keys, then open http://localhost:${DASHBOARD_PORT}/audit`],
|
|
106
|
+
["--schedule [days]", "Scan on a timer and notify you on this machine. Default 7 days, range 1-90. No account required."],
|
|
106
107
|
// Its own row now, rather than a clause inside --schedule's. It
|
|
107
108
|
// does not stand alone, which is why it used to be a clause — but
|
|
108
109
|
// a clause wraps, and `--email <address>` landing with the flag at
|
|
109
110
|
// the end of one line and its placeholder at the start of the next
|
|
110
111
|
// is not a flag anybody can read or copy.
|
|
111
|
-
["--email <address>", "With --schedule,
|
|
112
|
+
["--email <address>", "With --schedule, add email alerts for the most recent masked credential leak. A sign-in code is emailed to you."],
|
|
112
113
|
["--no-schedule", "Stop the timer. Leaves you signed in."],
|
|
113
114
|
// Its own pair of flags rather than a clause on --schedule: whether
|
|
114
115
|
// this machine scans and whether it may interrupt you are separate
|
|
115
116
|
// decisions, and nobody silencing a banner should have to re-state
|
|
116
117
|
// their schedule to do it.
|
|
117
118
|
["--notify", "Show a desktop notification when a scan finds a credential. On by default."],
|
|
118
|
-
["--no-notify", "Stop
|
|
119
|
+
["--no-notify", "Stop desktop notifications. Scheduled scans and optional email alerts are unaffected."],
|
|
119
120
|
["--status", "Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due."],
|
|
120
121
|
["-h, --help", "Show this help."],
|
|
121
122
|
],
|
|
122
123
|
},
|
|
123
124
|
],
|
|
124
|
-
footer: [
|
|
125
|
+
footer: [
|
|
126
|
+
"Every scan runs on this machine. Leak alerts appear as desktop system",
|
|
127
|
+
"notifications, never inside agent sessions. If you add email, only the",
|
|
128
|
+
"newest masked credential exposure and its metadata leave the machine.",
|
|
129
|
+
],
|
|
125
130
|
},
|
|
126
131
|
helpOptsFor(process.stdout),
|
|
127
132
|
);
|
|
@@ -209,6 +214,18 @@ function startProgress(): Progress {
|
|
|
209
214
|
const lines = Array.from({ length: n }, (_, i) => lineFor(i));
|
|
210
215
|
// Move the cursor back up over the previously-drawn block, then clear and
|
|
211
216
|
// rewrite each line in place.
|
|
217
|
+
//
|
|
218
|
+
// INVARIANT: nothing else may write to the terminal while this is running.
|
|
219
|
+
// The cursor-up is a fixed count, so any stray line pushes the cursor down
|
|
220
|
+
// and the next redraw repaints the block lower — stranding the top of the
|
|
221
|
+
// old frame above it, which reads as the audit having run twice. That is
|
|
222
|
+
// not hypothetical: Node's "SQLite is an experimental feature" warning did
|
|
223
|
+
// exactly this on every first run, two lines of it, and it is why
|
|
224
|
+
// `lib/sqlite-reader.ts` now filters that one warning at the source.
|
|
225
|
+
//
|
|
226
|
+
// There is no defensive fix from inside here — erasing to end of screen
|
|
227
|
+
// still leaves the stranded lines ABOVE the cursor. Keep the terminal
|
|
228
|
+
// quiet instead.
|
|
212
229
|
if (printed) process.stdout.write(`\x1b[${n}A`);
|
|
213
230
|
process.stdout.write(lines.map((l) => `\x1b[2K${l}`).join("\n") + "\n");
|
|
214
231
|
printed = true;
|
|
@@ -360,9 +377,12 @@ function heldByLine(held: AuditLockInfo | null): string {
|
|
|
360
377
|
* turning a good scan into exit 1 would make the scheduler back off from
|
|
361
378
|
* running the thing that actually matters.
|
|
362
379
|
*/
|
|
363
|
-
async function announceLeaksOnDesktop(
|
|
380
|
+
async function announceLeaksOnDesktop(
|
|
381
|
+
result: AuditResult,
|
|
382
|
+
report: Awaited<ReturnType<typeof reportHarm>>,
|
|
383
|
+
): Promise<void> {
|
|
364
384
|
try {
|
|
365
|
-
await announceLeaksOrThrow(result);
|
|
385
|
+
await announceLeaksOrThrow(result, report);
|
|
366
386
|
} catch (err) {
|
|
367
387
|
// The catch is not decoration. `notifyDesktop` documents that it never
|
|
368
388
|
// throws, but it is doing socket I/O, and so are the config read and the
|
|
@@ -376,42 +396,41 @@ async function announceLeaksOnDesktop(result: AuditResult): Promise<void> {
|
|
|
376
396
|
}
|
|
377
397
|
}
|
|
378
398
|
|
|
379
|
-
async function announceLeaksOrThrow(
|
|
380
|
-
|
|
399
|
+
async function announceLeaksOrThrow(
|
|
400
|
+
result: AuditResult,
|
|
401
|
+
report: Awaited<ReturnType<typeof reportHarm>>,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
// `leakIds` includes credentials this scan encountered before. A scheduled
|
|
404
|
+
// run is a fresh safety check, so it must raise a fresh system notification
|
|
405
|
+
// when the leak is still present; the old per-finding marker is why only the
|
|
406
|
+
// first test run ever produced a banner. Older cached results fall back to
|
|
407
|
+
// first-seen ids.
|
|
408
|
+
const ids = result.leakIds ?? result.newLeakIds ?? [];
|
|
381
409
|
if (ids.length === 0) return;
|
|
382
410
|
// Read at fire time, not at scan time: the switch answers "should this
|
|
383
411
|
// interrupt me", and it is the user's most recent answer that counts.
|
|
384
412
|
if (!readConfig().audit.notify) return;
|
|
385
413
|
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const n = claimed.length === 1 ? "a credential" : `${claimed.length} credentials`;
|
|
394
|
-
const title = "failproofai found a leaked credential";
|
|
395
|
-
// Fixed text with a count interpolated — the same rule as the in-CLI notice,
|
|
396
|
-
// for the same reason. A finding's own text comes from a repository this
|
|
397
|
-
// machine cloned, and notification bodies render markup on several Linux
|
|
398
|
-
// desktops.
|
|
399
|
-
//
|
|
400
|
-
// The second sentence is the only place most users are ever offered the
|
|
401
|
-
// digest. The scan is local and needs no account, so nothing else in the
|
|
402
|
-
// product has a reason to ask for an address — which is exactly why the
|
|
403
|
-
// audit's findings have historically reached nobody. A banner someone is
|
|
404
|
-
// already reading, about a key of their own, is the one moment the offer is
|
|
405
|
-
// worth anything.
|
|
414
|
+
const n = ids.length === 1 ? "a possible credential exposure" : `${ids.length} possible credential exposures`;
|
|
415
|
+
// Fixed text with no scanned content and no count. A finding's own text comes
|
|
416
|
+
// from a repository this machine cloned, and notification bodies render
|
|
417
|
+
// markup on several Linux desktops. The count is intentionally left to the
|
|
418
|
+
// report too: a banner must not turn detector candidates into a claim that
|
|
419
|
+
// the user leaked N real credentials.
|
|
406
420
|
//
|
|
407
421
|
// No action buttons, deliberately. `Notify` supports them, but a server
|
|
408
422
|
// delivers the click back as an `ActionInvoked` signal to the sender — and
|
|
409
423
|
// this process exits as soon as the scan finishes, so the button would be
|
|
410
|
-
// dead.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
424
|
+
// dead. A plain command the user can copy beats a button that does nothing.
|
|
425
|
+
const emailState: DesktopLeakEmailState =
|
|
426
|
+
report.kind === "sent"
|
|
427
|
+
? "sent"
|
|
428
|
+
: report.kind === "signed-out" || !readAuth()
|
|
429
|
+
? "missing"
|
|
430
|
+
: report.kind === "failed"
|
|
431
|
+
? "failed"
|
|
432
|
+
: "held";
|
|
433
|
+
const { title, body } = desktopLeakNotice(emailState);
|
|
415
434
|
|
|
416
435
|
// The two platforms need opposite things, and the split is not cosmetic. On
|
|
417
436
|
// Linux this process can reach the session bus itself. On macOS it cannot
|
|
@@ -420,12 +439,12 @@ async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
|
|
|
420
439
|
// one. See `macos-notifier.ts` for why that agent exists.
|
|
421
440
|
if (process.platform === "darwin") {
|
|
422
441
|
pruneMacNotifyQueue();
|
|
423
|
-
if (!queueMacNotification(
|
|
442
|
+
if (!queueMacNotification(ids[0], title, body)) {
|
|
424
443
|
process.stderr.write(`failproofai: found ${n} but could not queue a notification\n`);
|
|
425
444
|
} else if (!macNotifierInstalled()) {
|
|
426
|
-
// Queued, but nothing
|
|
427
|
-
//
|
|
428
|
-
//
|
|
445
|
+
// Queued, but nothing currently collects it. The payload is durable and
|
|
446
|
+
// will be consumed when the LaunchAgent returns.
|
|
447
|
+
// Worth a line, because the immediate symptom is otherwise silence.
|
|
429
448
|
process.stderr.write(
|
|
430
449
|
`failproofai: found ${n}; the notifier is not installed, so nothing will show it ` +
|
|
431
450
|
`(run \`failproofai config\`)\n`,
|
|
@@ -437,10 +456,9 @@ async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
|
|
|
437
456
|
const outcome = await notifyDesktop(
|
|
438
457
|
title,
|
|
439
458
|
body,
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
// bounded: at most one per distinct credential, ever.
|
|
459
|
+
// `0` asks the notification server for a fresh id. Each scheduled scan that
|
|
460
|
+
// still sees a leak is a new reminder, rather than replacing or suppressing
|
|
461
|
+
// the first banner forever.
|
|
444
462
|
0,
|
|
445
463
|
);
|
|
446
464
|
if (!outcome.ok) {
|
|
@@ -523,12 +541,9 @@ export async function runScheduledAudit(): Promise<number> {
|
|
|
523
541
|
`${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
|
|
524
542
|
);
|
|
525
543
|
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
await announceLeaksOnDesktop(result);
|
|
530
|
-
|
|
531
|
-
// Report harmful findings upstream, if the user switched emailed reports on.
|
|
544
|
+
// Email is optional and never controls the local scan. Run it first only so
|
|
545
|
+
// the desktop notification can truthfully say whether this particular
|
|
546
|
+
// alert was also emailed; every failure remains a non-fatal outcome.
|
|
532
547
|
//
|
|
533
548
|
// AFTER the dashboard cache is written and AFTER the success line, because
|
|
534
549
|
// the scan is the product and this is an optional extra on top of it.
|
|
@@ -542,6 +557,7 @@ export async function runScheduledAudit(): Promise<number> {
|
|
|
542
557
|
// would also make the manual command do a network call that
|
|
543
558
|
// `audit --help` promises it does not.
|
|
544
559
|
const outcome = await reportHarm(result);
|
|
560
|
+
await announceLeaksOnDesktop(result, outcome);
|
|
545
561
|
const line = describeOutcome(outcome);
|
|
546
562
|
if (line) {
|
|
547
563
|
// Anything other than a successful send goes to stderr: on a scheduled run
|
package/src/audit/harm-report.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turning an audit result into a harm report the api-server can act on.
|
|
3
3
|
*
|
|
4
|
-
* Runs only after a SCHEDULED scan (`failproofai audit --scheduled`)
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Runs only after a SCHEDULED scan (`failproofai audit --scheduled`) and only
|
|
5
|
+
* ever from the audit child — never the daemon, which holds no human credential
|
|
6
|
+
* precisely so that refresh rotation stays inside the audit lock. Without an
|
|
7
|
+
* email identity the scan and desktop notification still work; this module
|
|
8
|
+
* simply reports `signed-out` and sends nothing.
|
|
8
9
|
*
|
|
9
10
|
* ## What counts as harm
|
|
10
11
|
*
|
|
@@ -302,7 +303,10 @@ export function selectLeaks(findings: LeakFinding[], from: Date, to: Date): Repo
|
|
|
302
303
|
// is now, not where it was first noticed.
|
|
303
304
|
// Defensive even though `readLeakRecord` already sanitises: this is
|
|
304
305
|
// exported and takes whatever a caller hands it.
|
|
305
|
-
const seen = f.sightings?.
|
|
306
|
+
const seen = f.sightings?.reduce((latest, candidate) => {
|
|
307
|
+
if (!latest) return candidate;
|
|
308
|
+
return (ts(candidate.at) ?? 0) >= (ts(latest.at) ?? 0) ? candidate : latest;
|
|
309
|
+
}, undefined as LeakFinding["sightings"][number] | undefined);
|
|
306
310
|
out.push({
|
|
307
311
|
id: f.id,
|
|
308
312
|
display: f.fingerprint?.display ?? "[credential]",
|
|
@@ -356,6 +360,8 @@ export function buildHarmReport(
|
|
|
356
360
|
window_from: from.toISOString(),
|
|
357
361
|
window_to: windowTo.toISOString(),
|
|
358
362
|
harmful: selectHarmful(result, from, windowTo, { includeUnplaceable: isFirstReport }),
|
|
359
|
-
|
|
363
|
+
// One email, one actionable credential. Sending every row turns a leak
|
|
364
|
+
// alert into a backlog digest and obscures the event that just happened.
|
|
365
|
+
leaks: selectLeaks(leakFindings, from, windowTo).slice(0, 1),
|
|
360
366
|
};
|
|
361
367
|
}
|
package/src/audit/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { severityForBuiltin } from "./features";
|
|
|
19
19
|
import { findSecrets, flattenToolInput } from "./leak-scan";
|
|
20
20
|
import { fingerprintSecret, fingerprintId } from "./leak-fingerprint";
|
|
21
21
|
import { readLeakIdentity, readLeakRecord, writeLeakRecord } from "./leak-store";
|
|
22
|
-
import { upsertFinding, describeMechanism } from "./leak-record";
|
|
22
|
+
import { upsertFinding, describeMechanism, emptyRecord } from "./leak-record";
|
|
23
23
|
import { shortenPaths } from "./redact-example";
|
|
24
24
|
import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
|
|
25
25
|
import { initReplay, replayEvent, restoreReplay } from "./replay";
|
|
@@ -37,6 +37,64 @@ import {
|
|
|
37
37
|
|
|
38
38
|
const TRANSCRIPT_CONCURRENCY = 8;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Raw transcript bytes allowed in flight at once.
|
|
42
|
+
*
|
|
43
|
+
* The concurrency above counts FILES, which is the wrong unit. `streamEventsFrom`
|
|
44
|
+
* returns an array — despite the name it materialises every event of a
|
|
45
|
+
* transcript into JS objects — and a 57 MB JSONL becomes several hundred MB of
|
|
46
|
+
* them. On a real 1.15 GB corpus, 7 files hold 224 MB and the largest is
|
|
47
|
+
* 57.5 MB, so eight workers could put a quarter of the corpus in memory
|
|
48
|
+
* simultaneously. That machine printed `Aborted(OOM)` 22 times in one run.
|
|
49
|
+
*
|
|
50
|
+
* This is NOT what fixed those lines, and the distinction is worth keeping
|
|
51
|
+
* honest: they were measured at ~22 per run, appear within the first two
|
|
52
|
+
* seconds, are unchanged by this budget at 48 MB or at 16 MB, and do not fail
|
|
53
|
+
* the run — its counts come back correct. Their source is still unidentified.
|
|
54
|
+
* This bounds a real and separate failure mode (eight of the seven largest
|
|
55
|
+
* files in flight at once); it should not be read as having fixed the other.
|
|
56
|
+
*
|
|
57
|
+
* 48 MB of raw input is the budget because the expansion factor from JSONL to
|
|
58
|
+
* parsed objects is roughly 5-10x, which keeps the peak inside a default heap
|
|
59
|
+
* with room for the eight small-file workers this is meant not to slow down.
|
|
60
|
+
* Chosen to bound memory, not to be exactly right: the failure it prevents is a
|
|
61
|
+
* crash, and the cost of being conservative is that one very large file scans
|
|
62
|
+
* alone for a moment.
|
|
63
|
+
*/
|
|
64
|
+
const TRANSCRIPT_BYTE_BUDGET = 48 * 1024 * 1024;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Admission control by weight, so a worker waits for MEMORY as well as a slot.
|
|
68
|
+
*
|
|
69
|
+
* A file bigger than the whole budget is admitted alone rather than refused —
|
|
70
|
+
* otherwise the largest transcript on the machine could never be scanned, which
|
|
71
|
+
* is precisely the one most likely to hold something.
|
|
72
|
+
*/
|
|
73
|
+
class ByteGate {
|
|
74
|
+
private inFlight = 0;
|
|
75
|
+
private waiting: Array<() => void> = [];
|
|
76
|
+
|
|
77
|
+
constructor(private readonly budget: number) {}
|
|
78
|
+
|
|
79
|
+
async acquire(bytes: number): Promise<void> {
|
|
80
|
+
const want = Math.max(0, bytes);
|
|
81
|
+
while (this.inFlight > 0 && this.inFlight + want > this.budget) {
|
|
82
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
|
83
|
+
}
|
|
84
|
+
this.inFlight += want;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
release(bytes: number): void {
|
|
88
|
+
this.inFlight -= Math.max(0, bytes);
|
|
89
|
+
if (this.inFlight < 0) this.inFlight = 0;
|
|
90
|
+
// Wake everyone and let each re-test its own weight: a small task behind a
|
|
91
|
+
// large one should not be held by a queue position it does not need.
|
|
92
|
+
const waiters = this.waiting;
|
|
93
|
+
this.waiting = [];
|
|
94
|
+
for (const wake of waiters) wake();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
40
98
|
/** Canonicalize a policy name to its short, qualified form for display
|
|
41
99
|
* (`failproofai/foo` → `foo`). */
|
|
42
100
|
function shortPolicyName(name: string): string {
|
|
@@ -118,6 +176,50 @@ interface ScanOutcome {
|
|
|
118
176
|
resumed: boolean;
|
|
119
177
|
}
|
|
120
178
|
|
|
179
|
+
const CREDENTIAL_RESEARCH_MARKERS: ReadonlyArray<RegExp> = [
|
|
180
|
+
/\b(?:secret|credential)[-_ ](?:scanner|detection|detector|pattern|regex|corpus)\b/i,
|
|
181
|
+
/\b(?:trufflehog|trufflesecurity|gitleaks|detect-secrets|secret-detection-rules)\b/i,
|
|
182
|
+
/\b(?:SECRET_PATTERNS|findSecrets|isDocsLiteral|leak-scan)\b/,
|
|
183
|
+
/\b(?:fixture|placeholder|synthetic|sample) (?:api )?(?:key|token|secret|credential)s?\b/i,
|
|
184
|
+
/\b(?:grep|rg|ripgrep|scan|hunt|search)\b[^\n]{0,240}\b(?:secret|credential|token)s?\b/i,
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const CREDENTIAL_RESEARCH_PURPOSE =
|
|
188
|
+
/\b(?:hunt|scan|search|research|test)(?:ing)?\b[^\n]{0,80}\b(?:secret|credential|token)s?\b|\b(?:secret|credential|token)s?\b[^\n]{0,80}\b(?:scanner|detector|detection|research|fixtures?|corpus)\b/i;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Deliberate secret-detector research is full of realistic fake credentials.
|
|
192
|
+
* Reporting those as leaks is the feedback loop that made one measured session
|
|
193
|
+
* contribute 455 of the 500 rows in its own audit.
|
|
194
|
+
*
|
|
195
|
+
* Require independent signals across tool INPUTS. Results are excluded from
|
|
196
|
+
* classification: a normal command can print hostile text, while commands and
|
|
197
|
+
* URLs state what the agent intentionally set out to inspect. Two signals keep
|
|
198
|
+
* an ordinary discussion that happens to say "secret scanner" from silencing a
|
|
199
|
+
* session; dedicated detector/test work reliably carries several.
|
|
200
|
+
*/
|
|
201
|
+
export function isCredentialResearchSession(
|
|
202
|
+
events: NormalizedToolEvent[],
|
|
203
|
+
sessionDescription?: string,
|
|
204
|
+
): boolean {
|
|
205
|
+
if (sessionDescription && CREDENTIAL_RESEARCH_PURPOSE.test(sessionDescription)) return true;
|
|
206
|
+
const matched = new Set<number>();
|
|
207
|
+
let evidenceEvents = 0;
|
|
208
|
+
for (const event of events) {
|
|
209
|
+
const intent = flattenToolInput(event.toolInput);
|
|
210
|
+
let eventMatched = false;
|
|
211
|
+
for (let i = 0; i < CREDENTIAL_RESEARCH_MARKERS.length; i++) {
|
|
212
|
+
if (CREDENTIAL_RESEARCH_MARKERS[i].test(intent)) {
|
|
213
|
+
matched.add(i);
|
|
214
|
+
eventMatched = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (eventMatched) evidenceEvents++;
|
|
218
|
+
if (matched.size >= 2 || evidenceEvents >= 3) return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
121
223
|
async function scanOneTranscript(
|
|
122
224
|
meta: TranscriptMetadata,
|
|
123
225
|
resume?: { fromByte: number; detectorState: DetectorSessionState },
|
|
@@ -186,6 +288,8 @@ async function scanOneTranscript(
|
|
|
186
288
|
// Capture the session's cwd from the first event that carried one — every
|
|
187
289
|
// event in a single transcript shares the same cwd by construction.
|
|
188
290
|
result.cwd = result.cwd || events[0].cwd || "";
|
|
291
|
+
const suppressLeakScan = isCredentialResearchSession(events, meta.sessionDescription);
|
|
292
|
+
if (suppressLeakScan) result.leakScanSuppressed = "credential-research";
|
|
189
293
|
|
|
190
294
|
for (const event of events) {
|
|
191
295
|
// The 8 behavioural detectors are switched off with the rest of the old
|
|
@@ -226,7 +330,7 @@ async function scanOneTranscript(
|
|
|
226
330
|
// decision per policy, never the text that matched. Scanned separately so
|
|
227
331
|
// the leak report can name WHICH key, and so detection can be tuned for
|
|
228
332
|
// precision without dragging the redactor's recall down with it.
|
|
229
|
-
recordLeaks(result, event);
|
|
333
|
+
if (!suppressLeakScan) recordLeaks(result, event);
|
|
230
334
|
}
|
|
231
335
|
|
|
232
336
|
return { result, bytesScanned, detectorState: sessionState, resumed };
|
|
@@ -300,10 +404,19 @@ function leakSalt(): string | null {
|
|
|
300
404
|
* human about. Never throws: a scan that finds leaks it cannot write down has
|
|
301
405
|
* still found them, and the caller reports on the in-memory result either way.
|
|
302
406
|
*/
|
|
303
|
-
function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
|
|
407
|
+
export function persistLeaks(perTranscript: TranscriptAuditResult[], replaceExisting: boolean): string[] {
|
|
304
408
|
const fresh: string[] = [];
|
|
305
409
|
try {
|
|
306
|
-
const
|
|
410
|
+
const previous = readLeakRecord();
|
|
411
|
+
const previousIds = new Set(previous.findings.map((f) => f.id));
|
|
412
|
+
// A successful all-history scan is authoritative. Rebuilding is what lets
|
|
413
|
+
// detector precision fixes remove findings they now reject; merging made
|
|
414
|
+
// every false positive live for 90 days even after the code was fixed.
|
|
415
|
+
// Scoped or incomplete scans still merge because absence outside their
|
|
416
|
+
// coverage says nothing.
|
|
417
|
+
const record = replaceExisting
|
|
418
|
+
? emptyRecord(previous.salt, new Date().toISOString())
|
|
419
|
+
: previous;
|
|
307
420
|
for (const t of perTranscript) {
|
|
308
421
|
for (const leak of t.leaks ?? []) {
|
|
309
422
|
const { isNew } = upsertFinding(record, {
|
|
@@ -322,7 +435,10 @@ function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
|
|
|
322
435
|
mechanism: describeMechanism(leak.toolName, leak.direction, leak.path),
|
|
323
436
|
},
|
|
324
437
|
});
|
|
325
|
-
|
|
438
|
+
// During an authoritative rebuild every insertion is new to the empty
|
|
439
|
+
// record, but only ids absent from the previous record are new to the
|
|
440
|
+
// HUMAN and eligible for a notification.
|
|
441
|
+
if (isNew && !previousIds.has(leak.id)) fresh.push(leak.id);
|
|
326
442
|
}
|
|
327
443
|
}
|
|
328
444
|
writeLeakRecord(record);
|
|
@@ -351,7 +467,7 @@ function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): s
|
|
|
351
467
|
* came from the FIRST event in the file and the tail's came from the first
|
|
352
468
|
* event after the offset, so the older one wins.
|
|
353
469
|
*/
|
|
354
|
-
function mergeIncremental(
|
|
470
|
+
export function mergeIncremental(
|
|
355
471
|
cached: TranscriptAuditResult,
|
|
356
472
|
tail: TranscriptAuditResult,
|
|
357
473
|
): TranscriptAuditResult {
|
|
@@ -365,6 +481,12 @@ function mergeIncremental(
|
|
|
365
481
|
examplesByName: {},
|
|
366
482
|
rangeByName: { ...cached.rangeByName },
|
|
367
483
|
};
|
|
484
|
+
if (cached.leakScanSuppressed || tail.leakScanSuppressed) {
|
|
485
|
+
out.leakScanSuppressed = "credential-research";
|
|
486
|
+
out.leaks = [];
|
|
487
|
+
} else {
|
|
488
|
+
out.leaks = [...(cached.leaks ?? []), ...(tail.leaks ?? [])];
|
|
489
|
+
}
|
|
368
490
|
for (const [name, list] of Object.entries(cached.examplesByName)) {
|
|
369
491
|
out.examplesByName[name] = [...list];
|
|
370
492
|
}
|
|
@@ -547,18 +669,22 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
547
669
|
|
|
548
670
|
// 1. Discover transcripts across all selected CLIs.
|
|
549
671
|
const allTranscripts: TranscriptMetadata[] = [];
|
|
672
|
+
let discoveryErrors = 0;
|
|
550
673
|
for (const cli of clis) {
|
|
551
674
|
const adapter = ADAPTERS[cli];
|
|
552
675
|
let list: TranscriptMetadata[];
|
|
553
676
|
try {
|
|
554
677
|
list = await adapter.listTranscripts({ projects: opts.projects, sinceMs });
|
|
555
678
|
} catch {
|
|
679
|
+
discoveryErrors++;
|
|
556
680
|
continue; // adapter failures shouldn't kill the whole audit
|
|
557
681
|
}
|
|
558
682
|
allTranscripts.push(...list);
|
|
559
683
|
}
|
|
560
684
|
|
|
561
|
-
// 2. Scan each transcript (cache-aware), 8
|
|
685
|
+
// 2. Scan each transcript (cache-aware), 8 at a time and within a memory
|
|
686
|
+
// budget — see TRANSCRIPT_BYTE_BUDGET for why a file count is the wrong unit.
|
|
687
|
+
const gate = new ByteGate(TRANSCRIPT_BYTE_BUDGET);
|
|
562
688
|
let skipped = 0;
|
|
563
689
|
let errors = 0;
|
|
564
690
|
const tasks = allTranscripts.map((meta) => async (): Promise<TranscriptAuditResult> => {
|
|
@@ -572,6 +698,9 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
572
698
|
: undefined;
|
|
573
699
|
cachedPrefix = found?.kind === "resume" ? found.result : null;
|
|
574
700
|
}
|
|
701
|
+
// Memory admission. Taken AFTER the cache check above, so a cache hit — the
|
|
702
|
+
// common case on a warm machine — never waits for a slot it does not use.
|
|
703
|
+
await gate.acquire(meta.sizeBytes);
|
|
575
704
|
try {
|
|
576
705
|
const scan = await scanOneTranscript(meta, resume);
|
|
577
706
|
// A resumed scan produced hits for the TAIL only; the cached result holds
|
|
@@ -615,6 +744,11 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
615
744
|
examplesByName: {},
|
|
616
745
|
rangeByName: {},
|
|
617
746
|
};
|
|
747
|
+
} finally {
|
|
748
|
+
// Released on every path, including the error one — a task that threw
|
|
749
|
+
// still freed its memory, and holding its weight would shrink the budget
|
|
750
|
+
// permanently over a long scan until nothing could be admitted at all.
|
|
751
|
+
gate.release(meta.sizeBytes);
|
|
618
752
|
}
|
|
619
753
|
});
|
|
620
754
|
|
|
@@ -652,10 +786,19 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
652
786
|
// not alert again however many fresh sightings it accumulates, and a
|
|
653
787
|
// credential seen for the first time must alert even though its rule has
|
|
654
788
|
// fired a thousand times before.
|
|
655
|
-
const
|
|
789
|
+
const coversAllHistory =
|
|
790
|
+
opts.clis === undefined &&
|
|
791
|
+
opts.projects === undefined &&
|
|
792
|
+
opts.since === undefined &&
|
|
793
|
+
discoveryErrors === 0 &&
|
|
794
|
+
errors === 0 &&
|
|
795
|
+
skipped === 0;
|
|
796
|
+
const newFindingIds = persistLeaks(perTranscript, coversAllHistory);
|
|
797
|
+
const leakIds = [...new Set(perTranscript.flatMap((t) => (t.leaks ?? []).map((leak) => leak.id)))];
|
|
656
798
|
|
|
657
799
|
const auditResult: AuditResult = {
|
|
658
800
|
version: 2,
|
|
801
|
+
leakIds,
|
|
659
802
|
newLeakIds: newFindingIds,
|
|
660
803
|
scannedAt: new Date(startedAt).toISOString(),
|
|
661
804
|
scope: {
|
|
@@ -178,10 +178,8 @@ export function fingerprintSecret(value: string, fallbackLabel = "secret"): Leak
|
|
|
178
178
|
/**
|
|
179
179
|
* Every id `fingerprintId` can produce, and nothing else.
|
|
180
180
|
*
|
|
181
|
-
* Ids become FILENAMES
|
|
182
|
-
*
|
|
183
|
-
* built from unvalidated input is a directory traversal. Measured: passing
|
|
184
|
-
* `"../../../../tmp/PWNED"` to `markLeakNoticeDelivered` created that file.
|
|
181
|
+
* Ids become FILENAMES for pending macOS banners, so an id is a path component,
|
|
182
|
+
* and a path component built from unvalidated input is a directory traversal.
|
|
185
183
|
*
|
|
186
184
|
* Ids are HMAC hex today, so nothing in a normal run can carry a `/`. That is
|
|
187
185
|
* not the same as safe: the record is JSON read off disk, and `leaks.json` is a
|
package/src/audit/leak-record.ts
CHANGED
|
@@ -156,15 +156,19 @@ export function upsertFinding(
|
|
|
156
156
|
if (sighting.at > existing.lastSeen) existing.lastSeen = sighting.at;
|
|
157
157
|
if (sighting.at < existing.firstSeen) existing.firstSeen = sighting.at;
|
|
158
158
|
|
|
159
|
-
// Keep the
|
|
160
|
-
// the
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
// Keep the earliest sighting AND the most recent evidence. The report/email
|
|
160
|
+
// promises the CLI and timestamp of the latest exposure; keeping only the
|
|
161
|
+
// first five made those fields stale forever after a busy credential crossed
|
|
162
|
+
// the cap. Middle sightings are the expendable ones.
|
|
163
|
+
const seen = existing.sightings.some(
|
|
164
|
+
(s) => s.sessionId === sighting.sessionId && s.at === sighting.at,
|
|
165
|
+
);
|
|
166
|
+
if (!seen) {
|
|
167
|
+
existing.sightings.push(sighting);
|
|
168
|
+
existing.sightings.sort((a, b) => a.at.localeCompare(b.at));
|
|
169
|
+
if (existing.sightings.length > MAX_SIGHTINGS) {
|
|
170
|
+
existing.sightings.splice(1, existing.sightings.length - MAX_SIGHTINGS);
|
|
171
|
+
}
|
|
168
172
|
}
|
|
169
173
|
return { record, isNew: false };
|
|
170
174
|
}
|