opencode-codex-security 0.0.0-stage → 0.1.0

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,316 @@
1
+ import { resolve } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { CodexScanService, type SecurityFactory } from './codex-adapter.ts';
4
+ import { prepareScan, validatePlan, type ScanInputs, type ScanPlan } from './scan-plan.ts';
5
+ import { RunHistory } from './run-history.ts';
6
+ import type { ScanState, ScanList } from '../types.ts';
7
+ type Draft = { plan: ScanPlan; consumed: boolean; runId?: string; starting?: Promise<ScanState> };
8
+ type Run = {
9
+ state: ScanState;
10
+ service: CodexScanService;
11
+ unsubscribe: () => void;
12
+ cancelledBeforeStart?: boolean;
13
+ lastSavedAt?: number;
14
+ };
15
+ /** Independent approved runs. A draft or another run never mutates a running scan's inputs. */
16
+ export class PlannedScanService {
17
+ #project: string;
18
+ #factory?: SecurityFactory;
19
+ #mock: boolean;
20
+ #disposed = false;
21
+ #instance = randomUUID();
22
+ #revision = 0;
23
+ #drafts = new Map<string, Draft>();
24
+ #runs = new Map<string, Run>();
25
+ #archived = new Map<string, ScanState>();
26
+ #latestDraft?: string;
27
+ #latestRun?: string;
28
+ #listeners = new Set<(s: ScanState) => void>();
29
+ #history?: RunHistory;
30
+ #historyError?: string;
31
+ #ready: Promise<void>;
32
+ #writes = Promise.resolve();
33
+ readonly maxConcurrent = 2;
34
+ readonly historyLimit = 50;
35
+ constructor(
36
+ project: string,
37
+ options: { createClient?: SecurityFactory; mock?: boolean; historyDirectory?: string } = {},
38
+ ) {
39
+ this.#project = project; // prepareScan validates the anchor; never guess process.cwd().
40
+ this.#factory = options.createClient;
41
+ this.#mock = options.mock ?? false;
42
+ if (options.historyDirectory)
43
+ this.#history = new RunHistory(options.historyDirectory, this.historyLimit);
44
+ this.#ready = this.#load();
45
+ }
46
+ async #load() {
47
+ if (!this.#history) return;
48
+ try {
49
+ for (const s of await this.#history.load()) this.#archived.set(s.runId!, s);
50
+ } catch {
51
+ this.#historyError =
52
+ 'Local scan history is unavailable. New scans cannot start until it is writable.';
53
+ }
54
+ }
55
+ async ready() {
56
+ await this.#ready;
57
+ }
58
+ async flushHistory() {
59
+ await this.#writes;
60
+ }
61
+ #idle(plan?: ScanPlan): ScanState {
62
+ return {
63
+ instanceId: this.#instance,
64
+ status: 'idle',
65
+ scanId: null,
66
+ phase: plan ? 'Ready to review' : 'Choose a project scan',
67
+ progress: { done: 0, total: 0 },
68
+ findings: [],
69
+ events: [],
70
+ error: null,
71
+ startedAt: null,
72
+ finishedAt: null,
73
+ repository: plan?.repository ?? this.#project,
74
+ engine: 'codex-security-sdk',
75
+ skipped: 0,
76
+ revision: this.#revision,
77
+ ...(plan
78
+ ? {
79
+ plan: structuredClone(plan),
80
+ planConsumed: this.#drafts.get(plan.id)?.consumed ?? false,
81
+ mode: plan.settings.scan.mode,
82
+ model: plan.settings.codex.model,
83
+ }
84
+ : {}),
85
+ };
86
+ }
87
+ getState(input: { runId?: string; planId?: string } = {}): ScanState {
88
+ if (input.runId && input.planId) throw new Error('Select a run or a preview, not both.');
89
+ if (input.runId) {
90
+ const state = this.#runs.get(input.runId)?.state ?? this.#archived.get(input.runId);
91
+ if (!state) throw new Error('Unknown scan run.');
92
+ return structuredClone(state);
93
+ }
94
+ if (input.planId) {
95
+ const d = this.#drafts.get(input.planId);
96
+ if (!d) throw new Error('Unknown scan preview.');
97
+ return this.#idle(d.plan);
98
+ }
99
+ const d = this.#latestDraft ? this.#drafts.get(this.#latestDraft) : undefined;
100
+ if (d && !d.consumed) return this.#idle(d.plan);
101
+ if (this.#latestRun) return this.getState({ runId: this.#latestRun });
102
+ return this.#idle();
103
+ }
104
+ getPlan(planId: string) {
105
+ const d = this.#drafts.get(planId);
106
+ return d ? structuredClone(d.plan) : undefined;
107
+ }
108
+ async list(): Promise<ScanList> {
109
+ await this.#ready;
110
+ const runs = [...this.#runs.values()].map((r) => structuredClone(r.state));
111
+ for (const s of this.#archived.values())
112
+ if (!this.#runs.has(s.runId!)) runs.push(structuredClone(s));
113
+ runs.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
114
+ const drafts = [...this.#drafts.values()]
115
+ .filter((d) => !d.consumed && Date.parse(d.plan.expiresAt) > Date.now())
116
+ .map((d) => this.#idle(d.plan));
117
+ const preview = this.#latestDraft
118
+ ? drafts.find((s) => s.plan?.id === this.#latestDraft)
119
+ : undefined;
120
+ return {
121
+ engine: 'codex-security-sdk',
122
+ instanceId: this.#instance,
123
+ revision: this.#revision,
124
+ runs: runs.slice(0, this.historyLimit),
125
+ drafts,
126
+ ...(preview ? { preview } : {}),
127
+ maxConcurrent: this.maxConcurrent,
128
+ historyLimit: this.historyLimit,
129
+ ...(this.#historyError ? { historyError: this.#historyError } : {}),
130
+ };
131
+ }
132
+ subscribe(listener: (s: ScanState) => void) {
133
+ this.#listeners.add(listener);
134
+ return () => this.#listeners.delete(listener);
135
+ }
136
+ #emit(state: ScanState) {
137
+ this.#revision++;
138
+ for (const listener of this.#listeners) {
139
+ try {
140
+ listener(structuredClone(state));
141
+ } catch {}
142
+ }
143
+ }
144
+ #save(state: ScanState) {
145
+ if (!this.#history) return Promise.resolve();
146
+ const snapshot = structuredClone(state);
147
+ const work = this.#writes.then(() => this.#history!.save(snapshot));
148
+ this.#writes = work.catch(() => {
149
+ this.#historyError =
150
+ 'Some local scan history could not be saved. Keep this window open and check local storage.';
151
+ });
152
+ return work;
153
+ }
154
+ async prepare(input: ScanInputs = {}): Promise<ScanState> {
155
+ if (this.#disposed) throw new Error('Scan service is closed');
156
+ await this.#ready;
157
+ const plan = await prepareScan(this.#project, input, this.#mock);
158
+ if (this.#disposed) throw new Error('Scan service is closed');
159
+ for (const [id, d] of this.#drafts)
160
+ if (!d.runId && Date.parse(d.plan.expiresAt) <= Date.now()) this.#drafts.delete(id);
161
+ if ([...this.#drafts.values()].filter((d) => !d.consumed).length >= 20)
162
+ throw new Error('Too many unstarted previews. Wait for old previews to expire.');
163
+ this.#drafts.set(plan.id, { plan, consumed: false });
164
+ this.#latestDraft = plan.id;
165
+ const state = this.#idle(plan);
166
+ this.#emit(state);
167
+ return state;
168
+ }
169
+ async start(
170
+ input: { planId?: string; approveOpenAIInference?: boolean } = {},
171
+ ): Promise<ScanState> {
172
+ if (this.#disposed) throw new Error('Scan service is closed');
173
+ await this.#ready;
174
+ const d = input.planId ? this.#drafts.get(input.planId) : undefined;
175
+ if (input.approveOpenAIInference !== true || !d)
176
+ throw new Error('Prepare and approve this exact scan preview first.');
177
+ if (d.starting) return d.starting;
178
+ if (d.runId) {
179
+ const s = this.getState({ runId: d.runId });
180
+ if (s.status === 'running') return s;
181
+ throw new Error('This preview is already used. Prepare a new scan.');
182
+ }
183
+ if (d.consumed) throw new Error('This preview is already used. Prepare a new scan.');
184
+ if (
185
+ [...this.#drafts.values()].filter(
186
+ (v) => v.starting || (v.runId && this.#runs.get(v.runId)?.state.status === 'running'),
187
+ ).length >= this.maxConcurrent
188
+ )
189
+ throw new Error(
190
+ 'Two scans are already active in this project. Wait or cancel one before starting another.',
191
+ );
192
+ const task = this.#start(d);
193
+ d.starting = task;
194
+ try {
195
+ return await task;
196
+ } finally {
197
+ d.starting = undefined;
198
+ }
199
+ }
200
+ async #start(d: Draft): Promise<ScanState> {
201
+ try {
202
+ await validatePlan(d.plan);
203
+ } catch (e) {
204
+ d.consumed = true;
205
+ this.#emit(this.#idle(d.plan));
206
+ throw e;
207
+ }
208
+ if (this.#disposed) throw new Error('Scan service is closed');
209
+ if (this.#historyError) throw new Error(this.#historyError);
210
+ const runId = randomUUID(),
211
+ plan = structuredClone(d.plan),
212
+ service = new CodexScanService(plan.repository, { createClient: this.#factory });
213
+ d.consumed = true;
214
+ d.runId = runId;
215
+ this.#latestRun = runId;
216
+ const decorate = (state: ScanState): ScanState => ({
217
+ ...state,
218
+ runId,
219
+ repository: plan.repository,
220
+ plan: structuredClone(plan),
221
+ planConsumed: true,
222
+ mode: plan.settings.scan.mode,
223
+ model: plan.settings.codex.model,
224
+ });
225
+ const entry: Run = {
226
+ service,
227
+ state: decorate({
228
+ ...service.getState(),
229
+ status: 'running',
230
+ phase: 'Starting approved scan',
231
+ startedAt: new Date().toISOString(),
232
+ }),
233
+ unsubscribe: () => {},
234
+ };
235
+ this.#runs.set(runId, entry);
236
+ try {
237
+ await this.#save(entry.state);
238
+ } catch {
239
+ entry.state = {
240
+ ...entry.state,
241
+ status: 'error',
242
+ phase: 'Local history unavailable',
243
+ error: 'Scan did not start because its local history could not be saved.',
244
+ finishedAt: new Date().toISOString(),
245
+ };
246
+ this.#emit(entry.state);
247
+ return structuredClone(entry.state);
248
+ }
249
+ if (this.#disposed || entry.cancelledBeforeStart) {
250
+ entry.state = {
251
+ ...entry.state,
252
+ status: 'cancelled',
253
+ phase: 'Cancelled before execution',
254
+ finishedAt: new Date().toISOString(),
255
+ };
256
+ await this.#save(entry.state).catch(() => {});
257
+ this.#emit(entry.state);
258
+ return structuredClone(entry.state);
259
+ }
260
+ entry.unsubscribe = service.subscribe((state) => {
261
+ const previous = entry.state;
262
+ entry.state = decorate(state);
263
+ this.#emit(entry.state);
264
+ if (
265
+ state.status !== 'running' ||
266
+ state.scanId !== previous.scanId ||
267
+ state.phase !== previous.phase ||
268
+ Date.now() - (entry.lastSavedAt ?? 0) > 10_000
269
+ ) {
270
+ entry.lastSavedAt = Date.now();
271
+ void this.#save(entry.state).catch(() => {});
272
+ }
273
+ });
274
+ service.start({
275
+ approved: true,
276
+ repository: plan.repository,
277
+ allowNetworkModelInference: true,
278
+ maxCostUsd: plan.settings.scan.maxCostUsd,
279
+ auth: 'chatgpt',
280
+ settings: plan.settings,
281
+ scopePaths: [...plan.scopePaths],
282
+ scanPrompt: plan.scanPrompt,
283
+ contextFiles: plan.contextFiles.map((f) => resolve(plan.repository, f.path)),
284
+ });
285
+ return structuredClone(entry.state);
286
+ }
287
+ cancel(input: { runId?: string } = {}): ScanState {
288
+ let id = input.runId;
289
+ if (!id) {
290
+ const active = [...this.#runs.entries()].filter(([, r]) => r.state.status === 'running');
291
+ if (active.length !== 1) throw new Error('Select the exact run to cancel.');
292
+ id = active[0][0];
293
+ }
294
+ const run = this.#runs.get(id);
295
+ if (!run) throw new Error('Run is not active in this instance.');
296
+ run.cancelledBeforeStart = true;
297
+ run.service.cancel();
298
+ return structuredClone(run.state);
299
+ }
300
+ async wait(runId = this.#latestRun) {
301
+ if (!runId) return this.getState();
302
+ const run = this.#runs.get(runId);
303
+ if (!run) return this.getState({ runId });
304
+ await run.service.wait();
305
+ await this.#writes;
306
+ return this.getState({ runId });
307
+ }
308
+ async dispose() {
309
+ this.#disposed = true;
310
+ await Promise.allSettled([...this.#drafts.values()].map((d) => d.starting).filter(Boolean));
311
+ await Promise.all([...this.#runs.values()].map((r) => r.service.dispose()));
312
+ await this.#writes;
313
+ for (const r of this.#runs.values()) r.unsubscribe();
314
+ this.#listeners.clear();
315
+ }
316
+ }
@@ -0,0 +1,41 @@
1
+ import type { Finding } from '../types.ts';
2
+
3
+ /**
4
+ * Bounded report text only. This is defense in depth for recognizable credentials,
5
+ * not permission to expose raw sessions, reasoning, command output, or arbitrary files.
6
+ */
7
+ export function safeReportText(value: unknown, max = 48_000): string {
8
+ if (typeof value !== 'string') return '';
9
+ return value
10
+ .replace(
11
+ /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET)[^-]*-----[\s\S]*?(?:-----END [^-]+-----|$)/g,
12
+ '[REDACTED PRIVATE MATERIAL]',
13
+ )
14
+ .replace(
15
+ /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/g,
16
+ '[REDACTED CREDENTIAL]',
17
+ )
18
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]{12,}=*/gi, '$1[REDACTED]')
19
+ .replace(
20
+ /((?:["']?)(?:password|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret)(?:["']?)\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/gi,
21
+ '$1[REDACTED]',
22
+ )
23
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
24
+ .slice(0, max);
25
+ }
26
+
27
+ /** Whitelist and sanitize every free-text finding field, including local code evidence. */
28
+ export function safeFinding(finding: Finding): Finding {
29
+ return {
30
+ id: safeReportText(finding.id, 512),
31
+ title: safeReportText(finding.title, 1000),
32
+ severity: finding.severity,
33
+ path: safeReportText(finding.path, 4096),
34
+ line: finding.line,
35
+ cwe: safeReportText(finding.cwe, 512),
36
+ summary: safeReportText(finding.summary, 16_000),
37
+ evidence: safeReportText(finding.evidence, 24_000),
38
+ remediation: safeReportText(finding.remediation, 16_000),
39
+ confidence: safeReportText(finding.confidence, 128),
40
+ };
41
+ }