@itenl/sec 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/lib/report.mjs ADDED
@@ -0,0 +1,210 @@
1
+ import { repositoryDirectory, assertOutsideRepository, privateDirectory } from './config.mjs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { projectPolicy, publishSummary } from './project.mjs';
6
+
7
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
8
+ const inside = (root, path) => {
9
+ const rel = relative(root, path);
10
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
11
+ };
12
+ const safeIdentifier = (value) =>
13
+ typeof value === 'string' && /^[a-zA-Z0-9._:-]{1,200}$/.test(value);
14
+
15
+ export function rawReportDirectory(root, env) {
16
+ return join(repositoryDirectory(root, env), 'runs');
17
+ }
18
+
19
+ export function createReport(root, mode, head, tree, env) {
20
+ // UTC plus a UUID avoids collisions across machines and concurrent runs.
21
+ const startedAt = new Date().toISOString();
22
+ const runId = `${startedAt.replace(/[:.]/g, '-')}-${mode}-${randomUUID()}`;
23
+ const raw = join(rawReportDirectory(root, env), runId);
24
+ if (inside(root, raw))
25
+ throw new Error('Codex Security state/report directory must be outside the repository.');
26
+ assertOutsideRepository(root, raw);
27
+ privateDirectory(raw);
28
+ if (inside(realpathSync(root), realpathSync(raw)))
29
+ throw new Error('Raw report directory resolves inside the repository.');
30
+ const summary = join(raw, 'summary.md');
31
+ return { root, mode, head, tree, startedAt, runId, raw, summary };
32
+ }
33
+
34
+ export function readResult(run, expected) {
35
+ const policy = projectPolicy(run.project);
36
+ const manifest = JSON.parse(readFileSync(join(run.raw, 'scan-manifest.json'), 'utf8'));
37
+ const bytes = readFileSync(join(run.raw, 'findings.json'));
38
+ const document = JSON.parse(bytes);
39
+ const scan = manifest.scan;
40
+ if (
41
+ manifest.documentType !== 'codex-security.scan-manifest' ||
42
+ manifest.schemaVersion !== '1.0' ||
43
+ document.documentType !== 'codex-security.findings' ||
44
+ document.schemaVersion !== '1.0' ||
45
+ !safeIdentifier(scan?.id) ||
46
+ scan.status !== 'completed' ||
47
+ document.scanId !== scan.id ||
48
+ !Array.isArray(document.findings) ||
49
+ !scan.scope ||
50
+ !scan.target ||
51
+ !/^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/.test(scan.target.snapshotDigest || '') ||
52
+ (scan.warnings != null && (!Array.isArray(scan.warnings) || scan.warnings.length > 0))
53
+ )
54
+ throw new Error('Incomplete or inconsistent security scan artifacts.');
55
+ if (run.mode === 'staged' || run.mode === 'branch') {
56
+ if (
57
+ scan.mode !== 'branch_diff' ||
58
+ scan.target.kind !== 'git_diff' ||
59
+ scan.target.baseRevision !== expected.base ||
60
+ scan.target.headRevision !== expected.target
61
+ ) {
62
+ throw new Error('Security scan artifacts do not match the requested diff snapshot.');
63
+ }
64
+ } else if (scan.mode !== 'repository' || scan.target.revision !== run.head) {
65
+ throw new Error('Security scan artifacts do not match the repository target.');
66
+ }
67
+ const artifact = scan.artifacts?.find((entry) => entry.path === 'findings.json');
68
+ if (!artifact || artifact.sha256 !== hash(bytes))
69
+ throw new Error('Security findings checksum mismatch.');
70
+ // Do not allow a zero exit code to hide an empty/truncated report set.
71
+ if (!readFileSync(join(run.raw, 'report.md'), 'utf8').trim())
72
+ throw new Error('Security report is missing or empty.');
73
+ const counts = Object.fromEntries(
74
+ [...policy.blockSeverities, ...policy.warnSeverities].map((level) => [level, 0])
75
+ );
76
+ for (const finding of document.findings) {
77
+ const severity = finding.severity?.level;
78
+ if (!Object.hasOwn(counts, severity))
79
+ throw new Error('Unknown security finding severity; refusing to pass.');
80
+ counts[severity]++;
81
+ }
82
+ return { scan, findings: document.findings, counts };
83
+ }
84
+
85
+ function locationText(locations) {
86
+ if (!Array.isArray(locations)) return '见私有原始报告';
87
+ return (
88
+ locations
89
+ .filter(
90
+ (location) =>
91
+ typeof location.path === 'string' &&
92
+ !isAbsolute(location.path) &&
93
+ !location.path.split(/[\\/]/).includes('..') &&
94
+ /^[a-zA-Z0-9_./@()[\] -]+$/.test(location.path) &&
95
+ Number.isInteger(location.startLine) &&
96
+ location.startLine > 0
97
+ )
98
+ .map((location) => `${location.path}:${location.startLine}`)
99
+ .join(';') || '见私有原始报告'
100
+ );
101
+ }
102
+
103
+ export function finishReport(run, result, status, scannerStatus) {
104
+ const policy = projectPolicy(run.project);
105
+ const conclusion = status === 0 ? '允许通过' : status === 1 ? '风险阻断' : '扫描未完成,阻断';
106
+ const lines = [
107
+ '# 自动安全扫描摘要',
108
+ '',
109
+ `- 规则版本:${policy.version};时间:${run.startedAt}(${policy.timeZone})。`,
110
+ `- 执行标识:${run.runId}`,
111
+ `- 模式:${run.mode};命令:sec ${{ full: 'scan', staged: 'diff', branch: 'branch' }[run.mode]}`,
112
+ `- 扫描 HEAD:${run.head || '首次提交'};暂存树:${run.tree || '不适用'}`,
113
+ ...(run.comparison
114
+ ? [
115
+ `- 比较目标:${JSON.stringify(run.comparison.baseRef)};解析时提交:${run.comparison.baseTip}`,
116
+ `- 共同祖先:${run.comparison.mergeBase};差异终点:${run.comparison.target}`
117
+ ]
118
+ : []),
119
+ `- 结论:${conclusion};封装退出码:${status};CLI 退出码:${scannerStatus ?? '未启动'}`,
120
+ `- 策略:${policy.blockSeverities.join('/')} 阻断;${policy.warnSeverities.join('/') || '无'} 警告放行;错误、取消或产物不完整阻断。`,
121
+ ...(run.project
122
+ ? [
123
+ `- 项目配置:sec.config.json;规范:${run.project.policyFile ?? '未指定'};报告模板:${run.project.reportTemplate ?? '未指定'}。`,
124
+ `- 请求模型:${run.modelSettings?.model};推理强度:${run.modelSettings?.effort}。`
125
+ ]
126
+ : []),
127
+ '- 原始产物:本机私有状态目录中同一执行标识下的 scan-manifest.json、findings.json、report.md、wrapper-result.json。',
128
+ run.project?.summaryDir
129
+ ? '- 本摘要的私有原件保存在仓库外;脱敏副本按项目配置输出。'
130
+ : '- 本文件保存在用户私有状态目录,不写入被扫描仓库。',
131
+ ''
132
+ ];
133
+ if (result) {
134
+ lines.push(
135
+ `- 扫描 ID:${result.scan.id}`,
136
+ `- 快照:${result.scan.target.snapshotDigest}`,
137
+ `- 最终状态:${result.scan.status}`,
138
+ '',
139
+ '## 本轮发现',
140
+ '',
141
+ ...Object.entries(result.counts).map(([level, count]) => `- ${level}:${count}`),
142
+ '',
143
+ '| 本轮序号 | 稳定编号 | 严重级别 | 置信度 | 代码位置 |',
144
+ '| --- | --- | --- | --- | --- |'
145
+ );
146
+ for (const [index, finding] of result.findings.entries()) {
147
+ const confidence = ['high', 'medium', 'low'].includes(finding.confidence?.level)
148
+ ? finding.confidence.level
149
+ : '未提供';
150
+ lines.push(
151
+ `| ${index + 1} | ${result.tracking?.[index]?.id || '未导入'} | ${finding.severity.level} | ${confidence} | ${locationText(finding.locations)} |`
152
+ );
153
+ }
154
+ } else {
155
+ lines.push(
156
+ '未取得可验证的完整扫描产物;发现数量未知。诊断见终端及本机原始产物,不能视为零漏洞。'
157
+ );
158
+ }
159
+ lines.push(
160
+ '',
161
+ '## 覆盖与限制',
162
+ '',
163
+ `请求范围:${
164
+ run.mode === 'branch'
165
+ ? '指定目标的共同祖先到 HEAD 的已提交差异;暂存、未暂存及未跟踪内容不在范围内'
166
+ : run.mode === 'staged'
167
+ ? 'HEAD 到 index 的差异;未暂存修改不在范围内'
168
+ : '当前项目全仓工作区'
169
+ }。`,
170
+ '文件清点数、完整审查数、实际纳入/排除路径及验证限制:以原始 scan-manifest.json 的 scope 与 report.md 为准;未从自由文本推算计数。',
171
+ result?.tracking
172
+ ? `稳定编号已关联到${run.project?.trackerFile ?? '本机私有台账'};未再次报告不等于修复。关联不明确的新项需要人工复核。`
173
+ : '本轮发现未导入台账;未再次报告不等于修复。',
174
+ '自动摘要不复制漏洞载荷、源码、凭据或自由文本。影响、前提、证据和修复建议需在私有原始报告中复核,再按需要形成人工报告。',
175
+ '退出码 0 不代表整个项目无漏洞;本次扫描不自动修复、提交或推送。',
176
+ ''
177
+ );
178
+ mkdirSync(resolve(run.summary, '..'), { recursive: true });
179
+ writeFileSync(run.summary, lines.join('\n'), { flag: 'wx', mode: 0o600 });
180
+ writeFileSync(
181
+ join(run.raw, 'wrapper-result.json'),
182
+ JSON.stringify(
183
+ {
184
+ policyVersion: policy.version,
185
+ project: run.project ?? null,
186
+ projectInputs: run.projectInputs ?? [],
187
+ failOnSeverity: policy.failOnSeverity,
188
+ runId: run.runId,
189
+ mode: run.mode,
190
+ head: run.head,
191
+ tree: run.tree,
192
+ comparison: run.comparison ?? null,
193
+ modelSettings: run.modelSettings ?? null,
194
+ startedAt: run.startedAt,
195
+ completedAt: new Date().toISOString(),
196
+ status,
197
+ scannerStatus,
198
+ scanId: result?.scan.id ?? null,
199
+ counts: result?.counts ?? null,
200
+ tracking: result?.tracking ?? null
201
+ },
202
+ null,
203
+ 2
204
+ ),
205
+ { flag: 'wx', mode: 0o600 }
206
+ );
207
+ publishSummary(run);
208
+ console.log(`Codex Security raw reports: ${run.raw}`);
209
+ console.log(`Codex Security 中文摘要: ${run.summary}`);
210
+ }
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+
6
+ const defaultCapture = (command, args) =>
7
+ execFileSync(command, args, { encoding: 'utf8', stdio: 'pipe' }).trim();
8
+
9
+ export function prepareScanTemp(root, environment) {
10
+ const repository = realpathSync(root);
11
+ const inside = (path) => {
12
+ const rel = relative(repository, path);
13
+ return (
14
+ rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\'))
15
+ );
16
+ };
17
+ const probe = (candidate, create = false) => {
18
+ const path = resolve(candidate);
19
+ if (inside(path)) throw new Error('Temporary directory must be outside the repository.');
20
+ if (create) {
21
+ let ancestor = path;
22
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor);
23
+ if (inside(resolve(realpathSync(ancestor), relative(ancestor, path)))) {
24
+ throw new Error('Temporary directory resolves inside the repository.');
25
+ }
26
+ mkdirSync(path, { recursive: true, mode: 0o700 });
27
+ }
28
+ const directory = realpathSync(path);
29
+ if (inside(directory)) throw new Error('Temporary directory resolves inside the repository.');
30
+ const test = mkdtempSync(join(directory, 'codex-security-temp-probe-'));
31
+ rmSync(test, { recursive: true });
32
+ return directory;
33
+ };
34
+ const preferred =
35
+ process.platform === 'win32'
36
+ ? environment.TEMP || environment.TMP || tmpdir()
37
+ : environment.TMPDIR || environment.TMP || environment.TEMP || tmpdir();
38
+ let directory;
39
+ let fallback = false;
40
+ try {
41
+ directory = probe(preferred);
42
+ } catch {
43
+ fallback = true;
44
+ const cache = environment.XDG_CACHE_HOME || join(environment.HOME || homedir(), '.cache');
45
+ try {
46
+ directory = probe(join(cache, 'sec/tmp'), true);
47
+ } catch {
48
+ throw new Error(
49
+ 'No writable temporary directory outside the repository. Set TMPDIR to a writable directory and retry.'
50
+ );
51
+ }
52
+ }
53
+ // Node and Python use different precedence rules. Normalize all aliases for children only.
54
+ environment.TMPDIR = directory;
55
+ environment.TMP = directory;
56
+ environment.TEMP = directory;
57
+ return { directory, fallback };
58
+ }
59
+
60
+ export function findPython(environment = process.env, capture = defaultCapture) {
61
+ const candidates = [environment.PYTHON];
62
+ try {
63
+ candidates.push(capture('uv', ['python', 'find', '>=3.11']));
64
+ } catch {
65
+ // uv is optional; also accept a Python installation on PATH.
66
+ }
67
+ candidates.push('python3', 'python');
68
+ for (const candidate of candidates.filter(Boolean)) {
69
+ try {
70
+ capture(candidate, ['-c', 'import sys; assert sys.version_info >= (3, 10); import tomllib']);
71
+ return candidate;
72
+ } catch {
73
+ // Python 3.10 needs tomli instead of the standard-library tomllib.
74
+ try {
75
+ capture(candidate, ['-c', 'import sys; assert sys.version_info >= (3, 10); import tomli']);
76
+ return candidate;
77
+ } catch {
78
+ // Try the next interpreter.
79
+ }
80
+ }
81
+ }
82
+ throw new Error('Python >=3.10 is required (3.10 also needs tomli). Install Python or set PYTHON.');
83
+ }
package/lib/scan.mjs ADDED
@@ -0,0 +1,322 @@
1
+ import { engineEnvironment, providerArguments, ensureEngine, invokeEngine } from './engine.mjs';
2
+ import {
3
+ readConfig,
4
+ assertOutsideRepository,
5
+ userDirectories,
6
+ repositoryModelSettings,
7
+ branchBaseRef
8
+ } from './config.mjs';
9
+ import { snapshot as sourceSnapshot } from './verify.mjs';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { cpSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { parseArgs } from 'node:util';
15
+ import { findPython, prepareScanTemp } from './runtime.mjs';
16
+ import {
17
+ readProjectConfig,
18
+ projectInputs,
19
+ projectGuard,
20
+ projectPolicy,
21
+ preflightProject,
22
+ projectPath
23
+ } from './project.mjs';
24
+ import { createReport, finishReport, readResult } from './report.mjs';
25
+ import { mergeFindings, readTracker, saveTracker, trackerFile } from './tracker.mjs';
26
+
27
+ const environment = { ...process.env };
28
+ const capture = (command, args, options = {}) =>
29
+ execFileSync(command, args, {
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ env: environment,
33
+ ...options
34
+ }).trim();
35
+ const git = (cwd, ...args) => capture('git', ['-C', cwd, ...args]);
36
+
37
+ export async function main(mode = 'staged', args = []) {
38
+ if (!['full', 'staged', 'branch'].includes(mode))
39
+ throw new Error('Usage: sec scan|diff|branch [--base <ref>] [--dry-run]');
40
+ const { values } = parseArgs({
41
+ args,
42
+ options: {
43
+ ...(mode === 'branch' ? { base: { type: 'string' }, 'dry-run': { type: 'boolean' } } : {}),
44
+ model: { type: 'string' },
45
+ effort: { type: 'string' },
46
+ 'knowledge-base': { type: 'string', multiple: true }
47
+ },
48
+ allowPositionals: false
49
+ });
50
+ const config = readConfig();
51
+ const root = git(process.cwd(), 'rev-parse', '--show-toplevel');
52
+ const project = readProjectConfig(root);
53
+ const settings = repositoryModelSettings(root, config, values);
54
+ const policy = projectPolicy(project);
55
+ const guard = projectGuard(root, project);
56
+ preflightProject(root, project, (...args) => git(root, ...args));
57
+ if (project)
58
+ console.log(
59
+ `项目配置:sec.config.json;规范:${project.policyFile ?? '未指定'};模型:${settings.model} / ${settings.effort};门禁:${policy.failOnSeverity}。`
60
+ );
61
+ const baseRef = mode === 'branch' ? branchBaseRef(root, config, values.base, environment) : null;
62
+ if (mode === 'branch' && (!baseRef || baseRef.startsWith('-')))
63
+ throw new Error(
64
+ 'Invalid branch target. Use sec branch [--base <ref>] [--dry-run] (default: origin/HEAD).'
65
+ );
66
+ // Git hooks can set GIT_INDEX_FILE and other repository-specific variables.
67
+ // Resolve the real index first, then keep those variables out of the isolated repository.
68
+ const tree = mode === 'staged' ? git(root, 'write-tree') : null;
69
+ let head = null;
70
+ try {
71
+ head = git(root, 'rev-parse', '--verify', 'HEAD');
72
+ } catch {
73
+ /* Initial commit. */
74
+ }
75
+ let comparison = null;
76
+ if (mode === 'branch') {
77
+ if (!head) throw new Error('Branch scans require a committed HEAD.');
78
+ for (const operation of [
79
+ 'MERGE_HEAD',
80
+ 'CHERRY_PICK_HEAD',
81
+ 'REVERT_HEAD',
82
+ 'rebase-merge',
83
+ 'rebase-apply',
84
+ 'sequencer'
85
+ ]) {
86
+ if (existsSync(resolve(root, git(root, 'rev-parse', '--git-path', operation))))
87
+ throw new Error(
88
+ 'Finish the active merge, cherry-pick, revert or rebase before scanning branch changes.'
89
+ );
90
+ }
91
+ if (git(root, 'ls-files', '--unmerged'))
92
+ throw new Error('Resolve Git conflicts before scanning branch changes.');
93
+ let baseTip;
94
+ let bases;
95
+ try {
96
+ baseTip = git(root, 'rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`);
97
+ bases = git(root, 'merge-base', '--all', baseTip, head).split('\n').filter(Boolean);
98
+ } catch {
99
+ throw new Error(
100
+ `Cannot resolve target ${JSON.stringify(baseRef)} or find shared history. Fetch the target ref/history and retry.`
101
+ );
102
+ }
103
+ if (bases.length !== 1)
104
+ throw new Error('Expected one merge base; resolve ambiguous Git history before scanning.');
105
+ comparison = { baseRef, baseTip, mergeBase: bases[0], target: head };
106
+ console.log(`Codex Security branch target: ${JSON.stringify(baseRef)} (${baseTip})`);
107
+ console.log(
108
+ `Codex Security branch range: ${comparison.mergeBase}..${head} (committed changes only)`
109
+ );
110
+ console.log(
111
+ 'Codex Security: local refs only; no automatic fetch. Index and working changes are excluded.'
112
+ );
113
+ if (
114
+ git(root, 'rev-parse', `${comparison.mergeBase}^{tree}`) ===
115
+ git(root, 'rev-parse', `${head}^{tree}`)
116
+ ) {
117
+ console.log('Codex Security: no committed branch changes.');
118
+ return 0;
119
+ }
120
+ console.log(git(root, 'diff', '--stat', comparison.mergeBase, head, '--'));
121
+ if (values['dry-run']) {
122
+ console.log(
123
+ 'Codex Security: dry run only; scanner not invoked and no reports or tracker updates written.'
124
+ );
125
+ return 0;
126
+ }
127
+ }
128
+ if (mode === 'staged' && head && tree === git(root, 'rev-parse', `${head}^{tree}`)) {
129
+ console.log('Codex Security: no staged changes.');
130
+ return 0;
131
+ }
132
+ const ledgerPath = trackerFile(root, environment);
133
+ assertOutsideRepository(root, userDirectories(environment).config);
134
+ const sourceBefore = mode === 'full' ? sourceSnapshot(root, environment) : null;
135
+ const previousTracker = readTracker(ledgerPath, true, project?.idPrefix ?? 'SEC');
136
+ Object.assign(environment, engineEnvironment(environment));
137
+ delete environment.CODEX_SECURITY_PROJECT_CONFIG;
138
+ const originalGitEnvironment = { ...environment };
139
+ for (const name of git(root, 'rev-parse', '--local-env-vars').split('\n'))
140
+ delete environment[name];
141
+ const run = createReport(root, mode, head, tree, environment);
142
+ run.comparison = comparison;
143
+ run.modelSettings = settings;
144
+ run.project = project;
145
+ run.projectInputs = guard.inputs;
146
+ let temp;
147
+ let scannerStatus;
148
+ let result;
149
+ let status = 2;
150
+ try {
151
+ const temporary = prepareScanTemp(root, environment);
152
+ if (temporary.fallback) {
153
+ console.warn(
154
+ `Codex Security: inherited temporary directory is unavailable or inside the repository; using ${temporary.directory}`
155
+ );
156
+ }
157
+ ensureEngine(environment);
158
+ const suppliedContext = projectInputs(root, project).filter(
159
+ ({ absolute }) =>
160
+ !project?.reportTemplate || absolute !== projectPath(root, project.reportTemplate)
161
+ );
162
+ const knowledgePaths = suppliedContext.map(({ absolute }, index) => {
163
+ const copy = join(run.raw, 'context', String(index));
164
+ mkdirSync(join(run.raw, 'context'), { recursive: true, mode: 0o700 });
165
+ // Preserve extensions for official document format detection.
166
+ const destination = `${copy}-${absolute.split('/').at(-1)}`;
167
+ cpSync(absolute, destination, {
168
+ recursive: true,
169
+ dereference: false,
170
+ errorOnExist: true,
171
+ force: false
172
+ });
173
+ return destination;
174
+ });
175
+ guard.check();
176
+ const commonArgs = [
177
+ ...knowledgePaths.flatMap((path) => ['--knowledge-base', path]),
178
+ ...providerArguments(config, settings),
179
+ ...(values['knowledge-base'] || []).flatMap((path) => [
180
+ '--knowledge-base',
181
+ resolve(process.cwd(), path)
182
+ ]),
183
+ '--output-dir',
184
+ run.raw,
185
+ '--fail-on-severity',
186
+ policy.failOnSeverity,
187
+ '--python',
188
+ findPython(environment, capture),
189
+ '--headless',
190
+ '--codex',
191
+ 'approval_policy="never"'
192
+ ];
193
+ let snapshot = root;
194
+ let expected = {};
195
+ let rangeArgs = [];
196
+ if (mode === 'staged' || mode === 'branch') {
197
+ temp = mkdtempSync(join(temporary.directory, 'sec-snapshot-'));
198
+ snapshot = join(temp, 'repository');
199
+ mkdirSync(snapshot);
200
+ git(snapshot, 'init', '--quiet');
201
+ git(snapshot, 'config', 'core.hooksPath', '/dev/null');
202
+ // Share read-only object access; temporary commits stay in the isolated repository.
203
+ const objects = resolve(root, git(root, 'rev-parse', '--git-path', 'objects'));
204
+ writeFileSync(join(snapshot, '.git/objects/info/alternates'), `${objects}\n`);
205
+ const commitEnv = {
206
+ ...environment,
207
+ GIT_AUTHOR_NAME: 'Codex Security',
208
+ GIT_AUTHOR_EMAIL: 'security@localhost',
209
+ GIT_COMMITTER_NAME: 'Codex Security',
210
+ GIT_COMMITTER_EMAIL: 'security@localhost'
211
+ };
212
+ const commit = (args) =>
213
+ capture('git', ['-C', snapshot, '-c', 'commit.gpgsign=false', 'commit-tree', ...args], {
214
+ env: commitEnv
215
+ });
216
+ const base =
217
+ comparison?.mergeBase ??
218
+ head ??
219
+ commit([git(snapshot, 'mktree'), '-m', 'Empty security scan baseline']);
220
+ const target =
221
+ mode === 'branch'
222
+ ? head
223
+ : commit([tree, '-p', base, '-m', 'Staged security scan snapshot']);
224
+ expected = { base, target };
225
+ git(snapshot, 'checkout', '--quiet', '--detach', target);
226
+ rangeArgs = ['--diff', base, '--head', target];
227
+ }
228
+ console.log(
229
+ `Codex Security: ${policy.blockSeverities.join('/')} block; ${policy.warnSeverities.join('/') || 'none'} warn; scan errors block.`
230
+ );
231
+ console.log(`扫描模型:${settings.model};推理强度:${settings.effort}。`);
232
+ scannerStatus = (
233
+ await invokeEngine(['scan', snapshot, ...rangeArgs, ...commonArgs], {
234
+ cwd: snapshot,
235
+ env: environment
236
+ })
237
+ ).code;
238
+ if (![0, 1].includes(scannerStatus)) {
239
+ status = scannerStatus;
240
+ console.error(`Security scanner did not complete (exit ${scannerStatus}).`);
241
+ return status;
242
+ }
243
+ result = readResult(run, expected);
244
+ const blocked = policy.blockSeverities.some((level) => result.counts[level] > 0);
245
+ if (scannerStatus === 1 && !blocked)
246
+ throw new Error('Scanner exit code conflicts with the severity policy.');
247
+ status = blocked ? 1 : 0;
248
+ if (policy.warnSeverities.some((level) => result.counts[level] > 0)) {
249
+ console.warn(
250
+ `WARNING: Findings below ${policy.failOnSeverity} remain in the report; review them even though the gate allows them.`
251
+ );
252
+ }
253
+ if (blocked)
254
+ console.error(`Codex Security: ${policy.failOnSeverity} or higher findings fail this check.`);
255
+ } catch (error) {
256
+ if ([0, 1].includes(status)) status = 2;
257
+ throw error;
258
+ } finally {
259
+ try {
260
+ // Check the original index (including GIT_INDEX_FILE), even after scanner failure.
261
+ const currentTree =
262
+ mode === 'staged'
263
+ ? capture('git', ['-C', root, 'write-tree'], {
264
+ env: originalGitEnvironment
265
+ })
266
+ : null;
267
+ let currentHead = null;
268
+ try {
269
+ currentHead = capture('git', ['-C', root, 'rev-parse', '--verify', 'HEAD'], {
270
+ env: originalGitEnvironment
271
+ });
272
+ } catch {
273
+ /* Initial commit. */
274
+ }
275
+ if (
276
+ currentTree !== tree ||
277
+ currentHead !== head ||
278
+ (mode === 'full' && sourceSnapshot(root, originalGitEnvironment) !== sourceBefore)
279
+ ) {
280
+ throw new Error(
281
+ 'The staged content or HEAD changed during the security scan. Review and retry the scan.'
282
+ );
283
+ }
284
+ preflightProject(root, project, (...args) => git(root, ...args));
285
+ guard.check();
286
+ if (result && [0, 1].includes(status)) {
287
+ const merged = mergeFindings(previousTracker.tracker, result, run);
288
+ // Do not create an empty tracker just for a clean scan. Existing files still
289
+ // get a CAS check so concurrent edits cannot be silently ignored.
290
+ if (previousTracker.text !== null || merged.tracker.findings.length) {
291
+ if (project?.trackerFile) projectPath(root, project.trackerFile, { kind: 'file' });
292
+ saveTracker(ledgerPath, merged.tracker, previousTracker.text);
293
+ }
294
+ result.tracking = merged.associations;
295
+ console.log(
296
+ `Codex Security: 本轮 ${merged.associations.length} 项已关联稳定编号(新增 ${merged.associations.filter((entry) => entry.created).length} 项);${project?.trackerFile ? '已更新项目共享台账工作副本,未暂存' : '保存在本机私有台账'}。`
297
+ );
298
+ }
299
+ } catch (error) {
300
+ status = 2;
301
+ throw error;
302
+ } finally {
303
+ if (temp) rmSync(temp, { recursive: true, force: true });
304
+ finishReport(run, result, status, scannerStatus);
305
+ }
306
+ }
307
+ return status;
308
+ }
309
+
310
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
311
+ main(process.argv[2], process.argv.slice(3))
312
+ .then((status) => {
313
+ process.exitCode = status;
314
+ })
315
+ .catch((error) => {
316
+ console.error(`Codex Security: ${error.message}`);
317
+ console.error(
318
+ 'Security check did not complete. Resolve the error and retry; incomplete scans fail closed.'
319
+ );
320
+ process.exitCode = 2;
321
+ });
322
+ }