@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/LICENSE +21 -0
- package/README.md +688 -2
- package/bin/sec.mjs +16 -0
- package/lib/aliases.mjs +46 -0
- package/lib/auth.mjs +94 -0
- package/lib/cli.mjs +169 -0
- package/lib/config-command.mjs +133 -0
- package/lib/config.mjs +207 -0
- package/lib/engine.mjs +96 -0
- package/lib/policy.mjs +8 -0
- package/lib/project-init.mjs +111 -0
- package/lib/project.mjs +305 -0
- package/lib/report.mjs +210 -0
- package/lib/runtime.mjs +83 -0
- package/lib/scan.mjs +322 -0
- package/lib/setup.mjs +217 -0
- package/lib/tracker.mjs +279 -0
- package/lib/verify.mjs +388 -0
- package/package.json +49 -4
- package/templates/review-report.md +27 -0
- package/templates/security-policy.md +31 -0
package/lib/engine.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { engineVersion, userDirectories, modelSettings } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
export function engineEnvironment(source = process.env) {
|
|
7
|
+
const { config, state } = userDirectories(source);
|
|
8
|
+
const env = {
|
|
9
|
+
...source,
|
|
10
|
+
CODEX_HOME: join(config, 'engine-home'),
|
|
11
|
+
CODEX_SECURITY_STATE_DIR: join(state, 'engine')
|
|
12
|
+
};
|
|
13
|
+
delete env.CODEX_SECURITY_PROJECT_CONFIG;
|
|
14
|
+
return env;
|
|
15
|
+
}
|
|
16
|
+
export function engineCommand(env = process.env) {
|
|
17
|
+
const managed = join(userDirectories(env).config, 'runtime/node_modules/.bin/codex-security');
|
|
18
|
+
return existsSync(managed) ? managed : 'codex-security';
|
|
19
|
+
}
|
|
20
|
+
export function ensureEngine(env = process.env, run = execFileSync) {
|
|
21
|
+
let version;
|
|
22
|
+
try {
|
|
23
|
+
version = run(engineCommand(env), ['--version'], {
|
|
24
|
+
env,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
stdio: 'pipe',
|
|
27
|
+
timeout: 10000
|
|
28
|
+
}).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error('Scan engine unavailable. Run sec setup.');
|
|
31
|
+
}
|
|
32
|
+
if (!new RegExp(`(?:^|@|\\s)${engineVersion.replaceAll('.', '\\.')}($|\\s)`).test(version)) {
|
|
33
|
+
throw new Error(`Unsupported scan engine version. Run sec setup for version ${engineVersion}.`);
|
|
34
|
+
}
|
|
35
|
+
return version;
|
|
36
|
+
}
|
|
37
|
+
export function providerArguments(config, overrides = {}) {
|
|
38
|
+
const { model, effort } = modelSettings(config, overrides);
|
|
39
|
+
const args = [
|
|
40
|
+
'--codex',
|
|
41
|
+
`model=${JSON.stringify(model)}`,
|
|
42
|
+
'--codex',
|
|
43
|
+
`model_reasoning_effort=${JSON.stringify(effort)}`
|
|
44
|
+
];
|
|
45
|
+
if (config.baseUrl) {
|
|
46
|
+
args.push(
|
|
47
|
+
'--codex',
|
|
48
|
+
'model_provider="custom"',
|
|
49
|
+
'--codex',
|
|
50
|
+
`model_providers.custom=${'{'}name="custom",base_url=${JSON.stringify(config.baseUrl)},wire_api="responses",requires_openai_auth=true}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
55
|
+
export function invokeEngine(args, { cwd, env, raw, capture = false }) {
|
|
56
|
+
return new Promise((resolveResult, reject) => {
|
|
57
|
+
const child = spawn(engineCommand(env), args, {
|
|
58
|
+
cwd,
|
|
59
|
+
env,
|
|
60
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
61
|
+
});
|
|
62
|
+
let output = '',
|
|
63
|
+
diagnostics = '';
|
|
64
|
+
child.stdout?.on('data', (data) => {
|
|
65
|
+
output += data;
|
|
66
|
+
});
|
|
67
|
+
child.stderr?.on('data', (data) => {
|
|
68
|
+
diagnostics += data;
|
|
69
|
+
});
|
|
70
|
+
const interrupt = () => child.kill('SIGINT');
|
|
71
|
+
const terminate = () => child.kill('SIGTERM');
|
|
72
|
+
process.on('SIGINT', interrupt);
|
|
73
|
+
process.on('SIGTERM', terminate);
|
|
74
|
+
const cleanup = () => {
|
|
75
|
+
process.off('SIGINT', interrupt);
|
|
76
|
+
process.off('SIGTERM', terminate);
|
|
77
|
+
};
|
|
78
|
+
child.on('error', () => {
|
|
79
|
+
cleanup();
|
|
80
|
+
reject(new Error('Cannot start scan engine. Run sec doctor.'));
|
|
81
|
+
});
|
|
82
|
+
child.on('close', (code, signal) => {
|
|
83
|
+
cleanup();
|
|
84
|
+
if (capture && raw) {
|
|
85
|
+
writeFileSync(join(raw, 'verification.json'), output, { mode: 0o600 });
|
|
86
|
+
writeFileSync(join(raw, 'diagnostics.log'), diagnostics, {
|
|
87
|
+
mode: 0o600
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
resolveResult({
|
|
91
|
+
code: code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 2),
|
|
92
|
+
output
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
package/lib/policy.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Executable policy shared by discovery and repair verification.
|
|
2
|
+
export const policy = Object.freeze({
|
|
3
|
+
version: 1,
|
|
4
|
+
blockSeverities: Object.freeze(['critical', 'high', 'medium']),
|
|
5
|
+
warnSeverities: Object.freeze(['low']),
|
|
6
|
+
failOnSeverity: 'medium',
|
|
7
|
+
timeZone: 'UTC'
|
|
8
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { branchBaseRef, defaultModel, defaultEffort, readConfig } from './config.mjs';
|
|
6
|
+
import {
|
|
7
|
+
projectPaths,
|
|
8
|
+
projectConfigFile,
|
|
9
|
+
projectPath,
|
|
10
|
+
readProjectConfig,
|
|
11
|
+
saveProjectConfig,
|
|
12
|
+
validateProjectConfig
|
|
13
|
+
} from './project.mjs';
|
|
14
|
+
import { inferPrefix, readTracker } from './tracker.mjs';
|
|
15
|
+
|
|
16
|
+
function readTemplate(name) {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(new URL(`../templates/${name}`, import.meta.url), 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(`Bundled template missing or unreadable: ${name}. Reinstall sec.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function main(
|
|
24
|
+
args = [],
|
|
25
|
+
{ env = process.env, cwd = process.cwd(), log = console.log } = {}
|
|
26
|
+
) {
|
|
27
|
+
const { values, positionals } = parseArgs({
|
|
28
|
+
args,
|
|
29
|
+
options: { 'dry-run': { type: 'boolean' } },
|
|
30
|
+
allowPositionals: true
|
|
31
|
+
});
|
|
32
|
+
if (positionals.length !== 1 || positionals[0] !== 'project')
|
|
33
|
+
throw new Error('Use sec init project [--dry-run].');
|
|
34
|
+
const root = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
35
|
+
env,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: 'pipe'
|
|
38
|
+
}).trim();
|
|
39
|
+
const previous = readProjectConfig(root);
|
|
40
|
+
const previousText = previous ? readFileSync(projectConfigFile(root), 'utf8') : null;
|
|
41
|
+
const config = {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
...projectPaths,
|
|
44
|
+
knowledgeBase: [],
|
|
45
|
+
idPrefix: 'SEC',
|
|
46
|
+
baseRef: branchBaseRef(root, readConfig(env), undefined, env),
|
|
47
|
+
model: defaultModel,
|
|
48
|
+
effort: defaultEffort,
|
|
49
|
+
failOnSeverity: 'medium',
|
|
50
|
+
...previous
|
|
51
|
+
};
|
|
52
|
+
const ledgerPath = projectPath(root, config.trackerFile, { kind: 'file' });
|
|
53
|
+
if (existsSync(ledgerPath) && !previous?.idPrefix)
|
|
54
|
+
config.idPrefix = inferPrefix(readTracker(ledgerPath, false, null).tracker);
|
|
55
|
+
validateProjectConfig(config, root);
|
|
56
|
+
if (existsSync(ledgerPath)) readTracker(ledgerPath, false, config.idPrefix);
|
|
57
|
+
const plan = [
|
|
58
|
+
[config.policyFile, readTemplate('security-policy.md')],
|
|
59
|
+
[config.reportTemplate, readTemplate('review-report.md')],
|
|
60
|
+
[config.trackerFile, `${JSON.stringify({ schemaVersion: 1, findings: [] }, null, 2)}\n`]
|
|
61
|
+
];
|
|
62
|
+
for (const [path] of plan) projectPath(root, path, { kind: 'file' });
|
|
63
|
+
for (const key of ['summaryDir', 'reviewDir'])
|
|
64
|
+
projectPath(root, config[key], { kind: 'directory' });
|
|
65
|
+
const ignore = projectPath(root, '.gitignore', { kind: 'file' });
|
|
66
|
+
const oldIgnore = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
|
|
67
|
+
// Literal Git ignore patterns, including spaces and glob metacharacters in custom paths.
|
|
68
|
+
const escape = (path) => path.replace(/[\\*?\[\]#! ]/g, '\\$&');
|
|
69
|
+
const summaryRelative = relative(root, projectPath(root, config.summaryDir));
|
|
70
|
+
const trackerRelative = relative(root, ledgerPath);
|
|
71
|
+
const rules = [
|
|
72
|
+
`/${escape(summaryRelative)}/`,
|
|
73
|
+
`/${escape(trackerRelative)}.lock`,
|
|
74
|
+
`/${escape(trackerRelative)}.*.tmp`,
|
|
75
|
+
'/sec.config.json.lock',
|
|
76
|
+
'/sec.config.json.*.tmp'
|
|
77
|
+
];
|
|
78
|
+
const missingRules = rules.filter((rule) => !oldIgnore.split(/\r?\n/).includes(rule));
|
|
79
|
+
for (const [path] of plan) log(`${existsSync(join(root, path)) ? '保留' : '创建'} ${path}`);
|
|
80
|
+
log(`${previous ? '保留已有配置并补齐缺少的默认项' : '创建'} sec.config.json`);
|
|
81
|
+
if (missingRules.length) log('补充 .gitignore:自动摘要与台账临时文件');
|
|
82
|
+
log(`报告目录:${config.summaryDir}(自动摘要)、${config.reviewDir}(人工报告)`);
|
|
83
|
+
if (values['dry-run']) {
|
|
84
|
+
log('仅预览;未写文件、未调用模型。');
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
for (const [path, content] of plan)
|
|
88
|
+
if (!existsSync(join(root, path))) {
|
|
89
|
+
const target = projectPath(root, path, { kind: 'file' });
|
|
90
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
91
|
+
writeFileSync(target, content, { flag: 'wx', mode: 0o644 });
|
|
92
|
+
}
|
|
93
|
+
for (const key of ['summaryDir', 'reviewDir'])
|
|
94
|
+
mkdirSync(projectPath(root, config[key], { kind: 'directory' }), { recursive: true });
|
|
95
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(config))
|
|
96
|
+
saveProjectConfig(root, config, previousText);
|
|
97
|
+
if (missingRules.length) {
|
|
98
|
+
if ((existsSync(ignore) ? readFileSync(ignore, 'utf8') : '') !== oldIgnore)
|
|
99
|
+
throw new Error(
|
|
100
|
+
'.gitignore changed during initialization; inspect generated files before retrying.'
|
|
101
|
+
);
|
|
102
|
+
writeFileSync(
|
|
103
|
+
ignore,
|
|
104
|
+
`${oldIgnore}${oldIgnore && !oldIgnore.endsWith('\n') ? '\n' : ''}\n# sec generated summaries and tracker temporary files\n${missingRules.join('\n')}\n`,
|
|
105
|
+
{ mode: 0o644 }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
log('项目已初始化;请维护者检查通用规范和模板。已有文件未覆盖,未暂存、提交、联网或请求模型。');
|
|
109
|
+
log('配置与规范随业务仓库共享;凭据和完整原始证据保存在仓库外。本机旧私有台账不自动合并。');
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
copyFileSync,
|
|
12
|
+
constants,
|
|
13
|
+
openSync,
|
|
14
|
+
closeSync,
|
|
15
|
+
accessSync
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
18
|
+
import { policy } from './policy.mjs';
|
|
19
|
+
|
|
20
|
+
export const projectPaths = Object.freeze({
|
|
21
|
+
policyFile: '.agents/skills/project-security/SKILL.md',
|
|
22
|
+
reportTemplate: '.agents/skills/project-security/references/report-template.md',
|
|
23
|
+
trackerFile: 'docs/security/findings.json',
|
|
24
|
+
summaryDir: 'docs/security/reviews/generated',
|
|
25
|
+
reviewDir: 'docs/security/reviews'
|
|
26
|
+
});
|
|
27
|
+
export const projectKeys = [
|
|
28
|
+
...Object.keys(projectPaths),
|
|
29
|
+
'knowledgeBase',
|
|
30
|
+
'idPrefix',
|
|
31
|
+
'baseRef',
|
|
32
|
+
'model',
|
|
33
|
+
'effort',
|
|
34
|
+
'failOnSeverity'
|
|
35
|
+
];
|
|
36
|
+
export const levels = ['critical', 'high', 'medium', 'low'];
|
|
37
|
+
export const projectConfigFile = (root) => join(root, 'sec.config.json');
|
|
38
|
+
export const validPrefix = (prefix) =>
|
|
39
|
+
typeof prefix === 'string' &&
|
|
40
|
+
/^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*$/.test(prefix) &&
|
|
41
|
+
prefix.length <= 60;
|
|
42
|
+
export const findingId = (id) =>
|
|
43
|
+
typeof id === 'string' && /^([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*)-(\d{3,})$/.test(id);
|
|
44
|
+
|
|
45
|
+
// Project paths are data, never executable modules or machine-specific locations.
|
|
46
|
+
export function projectPath(root, value, { kind, required = false } = {}) {
|
|
47
|
+
if (
|
|
48
|
+
typeof value !== 'string' ||
|
|
49
|
+
!value ||
|
|
50
|
+
isAbsolute(value) ||
|
|
51
|
+
/^[A-Za-z]:/.test(value) ||
|
|
52
|
+
/[\\\x00-\x1f\x7f]/.test(value) ||
|
|
53
|
+
value.split('/').some((part) => part === '..' || part === '.git')
|
|
54
|
+
)
|
|
55
|
+
throw new Error(
|
|
56
|
+
'Project paths must be repository-relative, without traversal or .git components.'
|
|
57
|
+
);
|
|
58
|
+
const path = resolve(root, value),
|
|
59
|
+
rel = relative(root, path);
|
|
60
|
+
if (!rel || rel.startsWith('../') || isAbsolute(rel))
|
|
61
|
+
throw new Error('Project paths must stay inside the repository.');
|
|
62
|
+
let current = resolve(root);
|
|
63
|
+
for (const part of rel.split('/')) {
|
|
64
|
+
current = join(current, part);
|
|
65
|
+
try {
|
|
66
|
+
if (lstatSync(current).isSymbolicLink())
|
|
67
|
+
throw new Error('Project paths must not contain symlinks.');
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code !== 'ENOENT') throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(path)) {
|
|
73
|
+
const stat = lstatSync(path);
|
|
74
|
+
if (
|
|
75
|
+
(kind === 'file' && !stat.isFile()) ||
|
|
76
|
+
(kind === 'directory' && !stat.isDirectory()) ||
|
|
77
|
+
(!kind && !stat.isFile() && !stat.isDirectory())
|
|
78
|
+
)
|
|
79
|
+
throw new Error(`Invalid project path type: ${value}`);
|
|
80
|
+
} else if (required)
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Missing project input: ${value}. Run sec init project or correct sec config --project.`
|
|
83
|
+
);
|
|
84
|
+
return path;
|
|
85
|
+
}
|
|
86
|
+
export function validateProjectConfig(config, root) {
|
|
87
|
+
if (
|
|
88
|
+
!config ||
|
|
89
|
+
Array.isArray(config) ||
|
|
90
|
+
config.schemaVersion !== 1 ||
|
|
91
|
+
Object.keys(config).some((key) => !['schemaVersion', ...projectKeys].includes(key))
|
|
92
|
+
)
|
|
93
|
+
throw new Error(
|
|
94
|
+
'Unsupported project configuration fields/version. Credentials and provider URLs belong outside the repository.'
|
|
95
|
+
);
|
|
96
|
+
for (const key of ['baseRef', 'model', 'effort']) {
|
|
97
|
+
if (
|
|
98
|
+
config[key] !== undefined &&
|
|
99
|
+
(typeof config[key] !== 'string' ||
|
|
100
|
+
!config[key] ||
|
|
101
|
+
/[\x00-\x20\x7f]/.test(config[key]) ||
|
|
102
|
+
config[key].startsWith('-'))
|
|
103
|
+
)
|
|
104
|
+
throw new Error(`Invalid project ${key}.`);
|
|
105
|
+
}
|
|
106
|
+
if (config.idPrefix !== undefined && !validPrefix(config.idPrefix))
|
|
107
|
+
throw new Error('Invalid finding ID prefix. Use uppercase segments such as SEC or TEAM-SEC.');
|
|
108
|
+
if (config.failOnSeverity !== undefined && !levels.includes(config.failOnSeverity))
|
|
109
|
+
throw new Error('Invalid failOnSeverity.');
|
|
110
|
+
if (
|
|
111
|
+
config.knowledgeBase !== undefined &&
|
|
112
|
+
(!Array.isArray(config.knowledgeBase) ||
|
|
113
|
+
config.knowledgeBase.some((path) => typeof path !== 'string'))
|
|
114
|
+
)
|
|
115
|
+
throw new Error('knowledgeBase must be a JSON array of repository-relative paths.');
|
|
116
|
+
for (const key of Object.keys(projectPaths))
|
|
117
|
+
if (config[key] !== undefined)
|
|
118
|
+
projectPath(root, config[key], { kind: key.endsWith('Dir') ? 'directory' : 'file' });
|
|
119
|
+
for (const value of config.knowledgeBase || []) projectPath(root, value);
|
|
120
|
+
const files = [
|
|
121
|
+
'sec.config.json',
|
|
122
|
+
...['policyFile', 'reportTemplate', 'trackerFile']
|
|
123
|
+
.filter((key) => config[key])
|
|
124
|
+
.map((key) => config[key])
|
|
125
|
+
].map((p) => resolve(root, p));
|
|
126
|
+
if (new Set(files).size !== files.length)
|
|
127
|
+
throw new Error('Project configuration, policy, template and tracker must use distinct files.');
|
|
128
|
+
for (const file of files)
|
|
129
|
+
for (const other of files)
|
|
130
|
+
if (file !== other && file.startsWith(`${other}/`))
|
|
131
|
+
throw new Error('Project file paths must not contain other managed files.');
|
|
132
|
+
for (const key of ['summaryDir', 'reviewDir'])
|
|
133
|
+
if (config[key]) {
|
|
134
|
+
const directory = resolve(root, config[key]);
|
|
135
|
+
for (const file of files)
|
|
136
|
+
if (
|
|
137
|
+
directory === file ||
|
|
138
|
+
directory.startsWith(`${file}/`) ||
|
|
139
|
+
file.startsWith(`${directory}/`)
|
|
140
|
+
)
|
|
141
|
+
throw new Error(
|
|
142
|
+
'Report directories must not overlap configuration, policy, template or tracker files.'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
config.summaryDir &&
|
|
147
|
+
config.reviewDir &&
|
|
148
|
+
resolve(root, config.summaryDir) === resolve(root, config.reviewDir)
|
|
149
|
+
)
|
|
150
|
+
throw new Error('Automatic summaries and reviewed reports must use distinct directories.');
|
|
151
|
+
return config;
|
|
152
|
+
}
|
|
153
|
+
export function readProjectConfig(root) {
|
|
154
|
+
const path = projectPath(root, 'sec.config.json', { kind: 'file' });
|
|
155
|
+
if (!existsSync(path)) return null;
|
|
156
|
+
let config;
|
|
157
|
+
try {
|
|
158
|
+
config = JSON.parse(readFileSync(path, 'utf8'));
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error('Cannot parse sec.config.json.');
|
|
161
|
+
}
|
|
162
|
+
return validateProjectConfig(config, root);
|
|
163
|
+
}
|
|
164
|
+
export function saveProjectConfig(root, config, previous) {
|
|
165
|
+
validateProjectConfig(config, root);
|
|
166
|
+
const path = projectPath(root, 'sec.config.json', { kind: 'file' });
|
|
167
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
168
|
+
let lock;
|
|
169
|
+
try {
|
|
170
|
+
lock = openSync(`${path}.lock`, 'wx', 0o600);
|
|
171
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
172
|
+
if (current !== previous) throw new Error('Project configuration changed; no update saved.');
|
|
173
|
+
writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx', mode: 0o644 });
|
|
174
|
+
if (previous === null) copyFileSync(temp, path, constants.COPYFILE_EXCL);
|
|
175
|
+
else renameSync(temp, path);
|
|
176
|
+
} finally {
|
|
177
|
+
rmSync(temp, { force: true });
|
|
178
|
+
if (lock !== undefined) {
|
|
179
|
+
closeSync(lock);
|
|
180
|
+
rmSync(`${path}.lock`, { force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function projectPolicy(config) {
|
|
185
|
+
const failOnSeverity = config?.failOnSeverity ?? policy.failOnSeverity;
|
|
186
|
+
return {
|
|
187
|
+
version: policy.version,
|
|
188
|
+
failOnSeverity,
|
|
189
|
+
timeZone: policy.timeZone,
|
|
190
|
+
blockSeverities: levels.slice(0, levels.indexOf(failOnSeverity) + 1),
|
|
191
|
+
warnSeverities: levels.slice(levels.indexOf(failOnSeverity) + 1)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export function projectInputs(root, config) {
|
|
195
|
+
if (!config) return [];
|
|
196
|
+
const paths = [config.policyFile, config.reportTemplate, ...(config.knowledgeBase || [])].filter(
|
|
197
|
+
Boolean
|
|
198
|
+
);
|
|
199
|
+
return [...new Set(paths)].map((path) => {
|
|
200
|
+
const absolute = projectPath(root, path, { required: true });
|
|
201
|
+
if ([config.policyFile, config.reportTemplate].includes(path) && !lstatSync(absolute).isFile())
|
|
202
|
+
throw new Error('Policy and template must be regular text files.');
|
|
203
|
+
return { path: relative(root, absolute), absolute };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function inputDigest(path) {
|
|
207
|
+
const stat = lstatSync(path);
|
|
208
|
+
if (stat.isSymbolicLink()) throw new Error('Knowledge inputs must not contain symlinks.');
|
|
209
|
+
if (stat.isFile()) return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
210
|
+
if (!stat.isDirectory())
|
|
211
|
+
throw new Error('Knowledge inputs must be regular files or directories.');
|
|
212
|
+
return createHash('sha256')
|
|
213
|
+
.update(
|
|
214
|
+
JSON.stringify(
|
|
215
|
+
readdirSync(path)
|
|
216
|
+
.sort()
|
|
217
|
+
.map((name) => [name, inputDigest(join(path, name))])
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
.digest('hex');
|
|
221
|
+
}
|
|
222
|
+
export function projectGuard(root, config) {
|
|
223
|
+
if (JSON.stringify(readProjectConfig(root)) !== JSON.stringify(config))
|
|
224
|
+
throw new Error('Project configuration changed before scanning.');
|
|
225
|
+
const initial = [
|
|
226
|
+
{ path: 'sec.config.json', absolute: projectConfigFile(root) },
|
|
227
|
+
...projectInputs(root, config)
|
|
228
|
+
];
|
|
229
|
+
const manifest = () =>
|
|
230
|
+
initial.map(({ path, absolute }) => ({
|
|
231
|
+
path,
|
|
232
|
+
sha256: existsSync(absolute) ? inputDigest(absolute) : null
|
|
233
|
+
}));
|
|
234
|
+
const before = JSON.stringify(manifest());
|
|
235
|
+
return {
|
|
236
|
+
inputs: JSON.parse(before),
|
|
237
|
+
check() {
|
|
238
|
+
projectPath(root, 'sec.config.json', { kind: 'file' });
|
|
239
|
+
projectInputs(root, config);
|
|
240
|
+
if (JSON.stringify(manifest()) !== before)
|
|
241
|
+
throw new Error(
|
|
242
|
+
'Project configuration, policy, template or knowledge inputs changed during the scan; no tracker update saved.'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function publishSummary(run) {
|
|
248
|
+
if (!run.project?.summaryDir) return;
|
|
249
|
+
const directory = `${run.project.summaryDir}/${run.startedAt.slice(0, 10)}`;
|
|
250
|
+
let path;
|
|
251
|
+
try {
|
|
252
|
+
path = projectPath(run.root, `${directory}/${run.runId}.md`, { kind: 'file' });
|
|
253
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
254
|
+
writeFileSync(path, readFileSync(run.summary), { flag: 'wx', mode: 0o644 });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const metadataPath = join(run.raw, 'wrapper-result.json');
|
|
257
|
+
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
258
|
+
metadata.status = 2;
|
|
259
|
+
metadata.summaryExportError =
|
|
260
|
+
'Failed to publish project summary; private artifacts preserved. Tracker may already have been updated.';
|
|
261
|
+
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Project summary export failed; private artifacts remain in ${run.raw}. Tracker may already be updated. ${error.code ?? ''}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
console.log(`Codex Security 项目中文摘要: ${path}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function preflightProject(root, config, git) {
|
|
270
|
+
projectInputs(root, config);
|
|
271
|
+
if (!config) return;
|
|
272
|
+
for (const path of [
|
|
273
|
+
projectConfigFile(root),
|
|
274
|
+
...(config.trackerFile ? [projectPath(root, config.trackerFile)] : [])
|
|
275
|
+
]) {
|
|
276
|
+
if (existsSync(`${path}.lock`))
|
|
277
|
+
throw new Error(
|
|
278
|
+
'Project configuration or tracker is locked by another operation; retry after it finishes.'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
for (const key of ['trackerFile', 'summaryDir', 'reviewDir'])
|
|
282
|
+
if (config[key])
|
|
283
|
+
projectPath(root, config[key], { kind: key === 'trackerFile' ? 'file' : 'directory' });
|
|
284
|
+
for (const key of ['trackerFile', 'summaryDir'])
|
|
285
|
+
if (config[key]) {
|
|
286
|
+
const target = projectPath(root, config[key]);
|
|
287
|
+
let parent = dirname(target);
|
|
288
|
+
while (!existsSync(parent)) parent = dirname(parent);
|
|
289
|
+
accessSync(parent, constants.W_OK);
|
|
290
|
+
if (existsSync(target)) accessSync(target, constants.W_OK);
|
|
291
|
+
}
|
|
292
|
+
if (config.summaryDir) {
|
|
293
|
+
try {
|
|
294
|
+
git('check-ignore', '--quiet', '--no-index', '--', `${config.summaryDir}/sec-preflight.md`);
|
|
295
|
+
} catch {
|
|
296
|
+
throw new Error(
|
|
297
|
+
'Automatic summary directory must be ignored by Git. Run sec init project or update .gitignore before scanning.'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (git('ls-files', '--', config.summaryDir))
|
|
301
|
+
throw new Error(
|
|
302
|
+
'Automatic summary directory contains tracked files; choose an ignored output directory before scanning.'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|