refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 16:02:21 +09:00
parent 30ceac23c1
commit 4dc033cf33
72 changed files with 13370 additions and 1549 deletions
+166 -20
View File
@@ -10,6 +10,39 @@ checking, the typed contract checker, and this workflow drift check. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
Contract loading validates every registered command entry against the
authoritative root package-script graph and lifecycle/install policy before the
runner enters its execution callback. Foreign cwd/workspace script dispatch,
argument-sensitive dispatchers, and ineffective lifecycle suppression therefore
fail preflight even when the later `check:ci` command would never run.
For npm, that root-only boundary also parses options following an explicit
`run`/`run-script` target or implicit `test`/`start`/`stop`/`restart` target:
`--workspace`, `-w`, `--workspaces`, and `--prefix` are foreign manifest scope
and are rejected before execution. The first literal `--` ends npm option
parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ:
their options following the selected script name are forwarded to that script;
their cwd/workspace selectors are rejected only where the manager consumes
them before script selection.
The npm graph includes existing root-manifest `pre<script>` and `post<script>`
hooks around every explicit `run`/`run-script` dependency and implicit
`start`/`stop`/`restart`/`test` dependency. Hooks are omitted only when ordered
npm options establish an unambiguous effective `--ignore-scripts` value before
the first literal `--`; bare and explicitly true forms omit hooks, while false,
negative, contradictory, malformed, and post-delimiter forms keep traversal or
fail closed. Case-insensitive `npm_config_workspace`, `npm_config_workspaces`,
`npm_config_prefix`, `npm_config_userconfig`, and `npm_config_globalconfig`
assignments are rejected whenever the tokenized command invokes npm. Direct
assignments, static paths whose basename is `env`, exact `command`/`exec`
prefix chains, prior exports, and `set -a` assignments share one prefix grammar.
Only modeled non-scope `env -i`, `env -u`, `env --unset`, and `env --` forms are
allowed; cwd-changing or unknown options fail closed. Dynamic assignment names,
unmodeled environment mutation, npm `--userconfig`/`--globalconfig`, and
unquoted pathname expansion before the npm argument delimiter are rejected.
The gate runner checks the same inherited environment names before loading the
contract or entering the execution callback, so they cannot reach a child gate
process.
The dependency graph is:
```text
@@ -52,19 +85,98 @@ digest가 일치할 때만 report를 업로드한다. Promotion은 같은 archiv
별도 경로로 내려받고 SHA/member 검증을 마친 뒤 격리된 root에 추출하여 local
evidence를 read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다.
Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체를 변경 없이
그대로 승격한다.
그대로 승격한다. Local evidence 관계 검증에 필요한 policy와 verifier source도
archive member로 고정되며, checkout 밖의 격리된 cwd에서도 archive path와 기대
digest만으로 candidate-internal check를 재계산하고 archived secret-scan
policy/rule/SARIF/zero-finding/digest 관계를 검증한다. Archive에 없는 checkout
source를 다시 scan했다고 주장하지 않는다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다.
workflow의 provider job은 Linux runner에 실행 권한이 있는
`/usr/bin/bwrap`를 필수로 요구하며, trusted `process.execPath`를 sandbox 안의
`/tmp/node`에 read-only bind한다. Provider command는 bubblewrap 안에서
`/bin/sh -eu -c`로 비대화식 실행되고 30분 안에 종료되어야 한다. Sandbox는
workspace를 read-only로 bind하고 `.git`을 가리며, 별도의 `untrusted`
raw-evidence 하위 디렉터리만 writable로 노출한다. 따라서 command는 전달된
candidate/environment 값을 읽고 지정된 raw report 하나만 기록해야 하며,
workspace 수정, host home/toolcache 접근, sealed evidence 직접 기록에 의존하면
안 된다. Supervisor는 provider 종류에 해당하는 credential prefix와 제한된
환경만 전달하고, sandbox 또는 출력 경계를 만들 수 없으면 fail closed한다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. Linux
runner에 실행 가능한 `/usr/bin/bwrap`, `/usr/bin/prlimit`,
`/usr/bin/systemd-run`, `/usr/bin/systemctl`, `bwrap --size` 지원, active user
bus, systemd user manager 254 이상, unified cgroup v2와 delegated
memory/pids/CPU controller가 모두 필요하다. 실행 파일, user manager/version,
trust/archive/path 같은 host-side preflight 실패는 raw report 생성 전에 차단된다.
`bwrap --size` 수용 여부와 실제 delegated controller/limit 값은 owned raw inode를
만든 뒤 scope 안에서만 확정할 수 있으며, 이 단계의 실패는 해당 inode를
identity-bound cleanup하고 fail closed한다.
각 provider는 고유한 collected user scope에서 실행된다. Supervisor는 실행 전에
실제 cgroup membership과 `memory.max=1073741824`, `memory.swap.max=0`,
`pids.max=64`, `cpu.max="100000 100000"`을 확인한다. 내부 process에는 core 0,
file-size 8,388,607 bytes, open FD 64, CPU 1,200초 상한도 적용된다.
같은 UID 전체에 합산되는 `RLIMIT_NPROC`로 provider별 32개를 보장한다고 주장하지
않으며 aggregate PID authority는 cgroup `TasksMax=64`다.
Bubblewrap는 network namespace를 분리해 완전 offline으로 실행하고 workspace,
verified candidate, `.git` mask, `/tmp`, `/etc`, `/proc`, `/dev`를 read-only로
유지한다. Archive 검증·추출과 sandbox/trust preflight가 끝난 뒤 supervisor가
생성하고 inode를 고정한 정확한 raw report 파일 하나만 read-write bind된다.
주변 `untrusted` directory 전체는 writable이 아니다. 실행 전 또는 provider
실패 시, 그리고 성공적으로 sealed evidence를 게시한 뒤에도 supervisor가 소유한
inode만 atomic quarantine을 거쳐 제거하므로 빈 stale report 없이 재시도할 수 있다.
Provider command와 provider-prefixed environment는 bounded length-prefixed bwrap vector로
`systemd-run` stdin에 전달되어 supervisor/systemd/bwrap wrapper argv나 unit
metadata에 노출되지 않는다. 단, 최종 provider executable의 일반 argv는 같은
UID의 process inspection에 보일 수 있으므로 command 문자열과 인자에 token,
password, private-key material을 넣으면 안 된다. Credential은 반드시 해당 종류의
`VULNERABILITY_PROVIDER_*` 또는 `PROVENANCE_PROVIDER_*` environment로만 전달하고
`*_COMMAND`에는 넣지 않는다. 정상 provider 종료까지 같은 stdin을 parent-liveness
pipe로 열어 두며 supervisor hard death의 EOF를 받은 in-scope wrapper는 provider
process group 전체를 종료하고 dev/inode가 일치하는 raw report만 정리한다.
별도의 trusted guardian child는 provider scope 밖에서 filesystem transaction 전체를
소유한다. Client는 spawn 전에 canonical raw/evidence directory를
`O_DIRECTORY|O_NOFOLLOW`로 열고 identity를 확인한 뒤 provider kind와 nonce에서
canonical raw/final 및 nonce-private raw-staging/sealed-temp exact leaf를 확정한다.
Client가 두 private file을 `O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW` mode `0600`으로
미리 할당하고 dev/inode를 기록한다. Directory descriptor는 guardian fd 3/4,
private file descriptor는 fd 5/6으로 상속되며 guardian argv에는 Node와 helper
경로만 있다. 최초 canonical v2 frame에는 provider kind, absolute deadline,
32-byte nonce만 전달한다.
Guardian bootstrap은 procfs link를 읽기 전에 fd 5/6을 fstat한다. Procfs pathname은
canonical directory의 exact direct-child grammar를 만족하고 descriptor-relative
lstat이 이미 확보한 fd identity/type/mode/size/link count와 일치할 때만 해당
identity의 cleanup alias가 된다. Guard frame 검증 뒤 raw staging을 fixed raw leaf에
no-overwrite hard link하고 두 alias의 link count 2를 확인한 다음 private alias를
제거하고 raw directory를 sync한다. Canonical raw가 같은 identity와 link count 1로
남은 뒤에만 READY를 응답한다. READY 전 종료 시 client는 pre-spawn raw identity로
raw staging/canonical을, sealed identity로 temp/final을 각각 확인해 일치하는 alias만
정리한다. 현재 canonical pathname을 새 ownership identity로 승격하지 않으므로 외부
file과 concurrent same-kind winner를 보존하면서 같은 workspace를 즉시 재시도할 수
있다. Supervisor는 READY identity와 canonical target도 정확히 확인한다.
검증된 JSON bytes는 pinned temp inode에만 기록하고 `0400` 적용과 file sync를 마친다.
Guardian은 authenticated publish의 size/hash/identity를 재검증하고 같은 directory에서
no-replace `link(temp, final)`, temp unlink, directory sync를 수행한 뒤 PUBLISHED를
응답한다. `GITHUB_OUTPUT` append 이후 commit은 raw inode를 제거하고
`commitPending`으로만 전이하며, 깨끗한 control EOF까지 확인해야 PASS가 된다. 그 전의
EOF, deadline, 잘못된 frame/nonce, commit 뒤 추가 byte 또는 guardian 조기 종료는
identity가 일치하는 raw/temp/final을 모두 정리하고 fail closed한다. Provider wall
timeout 30분에 post-processing 10분을 더한 guardian lease 상한은 40분이다.
Client가 private allocation 뒤 guardian spawn 전에 hard stop되면 empty mode-`0600`
nonce-private leaf만 남을 수 있다. 기록되지 않은 pathname은 ownership 근거가 아니므로
자동 sweeping하지 않는다. 이 private leaf는 fixed raw/final name을 점유하지 않아
same-kind retry를 막지 않는다.
`GITHUB_OUTPUT`은 runner가 소유한 regular file이라는 실행기 계약을 전제로 한다.
Sealing/output I/O의 OS-level cancellation을 보장하지 않는다. Provider scope가 이미
수집된 뒤 guardian이 종료되면 scope-active latch는 늦은 kill을 시작하지 않고 lifecycle
error만 기록한다. 이후 publish/commit의 awaited failure가 identity가 고정된
raw/temp/final fallback을 모두 정리하고 fail closed한다.
Provider stdout/stderr는 credential을 포함할 수 있는 untrusted bytes이므로 CI log로
재전송하거나 보관하지 않고, byte 수만 합산해 1 MiB 상한을 적용한다. Guardian의
stderr/control fd가 닫혀 진단 출력이 `EPIPE`/`EBADF`가 되어도 cleanup 뒤 nonzero
종료는 생략되지 않는다. Provider wall-clock 상한은 30분이다. Wall
timeout, aggregate output 초과, parent-liveness loss, 실행 중 guardian loss는 provider process group을
명시적으로 SIGKILL한다. 일반 command 실패와 FD/CPU RLIMIT 종료는 실제 exit/signal로
systemd completion을 거치며, 모든 경로에서 wrapper 종료와 systemd unit/cgroup
collection을 확인한다. Adapter는 사전에 배치된 offline data와 supervisor candidate binding만
읽어 정확한 report inode에 기록해야 한다. Scope/cgroup limit drift, residual unit,
workspace·host home/toolcache·sealed evidence 접근 의존성은 모두 blocking failure다.
Workflow가 실행하는 action은 `scripts/contracts/ci-gates.ts`의 단일 typed,
runtime-frozen registry에서만 resolve된다. `uses:`에는 repository 별칭, tag,
@@ -192,6 +304,9 @@ Repository variables required by higher tiers:
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID` for separately managed
trusted Ed25519 verification material
두 provider role은 서로 다른 key ID뿐 아니라 canonical DER-SPKI public-key bytes도
사용해야 한다. 동일 key를 서로 다른 ID로 재등록한 구성도 finalizer가 거절한다.
If any external provider command, report, trust path, or key ID is absent,
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
generator/restore fallback.
@@ -215,20 +330,51 @@ consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보
manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시
검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest
revalidation을 대신하지 않는다.
Promotion job에는 job-level `if`가 없다. 기본 `needs` 성공 의미론으로 immutable
build, vulnerability provider, provenance provider 세 job이 모두 성공해야 하며,
`always()``cancelled()`로 cancellation을 덮어쓰지 않는다. Bare `always()`
step cleanup에만 사용된다. 다만 cancellation 시 cleanup 실행 여부는 workflow
정적 타입이나 단위 테스트로 증명하지 않았으며 runner/native smoke에서 확인해야
하는 신뢰 경계다.
Finalizer output은 `RUNNER_TEMP` 아래 random private directory이며 exact-five
upload는 `${{ steps.finalize.outputs.staging_root }}`만 사용한다. 바로 다음
`always()` cleanup은 finalizer의 token과 runner-temp device/inode를 모두
요구한다. stable `.release/promoted-staging` directory를 만들거나 재사용하지
않는다. exact five는 captured archive/report 두 개와 process 안에서 생성한
provider/promotion verification v3 두 개이며 promotion record는 provider record,
local assessment, report hashes와 run/source/candidate/nonces/key identities/
trust-policy hash를 함께 bind한다. 이 descriptor-relative 정리는 ancestor 교체와 symlink leaf를
fail-closed로 처리하지만 upload action의 same-UID pathname reopen 또는 atomic
upload는 `${{ steps.finalize.outputs.staging_root }}` 아래 다음 다섯 canonical
pathname만 사용한다: `release-candidate.tar.gz`, `vulnerability-report.json`,
`provenance-attestation.json`, `provider-verification.json`,
`promotion-verification.json`. 바로 다음
`always()` cleanup은 staging path/token, runner-temp device/inode와 staging-leaf
device/inode 여섯 output을 모두 요구한다. cleanup은 pin한 leaf descriptor에서
exact-five name만 unlink하고 non-recursive `rmdir`만 사용하므로 교체된 directory나
canary tree를 recursive 삭제하지 않는다. stable `.release/promoted-staging`
directory를 만들거나 재사용하지 않는다. exact five는 captured archive 한 개,
captured report 두 개와 process 안에서 생성한 provider/promotion verification v3
두 개이며 promotion
record는 provider record, local assessment, report hashes와 run/source/candidate/
nonces/key identities/trust-policy hash 및 signed `secretScanAttestation`을 함께
bind한다. 이 attestation은 PASS와 local-assessment/source-set/policy/SARIF/
scan-input digest를 포함한다. Supervisor/finalizer는 captured archive에서 기대
tuple을 유도해 exact equality를 확인하지만, 실제로 같은 source-set 전체를
독립 스캔하고 forged empty SARIF에 서명하지 않을 책임은 trusted vulnerability
provider에 있다. Staging은 restrictive
umask와 무관하게 directory `0700`, file `0400`을 강제하고, 모든 write 뒤 live
time으로 exact-five signature/freshness를 다시 확인한 뒤에만 output을 공개한다.
Descriptor-relative 정리는 ancestor/leaf 교체와 symlink를 fail-closed로 처리하지만
upload action의 same-UID pathname reopen 또는 atomic
`renameat2` handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인
전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five
upload-download와 success, validation failure, upload failure, cancellation 각각의
cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 `renameat2`
보장이 없다.
또한 portable Node의 `mkdir`와 최초 pathname `lstat`는 atomic하지 않다. 구현은
mkdir 직후 metadata를 저장하고 이후 `O_DIRECTORY|O_NOFOLLOW` descriptor의
device/inode와 비교한 뒤에만 permission을 바꾸지만, 최초 lstat보다 앞서 성공한
malicious same-UID 교체는 native/privilege 경계로 남는다. 따라서 `RUNNER_TEMP`
private `0700` ancestor와 exclusive single-tenant runner가 필수다.
실패 cleanup도 created device/inode와 opened descriptor가 일치한 뒤에만 활성화된다.
불일치 descriptor는 close만 수행하며 현재 visible replacement pathname은 unlink나
`rmdir`하지 않는다. 공격자가 original directory를 다른 이름이나 parent 밖으로
이동한 경우 portable Node parent scan으로 안전하게 회수할 수 없으므로, 공격자를
배제한 trusted runner/native cleanup 또는 격리된 test fixture가 잔여 directory를
후처리해야 한다.
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by