@spool-lab/core 0.4.12 → 0.4.20
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/dist/db/db.d.ts +10 -2
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +111 -3
- package/dist/db/db.js.map +1 -1
- package/dist/db/queries.d.ts +1 -2
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +23 -9
- package/dist/db/queries.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/projects/sessions.d.ts +29 -1
- package/dist/projects/sessions.d.ts.map +1 -1
- package/dist/projects/sessions.js +115 -11
- package/dist/projects/sessions.js.map +1 -1
- package/dist/security/index.d.ts +14 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +7 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/maintenance.d.ts +15 -0
- package/dist/security/maintenance.d.ts.map +1 -0
- package/dist/security/maintenance.js +68 -0
- package/dist/security/maintenance.js.map +1 -0
- package/dist/security/profile.d.ts +47 -0
- package/dist/security/profile.d.ts.map +1 -0
- package/dist/security/profile.js +122 -0
- package/dist/security/profile.js.map +1 -0
- package/dist/security/purge.d.ts +49 -0
- package/dist/security/purge.d.ts.map +1 -0
- package/dist/security/purge.js +159 -0
- package/dist/security/purge.js.map +1 -0
- package/dist/security/repo.d.ts +141 -0
- package/dist/security/repo.d.ts.map +1 -0
- package/dist/security/repo.js +469 -0
- package/dist/security/repo.js.map +1 -0
- package/dist/security/scan.d.ts +50 -0
- package/dist/security/scan.d.ts.map +1 -0
- package/dist/security/scan.js +120 -0
- package/dist/security/scan.js.map +1 -0
- package/dist/security/types.d.ts +128 -0
- package/dist/security/types.d.ts.map +1 -0
- package/dist/security/types.js +10 -0
- package/dist/security/types.js.map +1 -0
- package/dist/security/worker.d.ts +53 -0
- package/dist/security/worker.d.ts.map +1 -0
- package/dist/security/worker.js +180 -0
- package/dist/security/worker.js.map +1 -0
- package/dist/sync/source-paths.d.ts +1 -0
- package/dist/sync/source-paths.d.ts.map +1 -1
- package/dist/sync/source-paths.js +13 -3
- package/dist/sync/source-paths.js.map +1 -1
- package/dist/sync/syncer.d.ts +8 -1
- package/dist/sync/syncer.d.ts.map +1 -1
- package/dist/sync/syncer.js +26 -14
- package/dist/sync/syncer.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util/resolve-bin.d.ts +34 -0
- package/dist/util/resolve-bin.d.ts.map +1 -1
- package/dist/util/resolve-bin.js +175 -20
- package/dist/util/resolve-bin.js.map +1 -1
- package/package.json +5 -2
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// Findings + allowlist repository.
|
|
2
|
+
//
|
|
3
|
+
// Plain better-sqlite3 prepared statements. No Effect here — this
|
|
4
|
+
// module is called from both the scan worker (Effect, wraps these in
|
|
5
|
+
// Effect.sync) and the IPC handlers (Promise-shaped). Keeping it
|
|
6
|
+
// effect-free preserves the worker / boundary split documented in
|
|
7
|
+
// the spec's "Implementation: Effect TS scope" section.
|
|
8
|
+
//
|
|
9
|
+
// Invariant: `findings.value_hash` is FNV-1a, computed via the
|
|
10
|
+
// share-kit-aligned `hashValueForRedactExclude` so identity matches
|
|
11
|
+
// across surfaces (share editor's exclude list, security allowlists).
|
|
12
|
+
import { HIGH_SEVERITY_KINDS, INFO_SEVERITY_KINDS, severityOf } from '@spool-lab/redact';
|
|
13
|
+
function rowToFinding(r) {
|
|
14
|
+
return {
|
|
15
|
+
id: r.id,
|
|
16
|
+
sessionId: r.session_id,
|
|
17
|
+
messageId: r.message_id,
|
|
18
|
+
kind: r.kind,
|
|
19
|
+
valueHash: r.value_hash,
|
|
20
|
+
confidence: r.confidence,
|
|
21
|
+
provider: r.provider,
|
|
22
|
+
startOffset: r.start_offset,
|
|
23
|
+
endOffset: r.end_offset,
|
|
24
|
+
state: r.state,
|
|
25
|
+
detectedAt: r.detected_at,
|
|
26
|
+
stateChangedAt: r.state_changed_at,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Insert a batch of findings. Caller wraps in a transaction. */
|
|
30
|
+
export function insertFindings(db, rows) {
|
|
31
|
+
if (rows.length === 0)
|
|
32
|
+
return;
|
|
33
|
+
const stmt = db.prepare(`INSERT INTO findings
|
|
34
|
+
(session_id, message_id, kind, value_hash, confidence, provider,
|
|
35
|
+
start_offset, end_offset, state, state_changed_at)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
37
|
+
for (const r of rows) {
|
|
38
|
+
stmt.run(r.sessionId, r.messageId, r.kind, r.valueHash, r.confidence, r.provider, r.startOffset, r.endOffset, r.state, r.state !== 'active' ? new Date().toISOString() : null);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Delete all non-purged rows for the providers being rescanned —
|
|
42
|
+
* i.e. wipe the producer's previous output so the upcoming
|
|
43
|
+
* `insertFindings` is the canonical truth.
|
|
44
|
+
*
|
|
45
|
+
* Includes `state='dismissed'` deliberately: those rows are derived
|
|
46
|
+
* state. The user's "ignore" decisions live in `allowlist_session`
|
|
47
|
+
* and `allowlist_global` (preserved here), and the scan path
|
|
48
|
+
* re-emits findings as `state='dismissed'` whenever it encounters a
|
|
49
|
+
* hit that matches an allowlist row. Re-inserting also keeps
|
|
50
|
+
* `findings.value_hash` aligned with the per-kind allowlist
|
|
51
|
+
* preference (security.json `kindAllowlist`) — without this delete,
|
|
52
|
+
* a mute → unmute cycle would accumulate phantom dismissed rows
|
|
53
|
+
* (one set per cycle), inflating audit counts and breaking
|
|
54
|
+
* `riskByCategory` totals over time.
|
|
55
|
+
*
|
|
56
|
+
* Purged rows are the only state that's NOT producer-derived —
|
|
57
|
+
* they correspond to destructive `messages.content_text` rewrites
|
|
58
|
+
* the user explicitly approved. Preserving them is the audit
|
|
59
|
+
* contract documented in the design doc. */
|
|
60
|
+
export function deleteRefreshableFindings(db, sessionId, providers) {
|
|
61
|
+
if (providers.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
const placeholders = providers.map(() => '?').join(',');
|
|
64
|
+
db.prepare(`DELETE FROM findings
|
|
65
|
+
WHERE session_id = ?
|
|
66
|
+
AND state IN ('active','dismissed')
|
|
67
|
+
AND provider IN (${placeholders})`).run(sessionId, ...providers);
|
|
68
|
+
}
|
|
69
|
+
/** @deprecated Renamed to {@link deleteRefreshableFindings} after the
|
|
70
|
+
* active-only filter was found to leak phantom dismissed rows
|
|
71
|
+
* across mute→unmute cycles. Kept as a thin alias so callers
|
|
72
|
+
* outside the repo don't break mid-stack. */
|
|
73
|
+
export const deleteActiveFindings = deleteRefreshableFindings;
|
|
74
|
+
// ─── Counts (denormalised on sessions) ────────────────────────────
|
|
75
|
+
/** Recompute sessions.scan_finding_count + scan_high_count from the
|
|
76
|
+
* findings table.
|
|
77
|
+
*
|
|
78
|
+
* Counts EXCLUDE info-tier kinds (absolute-path, ip, internal-host).
|
|
79
|
+
* Those are stored — useful for an opt-in "Show informational
|
|
80
|
+
* signals" toggle — but they don't drive the Library row badge
|
|
81
|
+
* because their pattern-only signal has too high a false-positive
|
|
82
|
+
* rate to be meaningful at first glance. */
|
|
83
|
+
export function updateSessionCounts(db, sessionId) {
|
|
84
|
+
const active = db.prepare(`SELECT
|
|
85
|
+
SUM(CASE WHEN kind NOT IN (${infoKindsPlaceholders()}) THEN 1 ELSE 0 END) AS total,
|
|
86
|
+
SUM(CASE WHEN kind IN (${highKindsPlaceholders()}) THEN 1 ELSE 0 END) AS high
|
|
87
|
+
FROM findings
|
|
88
|
+
WHERE session_id = ? AND state = 'active'`).get(...INFO_SEVERITY_KINDS_ARRAY, ...HIGH_SEVERITY_KINDS_ARRAY, sessionId);
|
|
89
|
+
const purged = db.prepare(`SELECT COUNT(*) AS c
|
|
90
|
+
FROM findings
|
|
91
|
+
WHERE session_id = ? AND state = 'purged'`).get(sessionId);
|
|
92
|
+
db.prepare(`UPDATE sessions
|
|
93
|
+
SET scan_finding_count = ?,
|
|
94
|
+
scan_high_count = ?,
|
|
95
|
+
scan_purged_count = ?
|
|
96
|
+
WHERE id = ?`).run(active.total ?? 0, active.high ?? 0, purged.c, sessionId);
|
|
97
|
+
}
|
|
98
|
+
const HIGH_SEVERITY_KINDS_ARRAY = Array.from(HIGH_SEVERITY_KINDS);
|
|
99
|
+
const INFO_SEVERITY_KINDS_ARRAY = Array.from(INFO_SEVERITY_KINDS);
|
|
100
|
+
function highKindsPlaceholders() {
|
|
101
|
+
return HIGH_SEVERITY_KINDS_ARRAY.map(() => '?').join(',');
|
|
102
|
+
}
|
|
103
|
+
function infoKindsPlaceholders() {
|
|
104
|
+
return INFO_SEVERITY_KINDS_ARRAY.map(() => '?').join(',');
|
|
105
|
+
}
|
|
106
|
+
export function setSessionScanProfile(db, sessionId, profile, completedAt) {
|
|
107
|
+
db.prepare(`UPDATE sessions
|
|
108
|
+
SET scan_profile = ?, scan_completed_at = ?
|
|
109
|
+
WHERE id = ?`).run(profile, completedAt, sessionId);
|
|
110
|
+
}
|
|
111
|
+
/** Set every session's scan_profile to NULL — used by "Rescan all" so
|
|
112
|
+
* every session re-enqueues. */
|
|
113
|
+
export function invalidateAllScanProfiles(db) {
|
|
114
|
+
const r = db.prepare('UPDATE sessions SET scan_profile = NULL').run();
|
|
115
|
+
return r.changes;
|
|
116
|
+
}
|
|
117
|
+
/** Drop the scan profile for one session — used by the sync cascade
|
|
118
|
+
* when a session's messages change after first scan. */
|
|
119
|
+
export function invalidateSessionScanProfile(db, sessionId) {
|
|
120
|
+
db.prepare(`UPDATE sessions
|
|
121
|
+
SET scan_profile = NULL,
|
|
122
|
+
scan_completed_at = NULL
|
|
123
|
+
WHERE id = ?`).run(sessionId);
|
|
124
|
+
}
|
|
125
|
+
/** Sessions whose stored profile doesn't structurally match
|
|
126
|
+
* `currentProfile`. Used at boot for backfill enqueue. */
|
|
127
|
+
export function listSessionsNeedingScan(db, currentProfile) {
|
|
128
|
+
const rows = db.prepare(`SELECT id, scan_profile
|
|
129
|
+
FROM sessions
|
|
130
|
+
ORDER BY started_at DESC`).all();
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const r of rows) {
|
|
133
|
+
if (r.scan_profile === null || r.scan_profile !== currentProfile) {
|
|
134
|
+
out.push(r.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// ─── Reads (UI path) ──────────────────────────────────────────────
|
|
140
|
+
export function listFindings(db, filter) {
|
|
141
|
+
const where = [];
|
|
142
|
+
const params = [];
|
|
143
|
+
if (filter.sessionId !== undefined) {
|
|
144
|
+
where.push('session_id = ?');
|
|
145
|
+
params.push(filter.sessionId);
|
|
146
|
+
}
|
|
147
|
+
if (filter.kinds && filter.kinds.length > 0) {
|
|
148
|
+
const placeholders = filter.kinds.map(() => '?').join(',');
|
|
149
|
+
where.push(`kind IN (${placeholders})`);
|
|
150
|
+
params.push(...filter.kinds);
|
|
151
|
+
}
|
|
152
|
+
else if (filter.kind !== undefined) {
|
|
153
|
+
where.push('kind = ?');
|
|
154
|
+
params.push(filter.kind);
|
|
155
|
+
}
|
|
156
|
+
if (filter.state && filter.state !== 'any') {
|
|
157
|
+
where.push('state = ?');
|
|
158
|
+
params.push(filter.state);
|
|
159
|
+
}
|
|
160
|
+
else if (!filter.state) {
|
|
161
|
+
// Default: active only.
|
|
162
|
+
where.push("state = 'active'");
|
|
163
|
+
}
|
|
164
|
+
if (filter.severity === 'high') {
|
|
165
|
+
where.push(`kind IN (${highKindsPlaceholders()})`);
|
|
166
|
+
params.push(...HIGH_SEVERITY_KINDS_ARRAY);
|
|
167
|
+
}
|
|
168
|
+
else if (filter.severity === 'low') {
|
|
169
|
+
where.push(`kind NOT IN (${highKindsPlaceholders()})`);
|
|
170
|
+
params.push(...HIGH_SEVERITY_KINDS_ARRAY);
|
|
171
|
+
}
|
|
172
|
+
let pagination = '';
|
|
173
|
+
if (filter.limit !== undefined) {
|
|
174
|
+
pagination = ' LIMIT ? OFFSET ?';
|
|
175
|
+
params.push(filter.limit, filter.offset ?? 0);
|
|
176
|
+
}
|
|
177
|
+
const sql = `SELECT * FROM findings
|
|
178
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
179
|
+
ORDER BY detected_at DESC, id DESC${pagination}`;
|
|
180
|
+
const rows = db.prepare(sql).all(...params);
|
|
181
|
+
return rows.map(rowToFinding);
|
|
182
|
+
}
|
|
183
|
+
/** limit+1 peek to set `hasMore` without an extra COUNT round-trip. */
|
|
184
|
+
function paginate(filter, fetch) {
|
|
185
|
+
if (filter.limit === undefined)
|
|
186
|
+
return { rows: fetch(filter), hasMore: false };
|
|
187
|
+
const peeked = fetch({ ...filter, limit: filter.limit + 1 });
|
|
188
|
+
const hasMore = peeked.length > filter.limit;
|
|
189
|
+
return { rows: hasMore ? peeked.slice(0, filter.limit) : peeked, hasMore };
|
|
190
|
+
}
|
|
191
|
+
export function listFindingsPage(db, filter) {
|
|
192
|
+
return paginate(filter, f => listFindings(db, f));
|
|
193
|
+
}
|
|
194
|
+
/** Shared WHERE-clause builder for the session-findings join. Used by
|
|
195
|
+
* both `listSessionsWithFindings` (paginated rows) and
|
|
196
|
+
* `countSessionsWithFindings` (total) so the two stay consistent. */
|
|
197
|
+
function buildSessionFindingWhereSql(filter) {
|
|
198
|
+
const params = [];
|
|
199
|
+
const stateCondition = filter.state && filter.state !== 'any'
|
|
200
|
+
? 'f.state = ?'
|
|
201
|
+
: "f.state = 'active'";
|
|
202
|
+
if (filter.state && filter.state !== 'any')
|
|
203
|
+
params.push(filter.state);
|
|
204
|
+
let kindCondition = '';
|
|
205
|
+
const explicitKinds = filter.kinds && filter.kinds.length > 0
|
|
206
|
+
? filter.kinds
|
|
207
|
+
: filter.kind !== undefined ? [filter.kind] : undefined;
|
|
208
|
+
if (explicitKinds) {
|
|
209
|
+
const placeholders = explicitKinds.map(() => '?').join(',');
|
|
210
|
+
kindCondition = `AND f.kind IN (${placeholders})`;
|
|
211
|
+
params.push(...explicitKinds);
|
|
212
|
+
}
|
|
213
|
+
let severityCondition = '';
|
|
214
|
+
if (filter.severity === 'high') {
|
|
215
|
+
severityCondition = `AND f.kind IN (${highKindsPlaceholders()})`;
|
|
216
|
+
params.push(...HIGH_SEVERITY_KINDS_ARRAY);
|
|
217
|
+
}
|
|
218
|
+
else if (filter.severity === 'low') {
|
|
219
|
+
// Exclude both HIGH (not low) and INFO (noisy infra signals) so
|
|
220
|
+
// `severity:low` returns only the identity-tier kinds (email,
|
|
221
|
+
// phone, person-name, etc.).
|
|
222
|
+
severityCondition = `AND f.kind NOT IN (${highKindsPlaceholders()}) AND f.kind NOT IN (${infoKindsPlaceholders()})`;
|
|
223
|
+
params.push(...HIGH_SEVERITY_KINDS_ARRAY, ...INFO_SEVERITY_KINDS_ARRAY);
|
|
224
|
+
}
|
|
225
|
+
// Default exclusion: info-tier findings don't surface a session on
|
|
226
|
+
// their own — they're stored as an audit record (see Info drawer at
|
|
227
|
+
// the bottom of the page). Only when the user has explicitly pinned
|
|
228
|
+
// an info kind via filter.kind/filter.kinds do we let them through.
|
|
229
|
+
let infoExclusion = '';
|
|
230
|
+
if (!explicitKinds && filter.severity !== 'high' && filter.severity !== 'low') {
|
|
231
|
+
infoExclusion = `AND f.kind NOT IN (${infoKindsPlaceholders()})`;
|
|
232
|
+
params.push(...INFO_SEVERITY_KINDS_ARRAY);
|
|
233
|
+
}
|
|
234
|
+
let textCondition = '';
|
|
235
|
+
if (filter.text && filter.text.trim().length > 0) {
|
|
236
|
+
textCondition = `AND s.title LIKE ?`;
|
|
237
|
+
params.push(`%${filter.text.trim()}%`);
|
|
238
|
+
}
|
|
239
|
+
const whereClause = `${stateCondition} ${kindCondition} ${severityCondition} ${infoExclusion} ${textCondition}
|
|
240
|
+
AND COALESCE(s.message_count, 0) > 0`;
|
|
241
|
+
return { whereClause, params };
|
|
242
|
+
}
|
|
243
|
+
export function listSessionsWithFindings(db, filter) {
|
|
244
|
+
// For category/severity-aware filtering we need to compute counts
|
|
245
|
+
// from the findings table directly (denormalised counters can't
|
|
246
|
+
// express "only api-key findings"). We always recompute here.
|
|
247
|
+
const { whereClause, params } = buildSessionFindingWhereSql(filter);
|
|
248
|
+
const sql = `
|
|
249
|
+
SELECT
|
|
250
|
+
s.id AS id,
|
|
251
|
+
s.session_uuid AS session_uuid,
|
|
252
|
+
s.title AS title,
|
|
253
|
+
s.started_at AS started_at,
|
|
254
|
+
s.scan_completed_at AS scan_completed_at,
|
|
255
|
+
s.scan_purged_count AS purged_count,
|
|
256
|
+
s.message_count AS message_count,
|
|
257
|
+
s.model AS model,
|
|
258
|
+
s.cwd AS cwd,
|
|
259
|
+
src.name AS source,
|
|
260
|
+
p.display_name AS project_display_name,
|
|
261
|
+
SUM(1) AS finding_count,
|
|
262
|
+
SUM(CASE WHEN f.kind IN (${highKindsPlaceholders()}) THEN 1 ELSE 0 END) AS high_count
|
|
263
|
+
FROM sessions s
|
|
264
|
+
JOIN findings f ON f.session_id = s.id
|
|
265
|
+
JOIN sources src ON src.id = s.source_id
|
|
266
|
+
JOIN projects p ON p.id = s.project_id
|
|
267
|
+
WHERE ${whereClause}
|
|
268
|
+
GROUP BY s.id
|
|
269
|
+
ORDER BY high_count DESC, finding_count DESC, s.started_at DESC, s.id DESC
|
|
270
|
+
${filter.limit !== undefined ? 'LIMIT ? OFFSET ?' : ''}
|
|
271
|
+
`;
|
|
272
|
+
// Placeholder order matches SQL: SELECT's CASE WHEN IN (?) binds first,
|
|
273
|
+
// then the WHERE clause's params.
|
|
274
|
+
const allParams = [...HIGH_SEVERITY_KINDS_ARRAY, ...params];
|
|
275
|
+
if (filter.limit !== undefined) {
|
|
276
|
+
allParams.push(filter.limit, filter.offset ?? 0);
|
|
277
|
+
}
|
|
278
|
+
const rows = db.prepare(sql).all(...allParams);
|
|
279
|
+
return rows.map(r => ({
|
|
280
|
+
id: r.id,
|
|
281
|
+
sessionUuid: r.session_uuid,
|
|
282
|
+
title: r.title,
|
|
283
|
+
startedAt: r.started_at,
|
|
284
|
+
scanCompletedAt: r.scan_completed_at,
|
|
285
|
+
findingCount: r.finding_count,
|
|
286
|
+
highCount: r.high_count ?? 0,
|
|
287
|
+
purgedCount: r.purged_count ?? 0,
|
|
288
|
+
source: r.source,
|
|
289
|
+
messageCount: r.message_count ?? 0,
|
|
290
|
+
model: r.model,
|
|
291
|
+
cwd: r.cwd,
|
|
292
|
+
projectDisplayName: r.project_display_name,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
export function listSessionsWithFindingsPage(db, filter) {
|
|
296
|
+
return paginate(filter, f => listSessionsWithFindings(db, f));
|
|
297
|
+
}
|
|
298
|
+
/** Total distinct sessions matching the same filter as
|
|
299
|
+
* `listSessionsWithFindings` — used by the renderer to show
|
|
300
|
+
* "涉及 N 个会话" without loading every page. Cheaper than the list
|
|
301
|
+
* query: no SELECT projection, no GROUP BY, no ORDER BY. */
|
|
302
|
+
export function countSessionsWithFindings(db, filter) {
|
|
303
|
+
const { whereClause, params } = buildSessionFindingWhereSql(filter);
|
|
304
|
+
const sql = `
|
|
305
|
+
SELECT COUNT(DISTINCT s.id) AS total
|
|
306
|
+
FROM sessions s
|
|
307
|
+
JOIN findings f ON f.session_id = s.id
|
|
308
|
+
JOIN sources src ON src.id = s.source_id
|
|
309
|
+
JOIN projects p ON p.id = s.project_id
|
|
310
|
+
WHERE ${whereClause}
|
|
311
|
+
`;
|
|
312
|
+
const row = db.prepare(sql).get(...params);
|
|
313
|
+
return row.total;
|
|
314
|
+
}
|
|
315
|
+
/** Risk by category — one row per kind that has ≥ 1 active finding.
|
|
316
|
+
* Drives the Watchtower-style panel on the Security page. */
|
|
317
|
+
export function riskByCategory(db) {
|
|
318
|
+
const rows = db.prepare(`SELECT kind,
|
|
319
|
+
COUNT(*) AS count,
|
|
320
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
321
|
+
FROM findings
|
|
322
|
+
WHERE state = 'active'
|
|
323
|
+
GROUP BY kind
|
|
324
|
+
ORDER BY count DESC`).all();
|
|
325
|
+
return rows.map(r => ({
|
|
326
|
+
kind: r.kind,
|
|
327
|
+
severity: severityOf(r.kind),
|
|
328
|
+
count: r.count,
|
|
329
|
+
sessions: r.sessions,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
/** Read the live raw value for one finding. Used by the UI's review
|
|
333
|
+
* panel and the Purge confirm dialog. Returns null when the finding
|
|
334
|
+
* no longer points at a valid message (race against session
|
|
335
|
+
* deletion) or has been purged (offsets now point at the mask). */
|
|
336
|
+
export function getFindingValue(db, findingId) {
|
|
337
|
+
const row = db.prepare(`SELECT f.start_offset, f.end_offset, f.state, m.content_text
|
|
338
|
+
FROM findings f
|
|
339
|
+
LEFT JOIN messages m ON m.id = f.message_id
|
|
340
|
+
WHERE f.id = ?`).get(findingId);
|
|
341
|
+
if (!row || row.content_text === null)
|
|
342
|
+
return null;
|
|
343
|
+
if (row.state === 'purged')
|
|
344
|
+
return null;
|
|
345
|
+
return row.content_text.slice(row.start_offset, row.end_offset);
|
|
346
|
+
}
|
|
347
|
+
/** Bulk version of `getFindingValue` — one SQL query for N finding
|
|
348
|
+
* ids instead of N round-trips. Caller fans the result map back out
|
|
349
|
+
* by id; missing keys are treated as `null` (purged / vanished). */
|
|
350
|
+
export function getFindingValues(db, findingIds) {
|
|
351
|
+
const out = {};
|
|
352
|
+
if (findingIds.length === 0)
|
|
353
|
+
return out;
|
|
354
|
+
const placeholders = findingIds.map(() => '?').join(',');
|
|
355
|
+
const rows = db.prepare(`SELECT f.id, f.start_offset, f.end_offset, f.state, m.content_text
|
|
356
|
+
FROM findings f
|
|
357
|
+
LEFT JOIN messages m ON m.id = f.message_id
|
|
358
|
+
WHERE f.id IN (${placeholders})`).all(...findingIds);
|
|
359
|
+
for (const r of rows) {
|
|
360
|
+
if (r.content_text === null || r.state === 'purged') {
|
|
361
|
+
out[r.id] = null;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
out[r.id] = r.content_text.slice(r.start_offset, r.end_offset);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Fill in nulls for ids that didn't come back (deleted / cascade).
|
|
368
|
+
for (const id of findingIds) {
|
|
369
|
+
if (!(id in out))
|
|
370
|
+
out[id] = null;
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
const allowKey = (kind, hash) => `${kind}|${hash}`;
|
|
375
|
+
export function getAllowlists(db, sessionId) {
|
|
376
|
+
const sessionRows = db.prepare('SELECT kind, value_hash FROM allowlist_session WHERE session_id = ?').all(sessionId);
|
|
377
|
+
const globalRows = db.prepare('SELECT kind, value_hash FROM allowlist_global').all();
|
|
378
|
+
return {
|
|
379
|
+
session: new Set(sessionRows.map(r => allowKey(r.kind, r.value_hash))),
|
|
380
|
+
global: new Set(globalRows.map(r => allowKey(r.kind, r.value_hash))),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export function isAllowlisted(allow, kind, valueHash) {
|
|
384
|
+
const key = allowKey(kind, valueHash);
|
|
385
|
+
return allow.session.has(key) || allow.global.has(key);
|
|
386
|
+
}
|
|
387
|
+
export function addAllowlistSession(db, sessionId, kind, valueHash) {
|
|
388
|
+
db.prepare(`INSERT OR IGNORE INTO allowlist_session (session_id, kind, value_hash)
|
|
389
|
+
VALUES (?, ?, ?)`).run(sessionId, kind, valueHash);
|
|
390
|
+
}
|
|
391
|
+
export function addAllowlistGlobal(db, kind, valueHash) {
|
|
392
|
+
db.prepare(`INSERT OR IGNORE INTO allowlist_global (kind, value_hash) VALUES (?, ?)`).run(kind, valueHash);
|
|
393
|
+
}
|
|
394
|
+
export function removeAllowlistSession(db, sessionId, kind, valueHash) {
|
|
395
|
+
db.prepare(`DELETE FROM allowlist_session
|
|
396
|
+
WHERE session_id = ? AND kind = ? AND value_hash = ?`).run(sessionId, kind, valueHash);
|
|
397
|
+
}
|
|
398
|
+
export function removeAllowlistGlobal(db, kind, valueHash) {
|
|
399
|
+
db.prepare(`DELETE FROM allowlist_global WHERE kind = ? AND value_hash = ?`).run(kind, valueHash);
|
|
400
|
+
}
|
|
401
|
+
/** All allowlist rows from both tables, joined with the originating
|
|
402
|
+
* session metadata. Drives the Settings → Security pane's "Manage…"
|
|
403
|
+
* modal so the user can review and revoke past decisions. */
|
|
404
|
+
export function listAllowlistEntries(db) {
|
|
405
|
+
const sessionRows = db.prepare(`SELECT a.kind AS kind,
|
|
406
|
+
a.value_hash AS value_hash,
|
|
407
|
+
a.created_at AS created_at,
|
|
408
|
+
s.session_uuid AS session_uuid,
|
|
409
|
+
s.title AS session_title
|
|
410
|
+
FROM allowlist_session a
|
|
411
|
+
JOIN sessions s ON s.id = a.session_id
|
|
412
|
+
ORDER BY a.created_at DESC`).all();
|
|
413
|
+
const globalRows = db.prepare(`SELECT kind, value_hash, created_at
|
|
414
|
+
FROM allowlist_global
|
|
415
|
+
ORDER BY created_at DESC`).all();
|
|
416
|
+
return [
|
|
417
|
+
...globalRows.map((r) => ({
|
|
418
|
+
scope: 'global',
|
|
419
|
+
kind: r.kind,
|
|
420
|
+
valueHash: r.value_hash,
|
|
421
|
+
createdAt: r.created_at,
|
|
422
|
+
sessionUuid: null,
|
|
423
|
+
sessionTitle: null,
|
|
424
|
+
})),
|
|
425
|
+
...sessionRows.map((r) => ({
|
|
426
|
+
scope: 'session',
|
|
427
|
+
kind: r.kind,
|
|
428
|
+
valueHash: r.value_hash,
|
|
429
|
+
createdAt: r.created_at,
|
|
430
|
+
sessionUuid: r.session_uuid,
|
|
431
|
+
sessionTitle: r.session_title,
|
|
432
|
+
})),
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
// ─── Mutations called from IPC dismiss handlers ───────────────────
|
|
436
|
+
/** Flip a finding to 'dismissed' and, depending on scope, write the
|
|
437
|
+
* allowlist entry so future rescans honor the decision. Caller
|
|
438
|
+
* wraps in a transaction. */
|
|
439
|
+
export function dismissFinding(db, findingId, scope) {
|
|
440
|
+
const f = db.prepare('SELECT session_id, kind, value_hash FROM findings WHERE id = ?').get(findingId);
|
|
441
|
+
if (!f)
|
|
442
|
+
return;
|
|
443
|
+
db.prepare(`UPDATE findings
|
|
444
|
+
SET state = 'dismissed',
|
|
445
|
+
state_changed_at = datetime('now')
|
|
446
|
+
WHERE id = ?`).run(findingId);
|
|
447
|
+
if (scope === 'session') {
|
|
448
|
+
addAllowlistSession(db, f.session_id, f.kind, f.value_hash);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
addAllowlistGlobal(db, f.kind, f.value_hash);
|
|
452
|
+
}
|
|
453
|
+
updateSessionCounts(db, f.session_id);
|
|
454
|
+
}
|
|
455
|
+
/** Re-activate a dismissed finding and remove the allowlist entry
|
|
456
|
+
* that pinned it (both scopes, since UI doesn't always know which). */
|
|
457
|
+
export function undismissFinding(db, findingId) {
|
|
458
|
+
const f = db.prepare('SELECT session_id, kind, value_hash FROM findings WHERE id = ?').get(findingId);
|
|
459
|
+
if (!f)
|
|
460
|
+
return;
|
|
461
|
+
db.prepare(`UPDATE findings
|
|
462
|
+
SET state = 'active',
|
|
463
|
+
state_changed_at = datetime('now')
|
|
464
|
+
WHERE id = ?`).run(findingId);
|
|
465
|
+
removeAllowlistSession(db, f.session_id, f.kind, f.value_hash);
|
|
466
|
+
removeAllowlistGlobal(db, f.kind, f.value_hash);
|
|
467
|
+
updateSessionCounts(db, f.session_id);
|
|
468
|
+
}
|
|
469
|
+
//# sourceMappingURL=repo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo.js","sourceRoot":"","sources":["../../src/security/repo.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type Database from 'better-sqlite3';
|
|
3
|
+
import type { RedactProvider } from '@spool-lab/redact';
|
|
4
|
+
import type { FindingsChange } from './types.js';
|
|
5
|
+
declare const ScanError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
6
|
+
readonly _tag: "ScanError";
|
|
7
|
+
} & Readonly<A>;
|
|
8
|
+
export declare class ScanError extends ScanError_base<{
|
|
9
|
+
readonly sessionId: number;
|
|
10
|
+
readonly cause: unknown;
|
|
11
|
+
readonly reason: 'session-not-found' | 'provider-failed' | 'db-failed';
|
|
12
|
+
}> {
|
|
13
|
+
}
|
|
14
|
+
/** Result of one session scan — useful for tests and progress UIs. */
|
|
15
|
+
export interface ScanResult {
|
|
16
|
+
sessionId: number;
|
|
17
|
+
inserted: number;
|
|
18
|
+
/** Profile string written to sessions.scan_profile. */
|
|
19
|
+
profile: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ScanSessionDeps {
|
|
22
|
+
/** SQLite database handle (same instance the Syncer uses). */
|
|
23
|
+
db: Database.Database;
|
|
24
|
+
/** Active providers, evaluated in priority order. First entry wins
|
|
25
|
+
* on overlap (regex before pf — pattern matches are authoritative
|
|
26
|
+
* for known credential prefixes). */
|
|
27
|
+
providers: readonly RedactProvider[];
|
|
28
|
+
/** Profile string for the active provider set; persisted on
|
|
29
|
+
* successful scan. */
|
|
30
|
+
currentProfile: string;
|
|
31
|
+
/** Names of providers whose prior `active` findings should be
|
|
32
|
+
* deleted before re-insert. Almost always equals the names in
|
|
33
|
+
* `providers`; passed explicitly so the worker can choose to
|
|
34
|
+
* preserve historical pf findings even when pf is disabled. */
|
|
35
|
+
providerNames: readonly string[];
|
|
36
|
+
/** Sink for change notifications. The worker layer wraps PubSub
|
|
37
|
+
* here; tests pass a simple collector. */
|
|
38
|
+
publish: (change: FindingsChange) => Effect.Effect<void>;
|
|
39
|
+
/** Kind-level allowlist. Findings whose kind is in this set get
|
|
40
|
+
* inserted with state='dismissed' (instead of 'active') without
|
|
41
|
+
* needing a per-value allowlist row. Driven by the Settings →
|
|
42
|
+
* Security pane's multi-select. */
|
|
43
|
+
kindAllowlist?: ReadonlySet<string>;
|
|
44
|
+
}
|
|
45
|
+
/** Scan one session end to end. Idempotent: running twice produces
|
|
46
|
+
* the same findings (same value_hash + same offsets → same rows
|
|
47
|
+
* after the delete-then-insert reset). */
|
|
48
|
+
export declare function scanSession(sessionId: number, deps: ScanSessionDeps): Effect.Effect<ScanResult, ScanError>;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=scan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../src/security/scan.ts"],"names":[],"mappings":"AAaA,OAAO,EAAQ,MAAM,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAC1C,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAWvE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;;;;AAEhD,qBAAa,SAAU,SAAQ,eAA8B;IAC3D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,mBAAmB,GAAG,iBAAiB,GAAG,WAAW,CAAA;CACvE,CAAC;CAAG;AAOL,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,8DAA8D;IAC9D,EAAE,EAAE,QAAQ,CAAC,QAAQ,CAAA;IACrB;;0CAEsC;IACtC,SAAS,EAAE,SAAS,cAAc,EAAE,CAAA;IACpC;2BACuB;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB;;;oEAGgE;IAChE,aAAa,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC;+CAC2C;IAC3C,OAAO,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IACxD;;;wCAGoC;IACpC,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CACpC;AAED;;2CAE2C;AAC3C,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,eAAe,GACpB,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,CAiFtC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Per-session scan pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Effect-shaped so the worker can compose it with Queue / Stream /
|
|
4
|
+
// supervisor primitives. The detection itself runs against
|
|
5
|
+
// `messages.content_text` only — Purge later mutates that field,
|
|
6
|
+
// findings re-detect, and the audit trail stays consistent.
|
|
7
|
+
//
|
|
8
|
+
// Transaction shape: detection runs *outside* the transaction (it
|
|
9
|
+
// can take seconds with ML); only the final apply step is atomic.
|
|
10
|
+
// If the worker dies mid-detection, scan_profile was never updated
|
|
11
|
+
// → restart re-enqueues from scratch. If it dies inside the
|
|
12
|
+
// transaction, SQLite atomicity guarantees no half-applied state.
|
|
13
|
+
import { Data, Effect } from 'effect';
|
|
14
|
+
import { hashValueForRedactExclude } from '@spool-lab/redact';
|
|
15
|
+
import { deleteRefreshableFindings, insertFindings, setSessionScanProfile, updateSessionCounts, getAllowlists, isAllowlisted, } from './repo.js';
|
|
16
|
+
export class ScanError extends Data.TaggedError('ScanError') {
|
|
17
|
+
}
|
|
18
|
+
/** Scan one session end to end. Idempotent: running twice produces
|
|
19
|
+
* the same findings (same value_hash + same offsets → same rows
|
|
20
|
+
* after the delete-then-insert reset). */
|
|
21
|
+
export function scanSession(sessionId, deps) {
|
|
22
|
+
return Effect.gen(function* () {
|
|
23
|
+
// 1. Load messages outside the transaction.
|
|
24
|
+
const messages = yield* Effect.try({
|
|
25
|
+
try: () => deps.db.prepare(`SELECT id, content_text
|
|
26
|
+
FROM messages
|
|
27
|
+
WHERE session_id = ?
|
|
28
|
+
ORDER BY seq ASC`).all(sessionId),
|
|
29
|
+
catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
|
|
30
|
+
});
|
|
31
|
+
if (messages.length === 0) {
|
|
32
|
+
// Session has no messages yet. Still mark it scanned with the
|
|
33
|
+
// current profile so we don't loop on it; future sync will
|
|
34
|
+
// invalidate the profile and re-enqueue.
|
|
35
|
+
yield* applyEmpty(sessionId, deps);
|
|
36
|
+
yield* deps.publish({ type: 'session-rescanned', sessionId });
|
|
37
|
+
return { sessionId, inserted: 0, profile: deps.currentProfile };
|
|
38
|
+
}
|
|
39
|
+
// 2. Run providers per message. The flat list is enough — we
|
|
40
|
+
// dedupe later by (kind, value_hash, start_offset) so producers
|
|
41
|
+
// can be naive about overlap.
|
|
42
|
+
const allMatches = [];
|
|
43
|
+
for (const msg of messages) {
|
|
44
|
+
for (const provider of deps.providers) {
|
|
45
|
+
if (!provider.available())
|
|
46
|
+
continue;
|
|
47
|
+
const matches = yield* Effect.tryPromise({
|
|
48
|
+
try: () => provider.analyze(msg.content_text),
|
|
49
|
+
catch: (cause) => new ScanError({ sessionId, cause, reason: 'provider-failed' }),
|
|
50
|
+
});
|
|
51
|
+
for (const m of matches) {
|
|
52
|
+
allMatches.push({ ...m, messageId: msg.id });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 3. Dedupe across providers — pick the highest-confidence match
|
|
57
|
+
// for each (kind, value_hash, start_offset).
|
|
58
|
+
const merged = mergeMatches(allMatches);
|
|
59
|
+
// 4. Allowlist join → assign initial state.
|
|
60
|
+
const allow = yield* Effect.try({
|
|
61
|
+
try: () => getAllowlists(deps.db, sessionId),
|
|
62
|
+
catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
|
|
63
|
+
});
|
|
64
|
+
const inputs = merged.map((m) => ({
|
|
65
|
+
sessionId,
|
|
66
|
+
messageId: m.messageId,
|
|
67
|
+
kind: m.kind,
|
|
68
|
+
valueHash: m.valueHash,
|
|
69
|
+
confidence: m.confidence,
|
|
70
|
+
provider: m.provider,
|
|
71
|
+
startOffset: m.start,
|
|
72
|
+
endOffset: m.end,
|
|
73
|
+
state: (deps.kindAllowlist?.has(m.kind) || isAllowlisted(allow, m.kind, m.valueHash))
|
|
74
|
+
? 'dismissed'
|
|
75
|
+
: 'active',
|
|
76
|
+
}));
|
|
77
|
+
// 5. Apply atomically.
|
|
78
|
+
yield* Effect.try({
|
|
79
|
+
try: () => {
|
|
80
|
+
deps.db.transaction(() => {
|
|
81
|
+
deleteRefreshableFindings(deps.db, sessionId, deps.providerNames);
|
|
82
|
+
insertFindings(deps.db, inputs);
|
|
83
|
+
setSessionScanProfile(deps.db, sessionId, deps.currentProfile, new Date().toISOString());
|
|
84
|
+
updateSessionCounts(deps.db, sessionId);
|
|
85
|
+
})();
|
|
86
|
+
},
|
|
87
|
+
catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
|
|
88
|
+
});
|
|
89
|
+
yield* deps.publish({ type: 'session-rescanned', sessionId });
|
|
90
|
+
return { sessionId, inserted: inputs.length, profile: deps.currentProfile };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function applyEmpty(sessionId, deps) {
|
|
94
|
+
return Effect.try({
|
|
95
|
+
try: () => {
|
|
96
|
+
deps.db.transaction(() => {
|
|
97
|
+
deleteRefreshableFindings(deps.db, sessionId, deps.providerNames);
|
|
98
|
+
setSessionScanProfile(deps.db, sessionId, deps.currentProfile, new Date().toISOString());
|
|
99
|
+
updateSessionCounts(deps.db, sessionId);
|
|
100
|
+
})();
|
|
101
|
+
},
|
|
102
|
+
catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Pick the highest-confidence match per (messageId, kind, value_hash,
|
|
106
|
+
* start). The same vendor token detected by two providers should
|
|
107
|
+
* produce one finding, attributed to whoever was most confident. */
|
|
108
|
+
function mergeMatches(matches) {
|
|
109
|
+
const byKey = new Map();
|
|
110
|
+
for (const m of matches) {
|
|
111
|
+
const hash = hashValueForRedactExclude(m.value);
|
|
112
|
+
const key = `${m.messageId}|${m.kind}|${hash}|${m.start}`;
|
|
113
|
+
const existing = byKey.get(key);
|
|
114
|
+
if (!existing || m.confidence > existing.confidence) {
|
|
115
|
+
byKey.set(key, { ...m, valueHash: hash });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return Array.from(byKey.values());
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=scan.js.map
|