yam-flow 2.8.0 → 2.8.1
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 +12 -0
- package/COMMANDS.md +10 -0
- package/dist/bin/yam.js +21 -4
- 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/package.json +6 -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 +20 -4
- 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
package/skills/mission/SKILL.md
CHANGED
|
@@ -120,6 +120,10 @@ Good mission prompts include:
|
|
|
120
120
|
|
|
121
121
|
If the user invokes `$mission` without an approved plan, ask for the plan or propose a compact plan first.
|
|
122
122
|
|
|
123
|
+
## Commit, push and deployment security gate
|
|
124
|
+
|
|
125
|
+
Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
|
|
126
|
+
|
|
123
127
|
## Final Response
|
|
124
128
|
|
|
125
129
|
Report:
|
package/skills/quick/SKILL.md
CHANGED
|
@@ -74,6 +74,10 @@ Use `references/study-note.md` when any code, config, docs, or project artifact
|
|
|
74
74
|
Use `references/final-report.md` to close with remaining tasks and fix-first items when useful.
|
|
75
75
|
Use `references/token-budget-reporter.md` when a run needs measured budget feedback.
|
|
76
76
|
|
|
77
|
+
## Commit, push and deployment security gate
|
|
78
|
+
|
|
79
|
+
Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
|
|
80
|
+
|
|
77
81
|
## Final Response
|
|
78
82
|
|
|
79
83
|
Keep it compact:
|
package/skills/ueye/SKILL.md
CHANGED
|
@@ -182,6 +182,10 @@ Always consider:
|
|
|
182
182
|
- Interaction affordance.
|
|
183
183
|
- Consistency with the project's visual language.
|
|
184
184
|
|
|
185
|
+
## Commit, push and deployment security gate
|
|
186
|
+
|
|
187
|
+
Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
|
|
188
|
+
|
|
185
189
|
## Final Response
|
|
186
190
|
|
|
187
191
|
Report:
|
package/src/bin/yam.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { runSecurityCommand } from '../lib/security-integration.js';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import fsp from 'node:fs/promises';
|
|
@@ -224,6 +225,7 @@ Usage:
|
|
|
224
225
|
yam proof [dir|--from file] [--route route] [--truth status] [--command text] [--evidence text]
|
|
225
226
|
yam proof write [dir] [--format json|md] [--out file] [--route route] [--truth status] [--command text]
|
|
226
227
|
yam study-note check [dir] [--report file|--text text] [--json]
|
|
228
|
+
yam security <init|check|hooks|status> [--stage commit|push|ci|deploy] [--json]
|
|
227
229
|
yam next-step <report|verify> [--spec file|--receipt file] [--json]
|
|
228
230
|
yam loop report [--route route] [--intent text] [--stage id:status:note] [--evidence text] [--json]
|
|
229
231
|
yam scout receipt <create|verify> [options]
|
|
@@ -249,7 +251,7 @@ Usage:
|
|
|
249
251
|
yam safety [text...]
|
|
250
252
|
yam memory <init|add|list|summary|resolve> [dir] [options]
|
|
251
253
|
yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
|
|
252
|
-
yam template <project|ueye|mission|proof|tuning>
|
|
254
|
+
yam template <project|ueye|mission|proof|tuning|security|security-ci>
|
|
253
255
|
yam tune-log [dir]
|
|
254
256
|
yam install [--dry-run] [--json] [--replace-user-skill name]
|
|
255
257
|
yam uninstall
|
|
@@ -570,6 +572,7 @@ async function verify({ quiet = false } = {}) {
|
|
|
570
572
|
'context-reuse.md',
|
|
571
573
|
'markdown-management.md',
|
|
572
574
|
'study-note.md',
|
|
575
|
+
'security-check.md',
|
|
573
576
|
'final-report.md',
|
|
574
577
|
'token-budget-reporter.md',
|
|
575
578
|
'memory.md'
|
|
@@ -585,7 +588,7 @@ async function verify({ quiet = false } = {}) {
|
|
|
585
588
|
if (!hasHeading(projectTemplate, section)) issues.push(`project template missing section: ${section}`);
|
|
586
589
|
}
|
|
587
590
|
}
|
|
588
|
-
for (const template of ['ueye-review.md', 'ueye-comparison.md', 'mission-plan.md', 'runtime-proof.md', 'tuning-log.md']) {
|
|
591
|
+
for (const template of ['ueye-review.md', 'ueye-comparison.md', 'mission-plan.md', 'runtime-proof.md', 'tuning-log.md', 'security-check-note.md', 'security-gate.yml']) {
|
|
589
592
|
if (!await exists(path.join(ROOT, 'templates', template))) issues.push(`missing template: ${template}`);
|
|
590
593
|
}
|
|
591
594
|
for (const module of ['trust-kernel.js', 'ueye-artifacts.js']) {
|
|
@@ -6069,11 +6072,13 @@ async function printTemplate(name = '') {
|
|
|
6069
6072
|
mission: 'mission-plan.md',
|
|
6070
6073
|
proof: 'runtime-proof.md',
|
|
6071
6074
|
runtime: 'runtime-proof.md',
|
|
6072
|
-
tuning: 'tuning-log.md'
|
|
6075
|
+
tuning: 'tuning-log.md',
|
|
6076
|
+
security: 'security-check-note.md',
|
|
6077
|
+
'security-ci': 'security-gate.yml'
|
|
6073
6078
|
};
|
|
6074
6079
|
const file = map[key];
|
|
6075
6080
|
if (!file) {
|
|
6076
|
-
console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning>');
|
|
6081
|
+
console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning|security|security-ci>');
|
|
6077
6082
|
process.exitCode = 1;
|
|
6078
6083
|
return;
|
|
6079
6084
|
}
|
|
@@ -6349,6 +6354,7 @@ function showRequestedHelp(args: string[]) {
|
|
|
6349
6354
|
}
|
|
6350
6355
|
|
|
6351
6356
|
const commandUsage: Record<string, () => void> = {
|
|
6357
|
+
security: () => console.log('yam security <init|check|hooks enable|status> [--stage commit|push|ci|deploy] [--policy file --policy-digest sha256 --evidence file --note file]\nSee references/security-check.md for exact bindings and CI/deploy trust configuration.'),
|
|
6352
6358
|
context: contextUsage,
|
|
6353
6359
|
cleanup: cleanupUsage,
|
|
6354
6360
|
'study-note': studyNoteUsage,
|
|
@@ -6375,6 +6381,16 @@ async function main() {
|
|
|
6375
6381
|
const command = args[0] || 'help';
|
|
6376
6382
|
if (showRequestedHelp(args)) return;
|
|
6377
6383
|
if (command === 'help') return usage();
|
|
6384
|
+
if (command === 'security') {
|
|
6385
|
+
if (args.length === 1 || ['help', '--help', '-h'].includes(args[1])) {
|
|
6386
|
+
console.log('yam security <init|check|hooks enable|status> [--stage commit|push|ci|deploy] [--policy file --policy-digest sha256 --evidence file --note file]\nSee references/security-check.md for exact bindings and CI/deploy trust configuration.');
|
|
6387
|
+
return;
|
|
6388
|
+
}
|
|
6389
|
+
const result = await runSecurityCommand(args.slice(1), { cwd: process.cwd(), cliPath: fileURLToPath(import.meta.url) });
|
|
6390
|
+
console.log(JSON.stringify(result, null, 2));
|
|
6391
|
+
if (!result.ok) process.exitCode = 1;
|
|
6392
|
+
return;
|
|
6393
|
+
}
|
|
6378
6394
|
if (command === 'install') return install(args.slice(1));
|
|
6379
6395
|
if (command === 'uninstall') return uninstall(args.slice(1));
|
|
6380
6396
|
if (command === 'version') return console.log(VERSION);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const SECURITY_STATUSES = ['passed', 'needs_improvement', 'failed', 'unverified', 'not_applicable'] as const;
|
|
7
|
+
export type SecurityStatus = typeof SECURITY_STATUSES[number];
|
|
8
|
+
export type SecurityStage = 'commit' | 'push' | 'deploy';
|
|
9
|
+
export const SEC_AREAS = {
|
|
10
|
+
'SEC-01': '사용자·관리자·외부 서비스·DB·파일 사이의 신뢰 경계와 보호할 데이터·작업을 식별한다.',
|
|
11
|
+
'SEC-02': '명시적으로 허용한 접근만 열고, 사용자·서비스 계정에 필요한 최소 권한만 부여한다.',
|
|
12
|
+
'SEC-03': '화면을 우회한 API·서버 함수·DB·파일·백그라운드 작업에서도 사용자·조직·대상별 권한을 확인한다.',
|
|
13
|
+
'SEC-04': '계정 정지·승인 철회·탈퇴·소속 변경 후 기존 세션·토큰·캐시에서도 권한 회수가 반영된다.',
|
|
14
|
+
'SEC-05': '코드의 보안 정책·migration·ACL·환경 설정이 대상 배포 환경의 실제 상태와 일치한다.',
|
|
15
|
+
'SEC-06': '로그인·관리자 인증·세션 만료·로그아웃·비밀번호 재설정·계정 복구가 계정 탈취를 막도록 동작한다.',
|
|
16
|
+
'SEC-07': '외부 입력·파일 업로드·출력의 형식·크기·경로를 검증하고 SQL 주입·XSS·SSRF·CSRF 등 해당 공격을 방어한다.',
|
|
17
|
+
'SEC-08': 'API 키·토큰·개인키·개인정보가 코드·브라우저 번들·로그·점검 기록에 불필요하게 노출되지 않는다.',
|
|
18
|
+
'SEC-09': '의존성·CI/CD 권한·배포 자격증명·릴리스 산출물의 출처와 무결성을 확인해 공급망 위험을 통제한다.'
|
|
19
|
+
} as const;
|
|
20
|
+
export const SECURITY_AREA_IDS = Object.keys(SEC_AREAS) as Array<keyof typeof SEC_AREAS>;
|
|
21
|
+
export const STATUS_KO: Record<SecurityStatus, string> = {
|
|
22
|
+
passed: '점검 완료 - 이상 없음', needs_improvement: '점검 완료 - 보완 필요', failed: '점검 실패',
|
|
23
|
+
unverified: '미검증', not_applicable: '해당 없음'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface SecurityPolicy { version?: string; required_at?: Partial<Record<keyof typeof SEC_AREAS, SecurityStage[]>>; required?: string[]; digest?: string; }
|
|
27
|
+
export interface SecurityBinding { stage: SecurityStage; [key: string]: unknown }
|
|
28
|
+
export interface SecurityEvidence { reference: string; sha256: string; checker: string; checked_at: string; result?: string; }
|
|
29
|
+
export interface SecurityAreaRecord { id: keyof typeof SEC_AREAS; explanation: string; status: SecurityStatus; required_at: SecurityStage[]; evidence?: SecurityEvidence[]; reason?: string; finding?: string; severity?: string; owner?: string; follow_up?: string; mitigation?: string; }
|
|
30
|
+
export interface SecurityCheckInput { run_id?: string; time?: string; stage: SecurityStage; policy?: SecurityPolicy; policy_digest?: string; binding: SecurityBinding; environment: string; scope: string[]; checker: string; areas?: SecurityAreaRecord[]; evidence?: SecurityEvidence[]; retry_of?: string; }
|
|
31
|
+
export interface SecurityCheckResult { schema: 'yam.security-check-result.v1'; run_id: string; time: string; stage: SecurityStage; policy: SecurityPolicy; policy_digest: string; binding: SecurityBinding; environment: string; scope: string[]; checker: string; areas: SecurityAreaRecord[]; result: 'PASS' | 'BLOCKED'; blocking: string[]; deferred: string[]; errors: string[]; digest: string; retry_of?: string; }
|
|
32
|
+
|
|
33
|
+
const secretPatterns = [/https?:\/\/[^\s/]+:[^\s/]+@/i, /[?&](?:token|key|secret|password)=[^\s&]+/i, /\bgh[pousr]_[A-Za-z0-9]{20,}\b/i, /\bnpm_[A-Za-z0-9]{20,}\b/i, /\bsk-[A-Za-z0-9_-]{20,}\b/i, /\bAKIA[A-Z0-9]{16}\b/, /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, /(?:password|secret|token|api[_-]?key)\s*[:=]\s*[^\s,}]{8,}/i];
|
|
34
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
35
|
+
const unsafeText = (x: unknown): boolean => typeof x === 'string' && (/[\r\n\x00-\x1f\x7f<>]/.test(x) || x.length > 2048);
|
|
36
|
+
const iso = (x: unknown) => typeof x === 'string' && !Number.isNaN(Date.parse(x));
|
|
37
|
+
const obj = (x: unknown): x is Record<string, any> => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
38
|
+
const stable = (x: unknown): string => {
|
|
39
|
+
if (Array.isArray(x)) return `[${x.map(stable).join(',')}]`;
|
|
40
|
+
if (x && typeof x === 'object') return `{${Object.keys(x as Record<string, unknown>).filter(k => (x as Record<string, unknown>)[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable((x as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
41
|
+
return JSON.stringify(x);
|
|
42
|
+
};
|
|
43
|
+
const digest = (x: unknown) => `sha256:${createHash('sha256').update(stable(x)).digest('hex')}`;
|
|
44
|
+
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
|
|
45
|
+
const hasSecret = (x: unknown): boolean => secretPatterns.some((p) => p.test(typeof x === 'string' ? x : JSON.stringify(x)));
|
|
46
|
+
|
|
47
|
+
export function createSecurityStarter(input: { stage: SecurityStage; policy?: SecurityPolicy; binding: SecurityBinding; environment: string; scope?: string[]; checker?: string; now?: Date; run_id?: string }): SecurityCheckInput {
|
|
48
|
+
const policy = clone(input.policy || { version: 'security-check-v1', required_at: {} });
|
|
49
|
+
const requiredAt = policy.required_at || {};
|
|
50
|
+
return { run_id: input.run_id || `security-${randomUUID()}`, time: (input.now || new Date()).toISOString(), stage: input.stage, policy, policy_digest: policy.digest || digest(policy), binding: clone(input.binding), environment: input.environment, scope: input.scope || [], checker: input.checker || 'yam-security-check', areas: SECURITY_AREA_IDS.map((id) => ({ id, explanation: SEC_AREAS[id], status: 'unverified', required_at: requiredAt[id] || [input.stage] })) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function evidenceErrors(e: SecurityEvidence, index: number): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
if (!obj(e)) return [`evidence_invalid:${index}`];
|
|
56
|
+
if (typeof e.reference !== 'string' || !e.reference.trim() || e.reference.includes('\0')) out.push(`evidence_reference_invalid:${index}`);
|
|
57
|
+
if (!SHA256.test(String(e.sha256 || ''))) out.push(`evidence_sha256_invalid:${index}`);
|
|
58
|
+
if (typeof e.checker !== 'string' || !e.checker.trim() || hasSecret(e.checker)) out.push(`evidence_checker_invalid:${index}`);
|
|
59
|
+
if (!iso(e.checked_at)) out.push(`evidence_time_invalid:${index}`);
|
|
60
|
+
if (Object.values(e).some(unsafeText)) out.push(`evidence_text_invalid:${index}`);
|
|
61
|
+
if (hasSecret(e)) out.push(`evidence_secret:${index}`);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateSecurityCheck(input: unknown, expectedBinding?: SecurityBinding, expectedPolicy?: SecurityPolicy): SecurityCheckResult {
|
|
66
|
+
const errors: string[] = [];
|
|
67
|
+
const x = obj(input) ? input as SecurityCheckInput : {} as SecurityCheckInput;
|
|
68
|
+
const validRun = typeof x.run_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(x.run_id);
|
|
69
|
+
const runId = validRun ? x.run_id : `invalid-${randomUUID()}`;
|
|
70
|
+
if (!validRun) errors.push('run_id_invalid');
|
|
71
|
+
if (!['commit', 'push', 'deploy'].includes(x.stage)) errors.push('stage_invalid');
|
|
72
|
+
if (!iso(x.time)) errors.push('time_invalid');
|
|
73
|
+
if (!obj(x.binding)) errors.push('binding_missing');
|
|
74
|
+
if (x.binding?.stage !== x.stage) errors.push('binding_stage_mismatch');
|
|
75
|
+
if (x.stage === 'deploy' && x.environment !== x.binding?.environment) errors.push('environment_binding_mismatch');
|
|
76
|
+
if (Object.values(x).filter(v => typeof v === 'string').some(unsafeText)) errors.push('record_text_invalid');
|
|
77
|
+
if (expectedBinding && stable(x.binding) !== stable(expectedBinding)) errors.push('binding_mismatch');
|
|
78
|
+
if (typeof x.environment !== 'string' || !x.environment.trim()) errors.push('environment_invalid');
|
|
79
|
+
if (!Array.isArray(x.scope) || !x.scope.length || x.scope.some(v => typeof v !== 'string' || !v.trim() || unsafeText(v))) errors.push('scope_invalid');
|
|
80
|
+
if (typeof x.checker !== 'string' || !x.checker.trim()) errors.push('checker_invalid');
|
|
81
|
+
const policy = obj(x.policy) ? clone(x.policy) : { version: 'security-check-v1', required_at: {} };
|
|
82
|
+
const policyDigest = x.policy_digest || policy.digest || digest(policy);
|
|
83
|
+
if (expectedPolicy && stable(policy) !== stable(expectedPolicy)) errors.push('policy_mismatch');
|
|
84
|
+
if (typeof x.policy_digest === 'string' && !SHA256.test(x.policy_digest)) errors.push('policy_digest_invalid');
|
|
85
|
+
if (!Array.isArray(x.areas)) errors.push('areas_missing');
|
|
86
|
+
const supplied = Array.isArray(x.areas) ? x.areas : [];
|
|
87
|
+
const byId = new Map<string, SecurityAreaRecord>();
|
|
88
|
+
supplied.forEach((a, i) => { if (!obj(a) || !Object.prototype.hasOwnProperty.call(SEC_AREAS, a.id)) errors.push(`area_invalid:${i}`); else if (byId.has(a.id)) errors.push(`area_duplicate:${a.id}`); else byId.set(a.id, clone(a)); });
|
|
89
|
+
const requiredAt = (expectedPolicy || policy).required_at || {};
|
|
90
|
+
if (!policy.version || unsafeText(policy.version)) errors.push('policy_version_invalid');
|
|
91
|
+
for (const id of SECURITY_AREA_IDS) {
|
|
92
|
+
const stages = requiredAt[id];
|
|
93
|
+
if (!Array.isArray(stages) || !stages.length || stages.some(v => !['commit','push','deploy'].includes(v))) errors.push(`policy_requirement_invalid:${id}`);
|
|
94
|
+
}
|
|
95
|
+
for (const id of SECURITY_AREA_IDS) if (!byId.has(id)) errors.push(`area_missing:${id}`);
|
|
96
|
+
const areas = SECURITY_AREA_IDS.map((id) => {
|
|
97
|
+
const a = byId.get(id) || { id, explanation: SEC_AREAS[id], status: 'unverified' as const, required_at: requiredAt[id] || [x.stage] };
|
|
98
|
+
if (a.explanation !== SEC_AREAS[id]) errors.push(`explanation_mismatch:${id}`);
|
|
99
|
+
if (!SECURITY_STATUSES.includes(a.status)) errors.push(`status_invalid:${id}`);
|
|
100
|
+
const trustedRequired = Array.isArray(requiredAt[id]) && requiredAt[id].length ? requiredAt[id] : ['commit','push','deploy'] as SecurityStage[];
|
|
101
|
+
if (Object.values(a).filter(v => typeof v === 'string').some(unsafeText)) errors.push(`area_text_invalid:${id}`);
|
|
102
|
+
if (a.status === 'passed' && Array.isArray(a.evidence) && a.evidence.some(e => e?.result !== 'passed')) errors.push(`evidence_result_mismatch:${id}`);
|
|
103
|
+
if (a.status === 'passed' && (!Array.isArray(a.evidence) || !a.evidence.length)) errors.push(`evidence_missing:${id}`);
|
|
104
|
+
if (['SEC-01','SEC-08'].includes(id) && (a.status === 'not_applicable' || !trustedRequired.includes(x.stage))) errors.push(`mandatory_area_invalid:${id}`);
|
|
105
|
+
if (JSON.stringify(a.required_at) !== JSON.stringify(trustedRequired)) errors.push(`required_at_tampered:${id}`);
|
|
106
|
+
if (a.status === 'not_applicable' && (!a.reason || !a.reason.trim())) errors.push(`not_applicable_reason_missing:${id}`);
|
|
107
|
+
if (a.status === 'needs_improvement' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation)) errors.push(`improvement_details_missing:${id}`);
|
|
108
|
+
if (a.status === 'failed' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation || !a.evidence?.length)) errors.push(`failure_details_missing:${id}`);
|
|
109
|
+
if (a.evidence && !Array.isArray(a.evidence)) errors.push(`evidence_array_invalid:${id}`);
|
|
110
|
+
(Array.isArray(a.evidence) ? a.evidence : []).forEach((e, i) => errors.push(...evidenceErrors(e, i).map((z) => `${id}:${z}`)));
|
|
111
|
+
return { ...a, explanation: SEC_AREAS[id], required_at: trustedRequired };
|
|
112
|
+
});
|
|
113
|
+
if (areas.filter((a) => a.id === 'SEC-01').length !== 1 || areas.filter((a) => a.id === 'SEC-08').length !== 1) errors.push('mandatory_areas_missing');
|
|
114
|
+
if (hasSecret(x)) errors.push('record_contains_secret');
|
|
115
|
+
const blocking = areas.filter((a) => (a.required_at || []).includes(x.stage) && !['passed', 'not_applicable'].includes(a.status)).map((a) => `${a.id}:${a.status}`);
|
|
116
|
+
const allErrors = [...new Set(errors)];
|
|
117
|
+
const finalBlocking = [...new Set([...blocking, ...allErrors.map((e) => `contract:${e}`)])];
|
|
118
|
+
const result = finalBlocking.length ? 'BLOCKED' as const : 'PASS' as const;
|
|
119
|
+
const canonical = { schema: 'yam.security-check-result.v1' as const, retry_of: x.retry_of || 'none', run_id: runId, time: x.time, stage: x.stage, policy, policy_digest: policyDigest, binding: x.binding, environment: x.environment, scope: x.scope || [], checker: x.checker, areas, result, blocking: finalBlocking, deferred: areas.filter((a) => !(a.required_at || []).includes(x.stage) && a.status === 'unverified').map((a) => a.id), errors: allErrors };
|
|
120
|
+
return { ...canonical, digest: digest(canonical) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ensureSafePath(root: string, target: string): string {
|
|
124
|
+
const r = path.resolve(root); const t = path.resolve(target); if (t !== r && !t.startsWith(`${r}${path.sep}`)) throw new Error('path_outside_root'); return t;
|
|
125
|
+
}
|
|
126
|
+
async function noSymlinkComponents(root: string, target: string) {
|
|
127
|
+
const rel = path.relative(root, target); let cur = root;
|
|
128
|
+
for (const part of rel.split(path.sep).filter(Boolean)) { cur = path.join(cur, part); try { if ((await fsp.lstat(cur)).isSymbolicLink()) throw new Error('symlink_path_rejected'); } catch (e: any) { if (e.code !== 'ENOENT') throw e; } }
|
|
129
|
+
}
|
|
130
|
+
async function withLock(lock: string, fn: () => Promise<void>) { for (let i = 0; i < 50; i++) { try { await fsp.mkdir(lock); break; } catch (e: any) { if (e.code !== 'EEXIST') throw e; await new Promise((r) => setTimeout(r, 10)); } if (i === 49) throw new Error('security_note_lock_timeout'); } try { await fn(); } finally { await fsp.rm(lock, { recursive: true, force: true }); } }
|
|
131
|
+
|
|
132
|
+
export async function persistSecurityCheck(dir: string, input: SecurityCheckInput, result?: SecurityCheckResult, options: { notePath?: string } = {}) {
|
|
133
|
+
const root = await fsp.realpath(dir);
|
|
134
|
+
let checked = validateSecurityCheck(input);
|
|
135
|
+
// Integration may only add blocking findings; it cannot promote a failed contract.
|
|
136
|
+
if (result?.result === 'BLOCKED') {
|
|
137
|
+
checked = { ...checked, result: 'BLOCKED', errors: [...new Set([...checked.errors, ...result.errors])], blocking: [...new Set([...checked.blocking, ...result.blocking])] };
|
|
138
|
+
}
|
|
139
|
+
if (hasSecret(checked) || checked.errors.some(e => /text_invalid|secret/.test(e))) {
|
|
140
|
+
const sanitized = createSecurityStarter({ stage: ['commit','push','deploy'].includes(input?.stage) ? input.stage : 'commit', binding: { stage: input?.stage || 'commit', withheld: true }, environment: 'withheld', scope: ['unsafe input omitted'], checker: 'yam-security-check' });
|
|
141
|
+
checked = validateSecurityCheck(sanitized);
|
|
142
|
+
checked.errors.push('unsafe_input_redacted'); checked.blocking.push('contract:unsafe_input_redacted');
|
|
143
|
+
}
|
|
144
|
+
checked.digest = digest({ ...checked, digest: undefined });
|
|
145
|
+
const base = ensureSafePath(root, path.join(root, '.yam', 'security'));
|
|
146
|
+
await noSymlinkComponents(root, base);
|
|
147
|
+
await fsp.mkdir(base, { recursive: true, mode: 0o700 });
|
|
148
|
+
const runs = ensureSafePath(root, path.join(base, 'runs'));
|
|
149
|
+
const originalRoot = path.resolve(dir);
|
|
150
|
+
const requestedNote = options.notePath ? path.resolve(originalRoot, options.notePath) : path.join(originalRoot, '.yam', 'security', 'notes.md');
|
|
151
|
+
ensureSafePath(originalRoot, requestedNote);
|
|
152
|
+
const note = ensureSafePath(root, path.resolve(root, path.relative(originalRoot, requestedNote)));
|
|
153
|
+
const lock = path.join(base, '.lock');
|
|
154
|
+
await noSymlinkComponents(root, note);
|
|
155
|
+
await noSymlinkComponents(root, runs);
|
|
156
|
+
let runPath = '';
|
|
157
|
+
await withLock(lock, async () => {
|
|
158
|
+
await noSymlinkComponents(root, note);
|
|
159
|
+
await fsp.mkdir(path.dirname(note), { recursive: true, mode: 0o700 });
|
|
160
|
+
await fsp.mkdir(runs, { recursive: true, mode: 0o700 });
|
|
161
|
+
runPath = ensureSafePath(runs, path.join(runs, `${checked.run_id}.json`));
|
|
162
|
+
await noSymlinkComponents(root, runPath);
|
|
163
|
+
try { await fsp.lstat(runPath); throw new Error('security_run_exists'); } catch (e: any) { if (e.code !== 'ENOENT') throw e; }
|
|
164
|
+
const handle = await fsp.open(note, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
|
165
|
+
try {
|
|
166
|
+
const info = await handle.stat(); if (!info.isFile() || info.nlink !== 1) throw new Error('unsafe_note_file');
|
|
167
|
+
await fsp.writeFile(runPath, JSON.stringify(checked, null, 2) + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
168
|
+
await handle.writeFile(renderSecurityNote(checked), 'utf8'); await handle.sync();
|
|
169
|
+
} finally { await handle.close(); }
|
|
170
|
+
});
|
|
171
|
+
return { run_path: path.relative(root, runPath), note_path: path.relative(root, note), result: checked };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function renderSecurityNote(result: SecurityCheckResult): string {
|
|
175
|
+
const clean = (x: unknown) => String(x ?? '').replace(/[\r\n\x00-\x1f\x7f]/g, ' ');
|
|
176
|
+
const lines = ['-- Security check --', '', 'Contract: security-check-v1', `Run: ${clean(result.run_id)}; Time: ${clean(result.time)}`, `Stage: ${result.stage}; Policy: ${clean(result.policy_digest)}`, `Environment: ${clean(result.environment)}`, `Binding: ${clean(JSON.stringify(result.binding))}`, `Scope: ${result.scope.map(clean).join(', ')}`, `Checker: ${clean(result.checker)}`, ''];
|
|
177
|
+
result.areas.forEach((a, i) => {
|
|
178
|
+
lines.push(`${i + 1}. ${a.id} : "${SEC_AREAS[a.id]}" : ${STATUS_KO[a.status] || '미검증'}`);
|
|
179
|
+
lines.push(` - Required at: ${a.required_at.join(', ')}`);
|
|
180
|
+
if (Array.isArray(a.evidence) && a.evidence.length) lines.push(` - Evidence: ${a.evidence.map(e => `${clean(e.reference)} (${clean(e.sha256)}) by ${clean(e.checker)} at ${clean(e.checked_at)}`).join('; ')}`);
|
|
181
|
+
for (const field of ['reason','finding','severity','owner','follow_up','mitigation'] as const) if (a[field]) lines.push(` - ${field}: ${clean(a[field])}`);
|
|
182
|
+
});
|
|
183
|
+
lines.push('', `Retry of: ${clean(result.retry_of || 'none')}`, `Result: ${result.result}`, `Blocking: ${result.blocking.map(clean).join(', ') || 'none'}`, `Deferred: ${result.deferred.join(', ') || 'none'}`, '-- End Security check --', ''); return lines.join('\n');
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|