failproofai 1.0.4-beta.4 → 1.0.4-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +5 -5
- package/.next/standalone/.next/server/app/audit/page.js +3 -3
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +4 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +4 -4
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +3 -3
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +2 -2
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[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 +4 -4
- 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]__0ftmoxc._.js → [root-of-the-server]__17xnxlx._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__1ckpmkk._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00-s7h8._.js → [root-of-the-server]__1fvfw59._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0yl9wb5._.js → [root-of-the-server]__1ui5ur8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_0guxnh-._.js → _02b3i98._.js} +2 -2
- 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/{_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_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +22 -22
- package/.next/standalone/.next/static/chunks/{364cqbpeg1y5i.js → 07c6l1lq5_69b.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2bxr2_2h4kaqq.js → 12bsgz8czvcnn.js} +1 -1
- package/.next/standalone/.next/static/chunks/{38dqur43qthrl.js → 1gn08w2pgqh09.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1i-i0pxg9q_8y.js → 1xgq22tri86an.js} +1 -1
- package/.next/standalone/.next/static/chunks/{17tfki9x5rd_7.js → 20xkiqzvfkuvm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{42gwu2bsz4ske.js → 2a84ug32tzik2.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0po8n4tyotjf2.js → 32m2ryfankf_2.js} +1 -1
- package/.next/standalone/.next/static/chunks/3ioub_lpen-sq.js +1 -0
- package/.next/standalone/.next/static/chunks/{323cyb3a2s06y.js → 3jxd133cw2vm3.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0qx7gav1c2_vu.js → 44ncu88eq6z71.js} +1 -1
- package/.next/standalone/app/actions/get-leaks.ts +0 -5
- package/.next/standalone/app/actions/update-scheduled-audit.ts +4 -56
- package/.next/standalone/app/api/auth/status/route.ts +3 -2
- package/.next/standalone/app/settings/page.tsx +1 -1
- package/.next/standalone/app/settings/settings-client.tsx +13 -39
- package/.next/standalone/lib/auth/api-server-client.ts +6 -6
- package/.next/standalone/lib/codex-sessions.ts +20 -4
- package/.next/standalone/lib/copilot-sessions.ts +32 -1
- package/.next/standalone/lib/cursor-sessions.ts +62 -5
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +952 -1063
- package/dist/worker.mjs +173 -380
- 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 +2 -0
- package/src/audit/leak-fingerprint.ts +2 -4
- package/src/audit/leak-record.ts +13 -9
- 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 +2 -2
- 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/configure-wizard.ts +28 -0
- 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/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +0 -3
- package/.next/standalone/.next/static/chunks/04o5zfl7p5lhx.js +0 -1
- package/src/audit/leak-notice.ts +0 -259
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_ssgManifest.js +0 -0
package/src/audit/index.ts
CHANGED
|
@@ -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
|
@@ -156,15 +156,19 @@ export function upsertFinding(
|
|
|
156
156
|
if (sighting.at > existing.lastSeen) existing.lastSeen = sighting.at;
|
|
157
157
|
if (sighting.at < existing.firstSeen) existing.firstSeen = sighting.at;
|
|
158
158
|
|
|
159
|
-
// Keep the
|
|
160
|
-
// the
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
// Keep the earliest sighting AND the most recent evidence. The report/email
|
|
160
|
+
// promises the CLI and timestamp of the latest exposure; keeping only the
|
|
161
|
+
// first five made those fields stale forever after a busy credential crossed
|
|
162
|
+
// the cap. Middle sightings are the expendable ones.
|
|
163
|
+
const seen = existing.sightings.some(
|
|
164
|
+
(s) => s.sessionId === sighting.sessionId && s.at === sighting.at,
|
|
165
|
+
);
|
|
166
|
+
if (!seen) {
|
|
167
|
+
existing.sightings.push(sighting);
|
|
168
|
+
existing.sightings.sort((a, b) => a.at.localeCompare(b.at));
|
|
169
|
+
if (existing.sightings.length > MAX_SIGHTINGS) {
|
|
170
|
+
existing.sightings.splice(1, existing.sightings.length - MAX_SIGHTINGS);
|
|
171
|
+
}
|
|
168
172
|
}
|
|
169
173
|
return { record, isNew: false };
|
|
170
174
|
}
|
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.
|
|
@@ -255,8 +255,8 @@ export function queueMacNotification(
|
|
|
255
255
|
body: string,
|
|
256
256
|
home?: string,
|
|
257
257
|
): boolean {
|
|
258
|
-
// The id becomes a filename in a watched directory
|
|
259
|
-
//
|
|
258
|
+
// The id becomes a filename in a watched directory, so validate the path
|
|
259
|
+
// component before resolving it — see `isFindingId`.
|
|
260
260
|
if (!isFindingId(id)) return false;
|
|
261
261
|
const dir = macNotifyDir(home);
|
|
262
262
|
const tmp = resolve(dir, "..", `notify-${id}.tmp`);
|
|
@@ -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
|
}
|
|
@@ -68,13 +68,9 @@ const dim = (s: string) => wrap(ANSI_DIM, s);
|
|
|
68
68
|
const bold = (s: string) => wrap(ANSI_BOLD, s);
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Turn scheduled audits on
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* is to be told what it found — so this requires a session, exactly as the
|
|
75
|
-
* dashboard's `setAutoAuditAction` does. A timer set with nobody to tell is a
|
|
76
|
-
* switch that reads as on and produces nothing, discoverable only by noticing
|
|
77
|
-
* that no digest ever arrives.
|
|
71
|
+
* Turn scheduled audits on. Email is optional: `--email` signs in, while a
|
|
72
|
+
* plain `--schedule` enables the local scan and desktop notification without
|
|
73
|
+
* prompting for an account.
|
|
78
74
|
*
|
|
79
75
|
* The interval is written and then RE-READ, so what is printed is what the
|
|
80
76
|
* config actually kept — `readIntervalDays` owns the 1..90 clamp and a second
|
|
@@ -108,17 +104,13 @@ export async function runScheduleOn(
|
|
|
108
104
|
if (bad) throw new ScheduleCliError(bad);
|
|
109
105
|
}
|
|
110
106
|
|
|
111
|
-
const
|
|
107
|
+
const signedIn = emailArg === undefined
|
|
108
|
+
? { user: readAuth()?.user ?? null, prompted: false }
|
|
109
|
+
: await ensureSignedIn(emailArg);
|
|
112
110
|
|
|
113
111
|
const next = updateConfig({
|
|
114
|
-
// Stamped in the SAME call that sets `auto`, never separately: this records
|
|
115
|
-
// that a person completed a sign-in and read the disclosure printed below,
|
|
116
|
-
// and it is what `reportHarm` gates sending on. A machine that inherited
|
|
117
|
-
// `auto` from a release where it meant "scan locally" has no stamp and
|
|
118
|
-
// sends nothing until it comes through here.
|
|
119
112
|
audit: {
|
|
120
113
|
auto: true,
|
|
121
|
-
reportsConsentedAt: Date.now(),
|
|
122
114
|
...(days !== undefined ? { intervalDays: days } : {}),
|
|
123
115
|
},
|
|
124
116
|
});
|
|
@@ -134,12 +126,14 @@ export async function runScheduleOn(
|
|
|
134
126
|
// The list is the real payload from `report-harm.ts`: machine id, hostname,
|
|
135
127
|
// platform, the window bounds and the redacted examples.
|
|
136
128
|
const summary = [
|
|
137
|
-
`every ${interval} day${interval === 1 ? "" : "s"} ·
|
|
138
|
-
|
|
139
|
-
|
|
129
|
+
`every ${interval} day${interval === 1 ? "" : "s"} · desktop notification on leaks`,
|
|
130
|
+
signedIn.user
|
|
131
|
+
? `masked leak alerts also go to ${signedIn.user.email}`
|
|
132
|
+
: "email is optional · add it with --email you@example.com",
|
|
133
|
+
"email contains only the most recent masked credential exposure",
|
|
140
134
|
];
|
|
141
135
|
|
|
142
|
-
if (prompted) {
|
|
136
|
+
if (signedIn.prompted) {
|
|
143
137
|
// A sign-in just drew the frame, so the result continues it and the `└`
|
|
144
138
|
// closes both at once — rather than the frame ending and a loose line
|
|
145
139
|
// appearing underneath.
|
|
@@ -158,7 +152,7 @@ export async function runScheduleOn(
|
|
|
158
152
|
// exists to make visible.
|
|
159
153
|
warnIfDaemonWontRun();
|
|
160
154
|
|
|
161
|
-
if (prompted) {
|
|
155
|
+
if (signedIn.prompted) {
|
|
162
156
|
outro("failproofai audit --status · when the next scan is due");
|
|
163
157
|
} else {
|
|
164
158
|
process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`));
|
|
@@ -217,7 +211,7 @@ export function runScheduleStatus(): void {
|
|
|
217
211
|
|
|
218
212
|
// A session whose refresh window has closed cannot mint another access token,
|
|
219
213
|
// so it is a destination in name only. Showing the address for one would tell
|
|
220
|
-
// somebody their
|
|
214
|
+
// somebody their alerts are going somewhere they are not.
|
|
221
215
|
const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
|
|
222
216
|
// Beside the schedule, because they are the two halves of "what does this
|
|
223
217
|
// machine do on its own" — one decides whether it scans, the other whether it
|
|
@@ -230,19 +224,9 @@ export function runScheduleStatus(): void {
|
|
|
230
224
|
detail.push(["reports to", live ? live.user.email : dim("— signed out")]);
|
|
231
225
|
if (on && !live) {
|
|
232
226
|
// The state the reporter surfaces as "signed-out". Named here for the same
|
|
233
|
-
// reason the settings panel names it: the scans
|
|
234
|
-
// about
|
|
235
|
-
detail.push(["", pink("scans continue;
|
|
236
|
-
} else if (on && config.audit.reportsConsentedAt === undefined) {
|
|
237
|
-
// Signed in, scheduled, and still not sending: this machine set `audit.auto`
|
|
238
|
-
// when it only meant "scan locally", so nothing has consented to the digest
|
|
239
|
-
// leaving the box. Without this row the status screen would show a healthy
|
|
240
|
-
// schedule and a live address and still mail nothing, with no explanation
|
|
241
|
-
// anywhere the user can see.
|
|
242
|
-
detail.push([
|
|
243
|
-
"",
|
|
244
|
-
pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on"),
|
|
245
|
-
]);
|
|
227
|
+
// reason the settings panel names it: the scans and desktop notifications
|
|
228
|
+
// keep running, so silence about email would look like the feature failing.
|
|
229
|
+
detail.push(["", pink("scans and desktop alerts continue; email alerts are paused")]);
|
|
246
230
|
}
|
|
247
231
|
|
|
248
232
|
detail.push(["daemon", describeDaemon(daemon)]);
|
package/src/audit/types.ts
CHANGED
|
@@ -217,6 +217,11 @@ export interface AuditResult {
|
|
|
217
217
|
* Lets the dashboard answer "is this policy already on?" for
|
|
218
218
|
* detector-mapped policies that may not have hit during this audit. */
|
|
219
219
|
enabledBuiltinNames: string[];
|
|
220
|
+
/** Ids of credentials encountered by this scan, including ones already in
|
|
221
|
+
* the persistent leak record. Scheduled desktop notifications key on this
|
|
222
|
+
* list so every completed scan that still sees a leak can notify; the
|
|
223
|
+
* narrower `newLeakIds` remains for first-seen-only surfaces. */
|
|
224
|
+
leakIds?: string[];
|
|
220
225
|
/**
|
|
221
226
|
* Ids of credentials seen for the FIRST time in this scan.
|
|
222
227
|
*
|
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
type RetryProbe,
|
|
107
107
|
} from "./onboarding-attempt";
|
|
108
108
|
import { acquireOnboardingLock } from "./onboarding-lock";
|
|
109
|
+
import { readConfigRaw, updateConfig } from "./fp-config";
|
|
109
110
|
|
|
110
111
|
export interface WizardIO {
|
|
111
112
|
stdin?: TTYIn;
|
|
@@ -755,6 +756,19 @@ export async function runConfigureWizard(
|
|
|
755
756
|
// question, and no flag can supply it.
|
|
756
757
|
const preAnswered = Boolean(answers.token);
|
|
757
758
|
|
|
759
|
+
// Capture the preference BEFORE the daemon setup writes config.json. A fresh
|
|
760
|
+
// machine reaches `setDaemonConfigured(true)` with no config file; that helper
|
|
761
|
+
// necessarily materialises the conservative no-file default (`auto: false`).
|
|
762
|
+
// Looking after that write cannot distinguish "the user switched it off"
|
|
763
|
+
// from "setup just wrote its fallback", which is how new installs ended up
|
|
764
|
+
// with a seven-day scheduler that was explicitly disabled.
|
|
765
|
+
const initialAudit = readConfigRaw().raw.audit;
|
|
766
|
+
const hadExplicitAuditAuto =
|
|
767
|
+
!!initialAudit &&
|
|
768
|
+
typeof initialAudit === "object" &&
|
|
769
|
+
!Array.isArray(initialAudit) &&
|
|
770
|
+
typeof (initialAudit as Record<string, unknown>).auto === "boolean";
|
|
771
|
+
|
|
758
772
|
|
|
759
773
|
// Running the wizard itself under sudo configures the WRONG ACCOUNT, and
|
|
760
774
|
// does it silently: homedir() becomes /root, so the hooks land in root's
|
|
@@ -1439,6 +1453,20 @@ export async function runConfigureWizard(
|
|
|
1439
1453
|
// binary filename are both derived from it.
|
|
1440
1454
|
if (daemonInstalled) {
|
|
1441
1455
|
setDaemonConfigured(true, cliVersion);
|
|
1456
|
+
// A completed setup is the default opt-in to the LOCAL seven-day scan. It
|
|
1457
|
+
// does not imply an email address or make a network request: reporting is
|
|
1458
|
+
// independently gated by the signed-in audit identity. Reconfiguration
|
|
1459
|
+
// preserves an explicit `auto: false`, so the user's off switch remains an
|
|
1460
|
+
// off switch instead of being undone whenever they repair or upgrade the
|
|
1461
|
+
// daemon.
|
|
1462
|
+
if (!hadExplicitAuditAuto) {
|
|
1463
|
+
try {
|
|
1464
|
+
updateConfig({ audit: { auto: true } });
|
|
1465
|
+
} catch {
|
|
1466
|
+
// The daemon and hooks are already installed. Keep setup successful;
|
|
1467
|
+
// the status screen will expose that scheduling could not be persisted.
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1442
1470
|
// Only now — the unit points at the new binary, so older ones are no
|
|
1443
1471
|
// longer referenced by anything. Keeps the previous version for an
|
|
1444
1472
|
// offline rollback.
|