diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa37ca2..e8b5073 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,18 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + rookery_ref: + description: 'rookery ref (branch / tag / SHA) — defaults to main' + type: string + required: false + default: '' + release_tag: + description: 'release tag to publish under (e.g. v1.0.5-rc1). Required for workflow_dispatch since there is no tag context. Ignored on tag-push triggers.' + type: string + required: false + default: '' permissions: contents: write @@ -23,6 +35,12 @@ jobs: uses: actions/checkout@v6 with: repository: aflock-ai/rookery + # Defaults to main on tag pushes; workflow_dispatch input + # lets us cut a cilock-action dev release that embeds a + # specific rookery branch / tag (e.g. nk/ci-trace-mode-probe + # or v1.1.0-rc48) for testing new attestor code before + # merging to main. + ref: ${{ inputs.rookery_ref }} path: rookery - name: Setup Go @@ -34,6 +52,64 @@ jobs: run: npm install working-directory: cilock-action/shim + # When rookery_ref pins a non-main rookery (dev RC flow), + # cilock-action's go.sum can lag behind whatever transitive + # deps the pinned rookery brings in (e.g. rc50 split out + # commandrun/ebpf as its own module and pulled cilium/ebpf + # in through that boundary). Run `go mod tidy` against the + # current rookery checkout so go.sum is consistent before + # goreleaser builds. CI-local only — never committed back. + - name: Refresh go.sum against pinned rookery + if: github.event_name == 'workflow_dispatch' + working-directory: cilock-action + env: + GOTOOLCHAIN: auto + run: | + go mod tidy + # goreleaser refuses to release with a dirty tree. Commit + # the tidy result locally (CI workspace only; never pushed). + if ! git diff --quiet go.mod go.sum; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add go.mod go.sum + git commit -m "ci: go mod tidy against rookery_ref=${{ inputs.rookery_ref }} (CI-local)" + echo "tidy: applied changes to go.mod/go.sum" + else + echo "tidy: go.mod/go.sum already in sync" + fi + + # GoReleaser refuses to release unless the current HEAD has a + # semver tag pointing at it. On tag-push triggers GITHUB_REF_NAME + # is the tag itself; on workflow_dispatch (used for dev RCs that + # pin a non-main rookery_ref) we have to materialise one. Create + # a local tag at the dispatch ref and pass it via + # GORELEASER_CURRENT_TAG so goreleaser doesn't fall back to the + # stale v1 major-version tag and bail. + - name: Materialise release tag (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' + working-directory: cilock-action + run: | + TAG="${{ inputs.release_tag }}" + if [ -z "$TAG" ]; then + # Derive a snapshot-style tag from the rookery_ref so dev + # RCs are uniquely named. + ROOKERY="${{ inputs.rookery_ref }}" + ROOKERY="${ROOKERY//\//-}" + TAG="v0.0.0-dev-${ROOKERY:-snapshot}-$(git rev-parse --short HEAD)" + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -fa "$TAG" -m "Dev release ${TAG} (rookery_ref=${{ inputs.rookery_ref }})" + # Push the tag BEFORE goreleaser so `uses: aflock-ai/cilock-action@$TAG` + # downstream actually fetches the post-tidy HEAD (with shim + # changes, action.yml, etc.). Without this push, GitHub's + # release-create API points the tag at the default branch's + # HEAD, breaking every downstream consumer of the action. + git push origin "refs/tags/$TAG" + echo "GORELEASER_CURRENT_TAG=$TAG" >> "$GITHUB_ENV" + echo "CILOCK_RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + echo "Pushed tag $TAG to origin" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -42,8 +118,13 @@ jobs: workdir: cilock-action env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ env.GORELEASER_CURRENT_TAG }} + # Only roll the v1 major-version tag forward on real tag-push + # releases. Dev RCs (workflow_dispatch) get a uniquely-named + # release but shouldn't shift the v1 alias. - name: Update v1 major version tag + if: github.event_name == 'push' working-directory: cilock-action run: | git config user.name "github-actions[bot]" diff --git a/.github/workflows/smoke-multistep-chain.yml b/.github/workflows/smoke-multistep-chain.yml new file mode 100644 index 0000000..28d591b --- /dev/null +++ b/.github/workflows/smoke-multistep-chain.yml @@ -0,0 +1,242 @@ +name: smoke — multi-step chain verification + +# End-to-end two-step chain-of-custody smoke. Exercises every piece +# of the v0.3 chain pipeline: +# +# step 1 (source) — attest the source-tree state with cilock run +# step 2 (build) — attest the gh CLI build with cilock run +# chain proof — cilock prove-chain binds step 2's materials +# to step 1's signed Merkle root via per-leaf +# RFC 6962 inclusion proofs +# verify — cilock verify with a multi-step policy walks +# the chain: build's ArtifactsFrom=[source] +# resolves via the FilesystemChainSidecarSource, +# and every consumed material must either have +# an inclusion proof against source or match +# AllowedUntracked (toolchain reads) +# +# Negative case: tamper a source file between step 1 and step 2, +# rerun verify, must exit non-zero with a precise diagnostic +# naming the offending material. +# +# Requires cilock-action @ a release that includes the +# `cilock prove-chain` subcommand and policy.WithChainSidecarSource. + +on: + workflow_dispatch: + inputs: + cilock_action_ref: + description: 'cilock-action tag (must include prove-chain + chain sidecar source)' + default: 'v1.1.0-rc1' + required: true + push: + paths: + - '.github/workflows/smoke-multistep-chain.yml' + +permissions: + contents: read + id-token: write + +jobs: + multistep-chain: + name: source → build chain (gh CLI) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + + steps: + - name: Checkout source under test (gh CLI) + uses: actions/checkout@v4 + with: + repository: cli/cli + path: src + + - name: Show runner info + run: | + uname -a + cat /proc/version + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: src/go.mod + + # --- STEP 1: SOURCE ATTESTATION ------------------------------- + # Attest the source-tree state. The 'source' step's products + # are the files under src/ at HEAD — every source file the + # build will later consume. The action's git attestor requires + # a .git directory at workingdir, so we point at src/ (where + # actions/checkout placed the .git). + - name: Attest source tree (step 1) + uses: aflock-ai/cilock-action@v1.0.5-rc14 + with: + step: source + command: git rev-parse HEAD + # workingdir is the action's input name (not 'working-directory'). + workingdir: src + # 'products: **' (anchored to workingdir=src) records every + # file in the source tree under the product Merkle root — + # gives step 2's chain-proof generator a leaf for any + # material the build reads. + products: | + ** + outfile: /tmp/source.attestation.json + enable-archivista: 'false' + + - name: Confirm source leaf sidecar exists + run: | + ls -la /tmp/source.attestation.material.tree.json + jq '{schemaVersion, source, merkleRoot, treeSize, leafCount: (.leaves | length)}' \ + /tmp/source.attestation.material.tree.json + + # --- STEP 2: BUILD ATTESTATION -------------------------------- + # Build gh CLI under src/ (where its .git + go.mod live). + # Output bin/gh into src/bin/ so everything stays under the + # source workingdir. + - name: Attest gh CLI build (step 2) + uses: aflock-ai/cilock-action@v1.0.5-rc14 + with: + step: build + workingdir: src + command: go build -o bin/gh ./cmd/gh + products: | + bin/gh + trace: 'true' + outfile: /tmp/build.attestation.json + enable-archivista: 'false' + + # --- CHAIN PROOF GENERATION ----------------------------------- + # Read the build attestation's traced materials list, filter to + # files under src/ (the source-step coverage), and emit a chain + # sidecar with per-material RFC 6962 inclusion proofs against + # step 1's signed Merkle root. + - name: Build chain sidecar (cilock prove-chain) + run: | + set -euo pipefail + # Install cilock from the release the action pinned. + curl -fsSL https://github.com/aflock-ai/rookery/releases/download/v1.1.0-rc65/install.sh | bash -s -- /usr/local/bin + cilock --version + + # Extract materials from build attestation that live under src/. + # The build attestation's trace records ABSOLUTE paths; the + # source step's leaf sidecar stores paths RELATIVE to its + # workingdir (src/). Strip the workspace prefix so the + # 'path=sha256hex' tuples align with what BuildChainSidecar + # will look up. + PREFIX="$GITHUB_WORKSPACE/src/" + jq --arg prefix "$PREFIX" -r ' + .payload | @base64d | fromjson + | .predicate.attestations[]? + | select(.type | test("command-run")) + | .attestation.processes[]? + | (.openedFiles // {}) | to_entries[] + | select(.value != null and (.value | type) == "object") + | select(.key | startswith($prefix)) + | (.key | sub($prefix; "")) + "=" + (.value | to_entries[] | select(.key | test("sha256")) | .value) + ' /tmp/build.attestation.json | sort -u > /tmp/consumed.list + echo "consumed material count: $(wc -l < /tmp/consumed.list)" + head -5 /tmp/consumed.list + + # Build the chain sidecar. --consumed accepts repeated + # path=sha256hex args. + consumed_args=() + while IFS= read -r line; do + consumed_args+=(--consumed "$line") + done < /tmp/consumed.list + + mkdir -p /tmp/chain-sidecars + cilock prove-chain \ + --source-envelope /tmp/source.attestation.json \ + --source-sidecar /tmp/source.attestation.material.tree.json \ + --source-step source \ + --domain rookery-material/v0.3 \ + "${consumed_args[@]}" \ + --outfile /tmp/chain-sidecars/build.chain.json + + echo "=== chain sidecar summary ===" + jq '{ + schemaVersion, + sourceStep, + proofCount: (.materialProofs | length) + }' /tmp/chain-sidecars/build.chain.json + + # --- POLICY VERIFICATION (HAPPY PATH) ------------------------- + - name: Write multi-step policy + run: | + cat > /tmp/policy.json <<'POLICY' + { + "expires": "2030-12-01T00:00:00Z", + "steps": { + "source": { + "name": "source", + "attestations": [ + {"type": "https://aflock.ai/attestations/product/v0.3"} + ] + }, + "build": { + "name": "build", + "artifactsFrom": ["source"], + "attestations": [ + {"type": "https://aflock.ai/attestations/product/v0.3"}, + {"type": "https://aflock.ai/attestations/material/v0.3"} + ], + "allowedUntracked": [ + "/opt/hostedtoolcache/**", + "/usr/lib/**", + "/usr/include/**", + "/etc/**", + "/proc/**", + "/sys/**", + "/tmp/**" + ] + } + } + } + POLICY + echo "policy written" + + - name: Verify chain (must succeed) + run: | + cilock verify \ + --policy /tmp/policy.json \ + --attestation /tmp/source.attestation.json \ + --attestation /tmp/build.attestation.json \ + --chain-sidecar-dir /tmp/chain-sidecars \ + --require-sidecar + echo "✓ multi-step chain verified end-to-end" + + # --- NEGATIVE CASE: TAMPERED MATERIAL ------------------------- + # Forge a chain sidecar with one tampered material digest. The + # cryptographic check inside VerifyChainSidecar must fail. + - name: Negative test — tampered chain sidecar must fail verify + run: | + set +e + # Flip a bit in the first proof's audit path. + jq '.materialProofs[0].auditPath[0] = "deadbeef00000000000000000000000000000000000000000000000000000000"' \ + /tmp/chain-sidecars/build.chain.json > /tmp/chain-sidecars/build.chain.json.tampered + mv /tmp/chain-sidecars/build.chain.json.tampered /tmp/chain-sidecars/build.chain.json + + cilock verify \ + --policy /tmp/policy.json \ + --attestation /tmp/source.attestation.json \ + --attestation /tmp/build.attestation.json \ + --chain-sidecar-dir /tmp/chain-sidecars \ + --require-sidecar + rc=$? + if [ $rc -eq 0 ]; then + echo "::error::verify accepted a tampered chain sidecar — Merkle commitment broken" + exit 1 + fi + echo "✓ tampered sidecar correctly rejected (exit $rc)" + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: multistep-chain-attestations + path: | + /tmp/source.attestation.json + /tmp/source.leaves.sidecar.json + /tmp/build.attestation.json + /tmp/chain-sidecars/ + /tmp/policy.json + if-no-files-found: warn diff --git a/.github/workflows/smoke-npm-install.yml b/.github/workflows/smoke-npm-install.yml new file mode 100644 index 0000000..e51bdaf --- /dev/null +++ b/.github/workflows/smoke-npm-install.yml @@ -0,0 +1,128 @@ +name: smoke — npm install (many-products workload) + +# Heavy-products workload: `npm install` of a real package with a +# fat dependency tree creates thousands of files under node_modules/ +# and package-lock.json at the workspace root. This stresses: +# - the prePaths snapshot walk (fresh workdir, so should be empty) +# - TraceOutputs's writePaths growth +# - the product attestor's Merkle tree size +# - require-zero-drops under a sustained syscall storm +# +# Dispatch manually with rc input, or push-trigger when the workflow +# file itself changes. + +on: + workflow_dispatch: + inputs: + cilock_action_ref: + description: 'cilock-action tag (e.g. v1.0.5-rc13)' + default: 'v1.0.5-rc13' + required: true + npm_target: + description: 'package to install' + default: 'express' + required: true + push: + paths: + - '.github/workflows/smoke-npm-install.yml' + +permissions: + contents: read + id-token: write + +jobs: + npm-install-attestation: + name: npm install (heavy products) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + + steps: + - name: Show runner info + run: | + uname -a + node --version + npm --version + df -h / /tmp 2>/dev/null | head + + - name: Init empty npm project + minimal git repo + working-directory: ${{ github.workspace }} + run: | + # cilock's git attestor expects a repository — without + # actions/checkout we have a bare workspace, so seed one. + git init -q + git config user.email "smoke@example.com" + git config user.name "smoke" + npm init -y + git add package.json + git commit -q -m "initial" + ls -la + + # Use a hard-coded ref for push-trigger (workflow_dispatch + # populates inputs but push doesn't). Override via dispatch. + - name: Attest npm install with cilock + uses: aflock-ai/cilock-action@v1.0.5-rc13 + with: + step: npm-install-many-products + # npm install creates thousands of files under node_modules/ + # + writes package-lock.json + updates package.json. With the + # default products glob (workingDir/**) every written file + # under the workspace is a candidate product. + command: npm install ${{ github.event.inputs.npm_target || 'express' }} --save + # No explicit products list — let the workdir default kick in + # so we exercise the over-counting / classification path. + trace: 'true' + outfile: /tmp/npm-install-attestation.json + enable-archivista: 'false' + + - name: Report attestation stats + if: always() + run: | + if [ ! -f /tmp/npm-install-attestation.json ]; then + echo "::error::no attestation produced" + exit 1 + fi + BYTES=$(wc -c < /tmp/npm-install-attestation.json) + echo "attestation size: $BYTES bytes" + jq -r '.payload' /tmp/npm-install-attestation.json | base64 -d > /tmp/payload.json + echo "=== predicate type ===" + jq -r '.predicateType' /tmp/payload.json + echo "=== command-run summary ===" + jq '.predicate.attestations[]? | select(.type | test("command-run")) | .attestation | { + cmd: .cmd, + exit: .exitcode, + processes: (.processes | length), + durationNs: .summary.durationNs, + totals: .summary.totals, + outliers: .summary.outliers, + diagnostics: .summary.diagnostics + }' /tmp/payload.json + echo "=== product tree (v0.3) ===" + PRODS=$(jq -r '[.predicate.attestations[]? | select(.type | test("product/v0\\.3")) | .attestation.treeSize] | first // 0' /tmp/payload.json) + MAT=$(jq -r '[.predicate.attestations[]? | select(.type | test("material/v0\\.3")) | .attestation.treeSize] | first // 0' /tmp/payload.json) + echo "products (Merkle leaves): $PRODS" + echo "materials (Merkle leaves): $MAT" + jq '.predicate.attestations[]? | select(.type | test("product/v0\\.3")) | .attestation' /tmp/payload.json + jq '.predicate.attestations[]? | select(.type | test("material/v0\\.3")) | .attestation' /tmp/payload.json + # Expectations: an `npm install express` on a clean workspace + # writes ~50-200 files (node_modules of express has ~50 + # transitive deps, each with its own package.json + a handful + # of source files). We assert a meaningful range, not an + # exact count (npm/version drift moves the number). + if [ "${PRODS:-0}" -lt 10 ]; then + echo "::error::product count too low ($PRODS) — npm install should produce dozens of products at minimum" + exit 1 + fi + # Sanity upper bound — if we get hundreds of thousands of + # products, something is over-classifying. + if [ "${PRODS:-0}" -gt 50000 ]; then + echo "::warning::very high product count ($PRODS) — investigate include-glob or pre-existing-skip" + fi + echo "✓ heavy-products workload attested: $PRODS products, $MAT materials" + + - name: Upload attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-install-attestation + path: /tmp/npm-install-attestation.json + if-no-files-found: warn diff --git a/.github/workflows/smoke-v1.0.5-rc6.yml b/.github/workflows/smoke-v1.0.5-rc6.yml new file mode 100644 index 0000000..974322c --- /dev/null +++ b/.github/workflows/smoke-v1.0.5-rc6.yml @@ -0,0 +1,150 @@ +name: Downstream smoke — aflock-ai/cilock-action@v1.0.5-rc6 + +# Real-consumer smoke test for the v1.0.5-rc1 release. Exercises the +# action exactly as an external user would — `uses: aflock-ai/cilock-action@` +# — against representative build workloads. Confirms the rookery +# rc50 auto-rebuild path actually self-heals on hosted GHA runners +# end-to-end (kernel 6.17.0-1013-azure as of 2026-05-25). +# +# Triggers on workflow_dispatch only; not part of the release gate. + +on: + workflow_dispatch: + push: + paths: + - '.github/workflows/smoke-v1.0.5-rc6.yml' + branches: + - nk/ebpf-setcap-shim + +permissions: + contents: read + id-token: write # sigstore keyless + +jobs: + smoke: + name: ${{ matrix.workload }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + workload: + - hello-go + - hello-rust + - hello-shell + + steps: + - name: Show runner info + run: | + uname -a + cat /proc/version + + - name: Set up Go + if: matrix.workload == 'hello-go' + uses: actions/setup-go@v6 + with: + go-version: '1.22' + + - name: Prepare hello-go workload + if: matrix.workload == 'hello-go' + run: | + mkdir -p /tmp/work && cd /tmp/work + cat >main.go <<'EOF' + package main + import "fmt" + func main() { fmt.Println("hello from cilock-action smoke") } + EOF + cat >go.mod <<'EOF' + module example.com/smoke + go 1.22 + EOF + git init -q -b main . && git add -A && git -c user.email=s@m -c user.name=s commit -q -m s + + - name: Prepare hello-rust workload + if: matrix.workload == 'hello-rust' + run: | + mkdir -p /tmp/work && cd /tmp/work + cargo init --bin --name smoke . + git init -q -b main . && git add -A && git -c user.email=s@m -c user.name=s commit -q -m s + + - name: Prepare hello-shell workload + if: matrix.workload == 'hello-shell' + run: | + mkdir -p /tmp/work && cd /tmp/work + echo "#!/bin/sh" >hello.sh + echo 'echo "hello from cilock-action smoke"' >>hello.sh + chmod +x hello.sh + git init -q -b main . && git add -A && git -c user.email=s@m -c user.name=s commit -q -m s + + # The actual smoke: invoke the published v1.0.5-rc1 action with + # tracing enabled, matching what a downstream consumer would write. + # Uses sigstore keyless via GHA OIDC (id-token: write granted above). + - name: cilock-action — hello-go + if: matrix.workload == 'hello-go' + uses: aflock-ai/cilock-action@v1.0.5-rc6 + with: + step: smoke + command: go build . + workingdir: /tmp/work + trace: 'true' + outfile: /tmp/attest.json + enable-archivista: 'false' + + - name: cilock-action — hello-rust + if: matrix.workload == 'hello-rust' + uses: aflock-ai/cilock-action@v1.0.5-rc6 + with: + step: smoke + command: cargo build + workingdir: /tmp/work + trace: 'true' + outfile: /tmp/attest.json + enable-archivista: 'false' + + - name: cilock-action — hello-shell + if: matrix.workload == 'hello-shell' + uses: aflock-ai/cilock-action@v1.0.5-rc6 + with: + step: smoke + command: ./hello.sh + workingdir: /tmp/work + trace: 'true' + outfile: /tmp/attest.json + enable-archivista: 'false' + + - name: Verify attestation + zero drops + run: | + if [ ! -f /tmp/attest.json ]; then + echo "::error::no attestation file at /tmp/attest.json" + exit 1 + fi + BYTES=$(wc -c < /tmp/attest.json) + echo "attestation: $BYTES bytes" + jq -r '.payload' /tmp/attest.json | base64 -d > /tmp/payload.json + echo "=== capture mode / trace detail ===" + jq '.predicate.attestations[]? | select(.type | test("command-run")) | .attestation.summary | {captureMode, traceModeDetail, totals, diagnostics}' /tmp/payload.json + echo "=== process count ===" + jq '.predicate.attestations[]? | select(.type | test("command-run")) | .attestation.processes | length' /tmp/payload.json + # Hard drops only — data we couldn't surface at all. + # UnhashedOpens entries and FallbackHashFailures are soft + # signals recorded as per-file evidence in the attestation. + for k in fanotifyTimeouts fanotifyQueueOverflows fanotifyDigestsCapHit ringbufOpenatDrops ringbufReadTapDrops fsVeritySealFailures; do + v=$(jq -r ".predicate.attestations[]? | select(.type | test(\"command-run\")) | .attestation.summary.diagnostics.${k} // 0" /tmp/payload.json) + if [ "${v:-0}" -gt 0 ] 2>/dev/null; then + echo "::error::hard-drop violation: ${k}=${v}" + exit 1 + fi + echo " hard ${k}=${v}" + done + for k in unhashedOpensTotal fallbackHashFailures hashFailureSilentDrops partialReadFallbacks; do + v=$(jq -r ".predicate.attestations[]? | select(.type | test(\"command-run\")) | .attestation.summary.diagnostics.${k} // 0" /tmp/payload.json) + echo " soft ${k}=${v}" + done + echo "✓ zero hard-drop verified" + + - name: Upload attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-attest-${{ matrix.workload }} + path: /tmp/attest.json + if-no-files-found: warn diff --git a/action.yml b/action.yml index 3b87ead..05ecadb 100644 --- a/action.yml +++ b/action.yml @@ -44,6 +44,14 @@ inputs: description: "Hash algorithms" default: "sha256" + # === Zero-drop guarantee === + fanotify: + description: "fanotify integrity gate: 'auto' (default — on when CAP_SYS_ADMIN available), '1'/'on', '0'/'off'. Blocks the tracee on every open until userspace hashes the file. Closes the BPF capture path's silent-drop gap." + default: "auto" + require-zero-drops: + description: "Fail the attestation if the BPF/fanotify pipeline dropped any content (hash failures, unhashed opens, fanotify timeouts). Default true — safer to fail loudly than ship an attestation that silently lost coverage. Set to 'false' to accept drops with a warning." + default: "true" + # === TestifySec Platform === platform-url: description: "TestifySec platform URL. All service URLs are derived from this. Self-hosted customers override this." @@ -106,11 +114,24 @@ inputs: default: "false" # === Product/Material === + products: + description: | + Newline-separated list of paths or globs that count as the build's + products. Default: every file under workingDir. Override when the + build writes outputs outside the workspace (e.g., /tmp/, ~/.local/bin/) + or when only specific paths should be product-tagged. + + Examples: + products: | + bin/myapp + dist/** + /tmp/release-artifacts/** + default: "" product-include-glob: - description: "Glob for product file inclusion" - default: "*" + description: "DEPRECATED — use `products`. Glob for product file inclusion." + default: "" product-exclude-glob: - description: "Glob for product file exclusion" + description: "Glob for product file exclusion." # === Attestor exports === attestor-sbom-export: diff --git a/go.mod b/go.mod index 9bb6ff0..ab94785 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/aflock-ai/rookery/attestation v0.0.0 github.com/aflock-ai/rookery/plugins/attestors/commandrun v0.0.0 + github.com/aflock-ai/rookery/plugins/attestors/commandrun/ebpf v0.0.0-00010101000000-000000000000 github.com/aflock-ai/rookery/plugins/attestors/docker v0.0.0-00010101000000-000000000000 github.com/aflock-ai/rookery/plugins/attestors/git v0.0.0-00010101000000-000000000000 github.com/aflock-ai/rookery/plugins/attestors/githubaction v0.0.0 @@ -221,6 +222,8 @@ replace github.com/aflock-ai/rookery/attestation => ../rookery/attestation replace github.com/aflock-ai/rookery/plugins/attestors/commandrun => ../rookery/plugins/attestors/commandrun +replace github.com/aflock-ai/rookery/plugins/attestors/commandrun/ebpf => ../rookery/plugins/attestors/commandrun/ebpf + replace github.com/aflock-ai/rookery/plugins/attestors/configuration => ../rookery/plugins/attestors/configuration replace github.com/aflock-ai/rookery/plugins/attestors/environment => ../rookery/plugins/attestors/environment diff --git a/internal/attestation/run.go b/internal/attestation/run.go index e394df4..2ac230b 100644 --- a/internal/attestation/run.go +++ b/internal/attestation/run.go @@ -246,13 +246,144 @@ func buildTimestampers(cfg *config.Config) []timestamp.Timestamper { return ts } +// warnIfNoProducts emits a GitHub Actions warning when the build +// produced zero products. Without this, builds that write outside the +// workspace (e.g., to /tmp) or use a product glob that matches nothing +// silently produce an empty attestation — the operator only notices +// downstream when verifying. +// +// The warning surfaces the active glob and tells the user exactly +// where to override it. +func warnIfNoProducts(cfg *config.Config, results []workflow.RunResult) { + count := 0 + for _, r := range results { + for _, ca := range r.Collection.Attestations { + if p, ok := ca.Attestation.(attestation.Producer); ok { + count += len(p.Products()) + } + } + } + if count > 0 { + return + } + + glob := resolveProductIncludeGlob(cfg) + src := "default (workingDir/**)" + switch { + case len(cfg.Products) > 0: + src = fmt.Sprintf("`products` input (%d entr%s)", len(cfg.Products), pluralY(len(cfg.Products))) + case cfg.ProductIncludeGlob != "": + src = "legacy `product-include-glob` input" + } + fmt.Fprintf(os.Stderr, + "::warning::cilock-action: no products detected. Active glob: %q (from %s). "+ + "Set the `products` input on the action to one or more paths/globs "+ + "matching your build's output, e.g.:\n"+ + " - uses: aflock-ai/cilock-action@v1\n"+ + " with:\n"+ + " products: |\n"+ + " bin/myapp\n"+ + " dist/**\n", + glob, src) +} + +func pluralY(n int) string { + if n == 1 { + return "y" + } + return "ies" +} + +// resolveProductIncludeGlob picks the product glob the operator +// intended. Priority: +// 1. cfg.Products (newline-separated list of paths/globs) → wrap in +// a brace pattern so multiple entries match. +// 2. legacy cfg.ProductIncludeGlob single string. +// 3. default: workingDir/** so anything written inside the workspace +// counts as a product. Cache dirs (.cache, .gradle, etc.) are +// filtered out separately by the rookery cache-pattern matcher. +// +// The previous default of "*" matched every file written anywhere, +// turning every compiler intermediate into a "product". gh CLI smoke +// produced 9281 products under that rule. +func resolveProductIncludeGlob(cfg *config.Config) string { + // Trace mode emits absolute paths (e.g., + // /home/runner/work///bin/gh). Relative user input + // like "bin/gh" wouldn't match. Anchor every entry to the + // resolved workingDir. + anchor := func(p string) string { + if filepath.IsAbs(p) { + return p + } + base := cfg.WorkingDir + if base == "" { + if cwd, err := os.Getwd(); err == nil { + base = cwd + } + } + if base == "" { + return p + } + return filepath.Join(base, p) + } + + if len(cfg.Products) > 0 { + anchored := make([]string, 0, len(cfg.Products)) + for _, p := range cfg.Products { + anchored = append(anchored, anchor(p)) + } + if len(anchored) == 1 { + return anchored[0] + } + // gobwas/glob (rookery's product attestor) supports + // {a,b,c} brace patterns. + return "{" + strings.Join(anchored, ",") + "}" + } + if cfg.ProductIncludeGlob != "" { + // Legacy input: if relative, anchor it too for consistency. + return anchor(cfg.ProductIncludeGlob) + } + if cfg.WorkingDir != "" { + return strings.TrimRight(cfg.WorkingDir, "/") + "/**" + } + return "**" +} + func buildAttestors(cfg *config.Config, command []string) ([]attestation.Attestor, error) { - attestors := []attestation.Attestor{product.New(), material.New()} + // Resolve the products glob. Priority: explicit Products list > + // legacy ProductIncludeGlob > default (workingDir/**). + // + // Without this, every file the build writes — compiler temps, + // link intermediates, cache artifacts — gets tagged as a product + // (gh CLI build produced 9281 "products" with the prior default + // of "*"). The right model: products are the deliverable; default + // scope is whatever the build writes inside its workspace. + productIncludeGlob := resolveProductIncludeGlob(cfg) + + attestors := []attestation.Attestor{ + product.New(product.WithIncludeGlob(productIncludeGlob)), + material.New(), + } + if cfg.ProductExcludeGlob != "" { + // Replace the products attestor with one that has both globs. + attestors[0] = product.New( + product.WithIncludeGlob(productIncludeGlob), + product.WithExcludeGlob(cfg.ProductExcludeGlob), + ) + } if len(command) > 0 { + // CILOCK_FANOTIFY is read directly by the commandrun attestor + // at runtime (see EnvVarFanotify in rookery). Set it before + // constructing the attestor so it picks up the right value. + // Empty string preserves caller's existing env. + if cfg.Fanotify != "" { + _ = os.Setenv("CILOCK_FANOTIFY", cfg.Fanotify) + } attestors = append(attestors, commandrun.New( commandrun.WithCommand(command), commandrun.WithTracing(cfg.Trace), + commandrun.WithRequireZeroDrops(cfg.RequireZeroDrops), )) } @@ -323,6 +454,11 @@ func buildAttestationOpts(cfg *config.Config) ([]attestation.AttestationContextO func processResults(ctx context.Context, cfg *config.Config, results []workflow.RunResult) (*Result, error) { result := &Result{} + // Inspect the product attestor's output count BEFORE serialising + // so we can give the user actionable feedback when their products + // list (or default workingDir glob) matched nothing. + warnIfNoProducts(cfg, results) + for _, r := range results { envelope := r.SignedEnvelope diff --git a/internal/config/config.go b/internal/config/config.go index b99268e..0543a5c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,19 @@ type Config struct { Trace bool Hashes []string + // Zero-drop guarantee. Fanotify is the synchronous integrity gate + // that blocks the tracee on every open until userspace hashes the + // file — turns the BPF capture path's drop-tolerant "events" into + // a kernel-enforced "every file is recorded". RequireZeroDrops + // fails the attestation rather than ship one that silently lost + // content. + // + // Default for both via action.yml is on; consumers wanting the + // old loose semantics opt out explicitly. See [[zero-drop-architecture]] + // in project memory for the architectural rationale. + Fanotify string // "auto", "1"/"on", "0"/"off"; empty = action default + RequireZeroDrops bool + // Archivista EnableArchivista bool ArchivistaServer string @@ -77,7 +90,18 @@ type Config struct { EnvAddSensitiveKey []string EnvFilterSensitiveVars bool - // Product/Material globs + // Product/Material globs. + // + // Products is the user-facing input: a list of paths/globs that + // should be tagged as products in the attestation. Empty = default + // to "every file under WorkingDir" (most idiomatic builds — outputs + // in the workspace). Override when the build writes outside the + // workspace (e.g., /tmp, ~/.local/bin). + Products []string + + // ProductIncludeGlob is the legacy single-string glob input. + // Kept for backward compat; superseded by Products. When both are + // set, Products wins. ProductIncludeGlob string ProductExcludeGlob string diff --git a/internal/platform/github.go b/internal/platform/github.go index 98f079b..7b5a8db 100644 --- a/internal/platform/github.go +++ b/internal/platform/github.go @@ -70,6 +70,12 @@ func ParseGitHub() (*config.Config, error) { WorkingDir: ghInput("WORKINGDIR"), Trace: ghInputBool("TRACE"), + // Zero-drop guarantee — fanotify integrity gate + fail-closed + // drop accounting. Defaults are ON; consumers opt out explicitly. + // See [[zero-drop-architecture]] in project memory. + Fanotify: ghInputDefault("FANOTIFY", "auto"), + RequireZeroDrops: ghInputBoolDefault("REQUIRE_ZERO_DROPS", true), + // Archivista (derived from platform-url unless explicitly overridden) EnableArchivista: ghInputBoolDefault("ENABLE_ARCHIVISTA", true), @@ -96,8 +102,9 @@ func ParseGitHub() (*config.Config, error) { // Environment filtering EnvFilterSensitiveVars: ghInputBool("ENV_FILTER_SENSITIVE_VARS"), - // Product/Material - ProductIncludeGlob: ghInputDefault("PRODUCT_INCLUDE_GLOB", DefaultProductIncludeGlob), + // Product/Material — see Config.Products doc. + Products: parseProducts(ghInput("PRODUCTS")), + ProductIncludeGlob: ghInput("PRODUCT_INCLUDE_GLOB"), ProductExcludeGlob: ghInput("PRODUCT_EXCLUDE_GLOB"), // Attestor exports @@ -225,6 +232,26 @@ func parseActionInputs(s string) (map[string]string, error) { return m, nil } +// parseProducts splits the `products` input on newlines, trims, and +// drops empties/comments. Comma-separated entries on a single line are +// also accepted so `products: "bin/gh, dist/**"` works. +func parseProducts(s string) []string { + if s == "" { + return nil + } + out := []string{} + for _, line := range strings.Split(s, "\n") { + for _, part := range strings.Split(line, ",") { + p := strings.TrimSpace(part) + if p == "" || strings.HasPrefix(p, "#") { + continue + } + out = append(out, p) + } + } + return out +} + // parseKeyValueLines parses KEY=VALUE pairs separated by newlines. func parseKeyValueLines(s string) map[string]string { m := make(map[string]string) diff --git a/shim/index.js b/shim/index.js index ac89737..692755b 100644 --- a/shim/index.js +++ b/shim/index.js @@ -124,6 +124,95 @@ async function run() { // Make executable await exec.exec("chmod", ["+x", binaryPath]); + // Best-effort: install the BPF rebuild toolchain on Linux. cilock + // ships a pre-built .bpf.o embedded in its binary, but the object's + // CO-RE relocations are baked against the vmlinux.h of whichever + // kernel/arch the release was built on. On GHA hosted runners that + // can be the Azure-flavored kernel where x86_64 BTF differs from + // mainline — every kprobe poisons. + // + // To self-heal, cilock auto-rebuilds the .bpf.o from its embedded + // source against /sys/kernel/btf/vmlinux when CO-RE fails. That + // path needs clang + bpftool + libbpf-dev on PATH. Install them + // here, quiet on success. If they're already present, apt is a + // few-second no-op; if apt-get isn't available (container without + // sudo, unusual host), we skip silently and cilock will fall back + // to ptrace+seccomp. + if (os.platform() === "linux") { + try { + // Always-needed: clang + libbpf headers. + const baseExit = await exec.exec( + "sudo", + ["-n", "apt-get", "install", "-y", "-qq", + "clang", "llvm", "libbpf-dev"], + { silent: true, ignoreReturnCode: true } + ); + // bpftool is shipped two ways: standalone `bpftool` package + // (Ubuntu universe, not on every image) or via + // linux-tools-generic which drops a binary under + // /usr/lib/linux-tools//bpftool. rebuild_linux.go in + // rookery globs both, so we try standalone first then fall back. + let bpftoolExit = await exec.exec( + "sudo", + ["-n", "apt-get", "install", "-y", "-qq", "bpftool"], + { silent: true, ignoreReturnCode: true } + ); + if (bpftoolExit !== 0) { + bpftoolExit = await exec.exec( + "sudo", + ["-n", "apt-get", "install", "-y", "-qq", "linux-tools-generic"], + { silent: true, ignoreReturnCode: true } + ); + } + if (baseExit === 0 && bpftoolExit === 0) { + core.info( + "✓ Installed BPF rebuild toolchain — cilock will auto-rebuild its eBPF object against this kernel if the embedded one fails CO-RE" + ); + } else { + core.info( + "Note: BPF rebuild toolchain install partial/failed. cilock will try its embedded .bpf.o; on CO-RE failure it falls back to ptrace+seccomp tracing." + ); + } + } catch (e) { + core.info(`BPF rebuild toolchain install skipped (${e.message}); ptrace fallback still works`); + } + } + + // Best-effort: grant eBPF tracing capabilities so cilock uses the + // fast in-kernel tracing path instead of falling back to ptrace. + // + // We grant CAP_BPF + CAP_PERFMON only — NOT CAP_SYS_ADMIN. This + // avoids "essentially root" privilege while enabling kprobes / + // tracepoints for cilock's BPF program. Hosted GH Actions runners + // have NOPASSWD sudo, so this succeeds on the default config. + // In containers without sudo (most container: jobs), this fails + // silently and cilock falls back to ptrace+seccomp, which is + // slower but functionally equivalent. + try { + const setcapExit = await exec.exec( + "sudo", + ["-n", "setcap", "cap_bpf,cap_perfmon+ep", binaryPath], + { silent: true, ignoreReturnCode: true } + ); + if (setcapExit === 0) { + core.info( + "✓ Granted eBPF tracing capabilities (CAP_BPF, CAP_PERFMON) — cilock will use the faster eBPF path" + ); + } else { + core.warning( + "⚠ Could not grant eBPF capabilities (sudo unavailable or setcap denied). " + + "cilock will fall back to ptrace+seccomp tracing, which is significantly slower for typical builds. " + + "To enable eBPF tracing in a container, add to your job's container config:\n" + + " container:\n" + + " image: your-image\n" + + " options: --cap-add=BPF --cap-add=PERFMON\n" + + "Or set CILOCK_TRACE_MODE=ptrace to silence this warning." + ); + } + } catch (e) { + core.warning(`setcap attempt failed (${e.message}); cilock will use ptrace+seccomp tracing`); + } + // Run the Go binary — it reads INPUT_* env vars directly const exitCode = await exec.exec(binaryPath, [], { ignoreReturnCode: true,