From 45e998ead0632cb3ba8b614e1f6c912ade1a99cb Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Wed, 15 Jul 2026 11:12:14 +0100 Subject: [PATCH] feat: auto-attest docker images pushed in later steps Opt-in via attest-docker-artifact: true. On Linux runners the action installs bash shims for docker/buildx/docker-buildx that shadow the real binaries on the PATH of subsequent steps. After a successful push (docker push, build --push, or --output type=registry/push=true) the shim runs `kosli attest artifact --artifact-type=oci` for each pushed tag, deriving --name from the image ref unless artifact-name is set. Attest failures warn by default (fail-on-attest-error to opt out); a real docker failure is never masked. The shim source is embedded as a JS string constant so ncc bundles it into dist/ on release. Unit tests execute the shim against stub docker/kosli binaries; a new integration job pushes to a local registry:2 with KOSLI_DRY_RUN. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yaml | 48 +++++++ README.md | 82 +++++++++++ action.yml | 16 +++ package-lock.json | 4 +- package.json | 1 + src/index.js | 11 ++ src/shim.js | 189 +++++++++++++++++++++++++ src/wrapDocker.js | 73 ++++++++++ test/integration/Dockerfile | 4 + test/shim.test.js | 266 ++++++++++++++++++++++++++++++++++++ test/wrapDocker.test.js | 123 +++++++++++++++++ 11 files changed, 816 insertions(+), 1 deletion(-) create mode 100644 src/shim.js create mode 100644 src/wrapDocker.js create mode 100644 test/integration/Dockerfile create mode 100644 test/shim.test.js create mode 100644 test/wrapDocker.test.js diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 36cb45d..066a11b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,3 +78,51 @@ jobs: if resolved not in installed: print(f"resolved {resolved!r} not found in installed {installed!r}") sys.exit(1) + + attest-docker: + name: Attest docker artifact (integration) + runs-on: ubuntu-latest + services: + registry: + image: registry:2 + ports: + - 5000:5000 + env: + KOSLI_ORG: dummy-org + KOSLI_FLOW: dummy-flow + KOSLI_TRAIL: dummy-trail + KOSLI_API_TOKEN: dummy-token + KOSLI_DRY_RUN: "true" + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install deps and build + run: npm ci && npm run build + + - name: Setup kosli CLI with docker attestation + uses: ./ + with: + attest-docker-artifact: true + + - name: docker resolves to the shim + run: | + resolved=$(command -v docker) + [[ "$resolved" == "$RUNNER_TEMP"/kosli-docker-shim-*/docker ]] || { echo "docker is $resolved, not the shim"; exit 1; } + + - name: docker push attests the pushed tag + run: | + docker build -f test/integration/Dockerfile -t localhost:5000/kosli-shim-it:push . + docker push localhost:5000/kosli-shim-it:push 2>&1 | tee push.log + grep -q "kosli-shim: attesting localhost:5000/kosli-shim-it:push as 'kosli-shim-it'" push.log + grep -q "kosli-shim: attested localhost:5000/kosli-shim-it:push" push.log + + - name: docker buildx build --push attests the pushed tag + run: | + docker buildx build --push -t localhost:5000/kosli-shim-it:bx -f test/integration/Dockerfile . 2>&1 | tee bx.log + grep -q "kosli-shim: attested localhost:5000/kosli-shim-it:bx" bx.log + + - name: a build without --push does not attest + run: | + docker build -t localhost:5000/kosli-shim-it:nopush -f test/integration/Dockerfile . 2>&1 | tee nopush.log + ! grep -q "kosli-shim: attest" nopush.log diff --git a/README.md b/README.md index ab76873..009da22 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,79 @@ steps: version: latest ``` +## Automatically attest pushed Docker images + +On Linux runners the action can attest Docker images to Kosli automatically, with no +explicit `kosli attest artifact` step. Set `attest-docker-artifact: true` and the action +installs shims for `docker`, `buildx`, and `docker-buildx` that shadow the real binaries +in subsequent steps. Whenever a later step pushes an image — `docker push`, +`docker build --push`, `docker buildx build --push`, or a standalone `buildx build --push` +— the shim runs + +``` +kosli attest artifact "" --artifact-type=oci --name "" +``` + +for each pushed tag, after the push succeeds. `--artifact-type=oci` fingerprints the +image directly from the registry, so this works with buildx's default `docker-container` +driver, where the pushed image never enters the local image store. + +```yaml +env: + KOSLI_API_TOKEN: ${{ secrets.KOSLI_API_TOKEN }} + KOSLI_ORG: my-org + KOSLI_FLOW: my-flow + KOSLI_TRAIL: ${{ github.sha }} + +jobs: + build-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup kosli # must run BEFORE the build/push step + uses: kosli-dev/setup-cli-action@v5 + with: + attest-docker-artifact: true + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + push: true + tags: my-registry/my-image:${{ github.sha }} + # no explicit attest step - the push is attested automatically +``` + +> **Ordering matters.** The shims are added to the `PATH` of *subsequent* steps, so this +> action must run before the step that builds or pushes. + +By default the Kosli artifact `--name` is derived from each image ref (strip any digest, +take the last `/`-segment, strip the tag): `my-registry/my-image:1.2.3` → `my-image`. +That per-image default is what makes monorepos work — each image gets its own name. If +your Kosli flow template uses a different artifact name, set `artifact-name` (it applies +to every image pushed in the job, so it is intended for single-artifact repos). + +The shim reads the usual `KOSLI_API_TOKEN` / `KOSLI_ORG` / `KOSLI_FLOW` / `KOSLI_TRAIL` / +`KOSLI_HOST` environment variables, and the CLI auto-detects `--commit`, `--build-url`, +etc. on GitHub Actions. `KOSLI_DRY_RUN` also flows through unchanged. + +A failed attestation does not fail the push by default — it prints a warning and +continues. Set `fail-on-attest-error: true` to make it fail the step instead. A failed +`docker`/`buildx` command is never masked: its exit code is always propagated and nothing +is attested. + +### Limitations + +- Linux runners only; on other platforms the input is ignored with a warning. +- Only invocations that resolve the shimmed binaries via `PATH` are intercepted; tools + that call docker/buildx by absolute path bypass the shims. +- `docker push --all-tags` is skipped (the pushed tag set is not knowable from the + command line); a warning is printed. +- `attest-flags` is split on whitespace; flag values containing spaces are not supported. +- `docker buildx bake --push` and `docker compose push` are not intercepted. +- Shim and attestation output goes to stderr (so `$(docker push -q ...)` captures stay + clean), and appears as plain log lines rather than GitHub annotations. + ## Inputs The action supports the following inputs: @@ -80,6 +153,15 @@ The action supports the following inputs: Quote partial versions (see the note above). Defaults to `latest`. - `github-token`: Token used to authenticate the GitHub API calls that resolve `latest` or a major/minor pin. Defaults to `${{ github.token }}`; normally you do not need to set this. +- `attest-docker-artifact`: When `true` (Linux runners only), install the docker/buildx shims + described above so images pushed by subsequent steps are attested automatically. + Defaults to `false`. +- `artifact-name`: Kosli template artifact name used for every auto-attested image. Leave + empty (the default) to derive the name from each image ref. +- `attest-flags`: Extra flags appended verbatim to every `kosli attest artifact` call made by + the shim, e.g. `--annotate key=value`. +- `fail-on-attest-error`: When `true`, a failed auto-attestation fails the step that pushed + the image. Defaults to `false` (warn and continue). ## Outputs diff --git a/action.yml b/action.yml index ad11ed2..b98e1d0 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,22 @@ inputs: description: Token used to authenticate GitHub API calls when resolving `latest` or a major/minor pin. required: false default: ${{ github.token }} + attest-docker-artifact: + description: When true (Linux runners only), install shims for docker/buildx so any image pushed by a subsequent step is automatically attested to Kosli with `kosli attest artifact --artifact-type=oci`. This action must run before the step that builds or pushes. + required: false + default: "false" + artifact-name: + description: Kosli template artifact name used for every auto-attested image. Leave empty (the default) to derive the name from each image ref, which is what makes monorepos work. + required: false + default: "" + attest-flags: + description: Extra flags appended verbatim to every `kosli attest artifact` call made by the docker shim (e.g. `--annotate key=value`). + required: false + default: "" + fail-on-attest-error: + description: When true, a failed auto-attestation fails the step that pushed the image. Default is to warn and continue, keeping pushes non-blocking. + required: false + default: "false" outputs: version: description: The resolved Kosli CLI version that was installed. diff --git a/package-lock.json b/package-lock.json index 46a7014..b02a5f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@actions/core": "^3.0.1", "@actions/github": "^9.1.1", + "@actions/io": "^3.0.2", "@actions/tool-cache": "^4.0.0" }, "devDependencies": { @@ -69,7 +70,8 @@ "node_modules/@actions/io": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", - "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==" + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "license": "MIT" }, "node_modules/@actions/tool-cache": { "version": "4.0.0", diff --git a/package.json b/package.json index 6299165..283881d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@actions/core": "^3.0.1", "@actions/github": "^9.1.1", + "@actions/io": "^3.0.2", "@actions/tool-cache": "^4.0.0" }, "devDependencies": { diff --git a/src/index.js b/src/index.js index 22c91b6..a50821a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,12 @@ import os from "os"; import crypto from "crypto"; import fs from "fs"; +import path from "path"; import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; import { getAssetFilename, getChecksumsUrl, getDownloadUrl, resolveVersion, verifyChecksum } from "./download.js"; import { withRetries } from "./retry.js"; +import { installDockerShims } from "./wrapDocker.js"; async function setup() { try { @@ -36,6 +38,15 @@ async function setup() { core.addPath(pathToCLI); core.setOutput("version", resolvedVersion); console.log(`installed Kosli CLI v${resolvedVersion} to ${pathToCLI}`); + + if (core.getBooleanInput("attest-docker-artifact")) { + await installDockerShims({ + kosliBin: path.join(pathToCLI, "kosli"), + artifactName: core.getInput("artifact-name"), + attestFlags: core.getInput("attest-flags"), + failOnAttestError: core.getBooleanInput("fail-on-attest-error") + }); + } } catch (e) { core.setFailed(e); } diff --git a/src/shim.js b/src/shim.js new file mode 100644 index 0000000..0d9eff5 --- /dev/null +++ b/src/shim.js @@ -0,0 +1,189 @@ +// The docker/buildx shim installed by wrapDocker.js. The bash source is embedded +// as a string constant (not a .sh asset file) so `ncc` bundles it into dist/ by +// definition — asset relocation for files read at runtime is unreliable and would +// break only in released tags. +// +// Editing rules: this is a JS template literal, so every bash \${...} parameter +// expansion must be written with a backslash before the $; plain $VAR, "$@", +// $(...), $# and $1 need no escaping; never use backticks (use $(...)). +export const SHIM_SOURCE = `#!/usr/bin/env bash +# Kosli docker shim. Installed by kosli-dev/setup-cli-action when the +# attest-docker-artifact input is enabled. Runs the real binary, then attests +# any image refs pushed by the invocation via \`kosli attest artifact\`. +# No set -e / set -u: the real binary's exit code must be propagated explicitly +# and unset config must degrade gracefully. + +prog=$(basename "$0") +case "$prog" in + docker) real="$KOSLI_SHIM_REAL_DOCKER" ;; + buildx) real="$KOSLI_SHIM_REAL_BUILDX" ;; + docker-buildx) real="$KOSLI_SHIM_REAL_DOCKER_BUILDX" ;; + *) + echo "kosli-shim: unexpected invocation name '$prog'" >&2 + exit 127 + ;; +esac +if [ -z "$real" ] || [ ! -x "$real" ]; then + echo "kosli-shim: real binary for '$prog' is not configured" >&2 + exit 127 +fi + +"$real" "$@" +rc=$? +if [ "$rc" -ne 0 ]; then + exit "$rc" +fi + +# ---- collect pushed image refs from the original args ---- + +refs="" + +# Image refs never contain whitespace, so a space-separated list with a linear +# scan is a safe dedupe on bash 3.2 (macOS has no associative arrays). +add_ref() { + local r + for r in $refs; do + if [ "$r" = "$1" ]; then + return 0 + fi + done + refs="$refs $1" +} + +# localhost:5000/ns/app:1.2 -> app. Order matters: strip any @digest, take the +# last /-segment (keeps registry ports with the registry), then strip the :tag. +derive_name() { + local n + n="$1" + n=\${n%%@*} + n=\${n##*/} + n=\${n%%:*} + printf '%s' "$n" +} + +# Resolve the effective subcommand, skipping global flags. Value-taking flags +# use a guarded double shift: a bare \`shift 2\` with $# = 1 fails WITHOUT +# shifting, which would loop forever. +sub="" +if [ "$prog" = "docker" ]; then + while [ $# -gt 0 ]; do + case "$1" in + --config|--context|-c|-H|--host|-l|--log-level|--tlscacert|--tlscert|--tlskey) + shift + if [ $# -gt 0 ]; then shift; fi + ;; + -*) shift ;; + *) sub="$1"; shift; break ;; + esac + done + # docker image push/build and docker buildx build: step down one level. + if [ "$sub" = "image" ] && [ $# -gt 0 ]; then + sub="$1"; shift + fi + if [ "$sub" = "buildx" ] && [ $# -gt 0 ]; then + sub="$1"; shift + fi +else + # Invoked as buildx / docker-buildx: first non-flag arg is the subcommand. + while [ $# -gt 0 ]; do + case "$1" in + -*) shift ;; + *) sub="$1"; shift; break ;; + esac + done +fi + +case "$sub" in + push) + ref="" + all_tags="" + while [ $# -gt 0 ]; do + case "$1" in + -a|--all-tags) all_tags=1; shift ;; + --platform) + shift + if [ $# -gt 0 ]; then shift; fi + ;; + -*) shift ;; + *) ref="$1"; break ;; + esac + done + if [ -n "$all_tags" ]; then + echo "kosli-shim: 'push --all-tags' pushes an unknown set of tags; skipping attestation" >&2 + elif [ -n "$ref" ]; then + add_ref "$ref" + fi + ;; + build) + pushed="" + tags="" + while [ $# -gt 0 ]; do + case "$1" in + --push) pushed=1; shift ;; + -t|--tag) + shift + if [ $# -gt 0 ]; then + tags="$tags $1" + shift + fi + ;; + --tag=*|-t=*) + tags="$tags \${1#*=}" + shift + ;; + -o|--output) + # --push is shorthand for --output type=registry; catch the long form. + shift + if [ $# -gt 0 ]; then + case "$1" in + *push=true*|*type=registry*) pushed=1 ;; + esac + shift + fi + ;; + --output=*|-o=*) + case "\${1#*=}" in + *push=true*|*type=registry*) pushed=1 ;; + esac + shift + ;; + *) shift ;; + esac + done + if [ -n "$pushed" ]; then + for t in $tags; do + add_ref "$t" + done + fi + ;; +esac + +if [ -z "$refs" ]; then + exit 0 +fi + +# ---- attest each pushed ref ---- +# Everything (including kosli's stdout) goes to stderr so command substitutions +# like digest=$(docker push -q ...) in user scripts stay clean. + +for ref in $refs; do + name="$KOSLI_SHIM_ARTIFACT_NAME" + if [ -z "$name" ]; then + name=$(derive_name "$ref") + fi + echo "kosli-shim: attesting $ref as '$name'" >&2 + # KOSLI_SHIM_ATTEST_FLAGS is deliberately unquoted: it is a whitespace- + # separated list of extra flags (values containing spaces are unsupported). + if "$KOSLI_SHIM_KOSLI" attest artifact "$ref" --artifact-type=oci --name "$name" $KOSLI_SHIM_ATTEST_FLAGS 1>&2; then + echo "kosli-shim: attested $ref" >&2 + else + if [ "$KOSLI_SHIM_FAIL_ON_ERROR" = "true" ]; then + echo "kosli-shim: attestation failed for $ref" >&2 + exit 1 + fi + echo "kosli-shim: WARNING: attestation failed for $ref (continuing)" >&2 + fi +done + +exit 0 +`; diff --git a/src/wrapDocker.js b/src/wrapDocker.js new file mode 100644 index 0000000..43a668b --- /dev/null +++ b/src/wrapDocker.js @@ -0,0 +1,73 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import * as core from "@actions/core"; +import * as io from "@actions/io"; +import { SHIM_SOURCE } from "./shim.js"; + +// Every entrypoint a workflow step can push an image through: the docker CLI +// (which also fronts `docker buildx build`), the standalone buildx binary, and +// the buildx CLI plugin invoked directly. +export const SHIM_PROGRAMS = ["docker", "buildx", "docker-buildx"]; + +// Map the shim config to the env vars the shim script reads. The KOSLI_SHIM_ +// prefix cannot collide with the CLI's own KOSLI_ env binding. +export function buildShimEnv({ realPaths, kosliBin, artifactName, attestFlags, failOnAttestError }) { + const env = { + KOSLI_SHIM_KOSLI: kosliBin, + KOSLI_SHIM_ARTIFACT_NAME: artifactName, + KOSLI_SHIM_ATTEST_FLAGS: attestFlags, + KOSLI_SHIM_FAIL_ON_ERROR: failOnAttestError ? "true" : "false" + }; + for (const program of SHIM_PROGRAMS) { + env[`KOSLI_SHIM_REAL_${program.toUpperCase().replace(/-/g, "_")}`] = realPaths[program] || ""; + } + return env; +} + +// Install shims that shadow docker/buildx on the PATH of subsequent steps, so +// any image pushed later in the job is attested to Kosli automatically. Never +// throws: a broken wrap must not fail the CLI install. Deps are injectable for +// tests, in the style of resolveVersion(version, token, octokit). +export async function installDockerShims(config, deps = {}) { + const { + which = io.which, + platform = os.platform(), + env = process.env, + actions = core + } = deps; + + if (platform !== "linux") { + actions.warning("attest-docker-artifact is only supported on Linux runners; skipping docker shims"); + return; + } + + const realPaths = {}; + for (const program of SHIM_PROGRAMS) { + const found = await which(program, false); + if (found) { + realPaths[program] = found; + actions.info(`shimming ${program} (real binary: ${found})`); + } + } + const foundPrograms = Object.keys(realPaths); + if (foundPrograms.length === 0) { + actions.warning( + "attest-docker-artifact is enabled but none of docker/buildx/docker-buildx were found on PATH; skipping docker shims" + ); + return; + } + + // The shim dispatches on basename($0), so it is written once per program name. + const shimDir = fs.mkdtempSync(path.join(env.RUNNER_TEMP || os.tmpdir(), "kosli-docker-shim-")); + for (const program of foundPrograms) { + fs.writeFileSync(path.join(shimDir, program), SHIM_SOURCE, { mode: 0o755 }); + } + + const shimEnv = buildShimEnv({ realPaths, ...config }); + for (const [name, value] of Object.entries(shimEnv)) { + actions.exportVariable(name, value); + } + actions.addPath(shimDir); + actions.info(`docker shims installed to ${shimDir}; images pushed in subsequent steps will be attested to Kosli`); +} diff --git a/test/integration/Dockerfile b/test/integration/Dockerfile new file mode 100644 index 0000000..bddcdea --- /dev/null +++ b/test/integration/Dockerfile @@ -0,0 +1,4 @@ +# Minimal image for the attest-docker integration test: no base image pulls, +# so no Docker Hub rate limits in CI. +FROM scratch +COPY README.md / diff --git a/test/shim.test.js b/test/shim.test.js new file mode 100644 index 0000000..86bba49 --- /dev/null +++ b/test/shim.test.js @@ -0,0 +1,266 @@ +import test from "ava"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execFile } from "child_process"; +import { SHIM_SOURCE } from "../src/shim.js"; + +// The shim only ever installs on Linux runners; macOS CI additionally validates +// bash-3.2 compatibility. Git Bash path translation makes these tests more +// trouble than they are worth on Windows. +const shimTest = process.platform === "win32" ? test.skip : test; + +// Async equivalent of spawnSync: resolves with { status, stdout, stderr } so +// tests don't block the AVA worker's event loop and can run concurrently. +function execShim(file, args, env) { + return new Promise(resolve => { + execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => { + const status = error ? (typeof error.code === "number" ? error.code : 1) : 0; + resolve({ status, stdout, stderr }); + }); + }); +} + +// Writes the shim plus stub "real docker" and "kosli" scripts into a temp dir. +// Each stub appends its argv (one arg per line, then an --end-- marker) to a log +// file, so tests can assert on exactly what the shim invoked. +function makeHarness(t, options = {}) { + const { + prog = "docker", + realExit = 0, + kosliExit = 0, + artifactName = "", + attestFlags = "", + failOnError = "false" + } = options; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kosli-shim-test-")); + t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })); + + const realLog = path.join(dir, "real.log"); + const kosliLog = path.join(dir, "kosli.log"); + + const writeStub = (name, log, exitCode, stdoutLine) => { + const stubPath = path.join(dir, name); + const lines = [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$@" >> "${log}"`, + `printf -- '--end--\\n' >> "${log}"` + ]; + if (stdoutLine) { + lines.push(`echo "${stdoutLine}"`); + } + lines.push(`exit ${exitCode}`, ""); + fs.writeFileSync(stubPath, lines.join("\n"), { mode: 0o755 }); + return stubPath; + }; + + const realBinary = writeStub("real-binary", realLog, realExit, "real-stdout"); + const kosliBinary = writeStub("kosli-stub", kosliLog, kosliExit, ""); + + // The shim dispatches on basename($0), so its filename must be the program name. + const shimPath = path.join(dir, prog); + fs.writeFileSync(shimPath, SHIM_SOURCE, { mode: 0o755 }); + + const env = { + ...process.env, + KOSLI_SHIM_REAL_DOCKER: realBinary, + KOSLI_SHIM_REAL_BUILDX: realBinary, + KOSLI_SHIM_REAL_DOCKER_BUILDX: realBinary, + KOSLI_SHIM_KOSLI: kosliBinary, + KOSLI_SHIM_ARTIFACT_NAME: artifactName, + KOSLI_SHIM_ATTEST_FLAGS: attestFlags, + KOSLI_SHIM_FAIL_ON_ERROR: failOnError + }; + + const readCalls = log => { + if (!fs.existsSync(log)) { + return []; + } + return fs + .readFileSync(log, "utf8") + .split("--end--\n") + .filter(chunk => chunk !== "") + .map(chunk => chunk.split("\n").filter(line => line !== "")); + }; + + return { + run: args => execShim(shimPath, args, env), + kosliCalls: () => readCalls(kosliLog), + realCalls: () => readCalls(realLog) + }; +} + +function attestArgs(ref, name, extra = []) { + return ["attest", "artifact", ref, "--artifact-type=oci", "--name", name, ...extra]; +} + +shimTest("SHIM_SOURCE passes a bash syntax check", async t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kosli-shim-syntax-")); + t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, "shim.sh"); + fs.writeFileSync(file, SHIM_SOURCE); + const res = await execShim("bash", ["-n", file], process.env); + t.is(res.status, 0, res.stderr); +}); + +shimTest("docker push attests the ref with a derived name", async t => { + const h = makeHarness(t); + const res = await h.run(["push", "localhost:5000/ns/app:1"]); + t.is(res.status, 0, res.stderr); + t.deepEqual(h.kosliCalls(), [attestArgs("localhost:5000/ns/app:1", "app")]); +}); + +shimTest("the real binary receives the original args unchanged", async t => { + const h = makeHarness(t); + await h.run(["push", "localhost:5000/ns/app:1"]); + t.deepEqual(h.realCalls(), [["push", "localhost:5000/ns/app:1"]]); +}); + +shimTest("a real binary failure propagates its exit code and never attests", async t => { + const h = makeHarness(t, { realExit: 3 }); + const res = await h.run(["push", "localhost:5000/ns/app:1"]); + t.is(res.status, 3); + t.deepEqual(h.kosliCalls(), []); +}); + +shimTest("docker build --push attests every tag", async t => { + const h = makeHarness(t); + const res = await h.run(["build", "--push", "-t", "reg/a:1", "-t", "reg/b:1", "."]); + t.is(res.status, 0, res.stderr); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a"), attestArgs("reg/b:1", "b")]); +}); + +shimTest("duplicate tags are attested once", async t => { + const h = makeHarness(t); + await h.run(["build", "--push", "-t", "reg/a:1", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("--tag= form is collected", async t => { + const h = makeHarness(t); + await h.run(["build", "--push", "--tag=reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("docker buildx build --push attests", async t => { + const h = makeHarness(t); + await h.run(["buildx", "build", "--push", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("invoked as buildx, build --push attests", async t => { + const h = makeHarness(t, { prog: "buildx" }); + await h.run(["build", "--push", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("invoked as docker-buildx, build --push attests", async t => { + const h = makeHarness(t, { prog: "docker-buildx" }); + await h.run(["build", "--push", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("--output type=registry counts as a push", async t => { + const h = makeHarness(t); + await h.run(["build", "--output", "type=registry", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("--output=type=image,push=true counts as a push", async t => { + const h = makeHarness(t); + await h.run(["buildx", "build", "--output=type=image,push=true", "-t", "reg/a:1", "."]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("a build without --push does not attest", async t => { + const h = makeHarness(t); + const res = await h.run(["build", "-t", "reg/a:1", "."]); + t.is(res.status, 0, res.stderr); + t.deepEqual(h.kosliCalls(), []); +}); + +shimTest("docker image push attests", async t => { + const h = makeHarness(t); + await h.run(["image", "push", "reg/a:1"]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("value-taking global docker flags do not hide the subcommand", async t => { + const h = makeHarness(t); + await h.run(["--config", "/tmp/x", "push", "reg/a:1"]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a")]); +}); + +shimTest("push --all-tags warns and does not attest", async t => { + const h = makeHarness(t); + const res = await h.run(["push", "--all-tags", "reg/a"]); + t.is(res.status, 0); + t.deepEqual(h.kosliCalls(), []); + t.regex(res.stderr, /--all-tags/); +}); + +shimTest("artifact-name overrides derivation for every ref", async t => { + const h = makeHarness(t, { artifactName: "my-artifact" }); + await h.run(["build", "--push", "-t", "reg/a:1", "-t", "reg/b:1", "."]); + t.deepEqual(h.kosliCalls(), [ + attestArgs("reg/a:1", "my-artifact"), + attestArgs("reg/b:1", "my-artifact") + ]); +}); + +shimTest("attest-flags are appended to the kosli call", async t => { + const h = makeHarness(t, { attestFlags: "--annotate foo=bar" }); + await h.run(["push", "reg/a:1"]); + t.deepEqual(h.kosliCalls(), [attestArgs("reg/a:1", "a", ["--annotate", "foo=bar"])]); +}); + +// --- name derivation via real pushes --- + +const derivationCases = [ + { ref: "localhost:5000/ns/app:1", name: "app", label: "registry port is not a tag" }, + { ref: "foo/bar@sha256:0123456789abcdef", name: "bar", label: "digest ref" }, + { ref: "localhost/kosli-poc:8fff94fb10fb3527b8511eed19efe745313ab413", name: "kosli-poc", label: "sha tag" }, + { + ref: "artifactory.sdlc.ctl.gcp.db.com/dkr-public-local/com/db/espear/esr/espear-esr-reference-data-service:4.0.345", + name: "espear-esr-reference-data-service", + label: "deep path" + } +]; + +derivationCases.forEach(({ ref, name, label }) => { + shimTest(`derives name '${name}' from ${label}`, async t => { + const h = makeHarness(t); + await h.run(["push", ref]); + t.deepEqual(h.kosliCalls(), [attestArgs(ref, name)]); + }); +}); + +// --- failure handling --- + +shimTest("a failed attest warns and exits 0 by default", async t => { + const h = makeHarness(t, { kosliExit: 1 }); + const res = await h.run(["push", "reg/a:1"]); + t.is(res.status, 0); + t.regex(res.stderr, /WARNING: attestation failed for reg\/a:1/); +}); + +shimTest("a failed attest fails the step when fail-on-attest-error is true", async t => { + const h = makeHarness(t, { kosliExit: 1, failOnError: "true" }); + const res = await h.run(["push", "reg/a:1"]); + t.is(res.status, 1); +}); + +shimTest("an unexpected invocation name exits 127", async t => { + const h = makeHarness(t, { prog: "podman" }); + const res = await h.run(["push", "reg/a:1"]); + t.is(res.status, 127); + t.deepEqual(h.kosliCalls(), []); +}); + +shimTest("shim stdout carries only the real binary's stdout", async t => { + const h = makeHarness(t); + const res = await h.run(["push", "reg/a:1"]); + t.is(res.stdout, "real-stdout\n"); + t.regex(res.stderr, /kosli-shim: attested reg\/a:1/); +}); diff --git a/test/wrapDocker.test.js b/test/wrapDocker.test.js new file mode 100644 index 0000000..e637d37 --- /dev/null +++ b/test/wrapDocker.test.js @@ -0,0 +1,123 @@ +import test from "ava"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { buildShimEnv, installDockerShims, SHIM_PROGRAMS } from "../src/wrapDocker.js"; +import { SHIM_SOURCE } from "../src/shim.js"; + +const config = { + kosliBin: "/opt/kosli/kosli", + artifactName: "", + attestFlags: "", + failOnAttestError: false +}; + +function fakeActions() { + const calls = { warnings: [], infos: [], exported: {}, paths: [] }; + return { + calls, + warning: message => calls.warnings.push(message), + info: message => calls.infos.push(message), + exportVariable: (name, value) => { + calls.exported[name] = value; + }, + addPath: p => calls.paths.push(p) + }; +} + +// which(name, false) resolves "" when the tool is not found, like @actions/io. +function fakeWhich(available) { + return async name => available[name] || ""; +} + +function makeDeps(t, { which = {}, platform = "linux" } = {}) { + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "kosli-wrap-test-")); + t.teardown(() => fs.rmSync(runnerTemp, { recursive: true, force: true })); + const actions = fakeActions(); + return { + actions, + deps: { + which: fakeWhich(which), + platform, + env: { RUNNER_TEMP: runnerTemp }, + actions + } + }; +} + +test("buildShimEnv maps config and real paths to shim env vars", t => { + const env = buildShimEnv({ + realPaths: { docker: "/usr/bin/docker", "docker-buildx": "/usr/libexec/docker-buildx" }, + kosliBin: "/opt/kosli/kosli", + artifactName: "my-artifact", + attestFlags: "--annotate a=b", + failOnAttestError: true + }); + t.deepEqual(env, { + KOSLI_SHIM_KOSLI: "/opt/kosli/kosli", + KOSLI_SHIM_ARTIFACT_NAME: "my-artifact", + KOSLI_SHIM_ATTEST_FLAGS: "--annotate a=b", + KOSLI_SHIM_FAIL_ON_ERROR: "true", + KOSLI_SHIM_REAL_DOCKER: "/usr/bin/docker", + KOSLI_SHIM_REAL_BUILDX: "", + KOSLI_SHIM_REAL_DOCKER_BUILDX: "/usr/libexec/docker-buildx" + }); +}); + +test("installDockerShims writes an executable shim per found program", async t => { + const { actions, deps } = makeDeps(t, { + which: { docker: "/usr/bin/docker", buildx: "/usr/local/bin/buildx" } + }); + await installDockerShims(config, deps); + + t.is(actions.calls.paths.length, 1); + const shimDir = actions.calls.paths[0]; + t.regex(path.basename(shimDir), /^kosli-docker-shim-/); + for (const program of ["docker", "buildx"]) { + const shim = path.join(shimDir, program); + t.is(fs.readFileSync(shim, "utf8"), SHIM_SOURCE); + if (process.platform !== "win32") { + t.is(fs.statSync(shim).mode & 0o777, 0o755); + } + } + t.false(fs.existsSync(path.join(shimDir, "docker-buildx"))); +}); + +test("installDockerShims exports the shim config env vars", async t => { + const { actions, deps } = makeDeps(t, { which: { docker: "/usr/bin/docker" } }); + await installDockerShims( + { kosliBin: "/opt/kosli/kosli", artifactName: "app", attestFlags: "-x", failOnAttestError: true }, + deps + ); + t.deepEqual(actions.calls.exported, { + KOSLI_SHIM_KOSLI: "/opt/kosli/kosli", + KOSLI_SHIM_ARTIFACT_NAME: "app", + KOSLI_SHIM_ATTEST_FLAGS: "-x", + KOSLI_SHIM_FAIL_ON_ERROR: "true", + KOSLI_SHIM_REAL_DOCKER: "/usr/bin/docker", + KOSLI_SHIM_REAL_BUILDX: "", + KOSLI_SHIM_REAL_DOCKER_BUILDX: "" + }); +}); + +test("installDockerShims is a warning no-op on non-Linux platforms", async t => { + const { actions, deps } = makeDeps(t, { which: { docker: "/usr/bin/docker" }, platform: "darwin" }); + await installDockerShims(config, deps); + t.is(actions.calls.warnings.length, 1); + t.regex(actions.calls.warnings[0], /only supported on Linux/); + t.deepEqual(actions.calls.paths, []); + t.deepEqual(actions.calls.exported, {}); +}); + +test("installDockerShims warns and no-ops when no programs are found", async t => { + const { actions, deps } = makeDeps(t, { which: {} }); + await installDockerShims(config, deps); + t.is(actions.calls.warnings.length, 1); + t.regex(actions.calls.warnings[0], /none of docker\/buildx\/docker-buildx were found/); + t.deepEqual(actions.calls.paths, []); + t.deepEqual(actions.calls.exported, {}); +}); + +test("SHIM_PROGRAMS covers docker, buildx and the buildx plugin", t => { + t.deepEqual(SHIM_PROGRAMS, ["docker", "buildx", "docker-buildx"]); +});