@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/setup.mjs ADDED
@@ -0,0 +1,217 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { constants, copyFileSync, existsSync } from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { Writable } from 'node:stream';
7
+ import { parseArgs } from 'node:util';
8
+ import {
9
+ configFile,
10
+ defaultModel,
11
+ defaultEffort,
12
+ engineVersion,
13
+ privateDirectory,
14
+ readConfig,
15
+ saveConfig,
16
+ userDirectories,
17
+ validateConfig,
18
+ assertOutsideRepository
19
+ } from './config.mjs';
20
+ import { engineCommand, engineEnvironment, ensureEngine } from './engine.mjs';
21
+ import { findPython } from './runtime.mjs';
22
+ import { installAliases } from './aliases.mjs';
23
+
24
+ export async function prompt(
25
+ question,
26
+ { secret = false, input = process.stdin, output = process.stdout } = {}
27
+ ) {
28
+ let muted = false;
29
+ const writer = new Writable({
30
+ write(chunk, encoding, callback) {
31
+ if (!muted) output.write(chunk, encoding);
32
+ callback();
33
+ }
34
+ });
35
+ const reader = createInterface({
36
+ input,
37
+ output: writer,
38
+ terminal: true,
39
+ historySize: 0
40
+ });
41
+ try {
42
+ if (secret) {
43
+ output.write(question);
44
+ muted = true;
45
+ }
46
+ return await new Promise((resolve, reject) => {
47
+ reader.once('SIGINT', () => reject(new Error('Setup cancelled.')));
48
+ reader.once('close', () => reject(new Error('Input closed before setup completed.')));
49
+ reader.question(secret ? '' : question, resolve);
50
+ });
51
+ } finally {
52
+ reader.close();
53
+ writer.end();
54
+ if (secret) output.write('\n');
55
+ }
56
+ }
57
+ export function setupOptions(args) {
58
+ return parseArgs({
59
+ args,
60
+ options: {
61
+ 'api-key': { type: 'boolean' },
62
+ 'base-url': { type: 'string' },
63
+ model: { type: 'string' },
64
+ effort: { type: 'string' },
65
+ base: { type: 'string' },
66
+ 'no-aliases': { type: 'boolean' }
67
+ },
68
+ allowPositionals: false
69
+ }).values;
70
+ }
71
+ export async function main(
72
+ args = [],
73
+ {
74
+ env = { ...process.env },
75
+ ask = prompt,
76
+ run = execFileSync,
77
+ log = console.log,
78
+ tty = process.stdin.isTTY && process.stdout.isTTY
79
+ } = {}
80
+ ) {
81
+ const options = setupOptions(args);
82
+ if (!tty)
83
+ throw new Error(
84
+ 'Run sec setup in an interactive terminal. API keys are accepted only through hidden input.'
85
+ );
86
+ const [major, minor] = process.versions.node.split('.').map(Number);
87
+ if (!((major === 22 && minor >= 13) || major === 24 || major === 26))
88
+ throw new Error('Node 22.13+ (22.x), 24 or 26 is required.');
89
+ const directories = userDirectories(env);
90
+ // Setup is user-scoped and also works outside a Git repository.
91
+ let repository;
92
+ try {
93
+ repository = run('git', ['rev-parse', '--show-toplevel'], {
94
+ env,
95
+ encoding: 'utf8',
96
+ stdio: 'pipe'
97
+ }).trim();
98
+ } catch {
99
+ /* No repository needed. */
100
+ }
101
+ if (repository) {
102
+ assertOutsideRepository(repository, directories.config);
103
+ assertOutsideRepository(repository, directories.state);
104
+ }
105
+ const config = readConfig(env);
106
+ const baseUrl = (
107
+ options['base-url'] ??
108
+ (await ask(`API 服务地址(留空${config.baseUrl ? '保留当前地址' : '使用官方默认'}):`))
109
+ ).trim();
110
+ if (baseUrl) config.baseUrl = baseUrl;
111
+ else if (options['base-url'] === '') delete config.baseUrl;
112
+ const model = (options.model ?? (await ask(`模型(留空使用 ${config.model || defaultModel}):`))).trim();
113
+ config.model = model || config.model || defaultModel;
114
+ const effort = (
115
+ options.effort ?? (await ask(`推理强度(留空使用 ${config.effort || defaultEffort}):`))
116
+ ).trim();
117
+ config.effort = effort || config.effort || defaultEffort;
118
+ if (options.base) config.baseRef = options.base;
119
+ validateConfig(config);
120
+ const childEnv = engineEnvironment(env);
121
+ // Key entry must not be masked by an ambient key or personal Codex login.
122
+ delete childEnv.OPENAI_API_KEY;
123
+ delete childEnv.CODEX_API_KEY;
124
+ findPython(childEnv, (command, argv) =>
125
+ run(command, argv, {
126
+ env: childEnv,
127
+ encoding: 'utf8',
128
+ stdio: 'pipe'
129
+ }).trim()
130
+ );
131
+ let installed = true;
132
+ try {
133
+ ensureEngine(childEnv, run);
134
+ } catch {
135
+ installed = false;
136
+ }
137
+ privateDirectory(directories.config);
138
+ privateDirectory(directories.state);
139
+ privateDirectory(childEnv.CODEX_HOME);
140
+ if (!installed) {
141
+ const runtime = join(directories.config, 'runtime');
142
+ privateDirectory(runtime);
143
+ log(`安装独立扫描引擎 ${engineVersion},不更换其他全局工具…`);
144
+ try {
145
+ run(
146
+ 'npm',
147
+ [
148
+ 'install',
149
+ '--prefix',
150
+ runtime,
151
+ '--no-audit',
152
+ '--no-fund',
153
+ '--ignore-scripts',
154
+ `@openai/codex-security@${engineVersion}`
155
+ ],
156
+ { env: childEnv, stdio: 'pipe' }
157
+ );
158
+ ensureEngine(childEnv, run);
159
+ } catch {
160
+ throw new Error(
161
+ 'Cannot install the managed scan engine. Check npm access and permissions, then retry sec setup.'
162
+ );
163
+ }
164
+ }
165
+ let loggedIn = false;
166
+ if (!options['api-key']) {
167
+ try {
168
+ run(engineCommand(childEnv), ['login', 'status'], {
169
+ env: childEnv,
170
+ stdio: 'pipe',
171
+ timeout: 10000,
172
+ killSignal: 'SIGKILL'
173
+ });
174
+ loggedIn = true;
175
+ } catch (error) {
176
+ if (error.code === 'ETIMEDOUT' || error.signal === 'SIGKILL')
177
+ throw new Error('Login status timed out. Use sec setup --api-key to enter a key without this check.');
178
+ }
179
+ }
180
+ const replaceKey =
181
+ options['api-key'] ||
182
+ !loggedIn ||
183
+ /^y(es)?$/i.test((await ask('已有登录,是否更换 API key?[y/N] ')).trim());
184
+ if (replaceKey) {
185
+ let key = (await ask('API key(隐藏输入):', { secret: true })).trim();
186
+ if (!key || /[\s\x00-\x1f\x7f]/.test(key)) throw new Error('Empty or invalid API key; nothing saved.');
187
+ try {
188
+ run(engineCommand(childEnv), ['login', '--with-api-key'], {
189
+ env: childEnv,
190
+ input: `${key}\n`,
191
+ stdio: ['pipe', 'pipe', 'pipe']
192
+ });
193
+ } catch {
194
+ throw new Error('API key could not be saved. Check engine access; credentials were not printed.');
195
+ } finally {
196
+ key = '';
197
+ }
198
+ }
199
+ const path = configFile(env);
200
+ if (existsSync(path)) copyFileSync(path, `${path}.backup-${randomUUID()}`, constants.COPYFILE_EXCL);
201
+ saveConfig(config, env);
202
+ log(`扫描与复核默认模型:${config.model};推理强度:${config.effort}。可用单次 --model / --effort 覆盖。`);
203
+ log(
204
+ `用户级分支比较目标:${config.baseRef ?? 'origin/HEAD'}。以后可用 sec config set baseRef <ref> 修改,--local 仅针对当前仓库。`
205
+ );
206
+ log(`用户配置:${path}。API key 由独立的官方引擎登录目录保存。`);
207
+ if (env.OPENAI_API_KEY || env.CODEX_API_KEY)
208
+ log('当前终端环境中的 API key 会覆盖保存的登录;请自行确认来源。');
209
+ if (
210
+ !options['no-aliases'] &&
211
+ !/^n(o)?$/i.test((await ask('安装 sec:branch 等快捷命令到 zsh/bash 配置?[Y/n] ')).trim())
212
+ ) {
213
+ installAliases({ env, log });
214
+ }
215
+ log('Setup 完成,各项目可直接运行 sec branch。尚未请求模型,认证及模型权限以真实扫描为准。');
216
+ return 0;
217
+ }
@@ -0,0 +1,279 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { validPrefix, findingId } from './project.mjs';
3
+ import {
4
+ closeSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync
12
+ } from 'node:fs';
13
+ import { dirname, isAbsolute } from 'node:path';
14
+
15
+ export { trackerFile } from './config.mjs';
16
+ export const severities = ['critical', 'high', 'medium', 'low'];
17
+ export const statusNames = {
18
+ open: '待修复/调查',
19
+ pending_verification: '待验证',
20
+ verified_static: '已通过源码复核'
21
+ };
22
+ export const hash = (value) => createHash('sha256').update(value).digest('hex');
23
+ const identifier = (value) => typeof value === 'string' && /^[a-zA-Z0-9._:-]{1,200}$/.test(value);
24
+ const fingerprint = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
25
+
26
+ // Only repository-relative locations enter tracked files or terminal tables.
27
+ export function safeLocations(locations) {
28
+ return (Array.isArray(locations) ? locations : [])
29
+ .filter(
30
+ (location) =>
31
+ typeof location.path === 'string' &&
32
+ !isAbsolute(location.path) &&
33
+ !location.path.split(/[\\/]/).includes('..') &&
34
+ /^[a-zA-Z0-9_./@()[\] -]+$/.test(location.path) &&
35
+ Number.isSafeInteger(location.startLine) &&
36
+ location.startLine > 0
37
+ )
38
+ .map(({ path, startLine }) => ({ path, startLine }));
39
+ }
40
+
41
+ export function findingKey(finding) {
42
+ if (!identifier(finding.ruleId))
43
+ throw new Error('Finding rule ID is missing or invalid; tracker not updated.');
44
+ const locations = safeLocations(finding.locations);
45
+ if (!locations.length)
46
+ throw new Error('Finding has no safe repository location; tracker not updated.');
47
+ const roots = safeLocations(
48
+ finding.locations.filter((location) => location.role === 'root_control')
49
+ );
50
+ const paths = [
51
+ ...new Set((roots.length ? roots : locations).map((location) => location.path))
52
+ ].sort();
53
+ // Hash arbitrary anchor text: never copy model prose or payloads into the tracker.
54
+ // Without an anchor, do not guess that two occurrences in the same file are one issue.
55
+ const anchor = typeof finding.identity?.anchor === 'string' && finding.identity.anchor.trim();
56
+ return anchor ? hash(JSON.stringify([finding.ruleId, anchor, paths])) : null;
57
+ }
58
+
59
+ function assertRegularPath(path) {
60
+ // Refuse symlinks in the ledger and its managed parent directories.
61
+ for (let current = path, depth = 0; depth < 3; current = dirname(current), depth++) {
62
+ try {
63
+ const stat = lstatSync(current);
64
+ if (stat.isSymbolicLink() || (current === path && !stat.isFile()))
65
+ throw new Error(
66
+ 'Findings tracker path must use regular files/directories, without symlinks.'
67
+ );
68
+ } catch (error) {
69
+ if (error.code !== 'ENOENT') throw error;
70
+ }
71
+ }
72
+ }
73
+
74
+ export function readTracker(path, allowMissing = false, prefix = 'SEC') {
75
+ assertRegularPath(path);
76
+ let text;
77
+ try {
78
+ text = readFileSync(path, 'utf8');
79
+ } catch (error) {
80
+ if (allowMissing && error.code === 'ENOENT')
81
+ return { text: null, tracker: { schemaVersion: 1, findings: [] } };
82
+ throw error;
83
+ }
84
+ const tracker = JSON.parse(text);
85
+ if (tracker.schemaVersion !== 1 || !Array.isArray(tracker.findings))
86
+ throw new Error('Invalid security findings tracker.');
87
+ const ids = new Set();
88
+ for (const item of tracker.findings) {
89
+ if (
90
+ !findingId(item.id) ||
91
+ (prefix !== null && item.id.slice(0, item.id.lastIndexOf('-')) !== prefix) ||
92
+ !Number.isSafeInteger(Number(item.id.slice(item.id.lastIndexOf('-') + 1))) ||
93
+ ids.has(item.id) ||
94
+ !Object.hasOwn(statusNames, item.status) ||
95
+ !severities.includes(item.severity) ||
96
+ !identifier(item.baseline?.scanId) ||
97
+ !identifier(item.baseline?.ruleId) ||
98
+ typeof item.title !== 'string' ||
99
+ !item.title.trim() ||
100
+ typeof item.evidence !== 'string' ||
101
+ !item.evidence.trim() ||
102
+ (item.identityKeys != null &&
103
+ (!Array.isArray(item.identityKeys) || !item.identityKeys.every(fingerprint)))
104
+ )
105
+ throw new Error('Invalid or duplicate security finding in tracker.');
106
+ ids.add(item.id);
107
+ }
108
+ return { tracker, text };
109
+ }
110
+
111
+ export function saveTracker(path, tracker, previous) {
112
+ assertRegularPath(path);
113
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
114
+ const lock = `${path}.lock`,
115
+ temp = `${path}.${randomUUID()}.tmp`;
116
+ let fd;
117
+ try {
118
+ try {
119
+ fd = openSync(lock, 'wx', 0o600);
120
+ } catch {
121
+ throw new Error('Findings tracker is locked by another operation; no updates saved.');
122
+ }
123
+ assertRegularPath(path);
124
+ let current = null;
125
+ try {
126
+ current = readFileSync(path, 'utf8');
127
+ } catch (error) {
128
+ if (error.code !== 'ENOENT') throw error;
129
+ }
130
+ if (current !== previous)
131
+ throw new Error('Findings tracker changed during operation; no updates saved.');
132
+ writeFileSync(temp, `${JSON.stringify(tracker, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
133
+ renameSync(temp, path);
134
+ } finally {
135
+ rmSync(temp, { force: true });
136
+ if (fd !== undefined) {
137
+ closeSync(fd);
138
+ rmSync(lock, { force: true });
139
+ }
140
+ }
141
+ }
142
+
143
+ // Pure merge first, then one compare-and-swap write after scan/snapshot guards.
144
+ export function inferPrefix(tracker) {
145
+ const prefixes = new Set(
146
+ tracker.findings.map((item) => item.id.slice(0, item.id.lastIndexOf('-')))
147
+ );
148
+ if (prefixes.size > 1)
149
+ throw new Error('Tracker has mixed ID prefixes; review before configuring a shared tracker.');
150
+ return [...prefixes][0] ?? 'SEC';
151
+ }
152
+
153
+ export function mergeFindings(original, result, run) {
154
+ const tracker = structuredClone(original);
155
+ const prefix = run.project?.idPrefix ?? 'SEC';
156
+ if (
157
+ !validPrefix(prefix) ||
158
+ tracker.findings.some(
159
+ (item) => !findingId(item.id) || item.id.slice(0, item.id.lastIndexOf('-')) !== prefix
160
+ )
161
+ )
162
+ throw new Error('Tracker prefix does not match project configuration.');
163
+ let next =
164
+ Math.max(0, ...tracker.findings.map((item) => Number(item.id.slice(prefix.length + 1)))) + 1;
165
+ const associations = [];
166
+ const incoming = result.findings.map((finding) => ({
167
+ finding,
168
+ key: findingKey(finding),
169
+ sha256: hash(JSON.stringify(finding))
170
+ }));
171
+ const keys = incoming.map((entry) => entry.key);
172
+ for (const [index, { finding, key, sha256 }] of incoming.entries()) {
173
+ const candidates = key
174
+ ? tracker.findings.filter((item) => item.identityKeys?.includes(key))
175
+ : [];
176
+ // A repeated occurrence from the same scan is always idempotent, even without a stable anchor.
177
+ const occurrences = tracker.findings.filter((item) =>
178
+ item.observations?.some(
179
+ (seen) => seen.scanId === result.scan.id && seen.findingSha256 === sha256
180
+ )
181
+ );
182
+ const ambiguous =
183
+ candidates.length > 1 || (key && keys.filter((value) => value === key).length > 1);
184
+ let item =
185
+ occurrences.length === 1
186
+ ? occurrences[0]
187
+ : !ambiguous && candidates.length === 1
188
+ ? candidates[0]
189
+ : null;
190
+ const created = !item;
191
+ if (!item) {
192
+ if (!Number.isSafeInteger(next)) throw new Error('Finding ID sequence exhausted.');
193
+ item = {
194
+ id: `${prefix}-${String(next++).padStart(3, '0')}`,
195
+ title: `扫描发现:${finding.ruleId}`,
196
+ status: 'open',
197
+ severity: finding.severity.level,
198
+ baseline: {
199
+ scanId: result.scan.id,
200
+ ruleId: finding.ruleId,
201
+ confirmation: 'automatically_imported'
202
+ },
203
+ evidence:
204
+ '自动导入的结构化发现;完整描述及证据见私有 findings.json,人工复核后可补充脱敏说明。',
205
+ identityKeys: key ? [key] : [],
206
+ association: ambiguous
207
+ ? 'manual_review_ambiguous'
208
+ : key
209
+ ? 'exact_identity'
210
+ : 'manual_review_no_anchor'
211
+ };
212
+ tracker.findings.push(item);
213
+ }
214
+ const alreadySeen = item.observations?.some(
215
+ (seen) => seen.scanId === result.scan.id && seen.findingSha256 === sha256
216
+ );
217
+ if (!alreadySeen) {
218
+ const observation = {
219
+ scanId: result.scan.id,
220
+ runId: run.runId,
221
+ mode: run.mode,
222
+ head: run.head,
223
+ snapshotDigest: result.scan.target.snapshotDigest,
224
+ severity: finding.severity.level,
225
+ findingSha256: sha256
226
+ };
227
+ item.observations = [...(item.observations || []), observation];
228
+ item.locations = safeLocations(finding.locations);
229
+ item.lastSeen = observation;
230
+ // Keep conservative severity and original baseline/evidence. Absence never closes an item.
231
+ if (severities.indexOf(finding.severity.level) < severities.indexOf(item.severity))
232
+ item.severity = finding.severity.level;
233
+ if (item.status === 'verified_static') item.status = 'open';
234
+ // A staged scan can precede worktree fixes; don't silently remove an explicit pending verification.
235
+ if (item.baseline.confirmation === 'automatically_imported')
236
+ item.evidenceRef = { runId: run.runId, scanId: result.scan.id, index, sha256 };
237
+ }
238
+ associations.push({ id: item.id, created, association: item.association || 'exact_identity' });
239
+ }
240
+ return { tracker, associations };
241
+ }
242
+
243
+ const cell = (value) =>
244
+ String(value)
245
+ .replace(/[\p{Cc}\p{Cf}]/gu, '')
246
+ .replaceAll('|', '/')
247
+ .replaceAll('`', "'");
248
+ export function renderFindings(tracker, { severity, all = false } = {}) {
249
+ if (severity && !severities.includes(severity))
250
+ throw new Error('Severity must be critical, high, medium or low.');
251
+ const selected = tracker.findings.filter(
252
+ (item) =>
253
+ (all || item.status !== 'verified_static') && (!severity || item.severity === severity)
254
+ );
255
+ const lines = [
256
+ `${all ? '全部漏洞台账' : '未关闭项(待修复/调查 + 待验证)'}:${selected.length} 项`,
257
+ ''
258
+ ];
259
+ for (const level of severity ? [severity] : severities) {
260
+ const items = selected.filter((item) => item.severity === level);
261
+ lines.push(
262
+ `${{ critical: '严重', high: '高危', medium: '中危', low: '低危' }[level]} / ${level}:${items.length} 项`,
263
+ '',
264
+ '| 编号 | 状态 | 问题 | 代码位置 | 证据状态 |',
265
+ '| --- | --- | --- | --- | --- |'
266
+ );
267
+ for (const item of items)
268
+ lines.push(
269
+ `| ${item.id} | ${statusNames[item.status]} | ${cell(item.title)} | ${
270
+ safeLocations(item.locations)
271
+ .map((location) => `${location.path}:${location.startLine}`)
272
+ .join(';') || '见人工/私有报告'
273
+ } | ${item.lastSeen ? '扫描观察,见关联快照' : item.baseline.confirmation === 'historical_unconfirmed' ? '历史未确认' : '历史基线'}${item.association?.startsWith('manual_review') ? ';关联需人工复核' : ''} |`
274
+ );
275
+ lines.push('');
276
+ }
277
+ lines.push('只读取台账,不调用模型;历史项不代表当前代码仍可利用,未再次报告不等于已修复。');
278
+ return lines.join('\n');
279
+ }