The Build Was Green — So Who Put That Library In?
The signature checks out, but we never allowed that builder
한국어 원문으로 표시합니다.
목표
릴리스 묶음에 출처 증명을 붙여 서명하고, 증거가 모자란 묶음을 배포 전에 막는 관문을 프로그램으로 만듭니다. 막히는 경우를 세 가지 직접 만들어 확인합니다.
왜 중요한가
서명은 "누가 만들었나" 에 답하고 SBOM 은 "무엇이 들어 있나" 에 답합니다. 그런데 사고 보고서에 가장 많이 남는 문장은 셋 다 아닙니다 — "이 바이트가 어디서 왔는지 아무도 모른다" 입니다. 출처 증명은 그 질문의 답을 빌드 시점에 적어 두는 일입니다. 무엇을 입력으로 썼고, 어느 커밋에서, 어느 빌더가, 언제 만들었는지. 그리고 그 기록은 배포 전에 읽히지 않으면 없는 것과 같습니다. 관문이 하는 일이 그것이고, 관문의 값어치는 무엇을 막았는지가 아니라 왜 막았는지를 사람이 읽을 수 있는가에서 나옵니다. 마지막 경우를 꼭 직접 만들어 보세요 — 서명도 맞고 다이제스트도 맞는데, 그 빌더를 허락한 적이 없는 묶음입니다.
단계
/root/gate/release에 산출물과 SBOM 을 모으고/root/gate/subject.json을 적습니다./root/gate/release/provenance.json에 in-toto Statement 를 씁니다.- 재료의 다이제스트와 빌드 시각을 더합니다.
- 키 쌍을 만들어
/root/gate/release/provenance.json.sig로 서명합니다. /root/gate/allowed-builders.txt와/root/gate/release-gate.sh로 관문을 만듭니다.- 정상 묶음의 판정을
/root/gate/06-pass.json에 남깁니다. /root/gate/unsigned·/root/gate/tampered·/root/gate/rogue를 만들어/root/gate/07-reject.json에 남깁니다.- 네 판정을
/root/gate/08-audit.jsonl에 감사 기록으로 남깁니다.
참고
- 작업은 전부
/root/gate아래에서 합니다. 먼저mkdir -p /root/gate/release를 하세요. - 필드 이름은 SLSA Provenance v1 을 그대로 씁니다.
predicateType은 URL 창에 보이는 주소가 아니라 문서가 적어 둔https://slsa.dev/provenance/v1를 그대로 넣습니다. - 사유 코드는
sbom_missing·signature_missing·signature_invalid·subject_mismatch·builder_not_allowed다섯 개이고,reasons는 사전순 중복 없이 담습니다. - 셋째 묶음(
rogue)은 provenance 를 고친 뒤 다시 서명해야 합니다. 안 그러면 서명 쪽에서 먼저 걸려서 이 단계가 가르치려는 것이 드러나지 않습니다. - 흔한 실수 — 관문이 어떤 경우에도 0 으로 끝나게 두는 것(그러면 파이프라인이 멈추지
않습니다), 그리고
subject의name을 절대 경로로 적어 묶음 안에서 파일을 못 찾는 것입니다. - 실습 파드에는 볼륨이 없습니다. 세션이 끝나면
/root/gate는 통째로 사라집니다.
릴리스 묶음을 한자리에 모은다
/root/gate/release/ 아래에 /opt/fixtures/sbom/release/paygate-1.4.2.js 를 paygate-1.4.2.js 로, /opt/fixtures/sbom/sbom/paygate-1.4.2.cdx.json 을 sbom.cdx.json 으로 복사하세요. 그다음 /root/gate/subject.json 에 in-toto 의 subject 배열을 저장하세요 — 항목 하나이고 name 은 파일 이름, digest 는 {"sha256": 계산한 값} 입니다.
증명은 언제나 '무엇에 대한' 증명입니다. subject 가 그 '무엇' 이고, 이름이 아니라 다이제스트로 가리켜야 나중에 바꿔치기를 잡을 수 있습니다.
빌드가 무엇을 무엇으로부터 만들었는지 적는다
/root/gate/release/provenance.json 에 in-toto Statement 를 저장하세요. _type 은 "https://in-toto.io/Statement/v1", subject 는 1단계의 배열, predicateType 은 "https://slsa.dev/provenance/v1" 입니다. predicate.buildDefinition 에는 buildType "https://labhub.example/buildtypes/npm-bundle/v1" 과 externalParameters 를 두는데, externalParameters 는 source(uri "git+https://git.internal/payments/paygate", digest {"gitCommit": "9f2c1d7a4b6e8035c1a2d4f6b8093e5a7c1d2f40"})와 entryPoint "npm run build" 를 담습니다. predicate.runDetails.builder.id 는 "https://labhub.example/builders/paygate-ci@v3" 입니다.
필수는 buildDefinition 과 runDetails 이고, 그 안에서 buildType·externalParameters·builder 가 필수입니다. buildType 이 URI 인 이유는 '이 칸들을 어떻게 읽어야 하는지' 를 가리키는 주소이기 때문입니다.
재료와 시각을 적는다
/root/gate/release/provenance.json 의 predicate.buildDefinition 에 resolvedDependencies 를 더하세요. 항목은 uri 와 digest 를 갖고, 두 재료는 file:///opt/fixtures/sbom/release/package-lock.json 과 file:///opt/fixtures/sbom/sbom/paygate-1.4.2.cdx.json 이며 digest 는 {"sha256": 그 파일의 해시} 입니다. uri 사전순으로 담으세요. 그리고 predicate.runDetails.metadata 에 invocationId "build-2026-09-11-0007", startedOn "2026-09-11T03:40:00Z", finishedOn "2026-09-11T03:52:00Z" 를 적습니다.
재료에 다이제스트를 적어 두면 '같은 입력으로 다시 만들면 같은 것이 나오는가' 를 나중에 물어볼 수 있습니다. 이름만 적으면 그 질문을 던질 수 없습니다.
증명에 서명해 묶음에 넣는다
prime256v1 키 쌍을 /root/gate/release-2026.key 와 /root/gate/release-2026.pub 에 만들고, 그 비밀키로 /root/gate/release/provenance.json 에 SHA-256 서명을 해 /root/gate/release/provenance.json.sig 에 저장하세요.
서명되지 않은 증명은 SLSA 문서가 Build L1 로 두는 자리입니다 — 실수는 막지만 위조는 막지 못합니다. 서명이 붙어야 '빌드 뒤에 손댔는가' 를 물을 수 있습니다.
관문을 프로그램으로 적는다
/root/gate/allowed-builders.txt 에 허용 빌더를 한 줄에 하나씩 적고(이번에는 https://labhub.example/builders/paygate-ci@v3 하나), /root/gate/release-gate.sh 를 만드세요. bash /root/gate/release-gate.sh <묶음 디렉터리> 로 부르면 bundle·verdict·reasons 를 담은 JSON 한 덩이를 표준 출력에 내고, 막을 것이 있으면 1 로 끝납니다. 보는 것은 넷입니다 — sbom.cdx.json 의 components 가 비어 있지 않은가(없으면 sbom_missing), provenance.json.sig 가 있고 /root/gate/release-2026.pub 으로 검증되는가(없으면 signature_missing, 틀리면 signature_invalid), provenance.json 의 subject 다이제스트가 묶음 안 그 파일의 실제 해시와 같은가(다르면 subject_mismatch), builder.id 가 허용 목록에 있는가(없으면 builder_not_allowed). reasons 는 사전순으로 중복 없이 담습니다.
관문의 값어치는 '무엇을 막았는가' 가 아니라 '왜 막았는지를 사람이 읽을 수 있는가' 에서 나옵니다. 사유를 코드로 남기면 대시보드도 회고도 그 코드로 셉니다.
정상 묶음이 통과하는 것을 확인한다
bash /root/gate/release-gate.sh /root/gate/release 를 돌려 그 출력을 /root/gate/06-pass.json 에 저장하세요. verdict 는 pass, reasons 는 빈 배열이어야 합니다.
먼저 통과하는 것을 확인하지 않고 막는 것부터 시험하면, 관문이 '항상 막는' 것이어도 성공한 것처럼 보입니다.
증거가 모자란 묶음 셋을 만들어 막는다
/root/gate/release 를 복사해 /root/gate/unsigned(서명 파일만 지운 것)·/root/gate/tampered(paygate-1.4.2.js 의 내용을 바꾼 것)·/root/gate/rogue(provenance.json 의 builder.id 를 "https://labhub.example/builders/laptop@v1" 로 바꾸고 같은 키로 다시 서명한 것)를 만드세요. 셋을 각각 관문에 넣고 /root/gate/07-reject.json 에 unsigned·tampered·rogue 를 저장하되, 각 값은 exit(종료 코드)와 reasons(관문이 낸 사유 배열)를 담습니다.
셋째가 이 실습의 핵심입니다 — 서명은 멀쩡하고 다이제스트도 맞는데, 저 빌더를 우리가 허락한 적이 없습니다. 서명만 보는 관문은 이것을 통과시킵니다.
판정을 감사 기록으로 남긴다
네 판정을 /root/gate/08-audit.jsonl 에 한 줄씩 JSON 으로 남기세요. 줄은 bundle 이름 사전순(release·rogue·tampered·unsigned)이고, 각 줄은 bundle(디렉터리 이름만)·verdict·reasons·builder·artifact_sha256·checked_at("2026-09-11T04:00:00Z")을 담습니다. artifact_sha256 은 그 묶음 안 paygate-1.4.2.js 의 실제 해시입니다.
관문이 막았다는 사실은 그 순간 화면에만 남습니다. 나중에 '그때 왜 막혔지' 를 물으려면 판정과 그 근거를 함께 적어 두어야 합니다.