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/AGENTS.md
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
21. When relevant, include architecture hygiene in the Study Note: avoid dumping unrelated logic into `page.tsx`, one-off component CSS into `global.css`, or structured product data into broad DB `jsonb` blobs when existing project boundaries, scoped styles, typed tables, constraints, or indexes fit better.
|
|
24
24
|
22. When an artifact changed, put `Next step` immediately after Study Note. Build it from a quick whole-process scan with current situation, forward outlook, a concrete critical opinion, improvement recommendations, and ordered fix-first then planned actions with evidence and ownership; do not merely rename remaining tasks.
|
|
25
25
|
|
|
26
|
+
## Security check gate
|
|
27
|
+
|
|
28
|
+
Before commit, push, CI approval, or deployment, run the configured `yam security check` gate for the exact stage and record its Security check block. Use `references/security-check.md`; preserve every SEC explanation, evidence reference, and unresolved status. Missing required evidence or a note-write failure blocks the stage. Local hooks are bypassable; enforce protected merge/deployment with a trusted CI policy and required checks. Never present a validated supplied report as an independent vulnerability scan.
|
|
29
|
+
|
|
26
30
|
## Latest Update Trigger
|
|
27
31
|
|
|
28
32
|
When the user says exactly `yam 최신 업데이트 사항 반영해줘` in a chat for this project, treat that phrase as explicit authorization to check and apply reviewed updates for yam, Scrapling, and Insane Search.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to yam-flow are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.8.1 - 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
|
|
9
|
+
- Add nine explained SEC areas, exact-stage evidence validation and safe append-only Security check notes. Missing, stale or unresolved evidence blocks the operation.
|
|
10
|
+
- Add project snapshot checks, reviewed source/policy pins, commit/push hooks and required CI security checks. Package lifecycle verification uses the same reviewed revision on Linux, macOS and Windows.
|
|
11
|
+
- Retain sanitized security notes in CI artifacts and require actual environment inspection for deployment. Document operator-run npm publication and its enforcement limits.
|
|
12
|
+
|
|
13
|
+
### Verification
|
|
14
|
+
|
|
15
|
+
- Independent security audit, actual commit/push hooks, hosted security evidence and three-platform packaged lifecycle checks passed for the security implementation.
|
|
16
|
+
|
|
5
17
|
## v2.8.0 - 2026-09-01
|
|
6
18
|
|
|
7
19
|
### Added
|
package/COMMANDS.md
CHANGED
|
@@ -336,3 +336,13 @@ yam hook disable study-note --global
|
|
|
336
336
|
```
|
|
337
337
|
|
|
338
338
|
`yam hook status` exits nonzero for unreadable configs, stale paths, missing targets, duplicate handlers, or incomplete event coverage. Re-enable the affected profile to back up the file, preserve unrelated hooks, and migrate its command and events.
|
|
339
|
+
|
|
340
|
+
## Security check
|
|
341
|
+
|
|
342
|
+
- `yam security init`: create a private all-unverified draft and pin a reviewed policy; preserve existing files.
|
|
343
|
+
- `yam security check --stage commit|push|ci|deploy`: validate exact-target evidence and append Security check to Yam note; nonzero exit blocks.
|
|
344
|
+
- `yam security hooks enable`: explicitly install local pre-commit/pre-push hooks, preserving existing hooks.
|
|
345
|
+
- `yam security status`: show local configuration; remote protections remain not measured.
|
|
346
|
+
- `yam template security` / `yam template security-ci`: explained note and CI setup templates.
|
|
347
|
+
|
|
348
|
+
See `references/security-check.md` for stage arguments, trusted policy requirements and limitations. This validates supplied evidence; it does not automatically scan application vulnerabilities.
|
package/dist/bin/yam.js
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';
|
|
@@ -145,6 +146,7 @@ Usage:
|
|
|
145
146
|
yam proof [dir|--from file] [--route route] [--truth status] [--command text] [--evidence text]
|
|
146
147
|
yam proof write [dir] [--format json|md] [--out file] [--route route] [--truth status] [--command text]
|
|
147
148
|
yam study-note check [dir] [--report file|--text text] [--json]
|
|
149
|
+
yam security <init|check|hooks|status> [--stage commit|push|ci|deploy] [--json]
|
|
148
150
|
yam next-step <report|verify> [--spec file|--receipt file] [--json]
|
|
149
151
|
yam loop report [--route route] [--intent text] [--stage id:status:note] [--evidence text] [--json]
|
|
150
152
|
yam scout receipt <create|verify> [options]
|
|
@@ -170,7 +172,7 @@ Usage:
|
|
|
170
172
|
yam safety [text...]
|
|
171
173
|
yam memory <init|add|list|summary|resolve> [dir] [options]
|
|
172
174
|
yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
|
|
173
|
-
yam template <project|ueye|mission|proof|tuning>
|
|
175
|
+
yam template <project|ueye|mission|proof|tuning|security|security-ci>
|
|
174
176
|
yam tune-log [dir]
|
|
175
177
|
yam install [--dry-run] [--json] [--replace-user-skill name]
|
|
176
178
|
yam uninstall
|
|
@@ -489,6 +491,7 @@ async function verify({ quiet = false } = {}) {
|
|
|
489
491
|
'context-reuse.md',
|
|
490
492
|
'markdown-management.md',
|
|
491
493
|
'study-note.md',
|
|
494
|
+
'security-check.md',
|
|
492
495
|
'final-report.md',
|
|
493
496
|
'token-budget-reporter.md',
|
|
494
497
|
'memory.md'
|
|
@@ -507,7 +510,7 @@ async function verify({ quiet = false } = {}) {
|
|
|
507
510
|
issues.push(`project template missing section: ${section}`);
|
|
508
511
|
}
|
|
509
512
|
}
|
|
510
|
-
for (const template of ['ueye-review.md', 'ueye-comparison.md', 'mission-plan.md', 'runtime-proof.md', 'tuning-log.md']) {
|
|
513
|
+
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']) {
|
|
511
514
|
if (!await exists(path.join(ROOT, 'templates', template)))
|
|
512
515
|
issues.push(`missing template: ${template}`);
|
|
513
516
|
}
|
|
@@ -6038,11 +6041,13 @@ async function printTemplate(name = '') {
|
|
|
6038
6041
|
mission: 'mission-plan.md',
|
|
6039
6042
|
proof: 'runtime-proof.md',
|
|
6040
6043
|
runtime: 'runtime-proof.md',
|
|
6041
|
-
tuning: 'tuning-log.md'
|
|
6044
|
+
tuning: 'tuning-log.md',
|
|
6045
|
+
security: 'security-check-note.md',
|
|
6046
|
+
'security-ci': 'security-gate.yml'
|
|
6042
6047
|
};
|
|
6043
6048
|
const file = map[key];
|
|
6044
6049
|
if (!file) {
|
|
6045
|
-
console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning>');
|
|
6050
|
+
console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning|security|security-ci>');
|
|
6046
6051
|
process.exitCode = 1;
|
|
6047
6052
|
return;
|
|
6048
6053
|
}
|
|
@@ -6309,6 +6314,7 @@ function showRequestedHelp(args) {
|
|
|
6309
6314
|
return true;
|
|
6310
6315
|
}
|
|
6311
6316
|
const commandUsage = {
|
|
6317
|
+
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.'),
|
|
6312
6318
|
context: contextUsage,
|
|
6313
6319
|
cleanup: cleanupUsage,
|
|
6314
6320
|
'study-note': studyNoteUsage,
|
|
@@ -6336,6 +6342,17 @@ async function main() {
|
|
|
6336
6342
|
return;
|
|
6337
6343
|
if (command === 'help')
|
|
6338
6344
|
return usage();
|
|
6345
|
+
if (command === 'security') {
|
|
6346
|
+
if (args.length === 1 || ['help', '--help', '-h'].includes(args[1])) {
|
|
6347
|
+
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.');
|
|
6348
|
+
return;
|
|
6349
|
+
}
|
|
6350
|
+
const result = await runSecurityCommand(args.slice(1), { cwd: process.cwd(), cliPath: fileURLToPath(import.meta.url) });
|
|
6351
|
+
console.log(JSON.stringify(result, null, 2));
|
|
6352
|
+
if (!result.ok)
|
|
6353
|
+
process.exitCode = 1;
|
|
6354
|
+
return;
|
|
6355
|
+
}
|
|
6339
6356
|
if (command === 'install')
|
|
6340
6357
|
return install(args.slice(1));
|
|
6341
6358
|
if (command === 'uninstall')
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export declare const SECURITY_STATUSES: readonly ["passed", "needs_improvement", "failed", "unverified", "not_applicable"];
|
|
2
|
+
export type SecurityStatus = typeof SECURITY_STATUSES[number];
|
|
3
|
+
export type SecurityStage = 'commit' | 'push' | 'deploy';
|
|
4
|
+
export declare const SEC_AREAS: {
|
|
5
|
+
readonly 'SEC-01': "사용자·관리자·외부 서비스·DB·파일 사이의 신뢰 경계와 보호할 데이터·작업을 식별한다.";
|
|
6
|
+
readonly 'SEC-02': "명시적으로 허용한 접근만 열고, 사용자·서비스 계정에 필요한 최소 권한만 부여한다.";
|
|
7
|
+
readonly 'SEC-03': "화면을 우회한 API·서버 함수·DB·파일·백그라운드 작업에서도 사용자·조직·대상별 권한을 확인한다.";
|
|
8
|
+
readonly 'SEC-04': "계정 정지·승인 철회·탈퇴·소속 변경 후 기존 세션·토큰·캐시에서도 권한 회수가 반영된다.";
|
|
9
|
+
readonly 'SEC-05': "코드의 보안 정책·migration·ACL·환경 설정이 대상 배포 환경의 실제 상태와 일치한다.";
|
|
10
|
+
readonly 'SEC-06': "로그인·관리자 인증·세션 만료·로그아웃·비밀번호 재설정·계정 복구가 계정 탈취를 막도록 동작한다.";
|
|
11
|
+
readonly 'SEC-07': "외부 입력·파일 업로드·출력의 형식·크기·경로를 검증하고 SQL 주입·XSS·SSRF·CSRF 등 해당 공격을 방어한다.";
|
|
12
|
+
readonly 'SEC-08': "API 키·토큰·개인키·개인정보가 코드·브라우저 번들·로그·점검 기록에 불필요하게 노출되지 않는다.";
|
|
13
|
+
readonly 'SEC-09': "의존성·CI/CD 권한·배포 자격증명·릴리스 산출물의 출처와 무결성을 확인해 공급망 위험을 통제한다.";
|
|
14
|
+
};
|
|
15
|
+
export declare const SECURITY_AREA_IDS: Array<keyof typeof SEC_AREAS>;
|
|
16
|
+
export declare const STATUS_KO: Record<SecurityStatus, string>;
|
|
17
|
+
export interface SecurityPolicy {
|
|
18
|
+
version?: string;
|
|
19
|
+
required_at?: Partial<Record<keyof typeof SEC_AREAS, SecurityStage[]>>;
|
|
20
|
+
required?: string[];
|
|
21
|
+
digest?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SecurityBinding {
|
|
24
|
+
stage: SecurityStage;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
export interface SecurityEvidence {
|
|
28
|
+
reference: string;
|
|
29
|
+
sha256: string;
|
|
30
|
+
checker: string;
|
|
31
|
+
checked_at: string;
|
|
32
|
+
result?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface SecurityAreaRecord {
|
|
35
|
+
id: keyof typeof SEC_AREAS;
|
|
36
|
+
explanation: string;
|
|
37
|
+
status: SecurityStatus;
|
|
38
|
+
required_at: SecurityStage[];
|
|
39
|
+
evidence?: SecurityEvidence[];
|
|
40
|
+
reason?: string;
|
|
41
|
+
finding?: string;
|
|
42
|
+
severity?: string;
|
|
43
|
+
owner?: string;
|
|
44
|
+
follow_up?: string;
|
|
45
|
+
mitigation?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface SecurityCheckInput {
|
|
48
|
+
run_id?: string;
|
|
49
|
+
time?: string;
|
|
50
|
+
stage: SecurityStage;
|
|
51
|
+
policy?: SecurityPolicy;
|
|
52
|
+
policy_digest?: string;
|
|
53
|
+
binding: SecurityBinding;
|
|
54
|
+
environment: string;
|
|
55
|
+
scope: string[];
|
|
56
|
+
checker: string;
|
|
57
|
+
areas?: SecurityAreaRecord[];
|
|
58
|
+
evidence?: SecurityEvidence[];
|
|
59
|
+
retry_of?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface SecurityCheckResult {
|
|
62
|
+
schema: 'yam.security-check-result.v1';
|
|
63
|
+
run_id: string;
|
|
64
|
+
time: string;
|
|
65
|
+
stage: SecurityStage;
|
|
66
|
+
policy: SecurityPolicy;
|
|
67
|
+
policy_digest: string;
|
|
68
|
+
binding: SecurityBinding;
|
|
69
|
+
environment: string;
|
|
70
|
+
scope: string[];
|
|
71
|
+
checker: string;
|
|
72
|
+
areas: SecurityAreaRecord[];
|
|
73
|
+
result: 'PASS' | 'BLOCKED';
|
|
74
|
+
blocking: string[];
|
|
75
|
+
deferred: string[];
|
|
76
|
+
errors: string[];
|
|
77
|
+
digest: string;
|
|
78
|
+
retry_of?: string;
|
|
79
|
+
}
|
|
80
|
+
export declare function createSecurityStarter(input: {
|
|
81
|
+
stage: SecurityStage;
|
|
82
|
+
policy?: SecurityPolicy;
|
|
83
|
+
binding: SecurityBinding;
|
|
84
|
+
environment: string;
|
|
85
|
+
scope?: string[];
|
|
86
|
+
checker?: string;
|
|
87
|
+
now?: Date;
|
|
88
|
+
run_id?: string;
|
|
89
|
+
}): SecurityCheckInput;
|
|
90
|
+
export declare function validateSecurityCheck(input: unknown, expectedBinding?: SecurityBinding, expectedPolicy?: SecurityPolicy): SecurityCheckResult;
|
|
91
|
+
export declare function persistSecurityCheck(dir: string, input: SecurityCheckInput, result?: SecurityCheckResult, options?: {
|
|
92
|
+
notePath?: string;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
run_path: string;
|
|
95
|
+
note_path: string;
|
|
96
|
+
result: SecurityCheckResult;
|
|
97
|
+
}>;
|
|
98
|
+
export declare function renderSecurityNote(result: SecurityCheckResult): string;
|
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
export const SECURITY_STATUSES = ['passed', 'needs_improvement', 'failed', 'unverified', 'not_applicable'];
|
|
6
|
+
export const SEC_AREAS = {
|
|
7
|
+
'SEC-01': '사용자·관리자·외부 서비스·DB·파일 사이의 신뢰 경계와 보호할 데이터·작업을 식별한다.',
|
|
8
|
+
'SEC-02': '명시적으로 허용한 접근만 열고, 사용자·서비스 계정에 필요한 최소 권한만 부여한다.',
|
|
9
|
+
'SEC-03': '화면을 우회한 API·서버 함수·DB·파일·백그라운드 작업에서도 사용자·조직·대상별 권한을 확인한다.',
|
|
10
|
+
'SEC-04': '계정 정지·승인 철회·탈퇴·소속 변경 후 기존 세션·토큰·캐시에서도 권한 회수가 반영된다.',
|
|
11
|
+
'SEC-05': '코드의 보안 정책·migration·ACL·환경 설정이 대상 배포 환경의 실제 상태와 일치한다.',
|
|
12
|
+
'SEC-06': '로그인·관리자 인증·세션 만료·로그아웃·비밀번호 재설정·계정 복구가 계정 탈취를 막도록 동작한다.',
|
|
13
|
+
'SEC-07': '외부 입력·파일 업로드·출력의 형식·크기·경로를 검증하고 SQL 주입·XSS·SSRF·CSRF 등 해당 공격을 방어한다.',
|
|
14
|
+
'SEC-08': 'API 키·토큰·개인키·개인정보가 코드·브라우저 번들·로그·점검 기록에 불필요하게 노출되지 않는다.',
|
|
15
|
+
'SEC-09': '의존성·CI/CD 권한·배포 자격증명·릴리스 산출물의 출처와 무결성을 확인해 공급망 위험을 통제한다.'
|
|
16
|
+
};
|
|
17
|
+
export const SECURITY_AREA_IDS = Object.keys(SEC_AREAS);
|
|
18
|
+
export const STATUS_KO = {
|
|
19
|
+
passed: '점검 완료 - 이상 없음', needs_improvement: '점검 완료 - 보완 필요', failed: '점검 실패',
|
|
20
|
+
unverified: '미검증', not_applicable: '해당 없음'
|
|
21
|
+
};
|
|
22
|
+
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];
|
|
23
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
24
|
+
const unsafeText = (x) => typeof x === 'string' && (/[\r\n\x00-\x1f\x7f<>]/.test(x) || x.length > 2048);
|
|
25
|
+
const iso = (x) => typeof x === 'string' && !Number.isNaN(Date.parse(x));
|
|
26
|
+
const obj = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
27
|
+
const stable = (x) => {
|
|
28
|
+
if (Array.isArray(x))
|
|
29
|
+
return `[${x.map(stable).join(',')}]`;
|
|
30
|
+
if (x && typeof x === 'object')
|
|
31
|
+
return `{${Object.keys(x).filter(k => x[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable(x[k])}`).join(',')}}`;
|
|
32
|
+
return JSON.stringify(x);
|
|
33
|
+
};
|
|
34
|
+
const digest = (x) => `sha256:${createHash('sha256').update(stable(x)).digest('hex')}`;
|
|
35
|
+
const clone = (x) => JSON.parse(JSON.stringify(x));
|
|
36
|
+
const hasSecret = (x) => secretPatterns.some((p) => p.test(typeof x === 'string' ? x : JSON.stringify(x)));
|
|
37
|
+
export function createSecurityStarter(input) {
|
|
38
|
+
const policy = clone(input.policy || { version: 'security-check-v1', required_at: {} });
|
|
39
|
+
const requiredAt = policy.required_at || {};
|
|
40
|
+
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] })) };
|
|
41
|
+
}
|
|
42
|
+
function evidenceErrors(e, index) {
|
|
43
|
+
const out = [];
|
|
44
|
+
if (!obj(e))
|
|
45
|
+
return [`evidence_invalid:${index}`];
|
|
46
|
+
if (typeof e.reference !== 'string' || !e.reference.trim() || e.reference.includes('\0'))
|
|
47
|
+
out.push(`evidence_reference_invalid:${index}`);
|
|
48
|
+
if (!SHA256.test(String(e.sha256 || '')))
|
|
49
|
+
out.push(`evidence_sha256_invalid:${index}`);
|
|
50
|
+
if (typeof e.checker !== 'string' || !e.checker.trim() || hasSecret(e.checker))
|
|
51
|
+
out.push(`evidence_checker_invalid:${index}`);
|
|
52
|
+
if (!iso(e.checked_at))
|
|
53
|
+
out.push(`evidence_time_invalid:${index}`);
|
|
54
|
+
if (Object.values(e).some(unsafeText))
|
|
55
|
+
out.push(`evidence_text_invalid:${index}`);
|
|
56
|
+
if (hasSecret(e))
|
|
57
|
+
out.push(`evidence_secret:${index}`);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export function validateSecurityCheck(input, expectedBinding, expectedPolicy) {
|
|
61
|
+
const errors = [];
|
|
62
|
+
const x = obj(input) ? input : {};
|
|
63
|
+
const validRun = typeof x.run_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(x.run_id);
|
|
64
|
+
const runId = validRun ? x.run_id : `invalid-${randomUUID()}`;
|
|
65
|
+
if (!validRun)
|
|
66
|
+
errors.push('run_id_invalid');
|
|
67
|
+
if (!['commit', 'push', 'deploy'].includes(x.stage))
|
|
68
|
+
errors.push('stage_invalid');
|
|
69
|
+
if (!iso(x.time))
|
|
70
|
+
errors.push('time_invalid');
|
|
71
|
+
if (!obj(x.binding))
|
|
72
|
+
errors.push('binding_missing');
|
|
73
|
+
if (x.binding?.stage !== x.stage)
|
|
74
|
+
errors.push('binding_stage_mismatch');
|
|
75
|
+
if (x.stage === 'deploy' && x.environment !== x.binding?.environment)
|
|
76
|
+
errors.push('environment_binding_mismatch');
|
|
77
|
+
if (Object.values(x).filter(v => typeof v === 'string').some(unsafeText))
|
|
78
|
+
errors.push('record_text_invalid');
|
|
79
|
+
if (expectedBinding && stable(x.binding) !== stable(expectedBinding))
|
|
80
|
+
errors.push('binding_mismatch');
|
|
81
|
+
if (typeof x.environment !== 'string' || !x.environment.trim())
|
|
82
|
+
errors.push('environment_invalid');
|
|
83
|
+
if (!Array.isArray(x.scope) || !x.scope.length || x.scope.some(v => typeof v !== 'string' || !v.trim() || unsafeText(v)))
|
|
84
|
+
errors.push('scope_invalid');
|
|
85
|
+
if (typeof x.checker !== 'string' || !x.checker.trim())
|
|
86
|
+
errors.push('checker_invalid');
|
|
87
|
+
const policy = obj(x.policy) ? clone(x.policy) : { version: 'security-check-v1', required_at: {} };
|
|
88
|
+
const policyDigest = x.policy_digest || policy.digest || digest(policy);
|
|
89
|
+
if (expectedPolicy && stable(policy) !== stable(expectedPolicy))
|
|
90
|
+
errors.push('policy_mismatch');
|
|
91
|
+
if (typeof x.policy_digest === 'string' && !SHA256.test(x.policy_digest))
|
|
92
|
+
errors.push('policy_digest_invalid');
|
|
93
|
+
if (!Array.isArray(x.areas))
|
|
94
|
+
errors.push('areas_missing');
|
|
95
|
+
const supplied = Array.isArray(x.areas) ? x.areas : [];
|
|
96
|
+
const byId = new Map();
|
|
97
|
+
supplied.forEach((a, i) => { if (!obj(a) || !Object.prototype.hasOwnProperty.call(SEC_AREAS, a.id))
|
|
98
|
+
errors.push(`area_invalid:${i}`);
|
|
99
|
+
else if (byId.has(a.id))
|
|
100
|
+
errors.push(`area_duplicate:${a.id}`);
|
|
101
|
+
else
|
|
102
|
+
byId.set(a.id, clone(a)); });
|
|
103
|
+
const requiredAt = (expectedPolicy || policy).required_at || {};
|
|
104
|
+
if (!policy.version || unsafeText(policy.version))
|
|
105
|
+
errors.push('policy_version_invalid');
|
|
106
|
+
for (const id of SECURITY_AREA_IDS) {
|
|
107
|
+
const stages = requiredAt[id];
|
|
108
|
+
if (!Array.isArray(stages) || !stages.length || stages.some(v => !['commit', 'push', 'deploy'].includes(v)))
|
|
109
|
+
errors.push(`policy_requirement_invalid:${id}`);
|
|
110
|
+
}
|
|
111
|
+
for (const id of SECURITY_AREA_IDS)
|
|
112
|
+
if (!byId.has(id))
|
|
113
|
+
errors.push(`area_missing:${id}`);
|
|
114
|
+
const areas = SECURITY_AREA_IDS.map((id) => {
|
|
115
|
+
const a = byId.get(id) || { id, explanation: SEC_AREAS[id], status: 'unverified', required_at: requiredAt[id] || [x.stage] };
|
|
116
|
+
if (a.explanation !== SEC_AREAS[id])
|
|
117
|
+
errors.push(`explanation_mismatch:${id}`);
|
|
118
|
+
if (!SECURITY_STATUSES.includes(a.status))
|
|
119
|
+
errors.push(`status_invalid:${id}`);
|
|
120
|
+
const trustedRequired = Array.isArray(requiredAt[id]) && requiredAt[id].length ? requiredAt[id] : ['commit', 'push', 'deploy'];
|
|
121
|
+
if (Object.values(a).filter(v => typeof v === 'string').some(unsafeText))
|
|
122
|
+
errors.push(`area_text_invalid:${id}`);
|
|
123
|
+
if (a.status === 'passed' && Array.isArray(a.evidence) && a.evidence.some(e => e?.result !== 'passed'))
|
|
124
|
+
errors.push(`evidence_result_mismatch:${id}`);
|
|
125
|
+
if (a.status === 'passed' && (!Array.isArray(a.evidence) || !a.evidence.length))
|
|
126
|
+
errors.push(`evidence_missing:${id}`);
|
|
127
|
+
if (['SEC-01', 'SEC-08'].includes(id) && (a.status === 'not_applicable' || !trustedRequired.includes(x.stage)))
|
|
128
|
+
errors.push(`mandatory_area_invalid:${id}`);
|
|
129
|
+
if (JSON.stringify(a.required_at) !== JSON.stringify(trustedRequired))
|
|
130
|
+
errors.push(`required_at_tampered:${id}`);
|
|
131
|
+
if (a.status === 'not_applicable' && (!a.reason || !a.reason.trim()))
|
|
132
|
+
errors.push(`not_applicable_reason_missing:${id}`);
|
|
133
|
+
if (a.status === 'needs_improvement' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation))
|
|
134
|
+
errors.push(`improvement_details_missing:${id}`);
|
|
135
|
+
if (a.status === 'failed' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation || !a.evidence?.length))
|
|
136
|
+
errors.push(`failure_details_missing:${id}`);
|
|
137
|
+
if (a.evidence && !Array.isArray(a.evidence))
|
|
138
|
+
errors.push(`evidence_array_invalid:${id}`);
|
|
139
|
+
(Array.isArray(a.evidence) ? a.evidence : []).forEach((e, i) => errors.push(...evidenceErrors(e, i).map((z) => `${id}:${z}`)));
|
|
140
|
+
return { ...a, explanation: SEC_AREAS[id], required_at: trustedRequired };
|
|
141
|
+
});
|
|
142
|
+
if (areas.filter((a) => a.id === 'SEC-01').length !== 1 || areas.filter((a) => a.id === 'SEC-08').length !== 1)
|
|
143
|
+
errors.push('mandatory_areas_missing');
|
|
144
|
+
if (hasSecret(x))
|
|
145
|
+
errors.push('record_contains_secret');
|
|
146
|
+
const blocking = areas.filter((a) => (a.required_at || []).includes(x.stage) && !['passed', 'not_applicable'].includes(a.status)).map((a) => `${a.id}:${a.status}`);
|
|
147
|
+
const allErrors = [...new Set(errors)];
|
|
148
|
+
const finalBlocking = [...new Set([...blocking, ...allErrors.map((e) => `contract:${e}`)])];
|
|
149
|
+
const result = finalBlocking.length ? 'BLOCKED' : 'PASS';
|
|
150
|
+
const canonical = { schema: 'yam.security-check-result.v1', 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 };
|
|
151
|
+
return { ...canonical, digest: digest(canonical) };
|
|
152
|
+
}
|
|
153
|
+
function ensureSafePath(root, target) {
|
|
154
|
+
const r = path.resolve(root);
|
|
155
|
+
const t = path.resolve(target);
|
|
156
|
+
if (t !== r && !t.startsWith(`${r}${path.sep}`))
|
|
157
|
+
throw new Error('path_outside_root');
|
|
158
|
+
return t;
|
|
159
|
+
}
|
|
160
|
+
async function noSymlinkComponents(root, target) {
|
|
161
|
+
const rel = path.relative(root, target);
|
|
162
|
+
let cur = root;
|
|
163
|
+
for (const part of rel.split(path.sep).filter(Boolean)) {
|
|
164
|
+
cur = path.join(cur, part);
|
|
165
|
+
try {
|
|
166
|
+
if ((await fsp.lstat(cur)).isSymbolicLink())
|
|
167
|
+
throw new Error('symlink_path_rejected');
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
if (e.code !== 'ENOENT')
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function withLock(lock, fn) { for (let i = 0; i < 50; i++) {
|
|
176
|
+
try {
|
|
177
|
+
await fsp.mkdir(lock);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (e.code !== 'EEXIST')
|
|
182
|
+
throw e;
|
|
183
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
184
|
+
}
|
|
185
|
+
if (i === 49)
|
|
186
|
+
throw new Error('security_note_lock_timeout');
|
|
187
|
+
} try {
|
|
188
|
+
await fn();
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await fsp.rm(lock, { recursive: true, force: true });
|
|
192
|
+
} }
|
|
193
|
+
export async function persistSecurityCheck(dir, input, result, options = {}) {
|
|
194
|
+
const root = await fsp.realpath(dir);
|
|
195
|
+
let checked = validateSecurityCheck(input);
|
|
196
|
+
// Integration may only add blocking findings; it cannot promote a failed contract.
|
|
197
|
+
if (result?.result === 'BLOCKED') {
|
|
198
|
+
checked = { ...checked, result: 'BLOCKED', errors: [...new Set([...checked.errors, ...result.errors])], blocking: [...new Set([...checked.blocking, ...result.blocking])] };
|
|
199
|
+
}
|
|
200
|
+
if (hasSecret(checked) || checked.errors.some(e => /text_invalid|secret/.test(e))) {
|
|
201
|
+
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' });
|
|
202
|
+
checked = validateSecurityCheck(sanitized);
|
|
203
|
+
checked.errors.push('unsafe_input_redacted');
|
|
204
|
+
checked.blocking.push('contract:unsafe_input_redacted');
|
|
205
|
+
}
|
|
206
|
+
checked.digest = digest({ ...checked, digest: undefined });
|
|
207
|
+
const base = ensureSafePath(root, path.join(root, '.yam', 'security'));
|
|
208
|
+
await noSymlinkComponents(root, base);
|
|
209
|
+
await fsp.mkdir(base, { recursive: true, mode: 0o700 });
|
|
210
|
+
const runs = ensureSafePath(root, path.join(base, 'runs'));
|
|
211
|
+
const originalRoot = path.resolve(dir);
|
|
212
|
+
const requestedNote = options.notePath ? path.resolve(originalRoot, options.notePath) : path.join(originalRoot, '.yam', 'security', 'notes.md');
|
|
213
|
+
ensureSafePath(originalRoot, requestedNote);
|
|
214
|
+
const note = ensureSafePath(root, path.resolve(root, path.relative(originalRoot, requestedNote)));
|
|
215
|
+
const lock = path.join(base, '.lock');
|
|
216
|
+
await noSymlinkComponents(root, note);
|
|
217
|
+
await noSymlinkComponents(root, runs);
|
|
218
|
+
let runPath = '';
|
|
219
|
+
await withLock(lock, async () => {
|
|
220
|
+
await noSymlinkComponents(root, note);
|
|
221
|
+
await fsp.mkdir(path.dirname(note), { recursive: true, mode: 0o700 });
|
|
222
|
+
await fsp.mkdir(runs, { recursive: true, mode: 0o700 });
|
|
223
|
+
runPath = ensureSafePath(runs, path.join(runs, `${checked.run_id}.json`));
|
|
224
|
+
await noSymlinkComponents(root, runPath);
|
|
225
|
+
try {
|
|
226
|
+
await fsp.lstat(runPath);
|
|
227
|
+
throw new Error('security_run_exists');
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
if (e.code !== 'ENOENT')
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
const handle = await fsp.open(note, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
|
234
|
+
try {
|
|
235
|
+
const info = await handle.stat();
|
|
236
|
+
if (!info.isFile() || info.nlink !== 1)
|
|
237
|
+
throw new Error('unsafe_note_file');
|
|
238
|
+
await fsp.writeFile(runPath, JSON.stringify(checked, null, 2) + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
239
|
+
await handle.writeFile(renderSecurityNote(checked), 'utf8');
|
|
240
|
+
await handle.sync();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await handle.close();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return { run_path: path.relative(root, runPath), note_path: path.relative(root, note), result: checked };
|
|
247
|
+
}
|
|
248
|
+
export function renderSecurityNote(result) {
|
|
249
|
+
const clean = (x) => String(x ?? '').replace(/[\r\n\x00-\x1f\x7f]/g, ' ');
|
|
250
|
+
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)}`, ''];
|
|
251
|
+
result.areas.forEach((a, i) => {
|
|
252
|
+
lines.push(`${i + 1}. ${a.id} : "${SEC_AREAS[a.id]}" : ${STATUS_KO[a.status] || '미검증'}`);
|
|
253
|
+
lines.push(` - Required at: ${a.required_at.join(', ')}`);
|
|
254
|
+
if (Array.isArray(a.evidence) && a.evidence.length)
|
|
255
|
+
lines.push(` - Evidence: ${a.evidence.map(e => `${clean(e.reference)} (${clean(e.sha256)}) by ${clean(e.checker)} at ${clean(e.checked_at)}`).join('; ')}`);
|
|
256
|
+
for (const field of ['reason', 'finding', 'severity', 'owner', 'follow_up', 'mitigation'])
|
|
257
|
+
if (a[field])
|
|
258
|
+
lines.push(` - ${field}: ${clean(a[field])}`);
|
|
259
|
+
});
|
|
260
|
+
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 --', '');
|
|
261
|
+
return lines.join('\n');
|
|
262
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type SecurityBinding = Record<string, unknown>;
|
|
2
|
+
export interface SecurityCommandOptions {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
cliPath?: string;
|
|
5
|
+
stdin?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function collectSecurityBinding(cwd: string, stage: string, options: Map<string, string>, stdin?: string): SecurityBinding;
|
|
8
|
+
export declare function runSecurityCommand(args: string[], settings?: SecurityCommandOptions): Promise<Record<string, unknown>>;
|