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.
- package/CONTRIBUTING.md +15 -0
- package/DISCLOSURE +19 -0
- package/LICENSE +202 -0
- package/README.md +103 -2
- package/SECURITY.md +22 -0
- package/dist/gui-experimental.cjs +17 -0
- package/docs/GLOBAL-CONFIG.md +64 -0
- package/docs/MULTI-SCAN.md +62 -0
- package/docs/PUBLISHING.md +71 -0
- package/docs/RELEASING.md +53 -0
- package/docs/SDK.md +36 -0
- package/fixtures/vulnerable/docs/ARCHITECTURE.md +2 -0
- package/fixtures/vulnerable/src/accounts.js +4 -0
- package/fixtures/vulnerable/src/diagnostics.js +5 -0
- package/fixtures/vulnerable/src/download.js +5 -0
- package/fixtures/vulnerable/src/profile.js +4 -0
- package/package.json +79 -4
- package/src/core/codex-adapter.ts +430 -0
- package/src/core/credential-guard.ts +83 -0
- package/src/core/planned-scan.ts +316 -0
- package/src/core/report-safety.ts +41 -0
- package/src/core/result-summary.ts +512 -0
- package/src/core/run-history.ts +107 -0
- package/src/core/run-telemetry.ts +359 -0
- package/src/core/scan-plan.ts +182 -0
- package/src/core/scan-service.mjs +129 -0
- package/src/core/scanner.mjs +129 -0
- package/src/core/sdk-settings.ts +123 -0
- package/src/core/security-operations.ts +598 -0
- package/src/gui.tsx +102 -0
- package/src/operation-tools.ts +247 -0
- package/src/plan-view.ts +47 -0
- package/src/plugin.ts +346 -0
- package/src/rpc.ts +124 -0
- package/src/scan-display.ts +284 -0
- package/src/security-operations-panel.tsx +551 -0
- package/src/security-panel.tsx +1003 -0
- package/src/security-styles.ts +10 -0
- package/src/security-tui-operations.tsx +343 -0
- package/src/state-order.ts +21 -0
- package/src/tui.tsx +630 -0
- package/src/types.ts +84 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { open, opendir, realpath, lstat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve, relative, extname, sep } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const extensions = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
7
|
+
const ignored = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor']);
|
|
8
|
+
export const rules = [
|
|
9
|
+
{
|
|
10
|
+
id: 'sql-concat',
|
|
11
|
+
title: 'User input concatenated into SQL',
|
|
12
|
+
severity: 'high',
|
|
13
|
+
cwe: 'CWE-89',
|
|
14
|
+
pattern:
|
|
15
|
+
/\b(?:query|execute)\s*\(.*(?:SELECT|INSERT|UPDATE|DELETE).*\+\s*req\.(?:query|body|params)\./i,
|
|
16
|
+
summary:
|
|
17
|
+
'A request value is concatenated directly into a database query. This is a pattern match requiring review, not an exploit validation.',
|
|
18
|
+
remediation:
|
|
19
|
+
'Use a parameterized query and pass the request value as a separate parameter. Check authorization independently.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'shell-concat',
|
|
23
|
+
title: 'Request data reaches a shell command',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
cwe: 'CWE-78',
|
|
26
|
+
pattern: /\bexec(?:Sync)?\s*\(.*\+\s*req\.(?:query|body|params)\./,
|
|
27
|
+
summary:
|
|
28
|
+
'A request value is appended to a shell command. Shell metacharacters may alter the command. Reachability and exploitability are not validated.',
|
|
29
|
+
remediation:
|
|
30
|
+
'Avoid the shell. Use execFile with a fixed executable and argument array, plus strict input validation.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'html-concat',
|
|
34
|
+
title: 'Unescaped input written into HTML',
|
|
35
|
+
severity: 'medium',
|
|
36
|
+
cwe: 'CWE-79',
|
|
37
|
+
pattern: /\bres\.(?:send|write)\s*\(.*<[a-z].*\+\s*req\.(?:query|body|params)\./i,
|
|
38
|
+
summary:
|
|
39
|
+
'Request input is joined into an HTML response without visible escaping. This narrow pattern can miss sanitization elsewhere.',
|
|
40
|
+
remediation:
|
|
41
|
+
'Render through an auto-escaping template, or return structured JSON with the correct content type.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'file-concat',
|
|
45
|
+
title: 'Request data controls a file path',
|
|
46
|
+
severity: 'high',
|
|
47
|
+
cwe: 'CWE-22',
|
|
48
|
+
pattern: /\b(?:readFile|readFileSync|createReadStream)\s*\(.*\+\s*req\.(?:query|body|params)\./,
|
|
49
|
+
summary:
|
|
50
|
+
'Request input is appended to a filesystem path. Parent-directory segments may escape the intended directory.',
|
|
51
|
+
remediation:
|
|
52
|
+
'Resolve paths under an allowlisted directory and verify containment after canonicalization, or use fixed file identifiers.',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export async function collectFiles(root, signal, limits = {}) {
|
|
57
|
+
const base = await realpath(root);
|
|
58
|
+
if (!(await lstat(base)).isDirectory()) throw new Error('Scan target must be a directory');
|
|
59
|
+
const result = [];
|
|
60
|
+
let entries = 0;
|
|
61
|
+
const maxFiles = limits.maxFiles ?? 2000;
|
|
62
|
+
const maxEntries = limits.maxEntries ?? 20000;
|
|
63
|
+
async function visit(dir, depth) {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
if (depth > 30) throw new Error('Directory depth exceeds prototype limit');
|
|
66
|
+
const handle = await opendir(dir);
|
|
67
|
+
for await (const entry of handle) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
if (++entries > maxEntries) throw new Error('Repository exceeds prototype entry limit');
|
|
70
|
+
if (entry.name.startsWith('.') || ignored.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
71
|
+
const path = resolve(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) await visit(path, depth + 1);
|
|
73
|
+
else if (entry.isFile() && extensions.has(extname(entry.name))) {
|
|
74
|
+
if (result.length >= maxFiles) throw new Error('Repository exceeds prototype file limit');
|
|
75
|
+
result.push(path);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(base, 0);
|
|
80
|
+
return { base, files: result.sort() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function scanFile(base, path, signal) {
|
|
84
|
+
signal?.throwIfAborted();
|
|
85
|
+
// Reject changed symlinks and containment escapes before opening. O_NOFOLLOW
|
|
86
|
+
// protects the leaf; hostile concurrent directory mutation is not supported.
|
|
87
|
+
const canonical = await realpath(path);
|
|
88
|
+
const rel = relative(base, canonical);
|
|
89
|
+
if (rel.startsWith('..' + sep) || rel === '..' || resolve(base, rel) !== canonical)
|
|
90
|
+
throw new Error('File escaped scan root');
|
|
91
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
92
|
+
try {
|
|
93
|
+
const stat = await file.stat();
|
|
94
|
+
if (!stat.isFile()) return { findings: [], skipped: true };
|
|
95
|
+
if (stat.size > 256 * 1024) return { findings: [], skipped: true };
|
|
96
|
+
const bytes = Buffer.alloc(256 * 1024 + 1);
|
|
97
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
98
|
+
if (bytesRead > 256 * 1024) return { findings: [], skipped: true };
|
|
99
|
+
const source = bytes.subarray(0, bytesRead).toString('utf8');
|
|
100
|
+
if (source.includes('\0')) return { findings: [], skipped: true };
|
|
101
|
+
const findings = [];
|
|
102
|
+
for (const [index, line] of source.split(/\r?\n/).entries()) {
|
|
103
|
+
signal?.throwIfAborted();
|
|
104
|
+
if (/^\s*(?:\/\/|\*)/.test(line)) continue;
|
|
105
|
+
for (const rule of rules)
|
|
106
|
+
if (rule.pattern.test(line)) {
|
|
107
|
+
findings.push({
|
|
108
|
+
id: createHash('sha256')
|
|
109
|
+
.update(`${rule.id}:${rel}:${index + 1}:${line.trim()}`)
|
|
110
|
+
.digest('hex')
|
|
111
|
+
.slice(0, 16),
|
|
112
|
+
rule: rule.id,
|
|
113
|
+
title: rule.title,
|
|
114
|
+
severity: rule.severity,
|
|
115
|
+
path: rel.split(sep).join('/'),
|
|
116
|
+
line: index + 1,
|
|
117
|
+
cwe: rule.cwe,
|
|
118
|
+
summary: rule.summary,
|
|
119
|
+
evidence: line.trim().slice(0, 500),
|
|
120
|
+
remediation: rule.remediation,
|
|
121
|
+
confidence: 'pattern match; unvalidated',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { findings, skipped: false };
|
|
126
|
+
} finally {
|
|
127
|
+
await file.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
export interface CodexSettings {
|
|
6
|
+
model?: string;
|
|
7
|
+
reasoningEffort?: string;
|
|
8
|
+
daybreak?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ScanSettings {
|
|
11
|
+
mode?: 'standard' | 'deep';
|
|
12
|
+
maxCostUsd?: number;
|
|
13
|
+
maxTimeMinutes?: number;
|
|
14
|
+
cyberAccessProgram?: 'standard' | 'daybreak_blue' | 'daybreak_red';
|
|
15
|
+
workers?: number;
|
|
16
|
+
subagents?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface SdkSettings {
|
|
19
|
+
codex?: CodexSettings;
|
|
20
|
+
scan?: ScanSettings;
|
|
21
|
+
}
|
|
22
|
+
export interface ResolvedSdkSettings {
|
|
23
|
+
codex: Required<CodexSettings>;
|
|
24
|
+
scan: ScanSettings & { mode: 'standard' | 'deep'; maxCostUsd: number };
|
|
25
|
+
}
|
|
26
|
+
export const defaultSettings: ResolvedSdkSettings = {
|
|
27
|
+
codex: { model: 'gpt-5.6-sol', reasoningEffort: 'xhigh', daybreak: false },
|
|
28
|
+
scan: { mode: 'standard', maxCostUsd: 1 },
|
|
29
|
+
};
|
|
30
|
+
export function validateSettings(raw: unknown): SdkSettings {
|
|
31
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
32
|
+
throw new Error('SDK settings must be objects.');
|
|
33
|
+
const value = raw as Record<string, unknown>;
|
|
34
|
+
for (const section of ['codex', 'scan']) {
|
|
35
|
+
if (value[section] === undefined) continue;
|
|
36
|
+
const group = value[section];
|
|
37
|
+
if (!group || typeof group !== 'object' || Array.isArray(group))
|
|
38
|
+
throw new Error(`${section} settings must be an object.`);
|
|
39
|
+
const allowed =
|
|
40
|
+
section === 'codex'
|
|
41
|
+
? ['model', 'reasoningEffort', 'daybreak']
|
|
42
|
+
: ['mode', 'maxCostUsd', 'maxTimeMinutes', 'cyberAccessProgram', 'workers', 'subagents'];
|
|
43
|
+
for (const [key, val] of Object.entries(group)) {
|
|
44
|
+
if (!allowed.includes(key))
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Unsupported ${section} setting: ${key}. Credentials and arbitrary runtime overrides are not accepted.`,
|
|
47
|
+
);
|
|
48
|
+
if (
|
|
49
|
+
key === 'model' &&
|
|
50
|
+
(typeof val !== 'string' || !val.trim() || val.length > 200 || /[\x00-\x1f]/.test(val))
|
|
51
|
+
)
|
|
52
|
+
throw new Error('model must be a nonempty model ID.');
|
|
53
|
+
if (
|
|
54
|
+
key === 'reasoningEffort' &&
|
|
55
|
+
!['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(val as string)
|
|
56
|
+
)
|
|
57
|
+
throw new Error('Unsupported reasoningEffort.');
|
|
58
|
+
if (key === 'daybreak' && typeof val !== 'boolean')
|
|
59
|
+
throw new Error('daybreak must be true or false.');
|
|
60
|
+
if (key === 'mode' && !['standard', 'deep'].includes(val as string))
|
|
61
|
+
throw new Error('mode must be standard or deep.');
|
|
62
|
+
if (
|
|
63
|
+
key === 'cyberAccessProgram' &&
|
|
64
|
+
!['standard', 'daybreak_blue', 'daybreak_red'].includes(val as string)
|
|
65
|
+
)
|
|
66
|
+
throw new Error('Unsupported cyberAccessProgram.');
|
|
67
|
+
if (
|
|
68
|
+
['maxCostUsd', 'maxTimeMinutes', 'workers', 'subagents'].includes(key) &&
|
|
69
|
+
(typeof val !== 'number' ||
|
|
70
|
+
!Number.isFinite(val) ||
|
|
71
|
+
(key === 'subagents' ? val < 0 : val <= 0) ||
|
|
72
|
+
(['workers', 'subagents'].includes(key) && !Number.isInteger(val)))
|
|
73
|
+
)
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${key} must be positive${['workers', 'subagents'].includes(key) ? ' and integral' : ''}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
codex: value.codex as CodexSettings | undefined,
|
|
81
|
+
scan: value.scan as ScanSettings | undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function loadGlobalSettings(
|
|
85
|
+
path = join(
|
|
86
|
+
process.env.XDG_CONFIG_HOME || join(homedir(), '.config'),
|
|
87
|
+
'opencode',
|
|
88
|
+
'codex-security.json',
|
|
89
|
+
),
|
|
90
|
+
) {
|
|
91
|
+
try {
|
|
92
|
+
const info = await lstat(path);
|
|
93
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 32768)
|
|
94
|
+
throw new Error('Global config must be a regular JSON file of at most 32 KiB.');
|
|
95
|
+
const content = await readFile(path);
|
|
96
|
+
if (content.length > 32768) throw new Error('Global config too large.');
|
|
97
|
+
const raw = JSON.parse(content.toString('utf8'));
|
|
98
|
+
if (
|
|
99
|
+
!raw ||
|
|
100
|
+
typeof raw !== 'object' ||
|
|
101
|
+
Array.isArray(raw) ||
|
|
102
|
+
Object.keys(raw).some((k) => !['codex', 'scan'].includes(k))
|
|
103
|
+
)
|
|
104
|
+
throw new Error('Global config accepts only codex and scan sections.');
|
|
105
|
+
return {
|
|
106
|
+
settings: validateSettings(raw),
|
|
107
|
+
path,
|
|
108
|
+
digest: createHash('sha256').update(content).digest('hex'),
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
112
|
+
return { settings: {}, path, digest: null };
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function mergeSettings(...sources: SdkSettings[]): ResolvedSdkSettings {
|
|
117
|
+
const result = structuredClone(defaultSettings);
|
|
118
|
+
for (const source of sources) {
|
|
119
|
+
Object.assign(result.codex, source.codex);
|
|
120
|
+
Object.assign(result.scan, source.scan);
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|