# Jenkins: prove how a build ran, without changing the build script

Source: https://www.testifysec.com/docs/cilock/tutorials/jenkins-pipeline/

Add CI/lock to a Jenkins Groovy pipeline three ways (an inline step, a shared library, or a global shell hook) so every build signs a receipt of what ran, and verify the artifact against a signed policy before release. The build script itself never changes.

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.

 

| 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 `cilock` on its `PATH`. See [Installation](https://www.testifysec.com/docs/cilock/getting-started/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.
  
   
  
  ```bash
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](https://www.testifysec.com/docs/cilock/guides/choose-a-signer) and [Store attestations in Archivista](https://www.testifysec.com/docs/cilock/guides/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.

 

```groovy
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](https://www.jenkins.io/doc/book/pipeline/shared-libraries/), `vars/cilockSh.groovy`:

 

```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.

 

```groovy
@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:

 

```sh
#!/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:

 

```rego
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](https://www.testifysec.com/docs/cilock/concepts/policy-verification) for the format. Then the security team signs the policy with its own key:

 

```bash
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:

 

```bash
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](https://www.testifysec.com/docs/cilock/guides/verify-in-a-release-gate) for wiring `cilock verify` into a promotion step.
 - [`jenkins` attestor](https://www.testifysec.com/docs/cilock/attestors/jenkins) 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.
