failproofai 1.0.4-beta.4 → 1.0.4-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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.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 +15 -4
- package/.next/standalone/.next/server/app/settings/page.js +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/[root-of-the-server]__0--lkk6._.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]__0q0qzx1._.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]__1le9jqc._.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/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +11 -11
- package/.next/standalone/.next/server/chunks/lib_factory-projects_ts_1eo_bk-._.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]__0yl9wb5._.js → [root-of-the-server]__12hbpha._.js} +2 -2
- 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/_04ng_6x._.js +3 -0
- 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/{_0guxnh-._.js → _0a0zlpr._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_0h_douw._.js → _0s-77v5._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +4 -14
- 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_builtin-policies_ts_09j2ndl._.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 +33 -22
- package/.next/standalone/.next/static/chunks/{17tfki9x5rd_7.js → 083fk73jtv9a0.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1i-i0pxg9q_8y.js → 0divvxng208xo.js} +1 -1
- package/.next/standalone/.next/static/chunks/{38dqur43qthrl.js → 0f1pznnc7zlub.js} +1 -1
- package/.next/standalone/.next/static/chunks/{323cyb3a2s06y.js → 0ntdfr2552x-h.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0po8n4tyotjf2.js → 0t6ea7d3bkmdv.js} +1 -1
- package/.next/standalone/.next/static/chunks/{364cqbpeg1y5i.js → 15uon66hpwbgg.js} +1 -1
- package/.next/standalone/.next/static/chunks/{42gwu2bsz4ske.js → 1csubx9gwhsf6.js} +2 -2
- package/.next/standalone/.next/static/chunks/27du9n7ssja7l.js +1 -0
- package/.next/standalone/.next/static/chunks/2h_pcb1y8_jde.js +1 -0
- package/.next/standalone/.next/static/chunks/{2bxr2_2h4kaqq.js → 2pwfrn6_mw-1u.js} +1 -1
- package/.next/standalone/app/actions/get-leaks.ts +0 -5
- package/.next/standalone/app/actions/get-scheduled-audit.ts +5 -0
- package/.next/standalone/app/actions/update-scheduled-audit.ts +18 -57
- package/.next/standalone/app/api/auth/status/route.ts +3 -2
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -1
- package/.next/standalone/app/audit/_components/empty-state.tsx +1 -1
- package/.next/standalone/app/audit/_components/leak-section.tsx +1 -1
- package/.next/standalone/app/settings/page.tsx +1 -1
- package/.next/standalone/app/settings/settings-client.tsx +50 -45
- 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/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +991 -1101
- package/dist/worker.mjs +175 -382
- 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/package.json +9 -9
- package/pi-extension/index.ts +10 -18
- package/src/audit/cli.ts +72 -81
- package/src/audit/harm-report.ts +12 -6
- package/src/audit/index.ts +3 -1
- package/src/audit/leak-fingerprint.ts +2 -4
- package/src/audit/leak-record.ts +26 -10
- package/src/audit/leak-scan.ts +1 -1
- package/src/audit/leak-store.ts +1 -46
- package/src/audit/machine-store.ts +8 -9
- package/src/audit/macos-notifier.ts +56 -68
- package/src/audit/redact-example.ts +2 -0
- package/src/audit/report-harm.ts +31 -46
- package/src/audit/schedule-cli.ts +17 -33
- package/src/audit/types.ts +5 -0
- package/src/hooks/builtin-policies.ts +2 -2
- package/src/hooks/configure-wizard.ts +36 -1
- package/src/hooks/fp-config.ts +29 -54
- package/src/hooks/handler.ts +1 -79
- package/src/hooks/integrations.ts +4 -15
- package/src/hooks/notice.ts +12 -179
- package/src/hooks/uninstall-cli.ts +3 -3
- package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +0 -3
- package/.next/standalone/.next/static/chunks/04o5zfl7p5lhx.js +0 -1
- package/.next/standalone/.next/static/chunks/0qx7gav1c2_vu.js +0 -1
- package/src/audit/leak-notice.ts +0 -259
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_ssgManifest.js +0 -0
package/src/audit/index.ts
CHANGED
|
@@ -70,7 +70,7 @@ const TRANSCRIPT_BYTE_BUDGET = 48 * 1024 * 1024;
|
|
|
70
70
|
* otherwise the largest transcript on the machine could never be scanned, which
|
|
71
71
|
* is precisely the one most likely to hold something.
|
|
72
72
|
*/
|
|
73
|
-
class ByteGate {
|
|
73
|
+
export class ByteGate {
|
|
74
74
|
private inFlight = 0;
|
|
75
75
|
private waiting: Array<() => void> = [];
|
|
76
76
|
|
|
@@ -794,9 +794,11 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
794
794
|
errors === 0 &&
|
|
795
795
|
skipped === 0;
|
|
796
796
|
const newFindingIds = persistLeaks(perTranscript, coversAllHistory);
|
|
797
|
+
const leakIds = [...new Set(perTranscript.flatMap((t) => (t.leaks ?? []).map((leak) => leak.id)))];
|
|
797
798
|
|
|
798
799
|
const auditResult: AuditResult = {
|
|
799
800
|
version: 2,
|
|
801
|
+
leakIds,
|
|
800
802
|
newLeakIds: newFindingIds,
|
|
801
803
|
scannedAt: new Date(startedAt).toISOString(),
|
|
802
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
|
@@ -152,19 +152,30 @@ export function upsertFinding(
|
|
|
152
152
|
return { record, isNew: true };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Keep the earliest sighting AND the most recent evidence. The report/email
|
|
156
|
+
// promises the CLI and timestamp of the latest exposure; keeping only the
|
|
157
|
+
// first five made those fields stale forever after a busy credential crossed
|
|
158
|
+
// the cap. Middle sightings are the expendable ones.
|
|
159
|
+
const seen = existing.sightings.some(
|
|
160
|
+
(s) =>
|
|
161
|
+
s.sessionId === sighting.sessionId &&
|
|
162
|
+
s.at === sighting.at &&
|
|
163
|
+
s.mechanism.direction === sighting.mechanism.direction &&
|
|
164
|
+
s.mechanism.summary === sighting.mechanism.summary,
|
|
165
|
+
);
|
|
166
|
+
// Cached transcript results are folded through this function again. An
|
|
167
|
+
// identical sighting is therefore not a new exposure and must not inflate
|
|
168
|
+
// the count each time the same cache entry is reused.
|
|
169
|
+
if (seen) return { record, isNew: false };
|
|
170
|
+
|
|
155
171
|
existing.occurrences += 1;
|
|
156
172
|
if (sighting.at > existing.lastSeen) existing.lastSeen = sighting.at;
|
|
157
173
|
if (sighting.at < existing.firstSeen) existing.firstSeen = sighting.at;
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (existing.sightings.length < MAX_SIGHTINGS) {
|
|
164
|
-
const seen = existing.sightings.some(
|
|
165
|
-
(s) => s.sessionId === sighting.sessionId && s.at === sighting.at,
|
|
166
|
-
);
|
|
167
|
-
if (!seen) existing.sightings.push(sighting);
|
|
175
|
+
existing.sightings.push(sighting);
|
|
176
|
+
existing.sightings.sort((a, b) => a.at.localeCompare(b.at));
|
|
177
|
+
if (existing.sightings.length > MAX_SIGHTINGS) {
|
|
178
|
+
existing.sightings.splice(1, existing.sightings.length - MAX_SIGHTINGS);
|
|
168
179
|
}
|
|
169
180
|
return { record, isNew: false };
|
|
170
181
|
}
|
|
@@ -177,7 +188,12 @@ export function upsertFinding(
|
|
|
177
188
|
*/
|
|
178
189
|
export function pruneRecord(record: LeakRecord, nowMs: number): LeakRecord {
|
|
179
190
|
const cutoff = new Date(nowMs - FINDING_TTL_DAYS * 86_400_000).toISOString();
|
|
180
|
-
|
|
191
|
+
// A missing or malformed timestamp cannot be aged out safely. Keep it until
|
|
192
|
+
// the bounded-cap pass below rather than silently deleting a finding that the
|
|
193
|
+
// reporting path deliberately surfaces as having an unknown date.
|
|
194
|
+
const live = record.findings.filter(
|
|
195
|
+
(f) => !f.lastSeen || !Number.isFinite(Date.parse(f.lastSeen)) || f.lastSeen >= cutoff,
|
|
196
|
+
);
|
|
181
197
|
live.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0));
|
|
182
198
|
record.findings = live.slice(0, MAX_FINDINGS);
|
|
183
199
|
return record;
|
package/src/audit/leak-scan.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { SECRET_PATTERNS } from "../hooks/builtin-policies";
|
|
|
33
33
|
* `result.leaks` forever, which made a precision fix appear to do nothing on a
|
|
34
34
|
* warm machine. Bump this whenever matching or suppression semantics change.
|
|
35
35
|
*/
|
|
36
|
-
export const LEAK_SCAN_VERSION =
|
|
36
|
+
export const LEAK_SCAN_VERSION = 6;
|
|
37
37
|
|
|
38
38
|
/** One credential value found in one event. */
|
|
39
39
|
export interface SecretMatch {
|
package/src/audit/leak-store.ts
CHANGED
|
@@ -46,24 +46,6 @@ interface LeakIdentity {
|
|
|
46
46
|
salt: string;
|
|
47
47
|
/** Finding ids the user has said are not secrets. */
|
|
48
48
|
dismissed: string[];
|
|
49
|
-
/**
|
|
50
|
-
* When the user last actually LOOKED at the leak report, as epoch ms.
|
|
51
|
-
*
|
|
52
|
-
* This is what stops the in-CLI notice, and it replaces a per-finding "we
|
|
53
|
-
* emitted it once" marker that silently lost the alert twice in one day: once
|
|
54
|
-
* because it was claimed on an event whose channel the host ignores, and once
|
|
55
|
-
* because hooks were disabled in the project being tested. Both marked every
|
|
56
|
-
* finding delivered and showed nothing, with no retry, ever.
|
|
57
|
-
*
|
|
58
|
-
* Keyed on the user's own action rather than on ours, the failure direction
|
|
59
|
-
* inverts: a dropped notice simply reappears next session, and the only thing
|
|
60
|
-
* that silences it is the user opening the report — which is the outcome the
|
|
61
|
-
* notice exists to produce.
|
|
62
|
-
*
|
|
63
|
-
* `identity`, alongside `dismissed`, for the same reason: losing it re-alerts
|
|
64
|
-
* about credentials somebody has already seen and judged.
|
|
65
|
-
*/
|
|
66
|
-
reportViewedAt?: number;
|
|
67
49
|
}
|
|
68
50
|
|
|
69
51
|
/** Mode for both files. They carry no credential, but they do carry a machine
|
|
@@ -95,10 +77,6 @@ export function readLeakIdentity(home?: string): LeakIdentity {
|
|
|
95
77
|
return {
|
|
96
78
|
salt: existing.salt,
|
|
97
79
|
dismissed: existing.dismissed ?? [],
|
|
98
|
-
reportViewedAt:
|
|
99
|
-
typeof existing.reportViewedAt === "number" && Number.isFinite(existing.reportViewedAt)
|
|
100
|
-
? existing.reportViewedAt
|
|
101
|
-
: undefined,
|
|
102
80
|
};
|
|
103
81
|
}
|
|
104
82
|
const minted: LeakIdentity = { salt: randomBytes(32).toString("hex"), dismissed: [] };
|
|
@@ -146,7 +124,7 @@ export function writeLeakIdentity(identity: LeakIdentity, home?: string): boolea
|
|
|
146
124
|
function sanitizeFinding(raw: unknown): LeakFinding | null {
|
|
147
125
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
148
126
|
const f = raw as Record<string, unknown>;
|
|
149
|
-
// The id becomes a
|
|
127
|
+
// The id becomes a filename in the macOS notification queue, so anything
|
|
150
128
|
// that is not the exact minted shape disqualifies the whole entry.
|
|
151
129
|
if (typeof f.id !== "string" || !isFindingId(f.id)) return null;
|
|
152
130
|
|
|
@@ -219,29 +197,6 @@ export function writeLeakRecord(record: LeakRecord, home?: string, nowMs = Date.
|
|
|
219
197
|
}
|
|
220
198
|
|
|
221
199
|
/** Mark a finding as not-a-secret. Survives a reset; see the module header. */
|
|
222
|
-
/**
|
|
223
|
-
* Record that the user has seen the leak report.
|
|
224
|
-
*
|
|
225
|
-
* Called from the dashboard when it reads the findings, and from the
|
|
226
|
-
* interactive `failproofai audit`, which prints them. Both are the user
|
|
227
|
-
* looking — which is the only signal worth silencing the notice on.
|
|
228
|
-
*
|
|
229
|
-
* Never throws: failing to record a view costs one extra notice, which is the
|
|
230
|
-
* side this whole design errs on.
|
|
231
|
-
*/
|
|
232
|
-
export function markLeakReportViewed(home?: string, nowMs = Date.now()): void {
|
|
233
|
-
try {
|
|
234
|
-
const identity = readLeakIdentity(home);
|
|
235
|
-
writeJsonAtomically(
|
|
236
|
-
auditLeakIdentityFile(home),
|
|
237
|
-
{ ...identity, reportViewedAt: nowMs },
|
|
238
|
-
{ mode: FILE_MODE },
|
|
239
|
-
);
|
|
240
|
-
} catch {
|
|
241
|
-
// See the doc comment: an unrecorded view re-notifies once.
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
200
|
export function dismissFinding(id: string, home?: string): boolean {
|
|
246
201
|
const identity = readLeakIdentity(home);
|
|
247
202
|
if (identity.dismissed.includes(id)) return true;
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
* - `machine_id` is what the api-server keys reports on. Regenerate it and the
|
|
8
8
|
* server sees a brand-new machine, which burns a slot off the account's cap
|
|
9
9
|
* on every logout and splits one box's history into two.
|
|
10
|
-
* - `last_reported_at` is how far the last
|
|
11
|
-
* next report re-
|
|
12
|
-
* everything that ever happened as though it just did.
|
|
10
|
+
* - `last_reported_at` is how far the last credential alert reached. Reset it
|
|
11
|
+
* and the next report can re-cover old exposures as though they just happened.
|
|
13
12
|
*
|
|
14
13
|
* That is why this is a separate file from `session.json` rather than two more
|
|
15
14
|
* keys in it: signing out deletes the session, and neither of these may go with
|
|
@@ -22,7 +21,7 @@
|
|
|
22
21
|
* `state/telemetry-id` is already a stable per-machine random id and would have
|
|
23
22
|
* been free to reuse. It is deliberately not reused: that id is the anonymous
|
|
24
23
|
* PostHog person, and sending it alongside a verified email address would link
|
|
25
|
-
* the two the moment somebody
|
|
24
|
+
* the two the moment somebody adds email alerts. Opting into email
|
|
26
25
|
* should not de-anonymise telemetry, so this feature gets its own id and the
|
|
27
26
|
* two never meet.
|
|
28
27
|
*/
|
|
@@ -66,8 +65,8 @@ export function readMachineIdentity(home?: string): MachineIdentity | null {
|
|
|
66
65
|
/**
|
|
67
66
|
* Read the identity, creating it on first call.
|
|
68
67
|
*
|
|
69
|
-
* Only ever called from the reporting path, so a machine
|
|
70
|
-
*
|
|
68
|
+
* Only ever called from the email-reporting path, so a machine without a valid
|
|
69
|
+
* email identity never gets an id at all — there is nothing to mint one for.
|
|
71
70
|
*/
|
|
72
71
|
export function ensureMachineIdentity(home?: string): MachineIdentity {
|
|
73
72
|
const existing = readMachineIdentity(home);
|
|
@@ -81,13 +80,13 @@ export function ensureMachineIdentity(home?: string): MachineIdentity {
|
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
/**
|
|
84
|
-
* Record how far the last
|
|
83
|
+
* Record how far the last delivered credential alert reached.
|
|
85
84
|
*
|
|
86
85
|
* The value is the server's `next_window_from`, not the window this run
|
|
87
86
|
* scanned. The server is authoritative because it knows which reports actually
|
|
88
87
|
* produced an email — a report held by the cooldown, or one whose send failed,
|
|
89
88
|
* must not advance the watermark or its findings are silently dropped from every
|
|
90
|
-
* future
|
|
89
|
+
* future alert.
|
|
91
90
|
*/
|
|
92
91
|
export function recordReportWatermark(nextWindowFrom: string, home?: string): void {
|
|
93
92
|
const current = ensureMachineIdentity(home);
|
|
@@ -105,7 +104,7 @@ export function deleteMachineIdentity(home?: string): void {
|
|
|
105
104
|
/**
|
|
106
105
|
* A display name for this machine — its hostname.
|
|
107
106
|
*
|
|
108
|
-
* Shown in the
|
|
107
|
+
* Shown in the email alert so somebody with three boxes can tell which one is
|
|
109
108
|
* misbehaving, which is the whole reason it is sent. Falls back to `undefined`
|
|
110
109
|
* rather than a placeholder: the server keeps whatever label it already has when
|
|
111
110
|
* one is omitted, so guessing here would overwrite a good name with a bad one.
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
* would install for every account on the box, which is not what a per-user
|
|
25
25
|
* notification is.
|
|
26
26
|
*
|
|
27
|
-
* **
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* **A user-session `osascript`, not an applet binary launched directly.** The
|
|
28
|
+
* compiled applet path looked attractive because it could carry a FailproofAI
|
|
29
|
+
* bundle identifier, but launching `Contents/MacOS/applet` directly from
|
|
30
|
+
* launchd is not a dependable Notification Center client on current macOS.
|
|
31
|
+
* `osascript` from a user LaunchAgent is the same mechanism ordinary scheduled
|
|
32
|
+
* jobs use successfully. Delivery matters more than owning the label shown by
|
|
33
|
+
* Notification Center, so the runner uses that proven path.
|
|
34
34
|
*
|
|
35
35
|
* **A watched drop directory, not a resident process.** The agent is not a
|
|
36
36
|
* daemon: `WatchPaths` makes launchd start it only when a file appears, and it
|
|
@@ -45,25 +45,29 @@
|
|
|
45
45
|
* the thing that lasts.
|
|
46
46
|
*/
|
|
47
47
|
import { execFileSync } from "node:child_process";
|
|
48
|
-
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
49
|
import { homedir } from "node:os";
|
|
50
50
|
import { resolve } from "node:path";
|
|
51
51
|
|
|
52
52
|
import { runDir } from "../hooks/fp-home";
|
|
53
53
|
import { isFindingId } from "./leak-fingerprint";
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/** Stable label used to load, inspect, and remove the per-user LaunchAgent. */
|
|
56
56
|
export const MAC_NOTIFIER_LABEL = "ai.failproof.notifier";
|
|
57
57
|
|
|
58
58
|
/** Where a queued notification waits for the agent to pick it up. */
|
|
59
59
|
export const macNotifyDir = (home?: string) => resolve(runDir(home), "notify");
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* because it is a delivery mechanism, not something to launch. */
|
|
61
|
+
/** Legacy compiled applet path, retained so upgrades and uninstall remove it. */
|
|
63
62
|
export function macNotifierAppPath(home?: string): string {
|
|
64
63
|
return resolve(runDir(home), "..", "bin", "FailproofAI Notifier.app");
|
|
65
64
|
}
|
|
66
65
|
|
|
66
|
+
/** User-session runner invoked by launchd when a notification is queued. */
|
|
67
|
+
export function macNotifierRunnerPath(home?: string): string {
|
|
68
|
+
return resolve(runDir(home), "..", "bin", "failproofai-notifier.zsh");
|
|
69
|
+
}
|
|
70
|
+
|
|
67
71
|
export function macNotifierPlistPath(): string {
|
|
68
72
|
return resolve(homedir(), "Library", "LaunchAgents", `${MAC_NOTIFIER_LABEL}.plist`);
|
|
69
73
|
}
|
|
@@ -73,11 +77,11 @@ function escapeXml(s: string): string {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
|
-
* The
|
|
80
|
+
* The user-session runner: drain the directory, say each notification, exit.
|
|
77
81
|
*
|
|
78
|
-
* Payload format is two lines — title, then body
|
|
79
|
-
* AppleScript
|
|
80
|
-
*
|
|
82
|
+
* Payload format is two lines — title, then body. Values are passed to
|
|
83
|
+
* AppleScript as argv, never interpolated into source, so notification text
|
|
84
|
+
* cannot become AppleScript code.
|
|
81
85
|
*
|
|
82
86
|
* The file is REMOVED BEFORE the notification is posted, and that order is
|
|
83
87
|
* deliberate: `WatchPaths` fires on every change to the directory, so a file
|
|
@@ -88,32 +92,25 @@ function escapeXml(s: string): string {
|
|
|
88
92
|
*/
|
|
89
93
|
export function notifierScript(home?: string): string {
|
|
90
94
|
const dir = macNotifyDir(home);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
repeat with i from 2 to (count of lines_)
|
|
108
|
-
if noteBody is not "" then set noteBody to noteBody & " "
|
|
109
|
-
set noteBody to noteBody & (item i of lines_)
|
|
110
|
-
end repeat
|
|
111
|
-
display notification noteBody with title noteTitle
|
|
112
|
-
end if
|
|
113
|
-
end try
|
|
114
|
-
end if
|
|
115
|
-
end repeat
|
|
95
|
+
const quotedDir = `'${dir.replace(/'/g, `'\\''`)}'`;
|
|
96
|
+
return `#!/bin/zsh
|
|
97
|
+
set -u
|
|
98
|
+
setopt NULL_GLOB
|
|
99
|
+
|
|
100
|
+
drop_dir=${quotedDir}
|
|
101
|
+
|
|
102
|
+
for entry_path in "$drop_dir"/*; do
|
|
103
|
+
[ -f "$entry_path" ] || continue
|
|
104
|
+
note_title=$(/usr/bin/sed -n '1p' "$entry_path")
|
|
105
|
+
note_body=$(/usr/bin/sed -n '2,$p' "$entry_path" | /usr/bin/tr '\\n' ' ')
|
|
106
|
+
/bin/rm -f "$entry_path"
|
|
107
|
+
[ -n "$note_title" ] || continue
|
|
108
|
+
/usr/bin/osascript - "$note_title" "$note_body" <<'APPLESCRIPT'
|
|
109
|
+
on run argv
|
|
110
|
+
display notification (item 2 of argv) with title (item 1 of argv)
|
|
116
111
|
end run
|
|
112
|
+
APPLESCRIPT
|
|
113
|
+
done
|
|
117
114
|
`;
|
|
118
115
|
}
|
|
119
116
|
|
|
@@ -122,7 +119,7 @@ end run
|
|
|
122
119
|
* assert this file's shape from a Linux CI runner, which is every CI runner we
|
|
123
120
|
* have.
|
|
124
121
|
*/
|
|
125
|
-
export function notifierPlistContents(
|
|
122
|
+
export function notifierPlistContents(runnerPath: string, watchDir: string): string {
|
|
126
123
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
127
124
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
128
125
|
<plist version="1.0">
|
|
@@ -131,7 +128,7 @@ export function notifierPlistContents(appPath: string, watchDir: string): string
|
|
|
131
128
|
<string>${escapeXml(MAC_NOTIFIER_LABEL)}</string>
|
|
132
129
|
<key>ProgramArguments</key>
|
|
133
130
|
<array>
|
|
134
|
-
<string>${escapeXml(
|
|
131
|
+
<string>${escapeXml(runnerPath)}</string>
|
|
135
132
|
</array>
|
|
136
133
|
<!-- Started only when something is queued, and it exits when the queue is
|
|
137
134
|
empty. RunAtLoad would fire it once at login with nothing to say. -->
|
|
@@ -173,36 +170,26 @@ function run(cmd: string, args: string[]): void {
|
|
|
173
170
|
* machine scan, and may it interrupt), and both of those are real settings.
|
|
174
171
|
*
|
|
175
172
|
* Returns rather than throws. Notifications are one channel of several, and a
|
|
176
|
-
* Mac that cannot
|
|
177
|
-
*
|
|
173
|
+
* Mac that cannot load the user LaunchAgent must still finish setting up its
|
|
174
|
+
* daemon, hooks and policies — the parts that actually enforce anything.
|
|
178
175
|
*/
|
|
179
176
|
export function installMacNotifier(home?: string): MacNotifierResult {
|
|
180
177
|
if (process.platform !== "darwin") return { installed: false, reason: "not macOS" };
|
|
181
178
|
try {
|
|
182
|
-
const
|
|
179
|
+
const runner = macNotifierRunnerPath(home);
|
|
183
180
|
const watch = macNotifyDir(home);
|
|
184
181
|
mkdirSync(watch, { recursive: true, mode: 0o700 });
|
|
185
|
-
mkdirSync(resolve(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
rmSync(scriptPath, { force: true });
|
|
194
|
-
|
|
195
|
-
// Give the bundle its own identity, so the banner is attributed to
|
|
196
|
-
// failproofai instead of to whatever compiled it, and so the user gets one
|
|
197
|
-
// entry in System Settings > Notifications they can turn off themselves.
|
|
198
|
-
const plist = resolve(app, "Contents", "Info.plist");
|
|
199
|
-
run("defaults", ["write", plist, "CFBundleIdentifier", MAC_NOTIFIER_LABEL]);
|
|
200
|
-
run("defaults", ["write", plist, "CFBundleName", "failproofai"]);
|
|
201
|
-
// `defaults write` leaves the file 0600 and owned by us but in binary
|
|
202
|
-
// format; launchd and LaunchServices both read either, so no conversion.
|
|
182
|
+
mkdirSync(resolve(runner, ".."), { recursive: true });
|
|
183
|
+
writeFileSync(runner, notifierScript(home), { mode: 0o700 });
|
|
184
|
+
// writeFileSync preserves an existing file's mode. Repair it explicitly on
|
|
185
|
+
// upgrades from a partially-created or hand-edited runner.
|
|
186
|
+
chmodSync(runner, 0o700);
|
|
187
|
+
// Remove the applet used by older builds. The LaunchAgent below no longer
|
|
188
|
+
// references it, and leaving it behind makes diagnosis ambiguous.
|
|
189
|
+
rmSync(macNotifierAppPath(home), { recursive: true, force: true });
|
|
203
190
|
|
|
204
191
|
mkdirSync(resolve(macNotifierPlistPath(), ".."), { recursive: true });
|
|
205
|
-
writeFileSync(macNotifierPlistPath(), notifierPlistContents(
|
|
192
|
+
writeFileSync(macNotifierPlistPath(), notifierPlistContents(runner, watch), { mode: 0o644 });
|
|
206
193
|
|
|
207
194
|
const target = `gui/${process.getuid?.() ?? 0}`;
|
|
208
195
|
// bootout first: bootstrap fails outright on an already-loaded label, and
|
|
@@ -220,7 +207,7 @@ export function installMacNotifier(home?: string): MacNotifierResult {
|
|
|
220
207
|
}
|
|
221
208
|
|
|
222
209
|
/**
|
|
223
|
-
* Remove the agent and the
|
|
210
|
+
* Remove the agent and its runner, including the applet used by older builds.
|
|
224
211
|
*
|
|
225
212
|
* Called from `failproofai uninstall --purge`. Unconditional and quiet: an
|
|
226
213
|
* agent left behind after an uninstall is a plist launchd keeps trying to start
|
|
@@ -236,6 +223,7 @@ export function uninstallMacNotifier(home?: string): void {
|
|
|
236
223
|
}
|
|
237
224
|
rmSync(macNotifierPlistPath(), { force: true });
|
|
238
225
|
rmSync(macNotifierAppPath(home), { recursive: true, force: true });
|
|
226
|
+
rmSync(macNotifierRunnerPath(home), { force: true });
|
|
239
227
|
rmSync(macNotifyDir(home), { recursive: true, force: true });
|
|
240
228
|
}
|
|
241
229
|
|
|
@@ -255,8 +243,8 @@ export function queueMacNotification(
|
|
|
255
243
|
body: string,
|
|
256
244
|
home?: string,
|
|
257
245
|
): boolean {
|
|
258
|
-
// The id becomes a filename in a watched directory
|
|
259
|
-
//
|
|
246
|
+
// The id becomes a filename in a watched directory, so validate the path
|
|
247
|
+
// component before resolving it — see `isFindingId`.
|
|
260
248
|
if (!isFindingId(id)) return false;
|
|
261
249
|
const dir = macNotifyDir(home);
|
|
262
250
|
const tmp = resolve(dir, "..", `notify-${id}.tmp`);
|
|
@@ -281,7 +269,7 @@ export function queueMacNotification(
|
|
|
281
269
|
|
|
282
270
|
/** True when an agent is installed and could pick a queued file up. */
|
|
283
271
|
export function macNotifierInstalled(home?: string): boolean {
|
|
284
|
-
return existsSync(macNotifierPlistPath()) && existsSync(
|
|
272
|
+
return existsSync(macNotifierPlistPath()) && existsSync(macNotifierRunnerPath(home));
|
|
285
273
|
}
|
|
286
274
|
|
|
287
275
|
/**
|
|
@@ -573,6 +573,7 @@ export function redactAuditResult(result: AuditResult, home = homedir()): AuditR
|
|
|
573
573
|
// Salted, machine-local ids. They carry no path and cannot be reversed to
|
|
574
574
|
// a credential, so they pass through — but the decision is recorded here
|
|
575
575
|
// rather than inherited from a spread.
|
|
576
|
+
leakIds: result.leakIds,
|
|
576
577
|
newLeakIds: result.newLeakIds,
|
|
577
578
|
|
|
578
579
|
// Everything below carries a path or user content and is rewritten.
|
|
@@ -615,5 +616,6 @@ export const REDACTED_AUDIT_RESULT_KEYS: ReadonlyArray<keyof AuditResult> = [
|
|
|
615
616
|
"projectsScanned",
|
|
616
617
|
"eventsScanned",
|
|
617
618
|
"enabledBuiltinNames",
|
|
619
|
+
"leakIds",
|
|
618
620
|
"newLeakIds",
|
|
619
621
|
];
|
package/src/audit/report-harm.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The side effect a scheduled audit
|
|
3
|
-
*
|
|
2
|
+
* The optional remote side effect of a scheduled audit: telling the api-server
|
|
3
|
+
* about the newest masked credential exposure so an email alert can be sent.
|
|
4
4
|
*
|
|
5
5
|
* Separated from `harm-report.ts` on purpose. That module is pure — result in,
|
|
6
6
|
* payload out — and is where the windowing rules live and are tested. This one
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* token expired, whose network is down, or whose api-server is having a bad day
|
|
16
16
|
* must keep auditing itself locally and keep showing results on its own
|
|
17
17
|
* dashboard — the local feature does not depend on the remote one, and a person
|
|
18
|
-
* who never
|
|
18
|
+
* who never added an email must never have the local scan depend on this code.
|
|
19
19
|
*
|
|
20
20
|
* ## Why the CHILD does this and not the daemon
|
|
21
21
|
*
|
|
@@ -35,6 +35,7 @@ import { activeFindings, readLeakRecord } from "./leak-store";
|
|
|
35
35
|
import {
|
|
36
36
|
ensureMachineIdentity,
|
|
37
37
|
machineLabel,
|
|
38
|
+
readMachineIdentity,
|
|
38
39
|
recordReportWatermark,
|
|
39
40
|
} from "./machine-store";
|
|
40
41
|
import type { AuditResult } from "./types";
|
|
@@ -42,31 +43,26 @@ import type { AuditResult } from "./types";
|
|
|
42
43
|
/** What happened, for the one line the scheduled run prints. */
|
|
43
44
|
export type HarmReportOutcome =
|
|
44
45
|
| { kind: "disabled" }
|
|
45
|
-
| { kind: "
|
|
46
|
+
| { kind: "no-leak" }
|
|
46
47
|
| { kind: "signed-out" }
|
|
47
|
-
| { kind: "sent";
|
|
48
|
-
| { kind: "held";
|
|
48
|
+
| { kind: "sent"; leaks: number }
|
|
49
|
+
| { kind: "held"; leaks: number; reason: string }
|
|
49
50
|
| { kind: "failed"; error: string };
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
* Report this scan's
|
|
53
|
+
* Report this scan's newest credential exposure when an email identity exists.
|
|
53
54
|
*
|
|
54
55
|
* Returns an outcome rather than a boolean so the caller can say something
|
|
55
|
-
* truthful. "held" in particular is not a failure — a machine
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* truthful. "held" in particular is not a failure — a machine inside its
|
|
57
|
+
* cooldown is working exactly as intended, and a line that called that an error
|
|
58
|
+
* would train people to ignore the line.
|
|
58
59
|
*/
|
|
59
60
|
export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome> {
|
|
60
|
-
// ONE switch. `auto` means "scan on a timer AND tell me", because the reason
|
|
61
|
-
// to put a scan on a timer is to be told — a machine scanning quietly with
|
|
62
|
-
// nothing to report to is a feature that looks on and does nothing visible.
|
|
63
61
|
let auto = false;
|
|
64
|
-
let consentedAt: number | undefined;
|
|
65
62
|
let intervalDays = 7;
|
|
66
63
|
try {
|
|
67
64
|
const config = readConfig();
|
|
68
65
|
auto = config.audit.auto;
|
|
69
|
-
consentedAt = config.audit.reportsConsentedAt;
|
|
70
66
|
// Also the width of a FIRST report's window, so a new machine's opening
|
|
71
67
|
// digest covers the same period every later one will.
|
|
72
68
|
intervalDays = config.audit.intervalDays;
|
|
@@ -76,13 +72,18 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
|
|
|
76
72
|
}
|
|
77
73
|
if (!auto) return { kind: "disabled" };
|
|
78
74
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
// Decide whether there is anything emailable before touching auth or the
|
|
76
|
+
// network. Scheduled scans are local by default; an email identity is an
|
|
77
|
+
// optional delivery channel, and a scan with no credential leak must make no
|
|
78
|
+
// report request at all.
|
|
79
|
+
const prior = readMachineIdentity();
|
|
80
|
+
const report = buildHarmReport(
|
|
81
|
+
result,
|
|
82
|
+
prior?.last_reported_at,
|
|
83
|
+
intervalDays,
|
|
84
|
+
activeFindings(readLeakRecord()),
|
|
85
|
+
);
|
|
86
|
+
if (report.leaks.length === 0) return { kind: "no-leak" };
|
|
86
87
|
|
|
87
88
|
const auth = await getValidAccessToken();
|
|
88
89
|
if (!auth) {
|
|
@@ -102,18 +103,6 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
|
|
|
102
103
|
return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
// The leak record is read here rather than carried on the AuditResult: the
|
|
106
|
-
// digest wants every credential still live in this window, not only the ones
|
|
107
|
-
// this particular scan happened to re-encounter. A key that leaked last month
|
|
108
|
-
// and was not touched by today's transcripts is still a key to rotate.
|
|
109
|
-
const report = buildHarmReport(
|
|
110
|
-
result,
|
|
111
|
-
identity.last_reported_at,
|
|
112
|
-
intervalDays,
|
|
113
|
-
activeFindings(readLeakRecord()),
|
|
114
|
-
);
|
|
115
|
-
const hits = report.harmful.reduce((n, p) => n + p.hits, 0);
|
|
116
|
-
|
|
117
106
|
try {
|
|
118
107
|
const res = await submitAuditReport(auth.access_token, {
|
|
119
108
|
machine_id: identity.machine_id,
|
|
@@ -139,8 +128,8 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
|
|
|
139
128
|
}
|
|
140
129
|
|
|
141
130
|
return res.emailed
|
|
142
|
-
? { kind: "sent",
|
|
143
|
-
: { kind: "held",
|
|
131
|
+
? { kind: "sent", leaks: report.leaks.length }
|
|
132
|
+
: { kind: "held", leaks: report.leaks.length, reason: res.reason ?? "not_sent" };
|
|
144
133
|
} catch (err) {
|
|
145
134
|
// A 401 here means the session died between `getValidAccessToken` and this
|
|
146
135
|
// call — rare, and indistinguishable from any other failure as far as this
|
|
@@ -160,19 +149,15 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
|
|
|
160
149
|
export function describeOutcome(outcome: HarmReportOutcome): string | null {
|
|
161
150
|
switch (outcome.kind) {
|
|
162
151
|
case "disabled":
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return "failproofai: scheduled scans are on, but emailing what they find needs a fresh opt-in — run `failproofai audit --schedule` (or visit /settings) to turn digests on";
|
|
152
|
+
case "no-leak":
|
|
153
|
+
return null;
|
|
166
154
|
case "signed-out":
|
|
167
|
-
|
|
168
|
-
// from the audit page", which stopped being true when this release moved
|
|
169
|
-
// that dialog behind "invite a friend".
|
|
170
|
-
return "failproofai: emailed reports are on but this machine is signed out — run `failproofai audit --schedule` or visit /settings to resume them";
|
|
155
|
+
return "failproofai: a credential exposure was found, but no email is configured — run `failproofai audit --schedule --email you@example.com`";
|
|
171
156
|
case "sent":
|
|
172
|
-
return
|
|
157
|
+
return "failproofai: emailed the most recent credential exposure";
|
|
173
158
|
case "held":
|
|
174
|
-
return `failproofai:
|
|
159
|
+
return `failproofai: credential exposure reported, no email (${outcome.reason})`;
|
|
175
160
|
case "failed":
|
|
176
|
-
return `failproofai: could not send the
|
|
161
|
+
return `failproofai: could not send the credential alert: ${outcome.error}`;
|
|
177
162
|
}
|
|
178
163
|
}
|