failproofai 1.0.3 → 1.0.4-beta.2
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 +5 -5
- package/.next/standalone/.next/prerender-manifest.json +5 -5
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- 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.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/build-manifest.json +2 -2
- 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.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 +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +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-request/route_client-reference-manifest.js +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/login-verify/route_client-reference-manifest.js +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/logout/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- 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 +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- 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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- 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/build-manifest.json +2 -2
- 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.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/build-manifest.json +2 -2
- 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.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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- 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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- 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]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.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]__1adacul._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.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/lib_telemetry_ts_0dctyyw._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- 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]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -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 -69
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
- 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/node_modules_13n822a._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.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 +5 -5
- 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 +54 -21
- package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
- package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
- package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
- package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
- package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
- package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
- package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
- package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
- package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
- package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
- package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
- package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
- package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
- package/.next/standalone/app/actions/get-leaks.ts +100 -0
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
- package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
- package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
- package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
- package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
- package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
- package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
- package/.next/standalone/app/audit/audit-styles.css +114 -1
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
- package/.next/standalone/lib/auth/api-server-client.ts +28 -0
- package/.next/standalone/lib/claude-sessions.ts +101 -27
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
- package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
- package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +11 -11
- package/.next/standalone/package.json +14 -13
- package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
- package/.next/standalone/sdk/python/README.md +8 -0
- package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
- package/.next/standalone/sdk/python/uv.lock +29 -29
- package/.next/standalone/server.js +1 -1
- package/README.md +13 -58
- package/dist/cli.mjs +5368 -8756
- package/dist/worker.mjs +539 -312
- package/lib/auth/api-server-client.ts +28 -0
- package/lib/claude-sessions.ts +101 -27
- package/package.json +14 -13
- package/pi-extension/index.ts +42 -4
- package/scripts/changelog-open.py +115 -0
- package/src/audit/cli.ts +134 -0
- package/src/audit/desktop-notify.ts +420 -0
- package/src/audit/harm-report.ts +101 -0
- package/src/audit/index.ts +141 -13
- package/src/audit/leak-fingerprint.ts +200 -0
- package/src/audit/leak-notice.ts +161 -0
- package/src/audit/leak-record.ts +232 -0
- package/src/audit/leak-scan.ts +292 -0
- package/src/audit/leak-store.ts +217 -0
- package/src/audit/macos-notifier.ts +310 -0
- package/src/audit/redact-example.ts +266 -14
- package/src/audit/report-harm.ts +12 -1
- package/src/audit/report.ts +6 -3
- package/src/audit/schedule-cli.ts +37 -0
- package/src/audit/scoring.ts +49 -0
- package/src/audit/types.ts +41 -0
- package/src/hooks/builtin-policies.ts +67 -2
- package/src/hooks/configure-wizard.ts +20 -0
- package/src/hooks/fp-config.ts +71 -11
- package/src/hooks/fp-home.ts +27 -0
- package/src/hooks/handler.ts +59 -1
- package/src/hooks/integrations.ts +47 -13
- package/src/hooks/notice.ts +155 -0
- package/src/hooks/uninstall-cli.ts +15 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
- package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
- package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
- package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
- package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
- package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
- package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
- package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
- package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
- package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
package/src/audit/index.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
|
|
|
16
16
|
import { ADAPTERS } from "./cli-adapters";
|
|
17
17
|
import { AUDIT_DETECTORS } from "./detectors";
|
|
18
18
|
import { severityForBuiltin } from "./features";
|
|
19
|
+
import { findSecrets, flattenToolInput } from "./leak-scan";
|
|
20
|
+
import { fingerprintSecret, fingerprintId } from "./leak-fingerprint";
|
|
21
|
+
import { readLeakIdentity, readLeakRecord, writeLeakRecord } from "./leak-store";
|
|
22
|
+
import { upsertFinding, describeMechanism } from "./leak-record";
|
|
23
|
+
import { shortenPaths } from "./redact-example";
|
|
19
24
|
import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
|
|
20
25
|
import { initReplay, replayEvent, restoreReplay } from "./replay";
|
|
21
26
|
import {
|
|
@@ -183,19 +188,23 @@ async function scanOneTranscript(
|
|
|
183
188
|
result.cwd = result.cwd || events[0].cwd || "";
|
|
184
189
|
|
|
185
190
|
for (const event of events) {
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
191
|
+
// The 8 behavioural detectors are switched off with the rest of the old
|
|
192
|
+
// audit — see the header of `src/audit/scoring.ts`. The modules and their
|
|
193
|
+
// unit tests are untouched; nothing calls them. `sessionState` is still
|
|
194
|
+
// threaded through so a resumed scan keeps its shape and restoring this
|
|
195
|
+
// loop needs no plumbing.
|
|
196
|
+
// for (const detector of AUDIT_DETECTORS) {
|
|
197
|
+
// const hit = detector.detect(event, sessionState);
|
|
198
|
+
// if (!hit) continue;
|
|
199
|
+
// recordHit(
|
|
200
|
+
// result,
|
|
201
|
+
// detector.name,
|
|
202
|
+
// event.timestamp,
|
|
203
|
+
// event.cwd,
|
|
204
|
+
// truncateExample(hit.example),
|
|
205
|
+
// );
|
|
206
|
+
// }
|
|
207
|
+
// Replay through every builtin policy.
|
|
199
208
|
let replayHits;
|
|
200
209
|
try {
|
|
201
210
|
replayHits = await replayEvent(event);
|
|
@@ -212,11 +221,117 @@ async function scanOneTranscript(
|
|
|
212
221
|
truncateExample(example),
|
|
213
222
|
);
|
|
214
223
|
}
|
|
224
|
+
|
|
225
|
+
// Credential VALUES, which the replay above cannot report: it returns a
|
|
226
|
+
// decision per policy, never the text that matched. Scanned separately so
|
|
227
|
+
// the leak report can name WHICH key, and so detection can be tuned for
|
|
228
|
+
// precision without dragging the redactor's recall down with it.
|
|
229
|
+
recordLeaks(result, event);
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
return { result, bytesScanned, detectorState: sessionState, resumed };
|
|
218
233
|
}
|
|
219
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Fingerprint every credential in one event and attach the sightings.
|
|
237
|
+
*
|
|
238
|
+
* The raw value exists only inside this function. It is read out of the event,
|
|
239
|
+
* hashed into a salted id, rendered into a mask, and dropped — nothing that
|
|
240
|
+
* leaves here can reconstruct it.
|
|
241
|
+
*
|
|
242
|
+
* Input and result are scanned separately and tagged, because they are
|
|
243
|
+
* different exposures: an input is what the agent SENT (deniable at PreToolUse
|
|
244
|
+
* on 12/12 CLIs, and 39.5% of measured appearances) while a result is what it
|
|
245
|
+
* RECEIVED (blockable on 2/12, and only after the fact).
|
|
246
|
+
*/
|
|
247
|
+
function recordLeaks(result: TranscriptAuditResult, event: NormalizedToolEvent): void {
|
|
248
|
+
const salt = leakSalt();
|
|
249
|
+
if (!salt) return;
|
|
250
|
+
|
|
251
|
+
const filePath = (event.toolInput as { file_path?: unknown }).file_path;
|
|
252
|
+
const shortPath = typeof filePath === "string" ? shortenPaths(filePath) : null;
|
|
253
|
+
|
|
254
|
+
const scan = (text: string, direction: "input" | "result"): void => {
|
|
255
|
+
for (const match of findSecrets(text)) {
|
|
256
|
+
(result.leaks ??= []).push({
|
|
257
|
+
id: fingerprintId(match.value, salt),
|
|
258
|
+
fingerprint: fingerprintSecret(match.value, match.rule),
|
|
259
|
+
name: match.name,
|
|
260
|
+
rule: match.rule,
|
|
261
|
+
shaped: match.shaped,
|
|
262
|
+
timestamp: event.timestamp,
|
|
263
|
+
cwd: event.cwd,
|
|
264
|
+
toolName: event.toolName,
|
|
265
|
+
direction,
|
|
266
|
+
path: shortPath,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
scan(flattenToolInput(event.toolInput), "input");
|
|
272
|
+
if (event.toolResultText !== undefined) scan(event.toolResultText, "result");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The machine's HMAC salt, read once per process.
|
|
277
|
+
*
|
|
278
|
+
* Cached because `scanOneTranscript` runs this per EVENT — 340,918 of them on
|
|
279
|
+
* the measured corpus — and re-reading a file that many times would dominate
|
|
280
|
+
* the scan. `null` when the store is unreadable, which disables leak recording
|
|
281
|
+
* rather than failing the audit: a scan that cannot fingerprint is still a scan
|
|
282
|
+
* that can count.
|
|
283
|
+
*/
|
|
284
|
+
let cachedSalt: string | null | undefined;
|
|
285
|
+
function leakSalt(): string | null {
|
|
286
|
+
if (cachedSalt === undefined) {
|
|
287
|
+
try {
|
|
288
|
+
cachedSalt = readLeakIdentity().salt;
|
|
289
|
+
} catch {
|
|
290
|
+
cachedSalt = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return cachedSalt;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Merge this scan's sightings into `~/.failproofai/audit/leaks.json`.
|
|
298
|
+
*
|
|
299
|
+
* Returns the ids seen for the FIRST time, which is the set worth telling a
|
|
300
|
+
* human about. Never throws: a scan that finds leaks it cannot write down has
|
|
301
|
+
* still found them, and the caller reports on the in-memory result either way.
|
|
302
|
+
*/
|
|
303
|
+
function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
|
|
304
|
+
const fresh: string[] = [];
|
|
305
|
+
try {
|
|
306
|
+
const record = readLeakRecord();
|
|
307
|
+
for (const t of perTranscript) {
|
|
308
|
+
for (const leak of t.leaks ?? []) {
|
|
309
|
+
const { isNew } = upsertFinding(record, {
|
|
310
|
+
id: leak.id,
|
|
311
|
+
fingerprint: leak.fingerprint,
|
|
312
|
+
name: leak.name,
|
|
313
|
+
rule: leak.rule,
|
|
314
|
+
// A vendor shape is what licenses an alert to name a console; a
|
|
315
|
+
// name-only match knows something leaked, not who issued it.
|
|
316
|
+
confidence: leak.shaped ? "doc-verified" : "name-only",
|
|
317
|
+
sighting: {
|
|
318
|
+
cli: t.cli,
|
|
319
|
+
sessionId: t.sessionId,
|
|
320
|
+
cwd: shortenPaths(leak.cwd),
|
|
321
|
+
at: leak.timestamp,
|
|
322
|
+
mechanism: describeMechanism(leak.toolName, leak.direction, leak.path),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
if (isNew) fresh.push(leak.id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
writeLeakRecord(record);
|
|
329
|
+
} catch {
|
|
330
|
+
// Bookkeeping failure must not fail the audit — see leak-store.ts.
|
|
331
|
+
}
|
|
332
|
+
return fresh;
|
|
333
|
+
}
|
|
334
|
+
|
|
220
335
|
function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): string {
|
|
221
336
|
if (event.toolName === "Bash") {
|
|
222
337
|
const command = (event.toolInput as { command?: unknown }).command;
|
|
@@ -527,8 +642,21 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
527
642
|
eventsScanned += t.eventsScanned ?? 0;
|
|
528
643
|
}
|
|
529
644
|
|
|
645
|
+
// Fold every sighting into the persistent record. This is the ONLY place the
|
|
646
|
+
// leak record is written: `scanOneTranscript` runs 8-way concurrent and a
|
|
647
|
+
// per-transcript write would be eight processes racing one file — the exact
|
|
648
|
+
// shape that lost updates when it was measured (2 concurrent writers, 5/5
|
|
649
|
+
// trials, the loser's entry gone permanently).
|
|
650
|
+
//
|
|
651
|
+
// `newFindingIds` is what a notice keys on: an id already in the record must
|
|
652
|
+
// not alert again however many fresh sightings it accumulates, and a
|
|
653
|
+
// credential seen for the first time must alert even though its rule has
|
|
654
|
+
// fired a thousand times before.
|
|
655
|
+
const newFindingIds = persistLeaks(perTranscript);
|
|
656
|
+
|
|
530
657
|
const auditResult: AuditResult = {
|
|
531
658
|
version: 2,
|
|
659
|
+
newLeakIds: newFindingIds,
|
|
532
660
|
scannedAt: new Date(startedAt).toISOString(),
|
|
533
661
|
scope: {
|
|
534
662
|
cli: clis,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a leaked credential is NAMED once it leaves the machine.
|
|
3
|
+
*
|
|
4
|
+
* Every other redactor in this codebase exists to destroy a value. This one
|
|
5
|
+
* exists to describe one, because the leak report has the opposite job: a
|
|
6
|
+
* finding the reader cannot tie to a row in a vendor console is a finding they
|
|
7
|
+
* cannot act on. `[REDACTED: GitHub token]` tells you nothing you can revoke.
|
|
8
|
+
*
|
|
9
|
+
* The rule is: **enough to identify, never enough to use.**
|
|
10
|
+
*
|
|
11
|
+
* ## Why the mask character matters, which is not obvious
|
|
12
|
+
*
|
|
13
|
+
* The three most natural masking characters — `X`, `x` and `0` — RE-MATCH every
|
|
14
|
+
* vendor prefix pattern we ship. `AKIA` + sixteen `X`s satisfies
|
|
15
|
+
* `AKIA[A-Z0-9]{16}`, so a masked AWS key is reported by failproofai as a live
|
|
16
|
+
* AWS key on the next scan. The same holds for a masked connection string,
|
|
17
|
+
* whose pattern keys on `://…@` and does not care what sits between. So the
|
|
18
|
+
* mask is `•` (U+2022), which is in no credential charset anywhere, and the
|
|
19
|
+
* unit tests assert that every masked form fails to re-match.
|
|
20
|
+
*
|
|
21
|
+
* That is not a hypothetical: this product measured its own audit output
|
|
22
|
+
* feeding back into its own corpus, one leaked credential becoming seven
|
|
23
|
+
* reported findings across four sessions.
|
|
24
|
+
*
|
|
25
|
+
* ## Why the last four, and why not always
|
|
26
|
+
*
|
|
27
|
+
* Last-4 is what vendor consoles display next to a key, so it is the token that
|
|
28
|
+
* lets a human find the right row and press revoke. But it is only safe when
|
|
29
|
+
* what remains is genuinely unguessable. For a 40-character GitHub token,
|
|
30
|
+
* showing `ghp_` and four trailing characters leaves ~32 unknown base62
|
|
31
|
+
* characters — no help to anyone. For a 13-character human-chosen password,
|
|
32
|
+
* last-4 is nearly a third of the secret, and the corpus's one confirmed-live
|
|
33
|
+
* password was exactly 13 characters at 3.19 bits per character.
|
|
34
|
+
*
|
|
35
|
+
* So the tail is shown only when the value clears BOTH bars: a recognised
|
|
36
|
+
* public vendor prefix (which proves it is a minted token, not a typed
|
|
37
|
+
* password) AND enough residual length. Everything else is described by class
|
|
38
|
+
* and length alone.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The mask glyph. Never `X`, `x` or `0` — see the module header. */
|
|
42
|
+
const MASK_CHAR = "•";
|
|
43
|
+
|
|
44
|
+
/** Longest run of mask glyphs rendered, so a 200-char token stays readable. */
|
|
45
|
+
const MAX_MASK_RUN = 8;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimum total length before any trailing characters are shown.
|
|
49
|
+
*
|
|
50
|
+
* 24 is chosen so that even the shortest minted token we recognise keeps well
|
|
51
|
+
* over 100 bits of residual entropy after the prefix and tail are removed.
|
|
52
|
+
* Human-chosen passwords essentially never reach it, which is the point.
|
|
53
|
+
*/
|
|
54
|
+
const MIN_LEN_FOR_TAIL = 24;
|
|
55
|
+
|
|
56
|
+
/** How many trailing characters a vendor console typically shows. */
|
|
57
|
+
const TAIL_CHARS = 4;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Public, minted prefixes — the part of a credential that carries no secret.
|
|
61
|
+
*
|
|
62
|
+
* Each of these is published by its issuer and identical across every key they
|
|
63
|
+
* mint, so showing it discloses nothing while telling the reader which console
|
|
64
|
+
* to open. Ordered longest-first, so `sk-ant-api03-` wins over `sk-ant-` and
|
|
65
|
+
* `sk-`; the first match is the one rendered.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately NOT the full detection vocabulary. This list only has to be
|
|
68
|
+
* right about what is safe to PRINT — a prefix missing from it costs a less
|
|
69
|
+
* specific label, never a disclosure.
|
|
70
|
+
*/
|
|
71
|
+
const PUBLIC_PREFIXES: ReadonlyArray<readonly [string, string]> = [
|
|
72
|
+
["sk-ant-api03-", "Anthropic API key"],
|
|
73
|
+
["sk-ant-oat01-", "Anthropic OAuth token"],
|
|
74
|
+
["sk-ant-", "Anthropic key"],
|
|
75
|
+
["sk-proj-", "OpenAI project key"],
|
|
76
|
+
["sk-svcacct-", "OpenAI service-account key"],
|
|
77
|
+
["sk-admin-", "OpenAI admin key"],
|
|
78
|
+
["sk-or-v1-", "OpenRouter key"],
|
|
79
|
+
["github_pat_", "GitHub fine-grained token"],
|
|
80
|
+
["ghp_", "GitHub personal access token"],
|
|
81
|
+
["gho_", "GitHub OAuth token"],
|
|
82
|
+
["ghu_", "GitHub user-to-server token"],
|
|
83
|
+
["ghs_", "GitHub server-to-server token"],
|
|
84
|
+
["ghr_", "GitHub refresh token"],
|
|
85
|
+
["glpat-", "GitLab personal access token"],
|
|
86
|
+
["xoxb-", "Slack bot token"],
|
|
87
|
+
["xoxp-", "Slack user token"],
|
|
88
|
+
["xapp-", "Slack app-level token"],
|
|
89
|
+
["sk_live_", "Stripe live secret key"],
|
|
90
|
+
["sk_test_", "Stripe test secret key"],
|
|
91
|
+
["rk_live_", "Stripe restricted key"],
|
|
92
|
+
["AKIA", "AWS access key ID"],
|
|
93
|
+
["ASIA", "AWS temporary access key ID"],
|
|
94
|
+
["AIza", "Google API key"],
|
|
95
|
+
["hf_", "Hugging Face token"],
|
|
96
|
+
["gsk_", "Groq API key"],
|
|
97
|
+
["r8_", "Replicate token"],
|
|
98
|
+
["xai-", "xAI key"],
|
|
99
|
+
["npm_", "npm access token"],
|
|
100
|
+
["dop_v1_", "DigitalOcean token"],
|
|
101
|
+
["shpat_", "Shopify access token"],
|
|
102
|
+
["sbp_", "Supabase token"],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
export interface LeakFingerprint {
|
|
106
|
+
/** What the reader sees: `ghp_••••••••4f2a` or `[13-char password]`. */
|
|
107
|
+
display: string;
|
|
108
|
+
/** Human label for the class, e.g. "GitHub personal access token". */
|
|
109
|
+
label: string;
|
|
110
|
+
/** Total character length of the original value. */
|
|
111
|
+
length: number;
|
|
112
|
+
/** True when a minted public prefix was recognised — i.e. we can name a
|
|
113
|
+
* console to revoke at. False means "we know it leaked, not who issued it". */
|
|
114
|
+
attributed: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The recognised public prefix for a value, longest match first. */
|
|
118
|
+
function matchPublicPrefix(value: string): readonly [string, string] | null {
|
|
119
|
+
for (const entry of PUBLIC_PREFIXES) {
|
|
120
|
+
if (value.startsWith(entry[0])) return entry;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render a credential as something a human can recognise and nobody can use.
|
|
127
|
+
*
|
|
128
|
+
* `fallbackLabel` names the class when no public prefix is recognised — pass
|
|
129
|
+
* the detecting rule's own label (e.g. "assigned secret", "database password")
|
|
130
|
+
* so an unattributed finding still says what KIND of thing leaked.
|
|
131
|
+
*/
|
|
132
|
+
export function fingerprintSecret(value: string, fallbackLabel = "secret"): LeakFingerprint {
|
|
133
|
+
const length = value.length;
|
|
134
|
+
const matched = matchPublicPrefix(value);
|
|
135
|
+
|
|
136
|
+
if (!matched) {
|
|
137
|
+
// No minted prefix: this is a password, a first-party key, or a format we
|
|
138
|
+
// do not recognise. Nothing but the class and the length is safe to print,
|
|
139
|
+
// and the length is what lets the owner recognise their own value.
|
|
140
|
+
return {
|
|
141
|
+
display: `[${length}-char ${fallbackLabel}]`,
|
|
142
|
+
label: fallbackLabel,
|
|
143
|
+
length,
|
|
144
|
+
attributed: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const [prefix, label] = matched;
|
|
149
|
+
const bodyLength = length - prefix.length;
|
|
150
|
+
const run = MASK_CHAR.repeat(Math.max(1, Math.min(MAX_MASK_RUN, bodyLength)));
|
|
151
|
+
|
|
152
|
+
// The tail is the actionable half — but only when what stays hidden is
|
|
153
|
+
// genuinely unguessable. See the module header.
|
|
154
|
+
if (length >= MIN_LEN_FOR_TAIL && bodyLength > TAIL_CHARS * 2) {
|
|
155
|
+
return {
|
|
156
|
+
display: `${prefix}${run}${value.slice(-TAIL_CHARS)}`,
|
|
157
|
+
label,
|
|
158
|
+
length,
|
|
159
|
+
attributed: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { display: `${prefix}${run}`, label, length, attributed: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A stable identity for one distinct credential, for deduping across scans.
|
|
168
|
+
*
|
|
169
|
+
* Salted with a per-machine secret so the digest cannot be used as a lookup
|
|
170
|
+
* oracle: without the salt, an attacker holding a guessable candidate (and the
|
|
171
|
+
* corpus is full of dictionary passwords) could confirm it by comparing
|
|
172
|
+
* hashes. With it, the id is meaningful only on the machine that produced it.
|
|
173
|
+
*
|
|
174
|
+
* The salt therefore forecloses cross-machine dedupe until an org-scoped key
|
|
175
|
+
* exists — a deliberate trade recorded here so nobody reads a per-machine salt
|
|
176
|
+
* as an oversight.
|
|
177
|
+
*/
|
|
178
|
+
/**
|
|
179
|
+
* Every id `fingerprintId` can produce, and nothing else.
|
|
180
|
+
*
|
|
181
|
+
* Ids become FILENAMES — one marker file per notified finding, one queue file
|
|
182
|
+
* per pending macOS banner — so an id is a path component, and a path component
|
|
183
|
+
* built from unvalidated input is a directory traversal. Measured: passing
|
|
184
|
+
* `"../../../../tmp/PWNED"` to `markLeakNoticeDelivered` created that file.
|
|
185
|
+
*
|
|
186
|
+
* Ids are HMAC hex today, so nothing in a normal run can carry a `/`. That is
|
|
187
|
+
* not the same as safe: the record is JSON read off disk, and `leaks.json` is a
|
|
188
|
+
* file a user (or anything running as them) can edit. The guard is one regex
|
|
189
|
+
* and it makes the property structural instead of incidental.
|
|
190
|
+
*/
|
|
191
|
+
export function isFindingId(id: string): boolean {
|
|
192
|
+
return /^[0-9a-f]{16}$/.test(id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function fingerprintId(value: string, machineSalt: string): string {
|
|
196
|
+
// Lazily required so this module stays importable in the browser bundle,
|
|
197
|
+
// where the dashboard renders findings but never computes ids.
|
|
198
|
+
const { createHmac } = require("node:crypto") as typeof import("node:crypto");
|
|
199
|
+
return createHmac("sha256", machineSalt).update(value).digest("hex").slice(0, 16);
|
|
200
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding whether a finding still owes the user a notice — exactly once.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not a field on the record
|
|
5
|
+
*
|
|
6
|
+
* The obvious design is `notifiedAt` on each finding, written back after the
|
|
7
|
+
* notice goes out. Measured against this repo's real `writeJsonAtomically`
|
|
8
|
+
* semantics, that fails 100% of the time under the conditions it actually runs
|
|
9
|
+
* in: two concurrent sessions with the same finding produced two notices, four
|
|
10
|
+
* produced four, eight produced eight. Worse, two concurrent sessions with
|
|
11
|
+
* DIFFERENT findings produced two notices and a surviving watermark containing
|
|
12
|
+
* only one of them — the loser's mark permanently gone, so it re-notified
|
|
13
|
+
* forever afterwards. `lib/atomic-write.ts` says so in its own header: atomic
|
|
14
|
+
* per FILE, last-writer-wins per CONTENT, i.e. a lost update on a map.
|
|
15
|
+
*
|
|
16
|
+
* Running several agents at once in one project is not an edge case here; it is
|
|
17
|
+
* how the tool is used.
|
|
18
|
+
*
|
|
19
|
+
* So the claim is one marker FILE per finding, created with `wx` — O_EXCL,
|
|
20
|
+
* which the kernel makes atomic across processes. Eight concurrent claimants
|
|
21
|
+
* produce exactly one winner, verified 3/3 trials. Concurrent claims on
|
|
22
|
+
* different findings all succeed and all survive, because they touch different
|
|
23
|
+
* files.
|
|
24
|
+
*
|
|
25
|
+
* ## Claim ordering, and which way to fail
|
|
26
|
+
*
|
|
27
|
+
* The marker is written BEFORE the notice reaches the stream, and the caller
|
|
28
|
+
* only keeps it if the notice was actually shaped. The alternative orderings
|
|
29
|
+
* were both measured: emit-then-mark nags forever if the process dies between
|
|
30
|
+
* them (5 sessions → 5 notices), and mark-then-never-emit swallows the alert
|
|
31
|
+
* permanently with nothing on disk saying so (total delivered: 0).
|
|
32
|
+
*
|
|
33
|
+
* Between "told twice" and "never told", a security tool picks told twice.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
36
|
+
import { resolve } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { auditDir } from "../hooks/fp-home";
|
|
39
|
+
import { readLeakRecord, activeFindings } from "./leak-store";
|
|
40
|
+
import { isFindingId } from "./leak-fingerprint";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which way the user was told. Each channel claims independently.
|
|
44
|
+
*
|
|
45
|
+
* They are NOT one guarantee. The desktop banner and the in-CLI notice reach a
|
|
46
|
+
* person at different moments and fail in unrelated ways — and the banner has a
|
|
47
|
+
* failure it cannot detect at all: on a locked screen the shell accepts the
|
|
48
|
+
* call and returns an id while nobody sees anything. Letting a successful
|
|
49
|
+
* `Notify` claim the finding would then silence the in-session notice too, and
|
|
50
|
+
* the only channel that actually reached a human is the one we suppressed.
|
|
51
|
+
*
|
|
52
|
+
* So each channel gets its own marker directory and its own once-per-finding
|
|
53
|
+
* guarantee. A user at a desktop sees the banner AND the next session's notice;
|
|
54
|
+
* that is a bounded, deliberate duplicate, and it is the right side of the
|
|
55
|
+
* trade this file already made once (told twice beats never told).
|
|
56
|
+
*/
|
|
57
|
+
export type NoticeChannel = "cli" | "desktop";
|
|
58
|
+
|
|
59
|
+
/** One empty file per claimed finding, per channel. Cheap: a few hundred
|
|
60
|
+
* zero-byte files at the very worst, and the record itself is capped at 500
|
|
61
|
+
* findings. "cli" keeps the original directory name so an existing machine's
|
|
62
|
+
* claims survive the upgrade — renaming it would re-notify for every finding
|
|
63
|
+
* already delivered. */
|
|
64
|
+
const NOTICE_DIR = (home?: string, channel: NoticeChannel = "cli") =>
|
|
65
|
+
resolve(auditDir(home), channel === "cli" ? "notified" : `notified-${channel}`);
|
|
66
|
+
|
|
67
|
+
/** Markers older than this are swept, so the directory cannot grow forever on a
|
|
68
|
+
* machine that has been running for years. Comfortably longer than the
|
|
69
|
+
* record's own 90-day TTL, so a marker never outlives its finding. */
|
|
70
|
+
const MARKER_TTL_MS = 120 * 86_400_000;
|
|
71
|
+
|
|
72
|
+
export interface PendingNotice {
|
|
73
|
+
/** Findings that have never been notified about and are not dismissed. */
|
|
74
|
+
ids: string[];
|
|
75
|
+
count: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Findings that still owe the user a notice.
|
|
80
|
+
*
|
|
81
|
+
* Reads the record and subtracts what has already been claimed. Never throws:
|
|
82
|
+
* on any failure it returns "nothing pending", because a hook that cannot read
|
|
83
|
+
* its bookkeeping must stay silent rather than guess.
|
|
84
|
+
*/
|
|
85
|
+
export function pendingLeakNotice(home?: string, channel: NoticeChannel = "cli"): PendingNotice {
|
|
86
|
+
try {
|
|
87
|
+
const dir = NOTICE_DIR(home, channel);
|
|
88
|
+
const claimed = new Set(existsSync(dir) ? readdirSync(dir) : []);
|
|
89
|
+
const ids = activeFindings(readLeakRecord(home), home)
|
|
90
|
+
.map((f) => f.id)
|
|
91
|
+
.filter((id) => !claimed.has(id));
|
|
92
|
+
return { ids, count: ids.length };
|
|
93
|
+
} catch {
|
|
94
|
+
return { ids: [], count: 0 };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Claim these findings, atomically and per-finding.
|
|
100
|
+
*
|
|
101
|
+
* Returns the ids this process actually won. A caller that loses a race simply
|
|
102
|
+
* gets a shorter list — it never blocks, and it never overwrites a claim made
|
|
103
|
+
* by a concurrent session.
|
|
104
|
+
*/
|
|
105
|
+
export function markLeakNoticeDelivered(
|
|
106
|
+
ids: string[],
|
|
107
|
+
home?: string,
|
|
108
|
+
channel: NoticeChannel = "cli",
|
|
109
|
+
): string[] {
|
|
110
|
+
const won: string[] = [];
|
|
111
|
+
try {
|
|
112
|
+
const dir = NOTICE_DIR(home, channel);
|
|
113
|
+
mkdirSync(dir, { recursive: true });
|
|
114
|
+
for (const id of ids) {
|
|
115
|
+
// An id is about to become a filename. Anything that is not the exact
|
|
116
|
+
// shape `fingerprintId` mints is refused rather than resolved — see
|
|
117
|
+
// `isFindingId`. Refusing also means NOT reporting it as won, so a caller
|
|
118
|
+
// treats it as unclaimed and never records it as delivered.
|
|
119
|
+
if (!isFindingId(id)) continue;
|
|
120
|
+
try {
|
|
121
|
+
// `wx` is O_EXCL: it throws EEXIST if another process got here first.
|
|
122
|
+
// That throw IS the lock — there is no read-then-write window for two
|
|
123
|
+
// processes to both pass through.
|
|
124
|
+
writeFileSync(resolve(dir, id), "", { flag: "wx", mode: 0o600 });
|
|
125
|
+
won.push(id);
|
|
126
|
+
} catch {
|
|
127
|
+
// Already claimed by a concurrent session. Correct outcome: the user
|
|
128
|
+
// is told once, by whichever session got there first.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Cannot claim at all — the caller has already decided to emit, and being
|
|
133
|
+
// told twice beats never being told.
|
|
134
|
+
}
|
|
135
|
+
return won;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Drop markers for findings that no longer exist, and very old ones. */
|
|
139
|
+
export function pruneNoticeMarkers(home?: string, nowMs = Date.now()): void {
|
|
140
|
+
try {
|
|
141
|
+
const live = new Set(readLeakRecord(home).findings.map((f) => f.id));
|
|
142
|
+
for (const channel of ["cli", "desktop"] as const) {
|
|
143
|
+
const dir = NOTICE_DIR(home, channel);
|
|
144
|
+
if (!existsSync(dir)) continue;
|
|
145
|
+
for (const name of readdirSync(dir)) {
|
|
146
|
+
const path = resolve(dir, name);
|
|
147
|
+
let stale = !live.has(name);
|
|
148
|
+
if (!stale) {
|
|
149
|
+
try {
|
|
150
|
+
stale = nowMs - statSync(path).mtimeMs > MARKER_TTL_MS;
|
|
151
|
+
} catch {
|
|
152
|
+
stale = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (stale) rmSync(path, { force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Housekeeping only.
|
|
160
|
+
}
|
|
161
|
+
}
|