opencode-codex-security 0.0.0-stage → 0.1.1

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.
Files changed (42) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/DISCLOSURE +19 -0
  3. package/LICENSE +202 -0
  4. package/README.md +103 -2
  5. package/SECURITY.md +22 -0
  6. package/dist/gui-experimental.cjs +17 -0
  7. package/docs/GLOBAL-CONFIG.md +64 -0
  8. package/docs/MULTI-SCAN.md +62 -0
  9. package/docs/PUBLISHING.md +71 -0
  10. package/docs/RELEASING.md +53 -0
  11. package/docs/SDK.md +36 -0
  12. package/fixtures/vulnerable/docs/ARCHITECTURE.md +2 -0
  13. package/fixtures/vulnerable/src/accounts.js +4 -0
  14. package/fixtures/vulnerable/src/diagnostics.js +5 -0
  15. package/fixtures/vulnerable/src/download.js +5 -0
  16. package/fixtures/vulnerable/src/profile.js +4 -0
  17. package/package.json +79 -4
  18. package/src/core/codex-adapter.ts +430 -0
  19. package/src/core/credential-guard.ts +83 -0
  20. package/src/core/planned-scan.ts +316 -0
  21. package/src/core/report-safety.ts +41 -0
  22. package/src/core/result-summary.ts +512 -0
  23. package/src/core/run-history.ts +107 -0
  24. package/src/core/run-telemetry.ts +359 -0
  25. package/src/core/scan-plan.ts +182 -0
  26. package/src/core/scan-service.mjs +129 -0
  27. package/src/core/scanner.mjs +129 -0
  28. package/src/core/sdk-settings.ts +123 -0
  29. package/src/core/security-operations.ts +598 -0
  30. package/src/gui.tsx +102 -0
  31. package/src/operation-tools.ts +247 -0
  32. package/src/plan-view.ts +47 -0
  33. package/src/plugin.ts +346 -0
  34. package/src/rpc.ts +124 -0
  35. package/src/scan-display.ts +284 -0
  36. package/src/security-operations-panel.tsx +551 -0
  37. package/src/security-panel.tsx +1003 -0
  38. package/src/security-styles.ts +10 -0
  39. package/src/security-tui-operations.tsx +343 -0
  40. package/src/state-order.ts +21 -0
  41. package/src/tui.tsx +630 -0
  42. package/src/types.ts +84 -0
