yam-flow 2.8.0 → 2.8.2
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/AGENTS.md +4 -0
- package/CHANGELOG.md +22 -0
- package/COMMANDS.md +17 -2
- package/README.md +5 -1
- package/dist/bin/yam.js +162 -42
- package/dist/lib/design-production.d.ts +6 -6
- package/dist/lib/external-updates.d.ts +6 -1
- package/dist/lib/external-updates.js +93 -6
- package/dist/lib/hook-readiness.d.ts +54 -0
- package/dist/lib/hook-readiness.js +259 -0
- package/dist/lib/memory-records.d.ts +29 -0
- package/dist/lib/memory-records.js +92 -0
- package/dist/lib/security-check.d.ts +98 -0
- package/dist/lib/security-check.js +262 -0
- package/dist/lib/security-integration.d.ts +8 -0
- package/dist/lib/security-integration.js +428 -0
- package/dist/lib/verification-closure.d.ts +1 -1
- package/package.json +10 -3
- package/references/final-report.md +2 -0
- package/references/security-check.md +131 -0
- package/references/study-note.md +6 -0
- package/skills/deep/SKILL.md +4 -0
- package/skills/mission/SKILL.md +4 -0
- package/skills/quick/SKILL.md +4 -0
- package/skills/ueye/SKILL.md +4 -0
- package/src/bin/yam.ts +142 -39
- package/src/lib/external-updates.ts +87 -6
- package/src/lib/hook-readiness.ts +215 -0
- package/src/lib/memory-records.ts +109 -0
- package/src/lib/security-check.ts +184 -0
- package/src/lib/security-integration.ts +299 -0
- package/templates/security-check-note.md +42 -0
- package/templates/security-gate.yml +44 -0
- package/yam.manifest.json +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { constants, readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createSecurityStarter, validateSecurityCheck, persistSecurityCheck, SECURITY_AREA_IDS } from './security-check.js';
|
|
8
|
+
import type { SecurityCheckInput, SecurityPolicy, SecurityCheckResult } from './security-check.js';
|
|
9
|
+
|
|
10
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
11
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
12
|
+
const ZERO = /^0+$/;
|
|
13
|
+
export type SecurityBinding = Record<string, unknown>;
|
|
14
|
+
export interface SecurityCommandOptions { cwd?: string; cliPath?: string; stdin?: string }
|
|
15
|
+
const hash = (text: string | Buffer) => `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
16
|
+
function git(cwd: string, args: string[]): string {
|
|
17
|
+
try { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 }).trim(); }
|
|
18
|
+
catch { throw new Error('git binding inspection failed'); }
|
|
19
|
+
}
|
|
20
|
+
function revision(cwd: string, ref: string): string {
|
|
21
|
+
if (!OID.test(ref)) throw new Error('revision must be an exact Git object ID');
|
|
22
|
+
const result = git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
23
|
+
if (!OID.test(result)) throw new Error('invalid source commit');
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
function parseArgs(args: string[]): Map<string, string> {
|
|
27
|
+
const out = new Map<string, string>();
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
if (args[i] === '--json') continue;
|
|
30
|
+
if (!['--policy', '--policy-digest', '--evidence', '--note', '--stage', '--remote', '--destination', '--source', '--base', '--artifact', '--artifact-digest', '--configuration', '--configuration-digest', '--environment', '--cli'].includes(args[i]) || !args[i + 1] || args[i + 1].startsWith('--') || out.has(args[i])) throw new Error('invalid or duplicate security option');
|
|
31
|
+
out.set(args[i], args[++i]);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function collectSecurityBinding(cwd: string, stage: string, options: Map<string, string>, stdin = ''): SecurityBinding {
|
|
36
|
+
if (stage === 'commit') {
|
|
37
|
+
let base: string;
|
|
38
|
+
try { base = git(cwd, ['rev-parse', '--verify', 'HEAD']); }
|
|
39
|
+
catch { base = execFileSync('git', ['-C', cwd, 'hash-object', '-t', 'tree', '--stdin'], { input: '', encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); }
|
|
40
|
+
return { stage, staged_tree: git(cwd, ['write-tree']), base_revision: base };
|
|
41
|
+
}
|
|
42
|
+
if (stage === 'push') {
|
|
43
|
+
const remote = options.get('--remote');
|
|
44
|
+
if (!remote || /[\r\n\0]/.test(remote)) throw new Error('push requires remote identity');
|
|
45
|
+
const destination = options.get('--destination');
|
|
46
|
+
if (!destination || /[\r\n\0]/.test(destination)) throw new Error('push requires actual Git hook destination');
|
|
47
|
+
const lines = stdin.trim().split('\n').filter(Boolean);
|
|
48
|
+
if (!lines.length || lines.length > 256) throw new Error('push requires exact outgoing refs from hook stdin');
|
|
49
|
+
const push_refs = lines.map(line => {
|
|
50
|
+
const fields = line.trim().split(/\s+/);
|
|
51
|
+
if (fields.length !== 4 || !OID.test(fields[1]) || !OID.test(fields[3]) || ZERO.test(fields[1]) || !(fields[0] === 'HEAD' || fields[0].startsWith('refs/')) || !fields[2].startsWith('refs/')) throw new Error('invalid push ref or deletion; deletion requires separate reviewed handling');
|
|
52
|
+
revision(cwd, fields[1]);
|
|
53
|
+
return { local_ref: fields[0], local_oid: fields[1], remote_ref: fields[2], remote_oid: fields[3] };
|
|
54
|
+
});
|
|
55
|
+
if (new Set(push_refs.map(r => r.remote_ref)).size !== push_refs.length) throw new Error('duplicate outgoing destination ref');
|
|
56
|
+
return { stage, destination_digest: hash(destination), push_refs };
|
|
57
|
+
}
|
|
58
|
+
if (stage === 'ci') {
|
|
59
|
+
const source = revision(cwd, options.get('--source') || '');
|
|
60
|
+
const base = revision(cwd, options.get('--base') || '');
|
|
61
|
+
return { stage: 'push', context: 'ci', source_revision: source, base_revision: base, tree: git(cwd, ['rev-parse', `${source}^{tree}`]) };
|
|
62
|
+
}
|
|
63
|
+
if (stage === 'deploy') {
|
|
64
|
+
const source = revision(cwd, options.get('--source') || '');
|
|
65
|
+
const artifact = options.get('--artifact-digest') || '';
|
|
66
|
+
const configuration = options.get('--configuration-digest') || '';
|
|
67
|
+
const environment = options.get('--environment') || '';
|
|
68
|
+
if (!DIGEST.test(artifact) || !DIGEST.test(configuration) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(environment)) throw new Error('deploy requires exact artifact/configuration SHA256 and environment');
|
|
69
|
+
return { stage, source_revision: source, artifact_digest: artifact, configuration_digest: configuration, environment };
|
|
70
|
+
}
|
|
71
|
+
throw new Error('stage must be commit, push, ci or deploy');
|
|
72
|
+
}
|
|
73
|
+
async function readSafe(file: string): Promise<string> {
|
|
74
|
+
await safeParents(file);
|
|
75
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
76
|
+
try {
|
|
77
|
+
const info = await handle.stat();
|
|
78
|
+
if (!info.isFile() || info.size > 1024 * 1024) throw new Error('invalid security file');
|
|
79
|
+
return await handle.readFile('utf8');
|
|
80
|
+
} finally { await handle.close(); }
|
|
81
|
+
}
|
|
82
|
+
function quote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
83
|
+
async function exclusive(file: string, text: string, mode = 0o600): Promise<void> {
|
|
84
|
+
await safeParents(file);
|
|
85
|
+
const handle = await fs.open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
86
|
+
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
|
|
87
|
+
}
|
|
88
|
+
async function gitMetadata(cwd: string): Promise<{ root: string; directory: string; hooks: string; config: string }> {
|
|
89
|
+
const root = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
90
|
+
const directory = path.resolve(cwd, git(cwd, ['rev-parse', '--git-dir']));
|
|
91
|
+
const hookOverride = (() => { try { return git(cwd, ['config', '--get', 'core.hooksPath']); } catch { return ''; } })();
|
|
92
|
+
if (hookOverride) throw new Error('custom hooksPath requires explicit manual integration');
|
|
93
|
+
return { root, directory, hooks: path.join(directory, 'hooks'), config: path.join(directory, 'yam-security.json') };
|
|
94
|
+
}
|
|
95
|
+
async function enableHooks(cwd: string, options: Map<string, string>, cliPath?: string): Promise<Record<string, unknown>> {
|
|
96
|
+
if (process.platform === 'win32') throw new Error('automatic hook setup requires a POSIX Git shell; integrate manually on Windows');
|
|
97
|
+
const metadata = await gitMetadata(cwd);
|
|
98
|
+
const settings = JSON.parse(await readSafe(metadata.config));
|
|
99
|
+
if (settings.schema !== 'yam.security-install.v1' || !DIGEST.test(settings.policy_digest)) throw new Error('security init required before hooks enable');
|
|
100
|
+
const cli = await fs.realpath(cliPath || options.get('--cli') || fileURLToPath(new URL('../bin/yam.js', import.meta.url)));
|
|
101
|
+
const node = await fs.realpath(process.execPath);
|
|
102
|
+
if (!(await fs.stat(cli)).isFile()) throw new Error('built CLI path required');
|
|
103
|
+
const files = ['pre-commit', 'pre-push'].map(name => path.join(metadata.hooks, name));
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
try { await fs.lstat(file); throw new Error('existing Git hooks preserved; use manual integration'); }
|
|
106
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
107
|
+
}
|
|
108
|
+
await fs.mkdir(metadata.hooks, { recursive: true });
|
|
109
|
+
const common = `${quote(node)} ${quote(cli)} security check --policy ${quote(settings.policy)} --policy-digest ${quote(settings.policy_digest)} --evidence ${quote(settings.evidence)} --note ${quote(settings.note)}`;
|
|
110
|
+
const created: string[] = [];
|
|
111
|
+
try {
|
|
112
|
+
for (const [index, file] of files.entries()) {
|
|
113
|
+
const stage = index === 0 ? 'commit' : 'push';
|
|
114
|
+
const remote = index === 1 ? ' --remote "$1" --destination "$2"' : '';
|
|
115
|
+
await exclusive(file, `#!/bin/sh\n# yam.security-hook.v1\nexec ${common} --stage ${stage}${remote}\n`, 0o700);
|
|
116
|
+
created.push(file);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) { for (const file of created) await fs.unlink(file); throw error; }
|
|
119
|
+
return { ok: true, result: 'ENABLED', hooks: ['pre-commit', 'pre-push'], enforcement: 'local_bypassable', ci_required: true };
|
|
120
|
+
}
|
|
121
|
+
async function safeParents(file: string): Promise<void> {
|
|
122
|
+
let current = path.dirname(path.resolve(file));
|
|
123
|
+
while (current !== path.dirname(current)) {
|
|
124
|
+
try { const info = await fs.lstat(current); if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsafe security file parent'); }
|
|
125
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
126
|
+
current = path.dirname(current);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function initialize(cwd: string, options: Map<string, string>): Promise<Record<string, unknown>> {
|
|
130
|
+
const metadata = await gitMetadata(cwd);
|
|
131
|
+
const base = path.join(metadata.root, '.yam', 'security');
|
|
132
|
+
const policyPath = path.resolve(metadata.root, options.get('--policy') || '.yam/security/policy.json');
|
|
133
|
+
const evidencePath = path.resolve(metadata.root, options.get('--evidence') || '.yam/security/check.json');
|
|
134
|
+
const notePath = path.resolve(metadata.root, options.get('--note') || '.yam/security/notes.md');
|
|
135
|
+
for (const target of [policyPath, evidencePath, notePath]) {
|
|
136
|
+
if (!target.startsWith(metadata.root + path.sep)) throw new Error('init files must be inside repository');
|
|
137
|
+
await safeParents(target);
|
|
138
|
+
}
|
|
139
|
+
// All areas have explicit trusted requirements; live environment inspection is deferred to deploy.
|
|
140
|
+
const policy: SecurityPolicy = { version: 'security-check-v1', required_at: Object.fromEntries(SECURITY_AREA_IDS.map(id => [id, id === 'SEC-05' ? ['deploy'] : ['commit', 'push', 'deploy']])) };
|
|
141
|
+
const policyText = JSON.stringify(policy, null, 2) + '\n';
|
|
142
|
+
const binding = collectSecurityBinding(metadata.root, 'commit', options);
|
|
143
|
+
const starter = createSecurityStarter({ stage: 'commit', policy, binding: binding as any, environment: 'local', scope: ['initial project security assessment'], checker: 'operator-review-required' });
|
|
144
|
+
starter.policy_digest = hash(policyText);
|
|
145
|
+
const files = [policyPath, evidencePath, metadata.config];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
try { await fs.lstat(file); throw new Error('security init preserves existing files'); }
|
|
148
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
149
|
+
}
|
|
150
|
+
await safeParents(path.join(base, 'placeholder'));
|
|
151
|
+
await fs.mkdir(base, { recursive: true });
|
|
152
|
+
const created: string[] = [];
|
|
153
|
+
try {
|
|
154
|
+
for (const [file, content] of [
|
|
155
|
+
[policyPath, policyText], [evidencePath, JSON.stringify(starter, null, 2) + '\n'],
|
|
156
|
+
[metadata.config, JSON.stringify({ schema: 'yam.security-install.v1', policy: policyPath, policy_digest: hash(policyText), evidence: evidencePath, note: notePath }, null, 2) + '\n']
|
|
157
|
+
]) { await exclusive(file, content); created.push(file); }
|
|
158
|
+
} catch (error) { for (const file of created) await fs.unlink(file); throw error; }
|
|
159
|
+
return { ok: true, result: 'INITIALIZED', policy_digest: hash(policyText), evidence: path.relative(metadata.root, evidencePath), hooks_installed: false, evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
160
|
+
}
|
|
161
|
+
async function verifyReferencedEvidence(cwd: string, input: SecurityCheckInput): Promise<string[]> {
|
|
162
|
+
const errors: string[] = [];
|
|
163
|
+
for (const area of input.areas || []) {
|
|
164
|
+
if (!SECURITY_AREA_IDS.includes(area.id)) { errors.push('invalid_evidence_area'); continue; }
|
|
165
|
+
if (area.status === 'passed' && !area.evidence?.length) errors.push(`evidence_missing:${area.id}`);
|
|
166
|
+
for (const evidence of area.evidence || []) {
|
|
167
|
+
if (typeof evidence.reference !== 'string' || !evidence.reference || path.isAbsolute(evidence.reference) || /^[a-z]+:/i.test(evidence.reference)) { errors.push(`evidence_reference_invalid:${area.id}`); continue; }
|
|
168
|
+
const file = path.resolve(cwd, evidence.reference);
|
|
169
|
+
if (!file.startsWith(path.resolve(cwd) + path.sep)) { errors.push(`evidence_outside_repository:${area.id}`); continue; }
|
|
170
|
+
try { if (await digestFile(file, 16 * 1024 * 1024) !== evidence.sha256) errors.push(`evidence_digest_mismatch:${area.id}`); }
|
|
171
|
+
catch { errors.push(`evidence_unavailable:${area.id}`); }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return errors;
|
|
175
|
+
}
|
|
176
|
+
export async function runSecurityCommand(args: string[], settings: SecurityCommandOptions = {}): Promise<Record<string, unknown>> {
|
|
177
|
+
const cwd = await fs.realpath(path.resolve(settings.cwd || process.cwd()));
|
|
178
|
+
const subcommand = args[0];
|
|
179
|
+
try {
|
|
180
|
+
if (subcommand === 'hooks') {
|
|
181
|
+
if (args[1] !== 'enable') throw new Error('supported hook operation: enable');
|
|
182
|
+
return await enableHooks(cwd, parseArgs(args.slice(2)), settings.cliPath);
|
|
183
|
+
}
|
|
184
|
+
const options = parseArgs(args.slice(1));
|
|
185
|
+
if (subcommand === 'init') return await initialize(cwd, options);
|
|
186
|
+
if (subcommand === 'status') {
|
|
187
|
+
const metadata = await gitMetadata(cwd);
|
|
188
|
+
let configured = false;
|
|
189
|
+
try { const config = JSON.parse(await readSafe(metadata.config)); configured = config.schema === 'yam.security-install.v1'; } catch { /* unavailable is reported */ }
|
|
190
|
+
const installed: string[] = [];
|
|
191
|
+
for (const name of ['pre-commit', 'pre-push']) {
|
|
192
|
+
try { if ((await readSafe(path.join(metadata.hooks, name))).includes('# yam.security-hook.v1')) installed.push(name); } catch { /* unavailable is reported */ }
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, configured, local_hooks: installed, local_hooks_bypassable: true, remote_enforcement: 'not_measured', evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
195
|
+
}
|
|
196
|
+
if (subcommand !== 'check') throw new Error('supported commands: init, check, hooks enable, status');
|
|
197
|
+
return await check(cwd, options, settings);
|
|
198
|
+
} catch {
|
|
199
|
+
if (subcommand === 'check') {
|
|
200
|
+
const input = createSecurityStarter({ stage: 'commit', binding: { stage: 'commit', invalid_binding: true }, environment: 'local', scope: ['rejected security command'], checker: 'yam-security-check' });
|
|
201
|
+
const result = validateSecurityCheck(input);
|
|
202
|
+
result.errors.push('security_command_failed'); result.blocking.push('integration:security_command_failed'); result.result = 'BLOCKED';
|
|
203
|
+
try { const persisted = await persistSecurityCheck(cwd, input, result); return { ok: false, ...persisted, evidence_boundary: 'operator_supplied_not_automatic_security_scan' }; }
|
|
204
|
+
catch { return { ok: false, result: 'BLOCKED', errors: ['security_command_failed', 'security_note_persistence_failed'] }; }
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, result: 'BLOCKED', errors: ['security_command_failed'], evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function check(cwd: string, options: Map<string, string>, settings: SecurityCommandOptions): Promise<Record<string, unknown>> {
|
|
210
|
+
const stage = options.get('--stage') || 'commit';
|
|
211
|
+
const normalizedStage = stage === 'ci' ? 'push' : stage;
|
|
212
|
+
let config: any = {};
|
|
213
|
+
try { const metadata = await gitMetadata(cwd); config = JSON.parse(await readSafe(metadata.config)); } catch { /* an explicit trust pin can be provided instead */ }
|
|
214
|
+
const notePath = path.resolve(cwd, options.get('--note') || config.note || '.yam/security/notes.md');
|
|
215
|
+
let input: SecurityCheckInput;
|
|
216
|
+
let binding: SecurityBinding = { stage: normalizedStage, invalid_binding: true };
|
|
217
|
+
let policy: SecurityPolicy = { version: 'security-check-v1', required_at: Object.fromEntries(SECURITY_AREA_IDS.map(id => [id, ['commit', 'push', 'deploy']])) };
|
|
218
|
+
let result: SecurityCheckResult;
|
|
219
|
+
const gateErrors: string[] = [];
|
|
220
|
+
try {
|
|
221
|
+
const policyFile = path.resolve(cwd, options.get('--policy') || config.policy || '.yam/security/policy.json');
|
|
222
|
+
const pin = options.get('--policy-digest') || ((stage !== 'ci' && stage !== 'deploy') ? config.policy_digest : undefined);
|
|
223
|
+
if (!pin || !DIGEST.test(pin)) throw new Error('policy_pin_missing');
|
|
224
|
+
const policyText = await readSafe(policyFile);
|
|
225
|
+
if (hash(policyText) !== pin) throw new Error('policy_pin_mismatch');
|
|
226
|
+
policy = JSON.parse(policyText);
|
|
227
|
+
if (!policy || typeof policy.version !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(policy.version) || Object.keys(policy).some(key => !['version', 'required_at'].includes(key)) || !policy.required_at || Object.keys(policy.required_at).some(key => !SECURITY_AREA_IDS.includes(key as any)) || SECURITY_AREA_IDS.some(id => !Array.isArray(policy.required_at[id]))) throw new Error('policy_invalid');
|
|
228
|
+
for (const id of ['SEC-01', 'SEC-08'] as const) if (!['commit', 'push', 'deploy'].every(s => policy.required_at![id]!.includes(s as any))) throw new Error('mandatory_policy_invalid');
|
|
229
|
+
for (const id of SECURITY_AREA_IDS) if (!policy.required_at[id].length || policy.required_at[id].some(s => !['commit', 'push', 'deploy'].includes(s))) throw new Error('policy_stage_invalid');
|
|
230
|
+
const stdin = stage === 'push' ? (settings.stdin ?? await readSafeStdin()) : '';
|
|
231
|
+
if (stage === 'deploy') {
|
|
232
|
+
for (const [fileOption, digestOption] of [['--artifact', '--artifact-digest'], ['--configuration', '--configuration-digest']]) {
|
|
233
|
+
const file = options.get(fileOption);
|
|
234
|
+
if (!file || await digestFile(path.resolve(cwd, file), 1024 * 1024 * 1024) !== options.get(digestOption)) throw new Error('deployment_file_digest_mismatch');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
binding = collectSecurityBinding(cwd, stage, options, stdin);
|
|
238
|
+
const evidenceText = await readSafe(path.resolve(cwd, options.get('--evidence') || config.evidence || '.yam/security/check.json'));
|
|
239
|
+
input = JSON.parse(evidenceText);
|
|
240
|
+
if (input.policy_digest !== pin) gateErrors.push('policy_digest_mismatch');
|
|
241
|
+
if (input.stage !== normalizedStage) gateErrors.push('stage_mismatch');
|
|
242
|
+
if (SECURITY_AREA_IDS.some(id => !input.areas?.some(area => area.id === id))) gateErrors.push('area_coverage_missing');
|
|
243
|
+
if (input.areas?.some(area => ['SEC-01', 'SEC-08'].includes(area.id) && area.status === 'not_applicable')) gateErrors.push('mandatory_area_not_applicable');
|
|
244
|
+
gateErrors.push(...await verifyReferencedEvidence(cwd, input));
|
|
245
|
+
input = { ...input, retry_of: input.run_id, run_id: `security-${randomUUID()}`, time: new Date().toISOString() };
|
|
246
|
+
result = validateSecurityCheck(input, binding as any, policy);
|
|
247
|
+
if (result.errors.includes('record_contains_secret')) {
|
|
248
|
+
input = createSecurityStarter({ stage: normalizedStage as any, binding: binding as any, environment: 'redacted', scope: ['unsafe supplied record rejected'], checker: 'yam-security-check' });
|
|
249
|
+
result = validateSecurityCheck(input, binding as any);
|
|
250
|
+
gateErrors.push('unsafe_security_record_rejected');
|
|
251
|
+
}
|
|
252
|
+
// Inspect the index/refs once more before persisting so mid-check changes cannot retain PASS.
|
|
253
|
+
const fresh = collectSecurityBinding(cwd, stage, options, stdin);
|
|
254
|
+
if (JSON.stringify(fresh) !== JSON.stringify(binding)) gateErrors.push('binding_changed_during_check');
|
|
255
|
+
if (stage === 'deploy') {
|
|
256
|
+
for (const [fileOption, digestOption] of [['--artifact', '--artifact-digest'], ['--configuration', '--configuration-digest']]) {
|
|
257
|
+
if (await digestFile(path.resolve(cwd, options.get(fileOption)!), 1024 * 1024 * 1024) !== options.get(digestOption)) gateErrors.push('deployment_file_changed_during_check');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
input = createSecurityStarter({ stage: normalizedStage as any, binding: binding as any, environment: stage === 'deploy' ? options.get('--environment') || 'unavailable' : 'local', scope: ['security gate unavailable'], checker: 'yam-security-check' });
|
|
262
|
+
result = validateSecurityCheck(input, binding as any);
|
|
263
|
+
gateErrors.push('security_input_or_binding_unavailable');
|
|
264
|
+
}
|
|
265
|
+
if (gateErrors.length) {
|
|
266
|
+
result.result = 'BLOCKED';
|
|
267
|
+
result.errors = [...new Set([...result.errors, ...gateErrors])];
|
|
268
|
+
result.blocking = [...new Set([...result.blocking, ...gateErrors.map(e => `integration:${e}`)])];
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const persisted = await persistSecurityCheck(cwd, input, result, { notePath });
|
|
272
|
+
return { ok: persisted.result.result === 'PASS', ...persisted, evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
273
|
+
} catch { return { ok: false, result: 'BLOCKED', errors: ['security_note_persistence_failed'], evidence_boundary: 'operator_supplied_not_automatic_security_scan' }; }
|
|
274
|
+
}
|
|
275
|
+
async function readSafeStdin(): Promise<string> {
|
|
276
|
+
const text = readFileSync(0, 'utf8');
|
|
277
|
+
if (text.length > 128 * 1024) throw new Error('push_ref_input_too_large');
|
|
278
|
+
return text;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function digestFile(file: string, limit: number): Promise<string> {
|
|
282
|
+
await safeParents(file);
|
|
283
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
284
|
+
try {
|
|
285
|
+
const info = await handle.stat();
|
|
286
|
+
if (!info.isFile() || info.size > limit) throw new Error('invalid digest target');
|
|
287
|
+
const digest = createHash('sha256');
|
|
288
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
289
|
+
let position = 0;
|
|
290
|
+
while (position < info.size) {
|
|
291
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, info.size - position), position);
|
|
292
|
+
if (!bytesRead) throw new Error('digest target changed during read');
|
|
293
|
+
digest.update(buffer.subarray(0, bytesRead)); position += bytesRead;
|
|
294
|
+
}
|
|
295
|
+
const after = await handle.stat();
|
|
296
|
+
if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) throw new Error('digest target changed during read');
|
|
297
|
+
return `sha256:${digest.digest('hex')}`;
|
|
298
|
+
} finally { await handle.close(); }
|
|
299
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Yam note security-check starter
|
|
2
|
+
|
|
3
|
+
Copy the block below into the project's Yam note for each run. Replace every placeholder with actual scope/evidence; do not infer a pass from the template. The authoritative status and gate rules are in [Security check contract v1](../references/security-check.md).
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
-- Security check --
|
|
7
|
+
|
|
8
|
+
Contract: security-check-v1
|
|
9
|
+
Run: <unique run ID>; Time: <ISO timestamp with timezone>
|
|
10
|
+
Stage: <commit | push | deploy>; Policy: <stage-policy version>
|
|
11
|
+
Environment: <target environment or explicit local/not-applicable reason>
|
|
12
|
+
Binding: <commit: staged tree and base revision | push: outgoing commit IDs and destination | deploy: artifact digest, source commit, environment and configuration revision>
|
|
13
|
+
Scope: <changed paths and connected security boundaries>
|
|
14
|
+
Checker: <tool/version or named reviewer>; Evidence: <sanitized evidence references>
|
|
15
|
+
|
|
16
|
+
1. SEC-01 : "사용자·관리자·외부 서비스·DB·파일 사이의 신뢰 경계와 보호할 데이터·작업을 식별한다." : 미검증
|
|
17
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
18
|
+
2. SEC-02 : "명시적으로 허용한 접근만 열고, 사용자·서비스 계정에 필요한 최소 권한만 부여한다." : 미검증
|
|
19
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
20
|
+
3. SEC-03 : "화면을 우회한 API·서버 함수·DB·파일·백그라운드 작업에서도 사용자·조직·대상별 권한을 확인한다." : 미검증
|
|
21
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
22
|
+
4. SEC-04 : "계정 정지·승인 철회·탈퇴·소속 변경 후 기존 세션·토큰·캐시에서도 권한 회수가 반영된다." : 미검증
|
|
23
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
24
|
+
5. SEC-05 : "코드의 보안 정책·migration·ACL·환경 설정이 대상 배포 환경의 실제 상태와 일치한다." : 미검증
|
|
25
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
26
|
+
6. SEC-06 : "로그인·관리자 인증·세션 만료·로그아웃·비밀번호 재설정·계정 복구가 계정 탈취를 막도록 동작한다." : 미검증
|
|
27
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
28
|
+
7. SEC-07 : "외부 입력·파일 업로드·출력의 형식·크기·경로를 검증하고 SQL 주입·XSS·SSRF·CSRF 등 해당 공격을 방어한다." : 미검증
|
|
29
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
30
|
+
8. SEC-08 : "API 키·토큰·개인키·개인정보가 코드·브라우저 번들·로그·점검 기록에 불필요하게 노출되지 않는다." : 미검증
|
|
31
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
32
|
+
9. SEC-09 : "의존성·CI/CD 권한·배포 자격증명·릴리스 산출물의 출처와 무결성을 확인해 공급망 위험을 통제한다." : 미검증
|
|
33
|
+
- Required at: <stages>; Target/evidence/result/time: <actual check details or missing evidence>
|
|
34
|
+
|
|
35
|
+
Result: BLOCKED
|
|
36
|
+
Blocking: <required-at-current-stage unresolved IDs/subchecks or invalid record fields>
|
|
37
|
+
Deferred: <later-stage missing evidence, required stage, owner and action; none if absent>
|
|
38
|
+
Retry of: <prior run ID or none>
|
|
39
|
+
-- End Security check --
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For `점검 완료 - 보완 필요` or `점검 실패`, add a concrete finding, severity, owner, follow-up action/date and mitigation. For `해당 없음`, add a project/stage-specific reason. Keep missing evidence as `미검증`. All applicable conditional subchecks must appear in the details; one passing test cannot cover an untested requirement.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copy to the target project after configuring trusted repository variables.
|
|
2
|
+
# Require this job in branch protection and make deployment jobs depend on it.
|
|
3
|
+
name: Security check
|
|
4
|
+
on:
|
|
5
|
+
pull_request:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
jobs:
|
|
11
|
+
security:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
timeout-minutes: 10
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
persist-credentials: false
|
|
19
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
|
20
|
+
with:
|
|
21
|
+
node-version: 22.14.0
|
|
22
|
+
# The caller provisions reviewed yam code, policy and evidence before this step.
|
|
23
|
+
# Do not execute PR-supplied code with deployment credentials.
|
|
24
|
+
- name: Validate security evidence for exact revisions
|
|
25
|
+
env:
|
|
26
|
+
SECURITY_POLICY_DIGEST: ${{ vars.SECURITY_POLICY_DIGEST }}
|
|
27
|
+
SECURITY_SOURCE: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
28
|
+
SECURITY_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
|
29
|
+
run: >-
|
|
30
|
+
node /opt/trusted-yam/dist/bin/yam.js security check
|
|
31
|
+
--stage ci --source "$SECURITY_SOURCE" --base "$SECURITY_BASE"
|
|
32
|
+
--policy security/policy.json --policy-digest "$SECURITY_POLICY_DIGEST"
|
|
33
|
+
--evidence security/check.json --json
|
|
34
|
+
- name: Preserve sanitized security notes on success and failure
|
|
35
|
+
if: always()
|
|
36
|
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
37
|
+
with:
|
|
38
|
+
name: security-check-note
|
|
39
|
+
include-hidden-files: true
|
|
40
|
+
path: |
|
|
41
|
+
.yam/security/notes.md
|
|
42
|
+
.yam/security/runs/
|
|
43
|
+
retention-days: 14
|
|
44
|
+
if-no-files-found: error
|