署名は合っているが、そのビルダーを許可した覚えがない
한국어 원문으로 표시합니다.
목표
릴리스 묶음에 출처 증명을 붙여 서명하고, 증거가 모자란 묶음을 배포 전에 막는 관문을 프로그램으로 만듭니다. 막히는 경우를 세 가지 직접 만들어 확인합니다.
왜 중요한가
서명은 "누가 만들었나" 에 답하고 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 의 실제 해시입니다.
관문이 막았다는 사실은 그 순간 화면에만 남습니다. 나중에 '그때 왜 막혔지' 를 물으려면 판정과 그 근거를 함께 적어 두어야 합니다.