@@ -0,0 +1,512 @@
1
+ import { basename } from 'node:path';
2
+ import type { RepositoryFinding, ScanResult, SurfaceDisposition } from '@openai/codex-security';
3
+ import { safeReportText } from './report-safety.ts';
4
+
5
+ /** Counts describe returned records, not unique vulnerabilities or review completeness. */
6
+ export interface SummaryList<T> {
7
+ /** null means the source collection was unavailable, not empty. */
8
+ total: number | null;
9
+ items: T[];
10
+ /** Includes invalid entries that could not safely be projected. */
11
+ omitted: number;
12
+ }
13
+
14
+ export interface ReviewSurfaceSummary {
15
+ id: string | null;
16
+ label: string | null;
17
+ disposition: SurfaceDisposition | 'unknown';
18
+ riskArea: string | null;
19
+ }
20
+
21
+ export interface RepositoryFindingSummary {
22
+ findingId: string | null;
23
+ occurrenceId: string | null;
24
+ status: RepositoryFinding['status'] | 'unknown';
25
+ confirmedInLatestScan: boolean | null;
26
+ knownSince: string | null;
27
+ knownScanIds: SummaryList<string>;
28
+ matchedFindingIds: SummaryList<string>;
29
+ }
30
+
31
+ export interface ScanOutcomeSummary {
32
+ source: 'final-sdk-result';
33
+ repository: string;
34
+ returnedFindings: {
35
+ total: number | null;
36
+ reportable: number | null;
37
+ informational: number | null;
38
+ unclassified: number | null;
39
+ };
40
+ coverage: {
41
+ /** reviewState is a guarded, non-schema-stable projection, never a completeness claim. */
42
+ source: 'manifest-review-state' | 'unavailable';
43
+ surfaces: SummaryList<ReviewSurfaceSummary> & {
44
+ dispositions: Record<SurfaceDisposition | 'unknown', number> | null;
45
+ };
46
+ explicitExclusions: SummaryList<{ pattern: string | null; reason: string | null }>;
47
+ deferred: SummaryList<{
48
+ id: string | null;
49
+ reason: string | null;
50
+ paths: SummaryList<string>;
51
+ surfaceIds: SummaryList<string>;
52
+ }>;
53
+ resolvedDeferred: SummaryList<{ id: string | null; reason: string | null }>;
54
+ openQuestions: SummaryList<{ question: string | null }>;
55
+ scopeIncludePaths: SummaryList<string>;
56
+ scopeExcludePaths: SummaryList<string>;
57
+ reviewExcludedPaths: SummaryList<string>;
58
+ limitations: SummaryList<string>;
59
+ };
60
+ repositoryFindings: SummaryList<RepositoryFindingSummary> & {
61
+ available: boolean;
62
+ open: number | null;
63
+ closed: number | null;
64
+ unknownStatus: number | null;
65
+ confirmedInLatestScan: number | null;
66
+ notConfirmedInLatestScan: number | null;
67
+ unknownConfirmation: number | null;
68
+ withKnownSince: number | null;
69
+ withMatchedFindingIds: number | null;
70
+ };
71
+ /** Manifest-declared metadata only. No file reads, existence checks, or clickable URLs. */
72
+ artifacts: SummaryList<{ path: string; mediaType: string | null }>;
73
+ notes: string[];
74
+ }
75
+
76
+ const MAX_ITEMS = 20;
77
+ const MAX_TEXT = 320;
78
+ const MAX_ID = 160;
79
+ const MAX_PATH = 320;
80
+ const DISPOSITIONS: readonly SurfaceDisposition[] = [
81
+ 'reported',
82
+ 'no_issue_found',
83
+ 'rejected',
84
+ 'not_applicable',
85
+ 'needs_follow_up',
86
+ ];
87
+ const REPORTABLE_LEVELS = ['critical', 'high', 'medium', 'low'];
88
+ const SUMMARY_NOTES = [
89
+ 'Final returned finding counts are records, not new or unique vulnerability counts.',
90
+ 'Review details describe recorded dispositions, not exhaustive coverage or proof of security. Missing detail is unavailable, not zero.',
91
+ 'Repository status, confirmation, and matching fields are returned SDK metadata. Unconfirmed does not mean fixed; missing matches do not mean new. No deduplication progress is inferred.',
92
+ 'Artifact metadata is manifest-declared only. File contents and existence were not checked.',
93
+ ];
94
+
95
+ function record(value: unknown): Record<string, unknown> | undefined {
96
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
97
+ ? (value as Record<string, unknown>)
98
+ : undefined;
99
+ }
100
+
101
+ function text(value: unknown, max = MAX_TEXT): string | null {
102
+ const sanitized = safeReportText(value, max).trim();
103
+ return sanitized || null;
104
+ }
105
+
106
+ function list<T>(value: unknown, project: (value: unknown) => T | null): SummaryList<T> {
107
+ if (!Array.isArray(value)) return { total: null, items: [], omitted: 0 };
108
+ const items: T[] = [];
109
+ // Bound projection work as well as output. Aggregate counts below inspect all returned rows.
110
+ for (const entry of value.slice(0, MAX_ITEMS)) {
111
+ const projected = project(entry);
112
+ if (projected !== null) items.push(projected);
113
+ }
114
+ return { total: value.length, items, omitted: value.length - items.length };
115
+ }
116
+
117
+ function strings(value: unknown, max = MAX_TEXT): SummaryList<string> {
118
+ return list(value, (entry) => text(entry, max));
119
+ }
120
+
121
+ function disposition(value: unknown): SurfaceDisposition | 'unknown' {
122
+ return DISPOSITIONS.includes(value as SurfaceDisposition)
123
+ ? (value as SurfaceDisposition)
124
+ : 'unknown';
125
+ }
126
+
127
+ function repositoryStatus(value: unknown): RepositoryFindingSummary['status'] {
128
+ return value === 'open' || value === 'closed' ? value : 'unknown';
129
+ }
130
+
131
+ function confirmation(value: unknown): boolean | null {
132
+ return typeof value === 'boolean' ? value : null;
133
+ }
134
+
135
+ function returnedCounts(value: unknown): ScanOutcomeSummary['returnedFindings'] {
136
+ if (!Array.isArray(value))
137
+ return { total: null, reportable: null, informational: null, unclassified: null };
138
+ let reportable = 0,
139
+ informational = 0,
140
+ unclassified = 0;
141
+ for (const finding of value) {
142
+ const level = record(record(finding)?.severity)?.level;
143
+ if (typeof level === 'string' && REPORTABLE_LEVELS.includes(level)) reportable++;
144
+ else if (level === 'informational') informational++;
145
+ else unclassified++;
146
+ }
147
+ return { total: value.length, reportable, informational, unclassified };
148
+ }
149
+
150
+ function surfaces(value: unknown): ScanOutcomeSummary['coverage']['surfaces'] {
151
+ const projected = list(value, (entry): ReviewSurfaceSummary | null => {
152
+ const row = record(entry);
153
+ return row
154
+ ? {
155
+ id: text(row.id, MAX_ID),
156
+ label: text(row.label),
157
+ disposition: disposition(row.disposition),
158
+ riskArea: text(row.riskArea),
159
+ }
160
+ : null;
161
+ });
162
+ if (!Array.isArray(value)) return { ...projected, dispositions: null };
163
+ const dispositions: Record<SurfaceDisposition | 'unknown', number> = {
164
+ reported: 0,
165
+ no_issue_found: 0,
166
+ rejected: 0,
167
+ not_applicable: 0,
168
+ needs_follow_up: 0,
169
+ unknown: 0,
170
+ };
171
+ for (const row of value) dispositions[disposition(record(row)?.disposition)]++;
172
+ return { ...projected, dispositions };
173
+ }
174
+
175
+ function repositoryFindings(value: unknown): ScanOutcomeSummary['repositoryFindings'] {
176
+ const projected = list(value, (entry): RepositoryFindingSummary | null => {
177
+ const row = record(entry);
178
+ return row
179
+ ? {
180
+ findingId: text(row.findingId, MAX_ID),
181
+ occurrenceId: text(row.occurrenceId, MAX_ID),
182
+ status: repositoryStatus(row.status),
183
+ confirmedInLatestScan: confirmation(row.confirmedInLatestScan),
184
+ knownSince: text(row.knownSince, 64),
185
+ knownScanIds: strings(row.knownScanIds, MAX_ID),
186
+ matchedFindingIds: strings(row.matchedFindingIds, MAX_ID),
187
+ }
188
+ : null;
189
+ });
190
+ if (!Array.isArray(value))
191
+ return {
192
+ ...projected,
193
+ available: false,
194
+ open: null,
195
+ closed: null,
196
+ unknownStatus: null,
197
+ confirmedInLatestScan: null,
198
+ notConfirmedInLatestScan: null,
199
+ unknownConfirmation: null,
200
+ withKnownSince: null,
201
+ withMatchedFindingIds: null,
202
+ };
203
+ let open = 0,
204
+ closed = 0,
205
+ unknownStatus = 0,
206
+ confirmedInLatestScan = 0,
207
+ notConfirmedInLatestScan = 0,
208
+ unknownConfirmation = 0,
209
+ withKnownSince = 0,
210
+ withMatchedFindingIds = 0;
211
+ for (const entry of value) {
212
+ const row = record(entry);
213
+ const status = repositoryStatus(row?.status);
214
+ if (status === 'open') open++;
215
+ else if (status === 'closed') closed++;
216
+ else unknownStatus++;
217
+ const confirmed = confirmation(row?.confirmedInLatestScan);
218
+ if (confirmed === true) confirmedInLatestScan++;
219
+ else if (confirmed === false) notConfirmedInLatestScan++;
220
+ else unknownConfirmation++;
221
+ if (text(row?.knownSince, 64)) withKnownSince++;
222
+ if (
223
+ Array.isArray(row?.matchedFindingIds) &&
224
+ row.matchedFindingIds.some((id) => typeof id === 'string' && id.trim().length > 0)
225
+ )
226
+ withMatchedFindingIds++;
227
+ }
228
+ return {
229
+ ...projected,
230
+ available: true,
231
+ open,
232
+ closed,
233
+ unknownStatus,
234
+ confirmedInLatestScan,
235
+ notConfirmedInLatestScan,
236
+ unknownConfirmation,
237
+ withKnownSince,
238
+ withMatchedFindingIds,
239
+ };
240
+ }
241
+
242
+ function artifact(entry: unknown): ScanOutcomeSummary['artifacts']['items'][number] | null {
243
+ const row = record(entry);
244
+ const path = row?.path;
245
+ // A display-only relative manifest path, never an arbitrary file or a URL to open.
246
+ if (
247
+ typeof path !== 'string' ||
248
+ path.length > MAX_PATH ||
249
+ !path ||
250
+ path.startsWith('/') ||
251
+ /[\\:?#%]/.test(path) ||
252
+ path.split('/').some((part) => !part || part === '.' || part === '..') ||
253
+ [...path].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
254
+ )
255
+ return null;
256
+ const safePath = text(path, MAX_PATH);
257
+ if (!safePath) return null;
258
+ return { path: safePath, mediaType: text(row?.mediaType, 100) };
259
+ }
260
+
261
+ /**
262
+ * Project only final returned SDK metadata. This never reads artifacts or serializes a ScanResult,
263
+ * turnResult, report, finding evidence, command, or arbitrary reviewState extension.
264
+ * SDK 0.9.1 can remove reviewState during sealing and does not retain options.coverage. Missing
265
+ * detail therefore stays unavailable; even a populated reviewState cannot establish completeness.
266
+ */
267
+ export function summarizeScanResult(result: ScanResult, repository: string): ScanOutcomeSummary {
268
+ const scan = record(result?.manifest?.scan);
269
+ const scope = record(scan?.scope);
270
+ const reviewState = record(scan?.reviewState);
271
+ const review = record(result?.review) ?? record(scan?.review);
272
+ return {
273
+ source: 'final-sdk-result',
274
+ repository: text(basename(repository), MAX_ID) ?? '',
275
+ returnedFindings: returnedCounts(result?.findings?.findings),
276
+ coverage: {
277
+ source: reviewState ? 'manifest-review-state' : 'unavailable',
278
+ surfaces: surfaces(reviewState?.surfaces),
279
+ explicitExclusions: list(reviewState?.explicitExclusions, (entry) => {
280
+ const row = record(entry);
281
+ return row ? { pattern: text(row.pattern, MAX_PATH), reason: text(row.reason) } : null;
282
+ }),
283
+ deferred: list(reviewState?.deferred, (entry) => {
284
+ const row = record(entry);
285
+ return row
286
+ ? {
287
+ id: text(row.id, MAX_ID),
288
+ reason: text(row.reason),
289
+ paths: strings(row.paths, MAX_PATH),
290
+ surfaceIds: strings(row.surfaceIds, MAX_ID),
291
+ }
292
+ : null;
293
+ }),
294
+ resolvedDeferred: list(reviewState?.resolvedDeferred, (entry) => {
295
+ const row = record(entry);
296
+ return row ? { id: text(row.id, MAX_ID), reason: text(row.reason) } : null;
297
+ }),
298
+ openQuestions: list(reviewState?.openQuestions, (entry) => {
299
+ const row = record(entry);
300
+ return row ? { question: text(row.question) } : null;
301
+ }),
302
+ scopeIncludePaths: strings(scope?.includePaths, MAX_PATH),
303
+ scopeExcludePaths: strings(scope?.excludePaths, MAX_PATH),
304
+ reviewExcludedPaths: strings(review?.excludedPaths, MAX_PATH),
305
+ limitations: strings(scope?.limitations),
306
+ },
307
+ repositoryFindings: repositoryFindings(result?.repositoryFindings),
308
+ artifacts: list(scan?.artifacts, artifact),
309
+ notes: [...SUMMARY_NOTES],
310
+ };
311
+ }
312
+
313
+ function count(value: unknown): value is number {
314
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
315
+ }
316
+
317
+ function storedList<T>(
318
+ value: unknown,
319
+ project: (entry: unknown) => T | null,
320
+ ): SummaryList<T> | undefined {
321
+ const row = record(value);
322
+ if (!row || !Array.isArray(row.items) || !count(row.omitted)) return;
323
+ if (row.total === null) {
324
+ if (row.items.length || row.omitted !== 0) return;
325
+ return { total: null, items: [], omitted: 0 };
326
+ }
327
+ if (!count(row.total) || row.total - row.items.length !== row.omitted) return;
328
+ const projected = list(row.items, project);
329
+ return { total: row.total, items: projected.items, omitted: row.total - projected.items.length };
330
+ }
331
+
332
+ function storedStrings(value: unknown, max = MAX_TEXT): SummaryList<string> | undefined {
333
+ return storedList(value, (entry) => text(entry, max));
334
+ }
335
+
336
+ function storedCounts<K extends string>(
337
+ value: Record<string, unknown>,
338
+ total: number | null,
339
+ keys: readonly K[],
340
+ ): Record<K, number | null> | undefined {
341
+ const result = {} as Record<K, number | null>;
342
+ for (const key of keys) {
343
+ const item = value[key];
344
+ if (total === null) {
345
+ if (item !== null) return;
346
+ result[key] = null;
347
+ } else {
348
+ if (!count(item) || item > total) return;
349
+ result[key] = item;
350
+ }
351
+ }
352
+ return result;
353
+ }
354
+
355
+ /**
356
+ * Revalidate persisted metadata independently of the enclosing run. Reject malformed schema or
357
+ * contradictory counts, re-sanitize text, and rebuild the whitelist. Never trust saved notes or
358
+ * recompute whole-scan counts from the bounded item samples. undefined discards only the assessment.
359
+ */
360
+ export function sanitizeStoredOutcomeSummary(value: unknown): ScanOutcomeSummary | undefined {
361
+ const root = record(value);
362
+ if (root?.source !== 'final-sdk-result' || typeof root.repository !== 'string') return;
363
+ const returned = record(root.returnedFindings);
364
+ const coverage = record(root.coverage);
365
+ const repository = record(root.repositoryFindings);
366
+ if (!returned || !coverage || !repository) return;
367
+ if (returned.total !== null && !count(returned.total)) return;
368
+ const returnedCounts = storedCounts(returned, returned.total, [
369
+ 'reportable',
370
+ 'informational',
371
+ 'unclassified',
372
+ ]);
373
+ if (
374
+ !returnedCounts ||
375
+ (returned.total !== null &&
376
+ Object.values(returnedCounts).reduce<number>((sum, value) => sum + (value ?? 0), 0) !==
377
+ returned.total)
378
+ )
379
+ return;
380
+ if (coverage.source !== 'manifest-review-state' && coverage.source !== 'unavailable') return;
381
+ const rawSurfaces = record(coverage.surfaces);
382
+ const surfaceList = storedList(coverage.surfaces, (entry) => surfaces([entry]).items[0] ?? null);
383
+ if (!rawSurfaces || !surfaceList) return;
384
+ let dispositions: ScanOutcomeSummary['coverage']['surfaces']['dispositions'] = null;
385
+ if (surfaceList.total === null) {
386
+ if (rawSurfaces.dispositions !== null) return;
387
+ } else {
388
+ const rawCounts = record(rawSurfaces.dispositions);
389
+ if (!rawCounts) return;
390
+ const counts = storedCounts(rawCounts, surfaceList.total, [
391
+ ...DISPOSITIONS,
392
+ 'unknown',
393
+ ] as const);
394
+ if (
395
+ !counts ||
396
+ Object.values(counts).reduce<number>((sum, value) => sum + (value ?? 0), 0) !==
397
+ surfaceList.total
398
+ )
399
+ return;
400
+ // The numeric total branch above excludes null values for every disposition.
401
+ dispositions = counts as Record<SurfaceDisposition | 'unknown', number>;
402
+ }
403
+ const explicitExclusions = storedList(coverage.explicitExclusions, (entry) => {
404
+ const row = record(entry);
405
+ return row ? { pattern: text(row.pattern, MAX_PATH), reason: text(row.reason) } : null;
406
+ });
407
+ const deferred = storedList(coverage.deferred, (entry) => {
408
+ const row = record(entry);
409
+ if (!row) return null;
410
+ const paths = storedStrings(row.paths, MAX_PATH);
411
+ const surfaceIds = storedStrings(row.surfaceIds, MAX_ID);
412
+ return paths && surfaceIds
413
+ ? { id: text(row.id, MAX_ID), reason: text(row.reason), paths, surfaceIds }
414
+ : null;
415
+ });
416
+ const resolvedDeferred = storedList(coverage.resolvedDeferred, (entry) => {
417
+ const row = record(entry);
418
+ return row ? { id: text(row.id, MAX_ID), reason: text(row.reason) } : null;
419
+ });
420
+ const openQuestions = storedList(coverage.openQuestions, (entry) => {
421
+ const row = record(entry);
422
+ return row ? { question: text(row.question) } : null;
423
+ });
424
+ const scopeIncludePaths = storedStrings(coverage.scopeIncludePaths, MAX_PATH);
425
+ const scopeExcludePaths = storedStrings(coverage.scopeExcludePaths, MAX_PATH);
426
+ const reviewExcludedPaths = storedStrings(coverage.reviewExcludedPaths, MAX_PATH);
427
+ const limitations = storedStrings(coverage.limitations);
428
+ const artifacts = storedList(root.artifacts, artifact);
429
+ if (
430
+ !explicitExclusions ||
431
+ !deferred ||
432
+ !resolvedDeferred ||
433
+ !openQuestions ||
434
+ !scopeIncludePaths ||
435
+ !scopeExcludePaths ||
436
+ !reviewExcludedPaths ||
437
+ !limitations ||
438
+ !artifacts
439
+ )
440
+ return;
441
+ if (
442
+ coverage.source === 'unavailable' &&
443
+ [surfaceList, explicitExclusions, deferred, resolvedDeferred, openQuestions].some(
444
+ (collection) => collection.total !== null,
445
+ )
446
+ )
447
+ return;
448
+ const repositoryList = storedList(repository, (entry): RepositoryFindingSummary | null => {
449
+ const row = record(entry);
450
+ if (!row) return null;
451
+ const knownScanIds = storedStrings(row.knownScanIds, MAX_ID);
452
+ const matchedFindingIds = storedStrings(row.matchedFindingIds, MAX_ID);
453
+ if (!knownScanIds || !matchedFindingIds) return null;
454
+ return {
455
+ findingId: text(row.findingId, MAX_ID),
456
+ occurrenceId: text(row.occurrenceId, MAX_ID),
457
+ status: repositoryStatus(row.status),
458
+ confirmedInLatestScan: confirmation(row.confirmedInLatestScan),
459
+ knownSince: text(row.knownSince, 64),
460
+ knownScanIds,
461
+ matchedFindingIds,
462
+ };
463
+ });
464
+ if (!repositoryList || repository.available !== (repositoryList.total !== null)) return;
465
+ const repositoryCounts = storedCounts(repository, repositoryList.total, [
466
+ 'open',
467
+ 'closed',
468
+ 'unknownStatus',
469
+ 'confirmedInLatestScan',
470
+ 'notConfirmedInLatestScan',
471
+ 'unknownConfirmation',
472
+ 'withKnownSince',
473
+ 'withMatchedFindingIds',
474
+ ]);
475
+ if (!repositoryCounts) return;
476
+ if (
477
+ repositoryList.total !== null &&
478
+ ((repositoryCounts.open ?? 0) +
479
+ (repositoryCounts.closed ?? 0) +
480
+ (repositoryCounts.unknownStatus ?? 0) !==
481
+ repositoryList.total ||
482
+ (repositoryCounts.confirmedInLatestScan ?? 0) +
483
+ (repositoryCounts.notConfirmedInLatestScan ?? 0) +
484
+ (repositoryCounts.unknownConfirmation ?? 0) !==
485
+ repositoryList.total)
486
+ )
487
+ return;
488
+ return {
489
+ source: 'final-sdk-result',
490
+ repository: text(basename(root.repository), MAX_ID) ?? '',
491
+ returnedFindings: { total: returned.total, ...returnedCounts },
492
+ coverage: {
493
+ source: coverage.source,
494
+ surfaces: { ...surfaceList, dispositions },
495
+ explicitExclusions,
496
+ deferred,
497
+ resolvedDeferred,
498
+ openQuestions,
499
+ scopeIncludePaths,
500
+ scopeExcludePaths,
501
+ reviewExcludedPaths,
502
+ limitations,
503
+ },
504
+ repositoryFindings: {
505
+ ...repositoryList,
506
+ ...repositoryCounts,
507
+ available: repositoryList.total !== null,
508
+ },
509
+ artifacts,
510
+ notes: [...SUMMARY_NOTES],
511
+ };
512
+ }
@@ -0,0 +1,107 @@
1
+ import { sanitizeStoredTelemetry } from './run-telemetry.ts';
2
+ import { sanitizeStoredOutcomeSummary } from './result-summary.ts';
3
+ import { safeFinding } from './report-safety.ts';
4
+ import { mkdir, readdir, lstat, readFile, writeFile, rename, unlink } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import type { ScanState } from '../types.ts';
8
+ const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+ /** Private per-run files avoid read/modify/write races between independent runs. */
10
+ export class RunHistory {
11
+ readonly directory: string;
12
+ readonly limit: number;
13
+ constructor(directory: string, limit = 50) {
14
+ this.directory = directory;
15
+ this.limit = limit;
16
+ }
17
+ async load(): Promise<ScanState[]> {
18
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
19
+ if (!(await lstat(this.directory)).isDirectory()) throw new Error('Invalid history directory.');
20
+ const result: ScanState[] = [];
21
+ for (const name of await readdir(this.directory)) {
22
+ if (!name.endsWith('.json') || !idPattern.test(name.slice(0, -5))) continue;
23
+ try {
24
+ const path = join(this.directory, name),
25
+ info = await lstat(path);
26
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024) continue;
27
+ const s = JSON.parse(await readFile(path, 'utf8')) as ScanState;
28
+ if (
29
+ s.runId !== name.slice(0, -5) ||
30
+ typeof s.instanceId !== 'string' ||
31
+ !s.instanceId ||
32
+ !Array.isArray(s.findings) ||
33
+ !Array.isArray(s.events) ||
34
+ !['running', 'completed', 'cancelled', 'error'].includes(s.status)
35
+ )
36
+ continue;
37
+ if (
38
+ typeof s.startedAt !== 'string' ||
39
+ !Number.isFinite(Date.parse(s.startedAt)) ||
40
+ (s.finishedAt !== null &&
41
+ (typeof s.finishedAt !== 'string' || !Number.isFinite(Date.parse(s.finishedAt))))
42
+ )
43
+ continue;
44
+ if (
45
+ typeof s.repository !== 'string' ||
46
+ typeof s.engine !== 'string' ||
47
+ typeof s.phase !== 'string' ||
48
+ !Number.isFinite(s.revision) ||
49
+ !Number.isFinite(s.skipped) ||
50
+ !s.progress ||
51
+ !Number.isFinite(s.progress.done) ||
52
+ !Number.isFinite(s.progress.total)
53
+ )
54
+ continue;
55
+ if (
56
+ s.events.some((e) => !e || typeof e.time !== 'string' || typeof e.message !== 'string') ||
57
+ s.findings.some(
58
+ (f) =>
59
+ !f ||
60
+ typeof f.id !== 'string' ||
61
+ typeof f.title !== 'string' ||
62
+ typeof f.path !== 'string' ||
63
+ typeof f.summary !== 'string' ||
64
+ typeof f.evidence !== 'string' ||
65
+ typeof f.remediation !== 'string' ||
66
+ typeof f.cwe !== 'string' ||
67
+ typeof f.confidence !== 'string' ||
68
+ !Number.isFinite(f.line) ||
69
+ !['critical', 'high', 'medium', 'low'].includes(f.severity),
70
+ )
71
+ )
72
+ continue;
73
+ s.findings = s.findings.map(safeFinding);
74
+ s.telemetry = sanitizeStoredTelemetry(s.telemetry, s.repository);
75
+ s.assessment =
76
+ s.status === 'completed' ? sanitizeStoredOutcomeSummary(s.assessment) : undefined;
77
+ s.archived = true;
78
+ if (s.status === 'running') {
79
+ s.status = 'error';
80
+ s.phase = 'Previous run is no longer tracked';
81
+ s.error =
82
+ 'Completion is unknown after the previous plugin instance ended. Check SDK history before running again.';
83
+ }
84
+ result.push(s);
85
+ } catch {
86
+ /* A malformed record cannot prevent other history from loading. */
87
+ }
88
+ }
89
+ return result
90
+ .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''))
91
+ .slice(0, this.limit);
92
+ }
93
+ async save(state: ScanState) {
94
+ if (!state.runId || !idPattern.test(state.runId)) throw new Error('Invalid run ID.');
95
+ const content = JSON.stringify(state);
96
+ if (Buffer.byteLength(content) > 4 * 1024 * 1024)
97
+ throw new Error('History record is too large.');
98
+ const target = join(this.directory, `${state.runId}.json`),
99
+ temp = join(this.directory, `.${state.runId}.${randomUUID()}.tmp`);
100
+ await writeFile(temp, content, { mode: 0o600, flag: 'wx' });
101
+ try {
102
+ await rename(temp, target);
103
+ } finally {
104
+ await unlink(temp).catch(() => {});
105
+ }
106
+ }
107
+ }