Jenkins: prove how a build ran, without changing the build script
Many Jenkins pipelines call a build script that a product team owns: a Python or shell script that checks out code, compiles it, and packages an artifact. This tutorial adds CI/lock around that script so each build produces a signed receipt of what it ran, then checks the artifact against a signed policy before release. The script is never edited.
The example is a Groovy pipeline that runs python3 build.py. The script clones a Git repository, compiles Java with javac, and packages a jar with jar. Every command and policy below ran on open source Jenkins 2.580.1.
What you'll build
- A pipeline whose build step runs under
cilock run --trace, so the receipt names every program the script started (git,javac,jar) and hashes its inputs and outputs, including the jar. - A policy, signed by a separate key, that passes a build only when it exited 0, was traced, and the trace shows
git,javacandjarrunning. - A release check with
cilock verifythat passes the pipeline's jar and blocks a jar changed after the build.
You will add CI/lock to Jenkins in one of three ways. Pick one; they produce the same receipt.
| Option | What the pipeline author changes | Trade-off |
|---|---|---|
1. Inline cilock run | One line per step | Simplest and explicit. The cilock flags are copied into every Jenkinsfile. |
| 2. Shared library step | @Library and sh becomes cilockSh | The flags live in one Groovy file the security team owns. A small edit per pipeline. |
| 3. Global shell hook | Nothing | Every sh step on the controller runs under cilock, including small ones such as a file copy. |
A build that fails still fails the same way under every option: the developer sees the same error, Jenkins marks the build failed, and cilock signs a receipt that records the failure.
Prerequisites
-
A Linux Jenkins agent with
cilockon itsPATH. See Installation. -
Two key pairs: one the build machine signs receipts with, one the security team signs the policy with. Keep the policy key off the build agent.
for k in build-machine signing-team; do openssl genpkey -algorithm ED25519 -out "$k.key" openssl pkey -in "$k.key" -pubout -out "$k.pub" done -
Two environment variables on the agent:
CILOCK_KEY, the path tobuild-machine.key, andCILOCK_EVIDENCE, a directory receipts are written to.
The examples sign with a local key and no platform (--platform-url ""), so nothing leaves the agent. To sign keylessly or store receipts centrally instead, see Choose a signer and Store attestations in Archivista.
--trace records every program the build starts. On a kernel where cilock can load its eBPF programs it traces in the kernel; otherwise it uses ptrace. Either way the receipt says which.
Step 1: Choose how cilock enters the pipeline
Option 1: inline
Put cilock run in front of the command. Everything after -- is the build exactly as it was.
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'cilock run --step build --trace -k "$CILOCK_KEY" --platform-url "" -a environment -l error -o "$CILOCK_EVIDENCE/$JOB_BASE_NAME-$BUILD_NUMBER-build.json" -- python3 build.py "$REPO_URL"'
}
}
}
}Option 2: shared library
The security team owns one file in a Jenkins shared library, vars/cilockSh.groovy:
// cilockSh step: 'build', script: 'python3 build.py "$REPO_URL"'
// Runs the script exactly as `sh` would, under cilock.
def call(Map args) {
String step = args.step
String trace = args.get('trace', true) ? '--trace' : ''
String out = "${env.CILOCK_EVIDENCE}/${env.JOB_BASE_NAME}-${env.BUILD_NUMBER}-${step}.json"
String scriptFile = "${env.WORKSPACE_TMP}/cilock-${step}.sh"
writeFile file: scriptFile, text: args.script + '\n'
sh "cilock run --step '${step}' ${trace} -k \"\$CILOCK_KEY\" --platform-url \"\" -a environment -l error -o '${out}' -- /bin/sh -xe '${scriptFile}'"
}A product team loads the library and swaps sh for cilockSh. They type no cilock flags, and a flag change happens in one place.
@Library('cilock') _
pipeline {
agent any
stages {
stage('Build') {
steps { cilockSh step: 'build', script: 'python3 build.py "$REPO_URL"' }
}
}
}Option 3: global shell hook
Jenkins runs every pipeline sh step as <shell> -xe <script-file>. Set the shell to this wrapper under Manage Jenkins > System > Shell executable, and every sh step runs under cilock with no pipeline change:
#!/bin/sh
# Step name: CILOCK_STEP if the pipeline sets it, else the stage name.
# CILOCK_SKIP=1 runs the step without cilock.
if [ "${CILOCK_SKIP:-0}" = 1 ] || ! command -v cilock >/dev/null 2>&1; then
exec /bin/sh "$@"
fi
step=$(printf '%s' "${CILOCK_STEP:-${STAGE_NAME:-sh}}" | tr 'A-Z ' 'a-z-' | tr -cd 'a-z0-9-')
out="$CILOCK_EVIDENCE/${JOB_BASE_NAME:-job}-${BUILD_NUMBER:-0}-$step-$.json"
exec cilock run --step "$step" --trace -k "$CILOCK_KEY" --platform-url "" -a environment -l error -o "$out" -- /bin/sh "$@"The hook is controller-wide, and it covers sh steps only: bat and powershell steps need their own wrapper. It is a good way to start observing pipelines before deciding which steps a policy should cover.
Step 2: Write the policy
The policy says what a good build must show. These three rules read the command-run attestation, so every name they check comes from the trace, not from anything the script reported about itself:
package example.build
# Rule 1: the build script ran and finished cleanly.
deny[msg] {
not has_exit_code
msg := "no exit code recorded for the build"
}
has_exit_code {
is_number(input.exitcode)
}
deny[msg] {
has_exit_code
input.exitcode != 0
msg := sprintf("the build failed (exit code %v)", [input.exitcode])
}
# Rule 2: the observer saw the script run the tools we expect.
# These names come from the trace, not from anything the script reported.
required_tools := {"git", "javac", "jar"}
deny[msg] {
tool := required_tools[_]
not ran(tool)
msg := sprintf("the trace does not show '%v' running", [tool])
}
ran(tool) {
input.comms[_] == tool
}
# Rule 3: the run was traced. A receipt without a trace cannot show what the script did.
deny[msg] {
not traced
msg := "the build was not traced"
}
# object.get, so an untraced receipt (no summary at all) is denied rather than skipped.
traced {
object.get(object.get(input, "summary", {}), "captureMode", "") == "trace"
}Put it in a policy that names the build step, trusts only the build machine's key for it, and attaches the Rego to the step's command-run attestation. See Policies for the format. Then the security team signs the policy with its own key:
cilock sign -k signing-team.key -f policy.json -o policy.signed.jsonStep 3: Verify before release
The release check takes the artifact, the signed policy, the policy key, and the build's receipt with its product inventory:
cilock verify app.jar \
-p policy.signed.json -k signing-team.pub --platform-url "" \
-a "$CILOCK_EVIDENCE/app-12-build.json" \
-a "$CILOCK_EVIDENCE/app-12-build.json-product-inventory.json"The receipt path is the -o the build step wrote: here job app, build 12. The product inventory is written next to it.
The jar the pipeline built passes; the output names its digest and the step that produced it. The other two cases are blocked:
| Case | Result |
|---|---|
| The jar the pipeline built | Passes |
| The same jar with one byte changed after the build | Blocked: its digest is not in any subject of step build |
A receipt made without --trace | Blocked: "the build was not traced", and the trace does not show git, javac or jar |
What tracing adds
A receipt without --trace is still useful. It is signed, records the command, the exit code and the environment, and hashes the workspace inputs and every output, so a changed artifact is still blocked. Tracing adds how the build ran:
| In the signed receipt | Without --trace | With --trace |
|---|---|---|
| Command, exit code, signer | Yes | Yes |
| Inputs and outputs hashed | Workspace files | Also the toolchain files the build read |
| Programs that ran | No | Yes |
| Files read and written, network | No | Yes |
A team can start without tracing and turn it on where the policy needs it.
Next
- Verify in a release gate for wiring
cilock verifyinto a promotion step. jenkinsattestor to record the job, build number and agent alongside the receipt.
Reference generated from the product documentation. Match commands and support details to your installed release.