Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

On this page

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, javac and jar running.
  • A release check with cilock verify that 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.

OptionWhat the pipeline author changesTrade-off
1. Inline cilock runOne line per stepSimplest and explicit. The cilock flags are copied into every Jenkinsfile.
2. Shared library step@Library and sh becomes cilockShThe flags live in one Groovy file the security team owns. A small edit per pipeline.
3. Global shell hookNothingEvery 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 cilock on its PATH. 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 to build-machine.key, and CILOCK_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.json

Step 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:

CaseResult
The jar the pipeline builtPasses
The same jar with one byte changed after the buildBlocked: its digest is not in any subject of step build
A receipt made without --traceBlocked: "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 receiptWithout --traceWith --trace
Command, exit code, signerYesYes
Inputs and outputs hashedWorkspace filesAlso the toolchain files the build read
Programs that ranNoYes
Files read and written, networkNoYes

A team can start without tracing and turn it on where the policy needs it.

Next

Reference generated from the product documentation. Match commands and support details to your installed release.