Record what happened at each step of a build, sign it, store it next to the thing you built in an OCI registry, and let anyone who downloads it check it.
build step ─► cilock records inputs, command, outputs ─► signs it
─► stores the record beside the artifact in the registry
─► a downloader pulls both ─► cilock verify ─► PASSED / REFUSED
Two paths, same idea:
| Path | Artifact | Needs a platform? | Time |
|---|---|---|---|
| A. Binary (start here) | a plain executable, built in six steps | No — runs fully offline, registry optional | ~10 min |
| B. Container | a Docker image | Yes — the TestifySec platform publishes the record for you | ~20 min |
The registry used below is JFrog Artifactory, but anything OCI-compliant works (ghcr, Docker Hub, ECR, Harbor).
| Tool | Used for | Install (macOS) | Tested with |
|---|---|---|---|
cilock |
records, signs, uploads, verifies | curl -fsSL https://cilock.dev/install.sh | sh |
4.4.0, 4.5.0 |
go |
builds the sample app | brew install go |
1.26 |
openssl, jq, python3 |
keys, JSON | preinstalled / brew install jq |
jq 1.7.1 |
syft |
SBOM step | brew install syft |
1.23.1 |
trivy |
vulnerability-scan step | brew install trivy |
0.62.0 |
oras |
pushes/pulls the binary + evidence | brew install oras |
1.2.3 |
crane |
reads the registry (container path) | brew install crane |
0.20.3 |
docker + buildx |
builds the image (container path) | Docker Desktop | 29.6 |
Path A needs the first five; add oras to store in a registry. Path B needs all.
- Start a trial at jfrog.com. You get
https://<name>.jfrog.io. - Repository: trials usually come with a Docker repository already (for
example
docker-trial); check Artifactory → Artifacts. If not: Administration → Repositories → Create a Repository → Local → Docker, and note the key. - Token: avatar → Edit Profile → Generate an Identity Token. Copy it; it is shown once.
- Check it works:
docker login <name>.jfrog.io -u <your-email> # paste the token as the password
- An account at platform.testifysec.com (you are the owner of your tenant).
- Log cilock in, and create a product for this repo:
cilock login cilock use --product cilock-attach-demo # creates it if missing - Path B only — in the platform UI, Settings → Connectors → Container Registries → Add:
host
<name>.jfrog.io, repository key, username, the token from step 2.3, tick Docker build, leave Require policy pass off for now.
git clone <this repo> && cd cilock-attach-demo
cp demo/.env.local.example demo/.env.local && chmod 600 demo/.env.local
# edit demo/.env.local: REGISTRY_HOST, REPO_KEY, REGISTRY_USER, REGISTRY_PASSdemo/.env.local, the signing key (.keys/), generated policies and all build
output are gitignored. Nothing secret is committed.
cilock version && go version && syft version | head -1 && trivy --version | head -1
cilock doctor # only needed for uploads / Path B
source demo/.env.local && oras login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin <<<"$REGISTRY_PASS"
go test ./... # the sample app: prints a greetingOFFLINE=1 ./demo/10-binary-attest.sh # signs locally, nothing uploaded
./demo/10-binary-attest.sh # same, and uploads each record to the platformThe first run creates a throwaway signing key in .keys/. Six steps, each
wrapped in cilock run — the build commands themselves do not change:
| step | command | extra records |
|---|---|---|
| deps | go mod verify |
|
| test | go test -v ./... |
|
| build | go build -o dist/hello . |
SLSA provenance, Go build info |
| sbom | syft dist/hello -o spdx-json=… |
SBOM (SPDX) |
| scan | trivy fs --format json … |
vulnerability scan |
| package | tar czf dist/hello.tar.gz … |
Every step records the git commit, what it read, what it wrote, and the exact
command with its exit code. One signed envelope per step lands in
evidence/binary/, next to a material-inventory file per step.
./demo/11-binary-verify.shcilock policy from-bundles reads the six envelopes and writes the policy: one
entry per step, the key that signed it, and the step-to-step links it found by
matching file digests. Then three checks:
| case | expected |
|---|---|
| the binary you built | exit 0, PASSED |
| the same binary with one byte appended | exit 1 — not the file that was built |
the right binary, but the test step's record removed |
exit 1 — a step was skipped |
./demo/12-binary-store.sh v1Pushes two things into $REGISTRY_HOST/$REPO_KEY/hello:
hello:v1 the binary
hello:sha256-<binary digest>.att the six envelopes + their inventories
Then it pulls the binary into a clean folder, hashes it, derives the evidence tag from that hash, pulls the evidence and verifies the downloaded copy. PASSED, with nothing but the registry. The evidence tag is the binary's own fingerprint, so no lookup service is needed to find it.
Needs sections 3 and 4 above. The image-path scripts detect a hosted platform and tell you which UI click to make where a local platform would be scripted.
./demo/01-publish.sh v1 # build + push the image, record + sign + upload;
# waits for the PLATFORM to write sha256-<digest>.att
./demo/04-inspect.sh v1 # peel the .att open: media type, signer, what's inside,
# and that the signed payload names this image digest
./demo/02-verify.sh v1 # pull it back with crane, verify offline with cilock
./demo/03-policy-gate.sh v2 # turn "Require policy pass" on; one image gets its
# record published, the other is refused (fail-closed)What happens in 01-publish.sh: cilock wraps docker buildx build --push,
signs, and uploads to the platform. The platform matches the image subject to
your connector and writes the record to <repo>:sha256-<image digest>.att
using the credential stored on the connector — not your docker login. In
JFrog, "Deployed By" on the .att is that credential's owner.
02-verify.sh path A (platform-side verify) and 03-policy-gate.sh need a
policy bound to the product. On a local TestifySec development platform,
00-setup.sh creates everything (TestifySec engineers only: it drives the
platform's GraphQL API through the jade tool). On the hosted platform, create
the policy in Test Plans.
From the record's subjects, never guessed:
| record flavour | image ref | image digest |
|---|---|---|
| cilock / witness | …/imagereference subject |
…/imagedigest subject |
| cosign / SLSA | subject.name |
subject.digest.sha256 |
Target: <repo>:sha256-<digest>.att, one layer of type
application/vnd.dsse.envelope.v1+json — the cosign layout, so cosign-aware
tools find it.
A record is published only if all of these hold:
- it names an image (reference + digest) — plain files are skipped today;
- that digest equals the registry's manifest digest;
- the image repository is under the connector's host and repository key;
- (auto-sync) its type matches the connector's filter;
- the registry accepts the stored credential;
- (gate on) it passed the policy bound to its product.
Real errors met while building this demo, and what fixed them.
| You see | Why | Fix |
|---|---|---|
attestation too large … over the 4MiB limit, mostly command-run |
cilock stores the command's console output inside the record | send noisy output to a file: cilock run … -- sh -c 'make > build.log 2>&1' — the log is then recorded by digest |
upload 401: Invalid API credential from GitHub Actions after cilock trust github owner/repo |
your account issues OIDC tokens with immutable IDs (repo:owner@123/repo@456:…); the trusted pattern repo:owner/repo:* never matches |
read the real prefix: gh api repos/<owner>/<repo>/actions/oidc/customization/sub, then cilock trust --issuer https://token.actions.githubusercontent.com --subject '<sub_claim_prefix>:*' --verify |
cilock bundle create -s sha256:<hex> writes 0 envelopes |
subject digests are stored as bare hex | pass the digest without the sha256: prefix |
bundle create returns hundreds of envelopes |
the default walk (depth 5) follows shared subjects into other builds | --max-depth 1 |
bundle create 401 in CI |
it reads with a cilock login token or --archivista-headers only |
mint the workflow OIDC token for <platform>/archivista and pass --archivista-headers "Authorization: Bearer $TOKEN" |
| downloaded verify fails at a step with no products | the step's inventory file did not travel with its envelope | push *-inventory.json alongside *.att.json (the store script does) |
| trivy reports a HIGH "private key" | it scanned .keys/ |
the scan step skips .keys, evidence, download, release |
main.go, main_test.go the sample app
Dockerfile multi-stage build on Chainguard images
demo/.env.local.example settings template → demo/.env.local (gitignored)
demo/env.sh shared config + helpers, sourced by every script
demo/10-binary-attest.sh Path A: six attested build steps
demo/11-binary-verify.sh Path A: policy from the evidence; verify green / tampered / skipped
demo/12-binary-store.sh Path A: binary + evidence into the registry; verify the download
demo/00-setup.sh Path B, local dev platform only: key, policy, product, connector
demo/01-publish.sh Path B: build → record → platform publishes the .att
demo/02-verify.sh Path B: pull from the registry → verify
demo/03-policy-gate.sh Path B: fail-closed publish gate
demo/04-inspect.sh Path B: what is inside the .att
demo/README.md the 60-second explanation, how to read the output
- The platform publishes image records to the registry automatically;
for plain binaries the push is a pipeline step (
oras push) today. - The signing key here is a local file for simplicity. In CI, cilock signs keylessly with the workflow's identity; KMS and Vault signers are also supported.