diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e0eac..3babb39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: runs-on: [self-hosted, Linux, X64, clean-room, intel] steps: - uses: actions/checkout@v6 + - name: Sigstore signer guards (RED->GREEN) + run: bash scripts/check-sigstore-sign.sh --self-test && bash scripts/check-sigstore-sign.sh && bash scripts/sigstore-verify.sh --self-test - name: Lint workflow YAML shell: bash run: | diff --git a/.github/workflows/sigstore-sign.yml b/.github/workflows/sigstore-sign.yml new file mode 100644 index 0000000..26b4084 --- /dev/null +++ b/.github/workflows/sigstore-sign.yml @@ -0,0 +1,146 @@ +# Reusable keyless signing (cosign + GitHub OIDC), the ONE copy aprender and rmedia call (Noah 17:13Z ruling, item 2). +# Extracted from paiml/aprender#5562. No stored key, no typed credential: the job's OIDC token gets a short-lived +# Fulcio certificate and the signature lands in the Rekor transparency log. +# +# Signer identity: a called workflow signs as ITS OWN ref, so the certificate identity is +# https://github.com/paiml/.github/.github/workflows/sigstore-sign.yml@ +# and the issuer is https://token.actions.githubusercontent.com. Verify with scripts/sigstore-verify.sh (item 5). +# +# Verify pins the SAN (this workflow ref), the issuer, the CALLER repo and the caller workflow name (sigstore-verify.sh): +# paiml/.github is public, so the SAN alone is the same for any repo that calls this file. +# Caller (tag builds from main, in the clean-room release lane only): +# sign: +# needs: build +# if: github.ref_type == 'tag' +# permissions: { id-token: write, contents: write } +# uses: paiml/.github/.github/workflows/sigstore-sign.yml@ +# with: { artifact-name: release-assets, runs-on: '["self-hosted","Linux","X64","clean-room"]' } +name: sigstore-sign +on: + workflow_call: + inputs: + artifact-name: + description: 'actions/upload-artifact name holding the files to sign (the caller uploaded them)' + required: true + type: string + runs-on: + description: 'JSON array of runner labels; REQUIRED, no default (hosted runners are banned, so there is no safe one to assume)' + required: true + type: string + upload-to-release: + description: 'attach each .sigstore.json bundle to the tag release (needs contents: write)' + required: false + type: boolean + default: true + outputs: + sha256: + description: 'sha256sum manifest of the signed files (one " " line each)' + value: ${{ jobs.sign.outputs.sha256 }} + bundle: + description: 'name of the artifact holding the .sigstore.json bundles' + value: ${{ jobs.sign.outputs.bundle }} + tag: + description: 'the tag that was signed (github.ref_name)' + value: ${{ jobs.sign.outputs.tag }} + identity: + description: 'the certificate identity (the SAN): this workflow at the ref the caller pinned; pass it to sigstore-verify.sh as ' + value: ${{ jobs.sign.outputs.identity }} + rekor-index: + description: 'Rekor log index of each bundle, "=" per line' + value: ${{ jobs.sign.outputs.rekor-index }} + +permissions: {} + +jobs: + sign: + name: Sign (sigstore, keyless) + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 15 + permissions: + id-token: write # the keyless OIDC identity + contents: write # attach the bundles to the release + outputs: + sha256: ${{ steps.out.outputs.sha256 }} + tag: ${{ steps.out.outputs.tag }} + bundle: ${{ steps.out.outputs.bundle }} + identity: ${{ steps.out.outputs.identity }} + rekor-index: ${{ steps.out.outputs.rekor-index }} + steps: + - name: Refuse anything but a tag build + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" != tag ]; then echo "::error::sigstore-sign signs tag builds only (ref_type=$REF_TYPE, $REF_NAME)"; exit 1; fi + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + path: caller-src + - name: Refuse a tag that is not on main + working-directory: caller-src + env: + TAG_SHA: ${{ github.sha }} + run: | + set -euo pipefail + # checkout with fetch-depth 0 already fetched every branch; no credential is kept to fetch again + git rev-parse --verify --quiet refs/remotes/origin/main >/dev/null || { echo "::error::origin/main is not in the checkout"; exit 1; } + git merge-base --is-ancestor "$TAG_SHA" refs/remotes/origin/main \ + || { echo "::error::$TAG_SHA is not an ancestor of origin/main; release signing is for tag builds from main only"; exit 1; } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 + with: + cosign-release: v2.5.3 # pinned: sign-blob below asks for the new bundle format explicitly + install-dir: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bin # per-run: nothing is installed under the runner's $HOME + - name: Sign every file, keyless + id: out + working-directory: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign + env: + # per-run, per-attempt scratch: a reused runner never carries a previous run's files or bundles into this one + WORK: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }} + TUF_ROOT: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/tuf # cosign's TUF cache stays in the per-run dir, not $HOME + TAG: ${{ github.ref_name }} + ARTIFACT_NAME: ${{ inputs.artifact-name }} + run: | + set -euo pipefail + # flat, regular, unsigned inputs only: nothing is silently skipped, and the attach glob below sees every bundle + x=$(find . -mindepth 1 \( -type d -o ! -type f -o -name "*.sigstore.json" \) -print -quit) + [ -z "$x" ] || { echo "::error::artifact $ARTIFACT_NAME holds a nested path, a non-regular file or a *.sigstore.json ($x); refusing before anything is signed"; exit 1; } + case "$TAG" in */*) echo "::error::tag $TAG contains a slash"; exit 1;; esac + mkdir "$WORK/bundles"; : > "$WORK/manifest.txt"; : > "$WORK/rekor.txt" + n=0 + while IFS= read -r f; do + f="${f#./}" + sha256sum -- "$f" >> "$WORK/manifest.txt" + cosign sign-blob --yes --new-bundle-format --bundle "$WORK/bundles/$f.sigstore.json" -- "$f" > /dev/null + idx=$(jq -r '.verificationMaterial.tlogEntries[0].logIndex // empty' "$WORK/bundles/$f.sigstore.json") + [ -n "$idx" ] || { echo "::error::no Rekor log index in the bundle for $f"; exit 1; } + echo "$f=$idx" >> "$WORK/rekor.txt" + n=$((n + 1)) + done < <(find . -type f | LC_ALL=C sort) + [ "$n" -ge 1 ] || { echo "::error::artifact $ARTIFACT_NAME held no files to sign"; exit 1; } + b=$(find "$WORK/bundles" -name "*.sigstore.json" | LC_ALL=C sort | head -1) + # the identity is READ from the Fulcio certificate, never typed: it is what a verifier will see + id=$(jq -r ".verificationMaterial.certificate.rawBytes // .verificationMaterial.x509CertificateChain.certificates[0].rawBytes" "$b" | base64 -d | openssl x509 -inform DER -noout -ext subjectAltName | grep -o "https://[^, ]*" | head -1) + [ -n "$id" ] || { echo "::error::no signer identity in the certificate of $b"; exit 1; } + { + echo "sha256<> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sigstore-bundles-${{ github.ref_name }} + path: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bundles/ + - name: Attach the bundles to the release + if: ${{ inputs.upload-to-release }} + env: + GH_TOKEN: ${{ github.token }} + WORK: ${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }} + TAG: ${{ github.ref_name }} + run: gh release upload "$TAG" "$WORK"/bundles/*.sigstore.json --clobber --repo "$GITHUB_REPOSITORY" diff --git a/scripts/check-sigstore-sign.sh b/scripts/check-sigstore-sign.sh new file mode 100755 index 0000000..91f95ea --- /dev/null +++ b/scripts/check-sigstore-sign.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# The reusable signer must stay what the 17:13Z ruling made it: workflow_call only; runs-on required with no default; +# job permissions exactly id-token+contents write; a tag-only guard AND a main-ancestry guard that really exit 1, run +# before anything is signed; the new bundle format; no stored key; every output wired; every action pinned to a sha. +# PARSED, not grepped: the YAML is read with scripts/yaml2json.awk + jq (comments never count; no yq on the fleet), and each rule reads the field it is about. +# Usage: check-sigstore-sign.sh [file] | --self-test +set -euo pipefail +export PATH="/opt/fleet-bin/bin:/usr/local/bin:$PATH" +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +command -v awk >/dev/null || { echo "::error::awk is not on PATH" >&2; exit 1; } +command -v jq >/dev/null || { echo "::error::jq is not on PATH" >&2; exit 1; } +# yq-compatible reader over scripts/yaml2json.awk + jq (no yq on the clean-room fleet). Only the flags and the one yq-only +# function (tag == "!!map") this script uses; a file the awk subset cannot read exits 2, which every caller treats as RED. +yq() { local -a jf=(); local f="" q + while [ "${1:-}" != "" ] && [[ "$1" == -* ]]; do case "$1" in -r) jf+=(-r);; -e) jf+=(-e);; -I0) jf+=(-c);; -I2|-o=json) ;; *) echo "yq shim: unsupported flag $1" >&2; return 2;; esac; shift; done + q="${1//tag == \"!!map\"/type == \"object\"}"; f="$2" + awk -f "$here/yaml2json.awk" "$f" | jq "${jf[@]}" "$q"; } +q() { yq -r "$1" "$F"; } +declare -A BEH_MEMO # behaviour verdict per (guard body, ancestry body): a mutant that leaves both bodies alone costs nothing +behav() { + local g="$1" a="$2" w + w=$(mktemp -d) + printf '%s\n' "$g" > "$w/g.sh"; printf '%s\n' "$a" > "$w/a.sh" + ( cd "$w" && git init -q --bare o.git && git init -q seed && cd seed && git config user.email t@t && git config user.name t \ + && git checkout -q -b main && git commit -q --allow-empty -m a && git commit -q --allow-empty -m b && git checkout -q -b side \ + && git commit -q --allow-empty -m c && git push -q "$w/o.git" main side && cd .. && git clone -q o.git r ) > /dev/null 2>&1 + local A B C; A=$(git -C "$w/r" rev-parse origin/main~1); B=$(git -C "$w/r" rev-parse origin/main); C=$(git -C "$w/r" rev-parse origin/side) + : > "$w/ghenv"; : > "$w/ghpath" + runb() { # script cwd env... : production flags, empty environment + local s="$1" d="$2"; shift 2 + ( cd "$d" && env -i PATH="$PATH" HOME="$w" CI=true GITHUB_ACTIONS=true GITHUB_WORKFLOW=Release GITHUB_RUN_ID=1 GITHUB_RUN_ATTEMPT=1 \ + GITHUB_ENV="$w/ghenv" GITHUB_PATH="$w/ghpath" GITHUB_OUTPUT="$w/ghout" RUNNER_TEMP="$w" "$@" \ + bash --noprofile --norc -eo pipefail "$s" ) > /dev/null 2>&1; } + local job nm + for job in sign build x; do for nm in v1.0 main release/1 x; do + runb "$w/g.sh" "$w/r" GITHUB_JOB=$job REF_TYPE=tag REF_NAME=$nm || echo "RED tag guard body: a tag must pass ($job/$nm)" + ! runb "$w/g.sh" "$w/r" GITHUB_JOB=$job REF_TYPE=branch REF_NAME=$nm || echo "RED tag guard body: a branch must fail ($job/$nm)" + ! runb "$w/g.sh" "$w/r" GITHUB_JOB=$job REF_TYPE= REF_NAME=$nm || echo "RED tag guard body: an empty ref type must fail ($job/$nm)" + ! runb "$w/g.sh" "$w/r" GITHUB_JOB=$job REF_NAME=$nm || echo "RED tag guard body: an unset ref type must fail ($job/$nm)" + runb "$w/a.sh" "$w/r" GITHUB_JOB=$job TAG_SHA="$B" || echo "RED ancestry body: the tip of main must pass ($job)" + runb "$w/a.sh" "$w/r" GITHUB_JOB=$job TAG_SHA="$A" || echo "RED ancestry body: a commit on main must pass ($job)" + ! runb "$w/a.sh" "$w/r" GITHUB_JOB=$job TAG_SHA="$C" || echo "RED ancestry body: an off-main commit must fail ($job)" + ! runb "$w/a.sh" "$w/r" GITHUB_JOB=$job TAG_SHA=deadbeef || echo "RED ancestry body: a garbage sha must fail ($job)" + ! runb "$w/a.sh" "$w/r" GITHUB_JOB=$job TAG_SHA= || echo "RED ancestry body: an empty sha must fail ($job)" + ! runb "$w/a.sh" "$w/r" GITHUB_JOB=$job || echo "RED ancestry body: an unset sha must fail ($job)" + done; done + # off Actions entirely: the verdicts must not depend on GITHUB_ACTIONS either + ! ( cd "$w/r" && env -i PATH="$PATH" HOME="$w" REF_TYPE=branch REF_NAME=x bash --noprofile --norc -eo pipefail "$w/g.sh" ) > /dev/null 2>&1 || echo "RED tag guard body: a branch must fail off Actions" + ! ( cd "$w/r" && env -i PATH="$PATH" HOME="$w" TAG_SHA="$C" bash --noprofile --norc -eo pipefail "$w/a.sh" ) > /dev/null 2>&1 || echo "RED ancestry body: off-main must fail off Actions" + ( cd "$w/r" && git update-ref -d refs/remotes/origin/main && ! runb "$w/a.sh" "$w/r" GITHUB_JOB=sign TAG_SHA="$B" ) || echo "RED ancestry body: missing origin/main must fail" + [ ! -s "$w/ghenv" ] && [ ! -s "$w/ghpath" ] && [ ! -e "$w/ghout" ] || echo "RED a guard wrote GITHUB_ENV / GITHUB_PATH / GITHUB_OUTPUT" + rm -rf -- "${w:?}" +} +chk() { local F="$1" nog="${2:-}" bad=0 r i g a k + red() { echo "RED $1"; bad=1; } + yq -e '.' "$F" > /dev/null 2>&1 || { echo "RED not parseable YAML"; return 1; } + [ "$(q '.on | keys | join(",")')" = workflow_call ] || red "trigger is not workflow_call only" + [ "$(q '.on.workflow_call.inputs."runs-on".required')" = true ] || red "runs-on input is not required" + [ "$(q '.on.workflow_call.inputs."runs-on" | has("default")')" = false ] || red "runs-on input has a default" + [ "$(q '.on.workflow_call.inputs."artifact-name".required')" = true ] || red "artifact-name input is not required" + [ "$(q '.jobs.sign."runs-on"')" = '${{ fromJSON(inputs.runs-on) }}' ] || red "job runs-on is not the caller-supplied input" + [ "$(q '.permissions | length')" = 0 ] || red "top-level permissions are not empty" + [ "$(q '.jobs.sign.permissions | to_entries | map(.key + "=" + .value) | sort | join(",")')" = 'contents=write,id-token=write' ] \ + || red "job permissions are not exactly contents+id-token write" + [ "$(q '.jobs | keys | join(",")')" = sign ] || red "jobs are not exactly sign" + for i in sha256 tag bundle identity rekor-index; do + [ "$(q ".on.workflow_call.outputs.\"$i\".value")" = "\${{ jobs.sign.outputs.$i }}" ] || red "workflow output $i not wired to the job" + [ "$(q ".jobs.sign.outputs.\"$i\"")" = "\${{ steps.out.outputs.$i }}" ] || red "job output $i not wired to step out" + done + # the step named out must actually write each output it advertises + r=$(q '.jobs.sign.steps[] | select(.id == "out") | .run') + for i in sha256 rekor-index bundle identity tag; do + printf '%s\n' "$r" | grep -v '^ *#' | grep -qE "(echo \"$i(<<|=))" || red "step out never writes output $i" + done + # step order: tag guard, checkout, main-ancestry, download, installer, sign + [ "$(q '.jobs.sign.steps | length')" = 8 ] || red "steps are not exactly the eight: guard,checkout,ancestry,download,installer,sign,upload,attach" + [ "$(q '.jobs.sign.steps[0].run')" != null ] && printf '%s\n' "$(q '.jobs.sign.steps[0].run')" | grep -v '^ *#' | grep -qF '[ "$REF_TYPE" != tag ]' || red "step 0 is not the tag-only guard" + printf '%s\n' "$(q '.jobs.sign.steps[0].run')" | grep -vE '^ *#' | grep -qE '^ *if \[ "\$REF_TYPE" != tag \]; then .*exit 1; fi$' || red "tag guard does not exit 1 on a non-tag" + [ "$(q '.jobs.sign.steps[0].env.REF_TYPE')" = '${{ github.ref_type }}' ] || red "tag guard does not read github.ref_type" + [ "$(q '.jobs.sign.steps[1].uses | test("^actions/checkout@[0-9a-f]{40}$")')" = true ] || red "step 1 is not a sha-pinned checkout" + [ "$(q '.jobs.sign.steps[1].with."fetch-depth"')" = 0 ] || red "checkout is not full depth, main ancestry cannot be proven" + [ "$(q '.jobs.sign.steps[1].with."persist-credentials"')" = false ] || red "checkout keeps credentials" + r=$(q '.jobs.sign.steps[2].run') + printf '%s\n' "$r" | grep -v '^ *#' | grep -qF 'git merge-base --is-ancestor "$TAG_SHA" refs/remotes/origin/main' || red "step 2 is not the main-ancestry check" + printf '%s\n' "$r" | grep -v '^ *#' | grep -qE '\|\| \{ echo "::error::.*exit 1; \}' || red "main-ancestry check does not exit 1" + printf '%s\n' "$r" | grep -v '^ *#' | grep -qF 'set -euo pipefail' || red "main-ancestry step is not strict" + [ "$(q '.jobs.sign.steps[2].env.TAG_SHA')" = '${{ github.sha }}' ] || red "ancestry check does not test github.sha" + [ "$(q '.jobs.sign.steps[3].uses | test("^actions/download-artifact@[0-9a-f]{40}$")')" = true ] || red "step 3 is not the pinned download" + [ "$(q '.jobs.sign.steps[3].with.name')" = '${{ inputs.artifact-name }}' ] || red "download does not read the artifact-name input" + [ "$(q '.jobs.sign.steps[4].uses | test("^sigstore/cosign-installer@[0-9a-f]{40}$")')" = true ] || red "step 4 is not the pinned cosign installer" + [ "$(q '.jobs.sign.steps[4].with."cosign-release"')" = v2.5.3 ] || red "cosign is not pinned" + for i in 0 2 5; do + q ".jobs.sign.steps[$i].run" | grep -v '^ *#' | grep -qE '\|\| *(true|:)( |$|\\)|set \+e' && red "step $i masks a failure" + done + # GOLDEN: the parsed workflow (comments gone) must equal the reviewed golden byte for byte, so every key of every + # step is pinned — run bodies, env, shell, defaults, container, extra steps. An edit to the signer is an edit to + # scripts/sigstore-sign.golden.json in the same PR, which is the review. + here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + [ "$nog" = nogolden ] || [ "$(yq -o=json -I2 '.' "$F" | jq -S . | sha256sum)" = "$(sha256sum < "$here/sigstore-sign.golden.json")" ] || red "workflow differs from scripts/sigstore-sign.golden.json" + # LITERAL PINS (Q-GH91c): every executable body and every step's non-run keys are compared with text written in THIS + # file, comment lines aside. There is no hash and nothing to regenerate: changing a body means editing the literal + # below. The independent pin is the non-author quorum sign-off on the final commit, not a file in this PR. (The golden stays as a belt.) + want_run() { # step ; expected body on stdin + local got; got=$(q ".jobs.sign.steps[$1].run" | grep -v '^ *#'); [ "$got" = "$(cat)" ] || red "step $1 run body differs from the pinned literal"; } + want_step() { # step ; expected JSON of the step without its run key, on stdin + local got; got=$(yq -o=json -I0 ".jobs.sign.steps[$1] | del(.run)" "$F" | jq -cS .); [ "$got" = "$(jq -cS . <<< "$(cat)")" ] || red "step $1 keys differ from the pinned literal"; } + # GitHub expands ${{ }} inside '#' comment lines of a run body, so comments are not inert: no run may contain one + [ "$(yq -o=json '[.. | select(tag == "!!map") | select(has("run")) | .run | select(test("[$][{][{]"))] | length' "$F")" = 0 ] || red "a run body contains an expression (comments included)" + want_run 0 <<'PIN' +if [ "$REF_TYPE" != tag ]; then echo "::error::sigstore-sign signs tag builds only (ref_type=$REF_TYPE, $REF_NAME)"; exit 1; fi +PIN + want_run 2 <<'PIN' +set -euo pipefail +git rev-parse --verify --quiet refs/remotes/origin/main >/dev/null || { echo "::error::origin/main is not in the checkout"; exit 1; } +git merge-base --is-ancestor "$TAG_SHA" refs/remotes/origin/main \ + || { echo "::error::$TAG_SHA is not an ancestor of origin/main; release signing is for tag builds from main only"; exit 1; } +PIN + want_run 5 <<'PIN' +set -euo pipefail +x=$(find . -mindepth 1 \( -type d -o ! -type f -o -name "*.sigstore.json" \) -print -quit) +[ -z "$x" ] || { echo "::error::artifact $ARTIFACT_NAME holds a nested path, a non-regular file or a *.sigstore.json ($x); refusing before anything is signed"; exit 1; } +case "$TAG" in */*) echo "::error::tag $TAG contains a slash"; exit 1;; esac +mkdir "$WORK/bundles"; : > "$WORK/manifest.txt"; : > "$WORK/rekor.txt" +n=0 +while IFS= read -r f; do + f="${f#./}" + sha256sum -- "$f" >> "$WORK/manifest.txt" + cosign sign-blob --yes --new-bundle-format --bundle "$WORK/bundles/$f.sigstore.json" -- "$f" > /dev/null + idx=$(jq -r '.verificationMaterial.tlogEntries[0].logIndex // empty' "$WORK/bundles/$f.sigstore.json") + [ -n "$idx" ] || { echo "::error::no Rekor log index in the bundle for $f"; exit 1; } + echo "$f=$idx" >> "$WORK/rekor.txt" + n=$((n + 1)) +done < <(find . -type f | LC_ALL=C sort) +[ "$n" -ge 1 ] || { echo "::error::artifact $ARTIFACT_NAME held no files to sign"; exit 1; } +b=$(find "$WORK/bundles" -name "*.sigstore.json" | LC_ALL=C sort | head -1) +id=$(jq -r ".verificationMaterial.certificate.rawBytes // .verificationMaterial.x509CertificateChain.certificates[0].rawBytes" "$b" | base64 -d | openssl x509 -inform DER -noout -ext subjectAltName | grep -o "https://[^, ]*" | head -1) +[ -n "$id" ] || { echo "::error::no signer identity in the certificate of $b"; exit 1; } +{ + echo "sha256<> "$GITHUB_OUTPUT" +PIN + want_run 7 <<'PIN' +gh release upload "$TAG" "$WORK"/bundles/*.sigstore.json --clobber --repo "$GITHUB_REPOSITORY" +PIN + want_step 0 <<'PIN' +{"name":"Refuse anything but a tag build","env":{"REF_TYPE":"${{ github.ref_type }}","REF_NAME":"${{ github.ref_name }}"}} +PIN + want_step 1 <<'PIN' +{"uses":"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683","with":{"fetch-depth":0,"persist-credentials":false,"path":"caller-src"}} +PIN + want_step 2 <<'PIN' +{"name":"Refuse a tag that is not on main","working-directory":"caller-src","env":{"TAG_SHA":"${{ github.sha }}"}} +PIN + want_step 3 <<'PIN' +{"uses":"actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093","with":{"name":"${{ inputs.artifact-name }}","path":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign"}} +PIN + want_step 4 <<'PIN' +{"uses":"sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159","with":{"cosign-release":"v2.5.3","install-dir":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bin"}} +PIN + want_step 5 <<'PIN' +{"name":"Sign every file, keyless","id":"out","working-directory":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign","env":{"TAG":"${{ github.ref_name }}","ARTIFACT_NAME":"${{ inputs.artifact-name }}","TUF_ROOT":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/tuf","WORK":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}"}} +PIN + want_step 6 <<'PIN' +{"uses":"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02","with":{"name":"sigstore-bundles-${{ github.ref_name }}","path":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bundles/"}} +PIN + want_step 7 <<'PIN' +{"name":"Attach the bundles to the release","if":"${{ inputs.upload-to-release }}","env":{"GH_TOKEN":"${{ github.token }}","TAG":"${{ github.ref_name }}","WORK":"${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}"}} +PIN + [ "$(q '.jobs.sign | has("env")')" = false ] || red "job carries env (the per-run WORK dir is set per step: the runner context is not allowed at job level)" + # exact key sets and types, as literals (Q-GH91e): a regenerated golden cannot widen the interface + [ "$(yq -o=json -I0 '.on.workflow_call.inputs | to_entries | map(.key + ":" + .value.type + ":" + (.value.required | tostring)) | sort | join(",")' "$F" | jq -r .)" = 'artifact-name:string:true,runs-on:string:true,upload-to-release:boolean:false' ] || red "workflow inputs are not exactly artifact-name,runs-on,upload-to-release with their types" + [ "$(q '.on.workflow_call.outputs | keys | sort | join(",")')" = bundle,identity,rekor-index,sha256,tag ] || red "workflow outputs are not exactly bundle,identity,rekor-index,sha256,tag" + [ "$(q '.on.workflow_call | keys | sort | join(",")')" = inputs,outputs ] || red "workflow_call keys are not exactly inputs,outputs" + [ "$(q '.jobs.sign.outputs | keys | sort | join(",")')" = bundle,identity,rekor-index,sha256,tag ] || red "job outputs are not exactly bundle,identity,rekor-index,sha256,tag" + [ "$(q '.on.workflow_call.inputs."upload-to-release".default')" = true ] || red "upload-to-release default drifted" + # BEHAVIOUR (Q-GH91c): the two guard bodies are RUN the way Actions runs them — empty environment plus realistic + # Actions variables, the production shell flags, a clone of a bare origin — and must pass/fail the same under every + # job name, REF_NAME and GITHUB_ACTIONS value, so a body that detects a fixture or a job name cannot survive. + g=$(q '.jobs.sign.steps[0].run'); a=$(q '.jobs.sign.steps[2].run'); k=$(printf '%s\0%s' "$g" "$a" | sha256sum) + [ -n "${BEH_MEMO[$k]+x}" ] || BEH_MEMO[$k]=$(behav "$g" "$a") + [ -z "${BEH_MEMO[$k]}" ] || { printf '%s\n' "${BEH_MEMO[$k]}"; bad=1; } + # nothing may change HOW a step runs: no shell/defaults/container/job env, and env key sets are exact + [ "$(q 'keys | sort | join(",")')" = jobs,name,on,permissions ] || red "top-level keys are not exactly name,on,permissions,jobs" + [ "$(q '.jobs.sign | keys | sort | join(",")')" = name,outputs,permissions,runs-on,steps,timeout-minutes ] || red "job keys are not exactly name,outputs,permissions,runs-on,steps,timeout-minutes" + [ "$(q '[.jobs.sign.steps[] | has("shell")] | any')" = false ] || red "a step sets shell" + [ "$(q '[.jobs.sign.steps[] | has("container") or has("services")] | any')" = false ] || red "a step sets container/services" + for i in 0 2 5 7; do + case $i in 0) want=REF_NAME,REF_TYPE;; 2) want=TAG_SHA;; 5) want=ARTIFACT_NAME,TAG,TUF_ROOT,WORK;; 7) want=GH_TOKEN,TAG,WORK;; esac + [ "$(q ".jobs.sign.steps[$i].env | keys | sort | join(\",\")")" = "$want" ] || red "step $i env keys are not exactly $want" + done + for i in 1 3 4 6; do [ "$(q ".jobs.sign.steps[$i] | has(\"env\")")" = false ] || red "step $i carries env"; done + r=$(q '.jobs.sign.steps[5].run' | grep -v '^ *#') + printf '%s\n' "$r" | grep -qE '(^|[;&| ])exit 0' && red "signing step can exit 0 early" + [ "$(printf '%s\n' "$r" | grep -cE '^ *id=')" = 1 ] && printf '%s\n' "$r" | grep -qE '^ *id=\$\(jq -r "\.verificationMaterial' || red "identity is not read from the certificate" + printf '%s\n' "$r" | grep -qF "logIndex // empty" || red "Rekor index has a default" + printf '%s\n' "$r" | grep -qF 'sha256sum -- "$f" >> "$WORK/manifest.txt"' || red "manifest is not computed from the files" + [ "$(printf '%s\n' "$r" | grep -c 'cosign ')" = 1 ] || red "more than one cosign call in the signing step" + # no guard or signing step may be skipped or soft-failed + for i in 0 1 2 3 4 5; do + [ "$(q ".jobs.sign.steps[$i] | has(\"if\")")" = false ] || red "step $i is conditional" + [ "$(q ".jobs.sign.steps[$i].\"continue-on-error\" // false")" = false ] || red "step $i is continue-on-error" + done + [ "$(q '.jobs.sign."continue-on-error" // false')" = false ] || red "job is continue-on-error" + [ "$(q '.jobs.sign | has("if")')" = false ] || red "job is conditional" + # the sign step + [ "$(q '.jobs.sign.steps[5].id')" = out ] || red "step 5 is not the signing step" + [ "$(q '.jobs.sign.steps[5]."working-directory"')" = '${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign' ] || red "signing step is not in the downloaded dir" + r=$(q '.jobs.sign.steps[5].run' | grep -v '^ *#') + printf '%s\n' "$r" | head -1 | grep -qF 'set -euo pipefail' || red "signing step is not strict (set -euo pipefail first)" + printf '%s\n' "$r" | grep -qE 'cosign sign-blob --yes --new-bundle-format --bundle "\$WORK/bundles/\$f\.sigstore\.json" -- "\$f" > /dev/null$' || red "sign-blob is not the new-bundle-format keyless call, un-masked" + printf '%s\n' "$r" | grep -qE '\|\| *(true|:)|; *true *$|set \+e' && red "a failure is masked in the signing step" + printf '%s\n' "$r" | grep -qF 'no Rekor log index' || red "signing step does not require a Rekor index" + # nothing in the parsed document (comments are gone) may name a stored key or a secret + yq -o=json '.' "$F" | grep -qiE 'secrets|--key|cosign_(private_)?key|signing_key|private.?key|\.pem|\.key\b' && red "a stored key or secret is named" + # every action is a sha pin + r=$(yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses' "$F" | awk '!/@[0-9a-f]{40}$/') + [ -z "$r" ] || red "unpinned action: $r" + # the bundles reach the caller, and the release only when asked + [ "$(q '.jobs.sign.steps[6].uses | test("^actions/upload-artifact@[0-9a-f]{40}$")')" = true ] || red "step 6 is not the pinned bundle upload" + [ "$(q '.jobs.sign.steps[6].with.name')" = 'sigstore-bundles-${{ github.ref_name }}' ] || red "bundle artifact name drifted from the bundle output" + [ "$(q '.jobs.sign.steps[6].with.path')" = '${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bundles/' ] || red "bundle upload path is not the per-run bundles dir" + [ "$(q '.jobs.sign.steps[7].if')" = '${{ inputs.upload-to-release }}' ] || red "release attach is not gated on upload-to-release" + [ "$bad" = 0 ] && echo "ok sigstore-sign.yml"; return "$bad"; } +if [ "${1:-}" = --self-test ]; then + F0=.github/workflows/sigstore-sign.yml; t=$(mktemp -d); trap 'rm -rf -- "${t:?}"' EXIT; fail=0; n=0 + chk "$F0" > /dev/null || { echo "FAIL clean file must be GREEN"; fail=1; } + mut() { n=$((n + 1)); sed "$2" "$F0" > "$t/m.yml" + cmp -s "$F0" "$t/m.yml" && { echo "FAIL mutant did not apply: $1"; fail=1; return; } + chk "$t/m.yml" > /dev/null 2>&1 && { echo "FAIL mutant must be RED: $1"; fail=1; }; return 0; } + mut "trigger push" 's/^ workflow_call:/ push:/' + mut "runs-on gets a default" "s|^ required: true\n||;/^ runs-on:/,/^ upload-to-release:/s|^ type: string| type: string\n default: '[\"ubuntu-latest\"]'|" + mut "runs-on not required" '/^ runs-on:/,/^ upload-to-release:/s/required: true/required: false/' + mut "runner hard-coded hosted" 's/runs-on: ${{ fromJSON(inputs.runs-on) }}/runs-on: ubuntu-latest/' + mut "id-token dropped (comment keeps it)" 's/id-token: write # the keyless OIDC identity/id-token: read # id-token: write/' + mut "contents write dropped" 's/contents: write # attach/contents: read # attach/' + mut "extra permission" 's/^ contents: write # attach.*/&\n packages: write/' + mut "guard exit 0" 's/(ref_type=$REF_TYPE, $REF_NAME)"; exit 1; fi/(ref_type=$REF_TYPE, $REF_NAME)"; exit 0; fi/' + mut "guard flipped" 's/"$REF_TYPE" != tag/"$REF_TYPE" = tag/' + mut "guard if false &&" 's/if \[ "$REF_TYPE" != tag \]/if false \&\& [ "$REF_TYPE" != tag ]/' + mut "guard step conditional" '/name: Refuse anything but a tag build/a\ if: false' + mut "guard commented out" 's/^ if \[ "$REF_TYPE" != tag \]/ # if [ "$REF_TYPE" != tag ]/' + mut "guard reads wrong context" 's/REF_TYPE: ${{ github.ref_type }}/REF_TYPE: tag/' + mut "ancestry check masked" 's/refs\/remotes\/origin\/main \\$/refs\/remotes\/origin\/main || true \\/' + mut "ancestry exit dropped" 's/ exit 1; }$/ exit 0; }/' + mut "ancestry tests wrong sha" 's/TAG_SHA: ${{ github.sha }}/TAG_SHA: ${{ github.ref_name }}/' + mut "shallow checkout" 's/fetch-depth: 0/fetch-depth: 1/' + mut "checkout keeps credentials" 's/persist-credentials: false/persist-credentials: true/' + mut "legacy bundle format" 's/--yes --new-bundle-format --bundle/--yes --bundle/' + mut "sign-blob masked" 's/-- "$f" > \/dev\/null$/-- "$f" > \/dev\/null || true/' + mut "sign step not strict" 's/^ set -euo pipefail\n//;/Sign every file, keyless/,/mkdir -p/s/set -euo pipefail/set +e/' + mut "key flag --key TAB" 's/--yes --new-bundle-format/--yes --key\tk.pem --new-bundle-format/' + mut "secrets index syntax" "s/TAG: \${{ github.ref_name }}/TAG: \${{ secrets['K'] }}/" + mut "toJSON(secrets)" 's/TAG: ${{ github.ref_name }}/TAG: ${{ toJSON(secrets) }}/' + mut "COSIGN_KEY env" 's/^ TAG: ${{ github.ref_name }}/&\n COSIGN_KEY: x/' + mut "output unwired (workflow)" 's/value: ${{ jobs.sign.outputs.identity }}/value: x/' + mut "output unwired (job)" 's/rekor-index: ${{ steps.out.outputs.rekor-index }}/rekor-index: x/' + mut "step stops writing an output" 's/echo "bundle=sigstore-bundles-$TAG"/echo "bundle-x=1"/' + mut "Rekor index not required" 's/no Rekor log index in the bundle/skip/' + mut "action unpinned" 's/actions\/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093/actions\/download-artifact@v4/' + mut "cosign unpinned" 's/cosign-release: v2.5.3/cosign-release: latest/' + mut "release attach unconditional" 's/if: ${{ inputs.upload-to-release }}/if: true/' + mut "bundle name drift" 's/name: sigstore-bundles-${{ github.ref_name }}/name: bundles/' + # Q-GH91b: mutants that a structural grep let through. "B" rows run WITHOUT the golden, so the rule and + # behaviour checks must kill them on their own; every row is also RED with the golden. + mutb() { n=$((n + 1)); sed "$2" "$F0" > "$t/m.yml" + cmp -s "$F0" "$t/m.yml" && { echo "FAIL mutant did not apply: $1"; fail=1; return; } + chk "$t/m.yml" nogolden > /dev/null 2>&1 && { echo "FAIL (behaviour/rules alone) mutant must be RED: $1"; fail=1; } + chk "$t/m.yml" > /dev/null 2>&1 && { echo "FAIL (with golden) mutant must be RED: $1"; fail=1; }; return 0; } + mutb "exit 0 before the tag guard" 's/^ if \[ "$REF_TYPE" != tag \]/ exit 0\n&/' + mutb "REF_TYPE reassigned" 's/^ if \[ "$REF_TYPE" != tag \]/ REF_TYPE=tag\n&/' + mutb "guard then exit 0; exit 1" 's/ exit 1; fi$/ exit 0; exit 1; fi/' + mutb "guard disabled by true ||" 's/if \[ "$REF_TYPE" != tag \]/if true || [ "$REF_TYPE" != tag ]/' + mutb "step shell true" '/name: Refuse anything but a tag build/a\ shell: "true {0}"' + mutb "defaults.run.shell" 's/^permissions: {}$/&\ndefaults:\n run:\n shell: "true {0}"/' + mutb "BASH_ENV on the guard" 's/^ REF_NAME: ${{ github.ref_name }}/&\n BASH_ENV: \/dev\/null/' + mutb "ancestry exit 0 first" 's/^ set -euo pipefail\n # checkout//;/Refuse a tag that is not on main/,/rev-parse --verify/s/^ set -euo pipefail/ exit 0\n set -euo pipefail/' + mutb "TAG_SHA reassigned" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n TAG_SHA=$(git rev-parse HEAD)/' + mutb "git shadowed" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n git() { return 0; }/' + mutb "update-ref origin/main" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n git update-ref refs\/remotes\/origin\/main "$TAG_SHA"/' + mutb "ancestry or-chain HEAD" 's/refs\/remotes\/origin\/main \\$/refs\/remotes\/origin\/main || git merge-base --is-ancestor "$TAG_SHA" HEAD \\/' + mutb "sign step early exit 0" '/Sign every file, keyless/,/mkdir -p/s/^ set -euo pipefail/&\n exit 0/' + mutb "typed identity" 's/^ \[ -n "$id" \] || .*/ id=https:\/\/github.com\/paiml\/.github\/.github\/workflows\/sigstore-sign.yml@refs\/heads\/main/' + mutb "logIndex defaults to 0" "s|logIndex // empty|logIndex // 0|" + mutb "second keyed sign-blob" 's/^ sha256sum -- "$f" >> "$WORK\/manifest.txt"/&\n cosign sign-blob -k k.pem --tlog-upload=false -- "$f" > \/dev\/null/' + mutb "ninth step" '$a\ - run: "true"' + mutb "attach step if false" 's/if: ${{ inputs.upload-to-release }}/if: false/' + mutb "upload step continue-on-error" '/uses: actions\/upload-artifact@/i\ continue-on-error: true' + mutb "SIGSTORE_ID_TOKEN env" 's/^ TAG: ${{ github.ref_name }}$/&\n SIGSTORE_ID_TOKEN: x/' + mutb "PATH env on sign" 's/^ ARTIFACT_NAME: .*/&\n PATH: \/tmp/' + # Q-GH91c: bypasses that detect the job name / Actions / a ref shape, bare exits, PATH games, free `with:` keys, + # a stale-workspace reuse, and a step-7 retarget. All run without the golden: the literal pins and the realistic + # behaviour run must kill them on their own. + mutb "guard gated on GITHUB_JOB" 's/^ if \[ "$REF_TYPE" != tag \]/ [ "${GITHUB_JOB:-}" = sign ] \&\& exit 0\n&/' + mutb "guard gated on GITHUB_ACTIONS" 's/^ if \[ "$REF_TYPE" != tag \]/ [ -z "${GITHUB_ACTIONS:-}" ] || REF_TYPE=tag\n&/' + mutb "guard release/* case bypass" 's/^ if \[ "$REF_TYPE" != tag \]/ case "$REF_NAME" in release\/*) exit 0;; esac\n&/' + mutb "guard bare exit" 's/^ if \[ "$REF_TYPE" != tag \]/ exit\n&/' + mutb "guard exit quoted 0" 's/^ if \[ "$REF_TYPE" != tag \]/ exit "0"\n&/' + mutb "guard exit arith 0" 's/^ if \[ "$REF_TYPE" != tag \]/ exit $((0))\n&/' + mutb "ancestry export PATH" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n export PATH=\/tmp\/shadow:$PATH/' + mutb "ancestry GITHUB_PATH write" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n echo \/tmp\/shadow >> "$GITHUB_PATH"/' + mutb "ancestry exit on GITHUB_JOB" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n [ "${GITHUB_JOB:-}" = sign ] \&\& exit 0/' + mutb "sign export SIGSTORE var" '/Sign every file, keyless/,/mkdir/s/^ set -euo pipefail/&\n export SIGSTORE_REKOR_URL=http:\/\/evil/' + mutb "sign export COSIGN var" '/Sign every file, keyless/,/mkdir/s/^ set -euo pipefail/&\n export COSIGN_EXPERIMENTAL=0/' + mutb "sign printf -v id" 's/^ \[ -n "$id" \] || .*/ printf -v id "%s" https:\/\/x/' + mutb "sign typed echo identity" 's/^ echo "identity=$id"/ echo "identity=https:\/\/x"/' + mutb "logIndex default 7" "s|logIndex // empty|logIndex // empty // 7|" + mutb "forced idx" 's/^ \[ -n "$idx" \] || .*/ idx=1/' + mutb "extra manifest line" 's/echo "EOF_SHA"$/echo "EOF_SHA"; echo "deadbeef forged"/' + mutb "zero files not refused" 's/^ \[ "$n" -ge 1 \] || .*/ true/' + mutb "forged bundle path" 's/^ b=$(find .*/ b=\/tmp\/forged.json/' + mutb "narrowed find" 's/find . -type f | LC_ALL/find . -maxdepth 0 -type f | LC_ALL/' + mutb "second cosign via shadow" 's/^ n=0$/ cosign() { command cosign "$@"; command cosign "$@"; }\n&/' + mutb "stale workspace reused" 's/mkdir "$WORK\/bundles"/mkdir -p "$WORK\/bundles"/' + mutb "GITHUB_ENV write earlier" '/Refuse a tag that is not on main/,/merge-base/s/^ set -euo pipefail/&\n echo "BASH_ENV=\/tmp\/x" >> "$GITHUB_ENV"/' + mutb "download extra run-id" '/uses: actions\/download-artifact@/,/path:/s/^ path: .*/&\n run-id: 1/' + mutb "download extra repository" '/uses: actions\/download-artifact@/,/path:/s/^ path: .*/&\n repository: evil\/fork\n github-token: x/' + mutb "checkout other repository" '/uses: actions\/checkout@/,/path: caller-src/s/^ path: caller-src/&\n repository: evil\/fork/' + mutb "upload extra with" '/uses: actions\/upload-artifact@/,/path:/s/^ path: .*/&\n retention-days: 90\n overwrite: true/' + mutb "attach retargeted repo" 's/--repo "$GITHUB_REPOSITORY"/--repo evil\/fork/' + mutb "attach extra flag" 's/--clobber --repo/--clobber --draft --repo/' + mutb "upload-to-release default off" "/^ upload-to-release:/,/^ outputs:/s/default: true/default: false/" + mutb "job env added" 's/^ timeout-minutes: 15/&\n env:\n BASH_ENV: \/tmp\/x/' + mutb "WORK not per-run" 's|^ WORK: .*| WORK: ${{ runner.temp }}/sigstore-sign|' + mutb "tag output unwired" 's/^ tag: ${{ steps.out.outputs.tag }}/ tag: x/' + # Q-GH91d + mutb "expression in a guard comment" 's/^ if \[ "$REF_TYPE" != tag \]/ # ${{ fromJSON(\x27"\\nexit 0"\x27) }}\n&/' + mutb "expression in sign comment" 's/^ n=0$/ # ${{ github.sha }}\n&/' + mutb "installer to HOME" '/^ install-dir:/d' + mutb "installer install-dir elsewhere" 's|^ install-dir: .*| install-dir: /usr/local/bin|' + # Q-GH91e + mutb "nested files allowed" '/-mindepth 1 \\( -type d/d' + mutb "sigstore.json inputs allowed" 's/ -o -name "\*.sigstore.json" \\)/ \\)/' + mutb "symlinks allowed" 's/-type d -o ! -type f -o/-type d -o/' + mutb "refusal not exit" 's/(\$x); refusing before anything is signed"; exit 1; }/($x); refusing before anything is signed"; }/' + mutb "slash tag allowed" '/case "$TAG" in \*\/\*)/d' + mutb "TUF_ROOT dropped" '/^ TUF_ROOT:/d' + mutb "TUF_ROOT to HOME" 's|^ TUF_ROOT: .*| TUF_ROOT: ~/.sigstore|' + mutb "upload-to-release string" '/^ upload-to-release:/,/^ outputs:/s/type: boolean/type: string/' + mutb "extra input" 's/^ outputs:/ extra:\n type: string\n&/' + mutb "extra workflow output" 's/^permissions: {}/&/;/^ rekor-index:$/i\ extra:\n value: x' + mutb "extra job output" 's/^ tag: ${{ steps.out.outputs.tag }}/&\n extra: x/' + mutb "attach glob made recursive" 's|"$WORK"/bundles/\*.sigstore.json|"$WORK"/bundles/**/*.sigstore.json|' + # the yq-free reader fails CLOSED on YAML outside its subset (exit 2), and reads a known document right + for bad in "a: >\n x" "a: |+\n x" "a: |2\n x" "a: 1\na: 2" "a:\n b: 1\n b: 2"; do + ! printf "$bad\n" | awk -f "$here/yaml2json.awk" > /dev/null 2>&1 || { echo "FAIL yaml2json accepted: $bad"; fail=1; }; done + for bad in "a: &x 1" "a: *x" "a: !!str 1" "a: [1, 2]" "a: {b: 1}"; do + ! printf "%s\n" "$bad" | awk -f "$here/yaml2json.awk" > /dev/null 2>&1 || { echo "FAIL yaml2json accepted: $bad"; fail=1; }; done + [ "$(printf "a:\n - b: 1\n c: |\n x\n - d\n" | awk -f "$here/yaml2json.awk" | jq -cS .)" = "{\"a\":[{\"b\":1,\"c\":\"x\\n\"},\"d\"]}" ] || { echo "FAIL yaml2json misread a known document"; fail=1; } + [ "$fail" = 0 ] && echo "PASS check-sigstore-sign self-test: clean GREEN + $n mutants RED"; exit "$fail" +fi +chk "${1:-.github/workflows/sigstore-sign.yml}" diff --git a/scripts/sigstore-sign.golden.json b/scripts/sigstore-sign.golden.json new file mode 100644 index 0000000..2b83a6f --- /dev/null +++ b/scripts/sigstore-sign.golden.json @@ -0,0 +1,135 @@ +{ + "jobs": { + "sign": { + "name": "Sign (sigstore, keyless)", + "outputs": { + "bundle": "${{ steps.out.outputs.bundle }}", + "identity": "${{ steps.out.outputs.identity }}", + "rekor-index": "${{ steps.out.outputs.rekor-index }}", + "sha256": "${{ steps.out.outputs.sha256 }}", + "tag": "${{ steps.out.outputs.tag }}" + }, + "permissions": { + "contents": "write", + "id-token": "write" + }, + "runs-on": "${{ fromJSON(inputs.runs-on) }}", + "steps": [ + { + "env": { + "REF_NAME": "${{ github.ref_name }}", + "REF_TYPE": "${{ github.ref_type }}" + }, + "name": "Refuse anything but a tag build", + "run": "if [ \"$REF_TYPE\" != tag ]; then echo \"::error::sigstore-sign signs tag builds only (ref_type=$REF_TYPE, $REF_NAME)\"; exit 1; fi\n" + }, + { + "uses": "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "with": { + "fetch-depth": 0, + "path": "caller-src", + "persist-credentials": false + } + }, + { + "env": { + "TAG_SHA": "${{ github.sha }}" + }, + "name": "Refuse a tag that is not on main", + "run": "set -euo pipefail\n# checkout with fetch-depth 0 already fetched every branch; no credential is kept to fetch again\ngit rev-parse --verify --quiet refs/remotes/origin/main >/dev/null || { echo \"::error::origin/main is not in the checkout\"; exit 1; }\ngit merge-base --is-ancestor \"$TAG_SHA\" refs/remotes/origin/main \\\n || { echo \"::error::$TAG_SHA is not an ancestor of origin/main; release signing is for tag builds from main only\"; exit 1; }\n", + "working-directory": "caller-src" + }, + { + "uses": "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093", + "with": { + "name": "${{ inputs.artifact-name }}", + "path": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign" + } + }, + { + "uses": "sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159", + "with": { + "cosign-release": "v2.5.3", + "install-dir": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bin" + } + }, + { + "env": { + "ARTIFACT_NAME": "${{ inputs.artifact-name }}", + "TAG": "${{ github.ref_name }}", + "TUF_ROOT": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/tuf", + "WORK": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}" + }, + "id": "out", + "name": "Sign every file, keyless", + "run": "set -euo pipefail\n# flat, regular, unsigned inputs only: nothing is silently skipped, and the attach glob below sees every bundle\nx=$(find . -mindepth 1 \\( -type d -o ! -type f -o -name \"*.sigstore.json\" \\) -print -quit)\n[ -z \"$x\" ] || { echo \"::error::artifact $ARTIFACT_NAME holds a nested path, a non-regular file or a *.sigstore.json ($x); refusing before anything is signed\"; exit 1; }\ncase \"$TAG\" in */*) echo \"::error::tag $TAG contains a slash\"; exit 1;; esac\nmkdir \"$WORK/bundles\"; : > \"$WORK/manifest.txt\"; : > \"$WORK/rekor.txt\"\nn=0\nwhile IFS= read -r f; do\n f=\"${f#./}\"\n sha256sum -- \"$f\" >> \"$WORK/manifest.txt\"\n cosign sign-blob --yes --new-bundle-format --bundle \"$WORK/bundles/$f.sigstore.json\" -- \"$f\" > /dev/null\n idx=$(jq -r '.verificationMaterial.tlogEntries[0].logIndex // empty' \"$WORK/bundles/$f.sigstore.json\")\n [ -n \"$idx\" ] || { echo \"::error::no Rekor log index in the bundle for $f\"; exit 1; }\n echo \"$f=$idx\" >> \"$WORK/rekor.txt\"\n n=$((n + 1))\ndone < <(find . -type f | LC_ALL=C sort)\n[ \"$n\" -ge 1 ] || { echo \"::error::artifact $ARTIFACT_NAME held no files to sign\"; exit 1; }\nb=$(find \"$WORK/bundles\" -name \"*.sigstore.json\" | LC_ALL=C sort | head -1)\n# the identity is READ from the Fulcio certificate, never typed: it is what a verifier will see\nid=$(jq -r \".verificationMaterial.certificate.rawBytes // .verificationMaterial.x509CertificateChain.certificates[0].rawBytes\" \"$b\" | base64 -d | openssl x509 -inform DER -noout -ext subjectAltName | grep -o \"https://[^, ]*\" | head -1)\n[ -n \"$id\" ] || { echo \"::error::no signer identity in the certificate of $b\"; exit 1; }\n{\n echo \"sha256<> \"$GITHUB_OUTPUT\"\n", + "working-directory": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/sign" + }, + { + "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + "with": { + "name": "sigstore-bundles-${{ github.ref_name }}", + "path": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}/bundles/" + } + }, + { + "env": { + "GH_TOKEN": "${{ github.token }}", + "TAG": "${{ github.ref_name }}", + "WORK": "${{ runner.temp }}/sigstore-sign-${{ github.run_id }}-${{ github.run_attempt }}" + }, + "if": "${{ inputs.upload-to-release }}", + "name": "Attach the bundles to the release", + "run": "gh release upload \"$TAG\" \"$WORK\"/bundles/*.sigstore.json --clobber --repo \"$GITHUB_REPOSITORY\"" + } + ], + "timeout-minutes": 15 + } + }, + "name": "sigstore-sign", + "on": { + "workflow_call": { + "inputs": { + "artifact-name": { + "description": "actions/upload-artifact name holding the files to sign (the caller uploaded them)", + "required": true, + "type": "string" + }, + "runs-on": { + "description": "JSON array of runner labels; REQUIRED, no default (hosted runners are banned, so there is no safe one to assume)", + "required": true, + "type": "string" + }, + "upload-to-release": { + "default": true, + "description": "attach each .sigstore.json bundle to the tag release (needs contents: write)", + "required": false, + "type": "boolean" + } + }, + "outputs": { + "bundle": { + "description": "name of the artifact holding the .sigstore.json bundles", + "value": "${{ jobs.sign.outputs.bundle }}" + }, + "identity": { + "description": "the certificate identity (the SAN): this workflow at the ref the caller pinned; pass it to sigstore-verify.sh as ", + "value": "${{ jobs.sign.outputs.identity }}" + }, + "rekor-index": { + "description": "Rekor log index of each bundle, \"=\" per line", + "value": "${{ jobs.sign.outputs.rekor-index }}" + }, + "sha256": { + "description": "sha256sum manifest of the signed files (one \" \" line each)", + "value": "${{ jobs.sign.outputs.sha256 }}" + }, + "tag": { + "description": "the tag that was signed (github.ref_name)", + "value": "${{ jobs.sign.outputs.tag }}" + } + } + } + }, + "permissions": {} +} diff --git a/scripts/sigstore-verify.sh b/scripts/sigstore-verify.sh new file mode 100755 index 0000000..d2dca28 --- /dev/null +++ b/scripts/sigstore-verify.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Verify a keyless-signed file before using it (Noah 17:13Z items 5+6). No third state: any failure exits non-zero. +# Usage: sigstore-verify.sh [] | --self-test +# identity = the exact certificate identity, i.e. the sigstore-sign.yml ref the release pinned. +# caller-repo = owner/repo that CALLED the signer (e.g. paiml/rmedia). The SAN is the same for every caller +# of this public workflow, so the SAN alone proves nothing about who asked for the signature. +# caller-workflow-name = the caller's workflow `name:` (e.g. Release). +# caller-ref = optional, e.g. refs/tags/v-rmedia-cli-0.6.0. +# The verifier is always `cosign` on PATH. There is no environment override: a variable that swaps the verifier +# would let COSIGN=true verify anything. The self-test replaces it by shadowing the function below, not by env. +set -euo pipefail +ISSUER="https://token.actions.githubusercontent.com" +run_cosign() { command cosign "$@"; } +verify() { # file bundle identity repo workflow [ref] + [ "$#" -ge 5 ] || { echo "REFUSE: need file bundle identity caller-repo caller-workflow-name" >&2; return 1; } + # an unknown flag is not a positional pin: it must never be read as an identity, repo or workflow + local x; for x in "$@"; do case "$x" in -*) echo "REFUSE: unknown flag $x" >&2; return 1;; esac; done + # cosign skips an EMPTY pin, so an empty one must never reach it + local a; for a in "$@"; do [ -n "$a" ] || { echo "REFUSE: an empty argument (cosign would skip that pin)" >&2; return 1; }; done + [ -f "$1" ] && [ -f "$2" ] || { echo "REFUSE: file or bundle missing" >&2; return 1; } + local pin=(--certificate-identity "$3" --certificate-oidc-issuer "$ISSUER" + --certificate-github-workflow-repository "$4" --certificate-github-workflow-name "$5") + [ -z "${6:-}" ] || pin+=(--certificate-github-workflow-ref "$6") + run_cosign verify-blob --new-bundle-format --bundle "$2" "${pin[@]}" -- "$1" > /dev/null 2>&1 \ + || { echo "REFUSE: signature does not verify for $3 as called by $4 ($5)" >&2; return 1; } + echo "ok: $1 verified as $3 called by $4 ($5)" +} +if [ "${1:-}" = --self-test ]; then + t=$(mktemp -d); trap 'rm -rf -- "${t:?}"' EXIT + # model of cosign verify-blob: the bundle records " "; + # verify = every pinned flag matches the record, and a flag that is not given is not checked. + run_cosign() { + local b="" i="" o="" r="" w="" g="" f="" nb=0 + shift # verify-blob + while [ $# -gt 0 ]; do case "$1" in + --new-bundle-format) nb=1;; --bundle) b="$2"; shift;; --certificate-identity) i="$2"; shift;; + --certificate-oidc-issuer) o="$2"; shift;; --certificate-github-workflow-repository) r="$2"; shift;; + --certificate-github-workflow-name) w="$2"; shift;; --certificate-github-workflow-ref) g="$2"; shift;; + --) f="$2"; shift;; *) return 1;; esac; shift; done # an unknown flag is a refusal, never ignored + local s bi bo br bw bg; read -r s bi bo br bw bg < "$b" + [ "$nb" = 1 ] && [ "$s" = "$(sha256sum "$f" | cut -d' ' -f1)" ] && { [ -z "$i" ] || [ "$bi" = "$i" ]; } && { [ -z "$o" ] || [ "$bo" = "$o" ]; } \ + && { [ -z "$r" ] || [ "$br" = "$r" ]; } && { [ -z "$w" ] || [ "$bw" = "$w" ]; } && { [ -z "$g" ] || [ "$bg" = "$g" ]; } + } + GOOD=https://github.com/paiml/.github/.github/workflows/sigstore-sign.yml@refs/heads/main + REPO=paiml/rmedia; WF=Release; REF=refs/tags/v-rmedia-cli-0.6.0 + LIT_ISSUER=https://token.actions.githubusercontent.com # literal, not $ISSUER: a wrong constant must not survive + printf 'payload' > "$t/bin"; printf '%s %s %s %s %s %s\n' "$(sha256sum "$t/bin" | cut -d' ' -f1)" "$GOOD" "$LIT_ISSUER" "$REPO" "$WF" "$REF" > "$t/bin.sigstore.json" + fail=0 + rows=0 + want() { # want ok|refuse "name" args... + local w="$1" n="$2"; shift 2; rows=$((rows + 1)) + if ( verify "$@" ) > /dev/null 2>&1; then [ "$w" = ok ] || { echo "FAIL $n must refuse"; fail=1; } + else [ "$w" = refuse ] || { echo "FAIL $n must install"; fail=1; }; fi + } + printf 'tampered' > "$t/bin2"; cp "$t/bin.sigstore.json" "$t/bin2.sigstore.json" + want ok "valid" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF" "$REF" + want ok "valid without ref pin" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF" + want refuse "tampered binary" "$t/bin2" "$t/bin2.sigstore.json" "$GOOD" "$REPO" "$WF" "$REF" + want refuse "other workflow SAN" "$t/bin" "$t/bin.sigstore.json" "https://github.com/evil/repo/.github/workflows/x.yml@refs/heads/main" "$REPO" "$WF" "$REF" + want refuse "same SAN, other repo" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "evil/rmedia" "$WF" "$REF" + want refuse "same SAN, other caller workflow" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "Nightly" "$REF" + want refuse "other caller ref" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF" "refs/tags/v-other" + want refuse "no repo pin given" "$t/bin" "$t/bin.sigstore.json" "$GOOD" + want refuse "empty repo pin" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "" "$WF" "$REF" + want refuse "empty workflow pin" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "" "$REF" + want refuse "empty ref pin" "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF" "" + want refuse "empty identity" "$t/bin" "$t/bin.sigstore.json" "" "$REPO" "$WF" "$REF" + printf '%s %s %s %s %s %s\n' "$(sha256sum "$t/bin" | cut -d' ' -f1)" "$GOOD" "https://evil.example/issuer" "$REPO" "$WF" "$REF" > "$t/wi.sigstore.json" + want refuse "wrong issuer in the cert" "$t/bin" "$t/wi.sigstore.json" "$GOOD" "$REPO" "$WF" "$REF" + want refuse "missing bundle" "$t/bin" "$t/none.sigstore.json" "$GOOD" "$REPO" "$WF" "$REF" + # unknown flags, with VALID pins and files that exist (cwd = $t), so ONLY the flag refusal can stop them (Q-GH91e) + cp "$t/bin" "$t/--insecure-ignore-tlog"; cp "$t/bin.sigstore.json" "$t/--trusted-root" + wantcd() { local w="$1" n="$2"; shift 2; rows=$((rows + 1)); if ( cd "$t" && verify "$@" ) > /dev/null 2>&1; then [ "$w" = ok ] || { echo "FAIL $n must refuse"; fail=1; }; else [ "$w" = refuse ] || { echo "FAIL $n must install"; fail=1; }; fi; } + wantcd refuse "bare flag-named file" --insecure-ignore-tlog bin.sigstore.json "$GOOD" "$REPO" "$WF" + wantcd refuse "flag-named bundle" bin --trusted-root "$GOOD" "$REPO" "$WF" + wantcd refuse "7th arg is a flag" bin bin.sigstore.json "$GOOD" "$REPO" "$WF" "$REF" --insecure-ignore-tlog + wantcd refuse "flag in the ref slot" bin bin.sigstore.json "$GOOD" "$REPO" "$WF" --certificate-identity-regexp=.* + wantcd ok "same files, no flags" bin bin.sigstore.json "$GOOD" "$REPO" "$WF" "$REF" + rows=$((rows + 1)); ( run_cosign verify-blob --new-bundle-format --bundle "$t/bin.sigstore.json" --bogus-flag -- "$t/bin" ) > /dev/null 2>&1 && { echo 'FAIL the cosign model must refuse an unknown flag'; fail=1; } + # THE REAL ARGV: the unshadowed script runs a recording fake `cosign` and the argv must equal this list exactly, so a + # dropped pin, an added --insecure-*/--*-regexp/--trusted-root, or a reordered flag is RED whatever the model says + mkdir "$t/fb"; printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$@" > "$REC"\n' > "$t/fb/cosign"; chmod +x "$t/fb/cosign" + argv() { REC="$t/argv" PATH="$t/fb:$PATH" bash "$0" "$@" > /dev/null 2>&1 && cat "$t/argv"; } + exp_base="verify-blob +--new-bundle-format +--bundle +$t/bin.sigstore.json +--certificate-identity +$GOOD +--certificate-oidc-issuer +https://token.actions.githubusercontent.com +--certificate-github-workflow-repository +$REPO +--certificate-github-workflow-name +$WF" + rows=$((rows + 1)); [ "$(argv "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF" "$REF")" = "$exp_base +--certificate-github-workflow-ref +$REF +-- +$t/bin" ] || { echo "FAIL real argv (with ref) is not the exact pinned list"; fail=1; } + rows=$((rows + 1)); [ "$(argv "$t/bin" "$t/bin.sigstore.json" "$GOOD" "$REPO" "$WF")" = "$exp_base +-- +$t/bin" ] || { echo "FAIL real argv (no ref) is not the exact pinned list"; fail=1; } + # the verifier cannot be swapped from the environment: COSIGN=true must not make a bad file verify + cat > "$t/true" <<'S' +#!/usr/bin/env bash +exit 0 +S + chmod +x "$t/true" + rows=$((rows + 1)) + if COSIGN="$t/true" COSIGN_BIN="$t/true" COSIGN_PATH="$t/true" SIGSTORE_ISSUER=x SIGSTORE_ID_TOKEN=x bash "$0" "$t/bin2" "$t/bin2.sigstore.json" "$GOOD" "$REPO" "$WF" > /dev/null 2>&1; then echo "FAIL COSIGN in the environment replaced the verifier"; fail=1; fi + [ "$fail" = 0 ] && echo "PASS sigstore-verify self-test: $rows rows" + exit "$fail" +fi +verify "$@" diff --git a/scripts/yaml2json.awk b/scripts/yaml2json.awk new file mode 100644 index 0000000..78fb524 --- /dev/null +++ b/scripts/yaml2json.awk @@ -0,0 +1,120 @@ +# yaml2json.awk — block-style YAML subset -> JSON, so the signer guards need no yq (the clean-room fleet has none). +# Supported: block maps, block sequences, plain/'single'/"double" scalars, | and > block scalars (-, +), {} and [], full-line +# and trailing comments. ANYTHING ELSE (anchors, aliases, tags, non-empty flow collections, tabs, multi-doc) is a hard +# error, exit 2: an unreadable file is a NO-GO, never a pass. +function die(m) { printf "yaml2json: line %d: %s\n", NR_cur, m > "/dev/stderr"; failed = 1; exit 2 } +function jstr(s, o, i, c) { + o = "\"" + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else if (c == "\n") o = o "\\n" + else if (c == "\t") o = o "\\t"; else if (c == "\r") o = o "\\r"; else o = o c + } + return o "\"" +} +function scalar(v, q, e, s, i, c) { + sub(/^[ ]+/, "", v) + if (v == "") return "null" + q = substr(v, 1, 1) + if (q == "\"" || q == "'") { + s = ""; i = 2 + while (i <= length(v)) { + c = substr(v, i, 1) + if (q == "'" && c == "'") { if (substr(v, i + 1, 1) == "'") { s = s "'"; i += 2; continue } break } + if (q == "\"" && c == "\"") break + if (q == "\"" && c == "\\") { i++; e = substr(v, i, 1) + if (e == "n") s = s "\n"; else if (e == "t") s = s "\t"; else if (e == "\"" || e == "\\" || e == "/") s = s e; else die("unsupported escape \\" e) + i++; continue } + s = s c; i++ + } + if (i > length(v)) die("unterminated quoted scalar") + e = substr(v, i + 1); if (e !~ /^[ ]*(#.*)?$/) die("text after a quoted scalar") + return jstr(s) + } + if (q == "&" || q == "*" || q == "!" || q == "%" || q == "@" || q == "`") die("anchor, alias, tag or reserved indicator") + sub(/[ ]+#.*$/, "", v); sub(/[ ]+$/, "", v) + if (v == "{}") return "{}" + if (v == "[]") return "[]" + if (q == "{" || q == "[") die("non-empty flow collection") + if (v == "true") return "true"; if (v == "false") return "false"; if (v == "null" || v == "~") return "null" + if (v ~ /^-?(0|[1-9][0-9]*)$/) return v + return jstr(v) +} +function sep( f) { f = sp; if (cnt[f]++) out = out ","; } +function closeto(n) { while (sp > 0 && ind[sp] > n) { out = out (typ[sp] == "o" ? "}" : "]"); sp-- } } +function openframe(t, n) { sp++; ind[sp] = n; typ[sp] = t; cnt[sp] = 0; fid[sp] = ++nfid; out = out (t == "o" ? "{" : "[") } +function block(parent_n, hdr, chomp, j, l, bi, body, txt, nl, k, fold, line) { + if (hdr !~ /^[|]-?[ ]*$/) die("only literal block scalars (pipe, pipe-minus) are read; folded, keep and indentation indicators are refused") + chomp = (hdr ~ /-/) ? "-" : (hdr ~ /\+/ ? "+" : ""); fold = (substr(hdr, 1, 1) == ">") + body = ""; nl = 0; bi = -1 + for (j = i + 1; j <= N; j++) { + l = L[j] + if (l ~ /^[ ]*$/) { nl++; pend[nl] = ""; continue } + k = match(l, /[^ ]/) - 1 + if (k <= parent_n) break + if (bi < 0) bi = k + if (k < bi) break + for (nl = nl; nl > 0; nl--) { body = body (fold ? "" : "\n") } + nl = 0 + line = substr(l, bi + 1) + body = body (body == "" && !started ? "" : "") line "\n" + started = 1 + } + started = 0 + i = j - 1 + txt = body + if (fold) { gsub(/\n/, " ", txt); sub(/ $/, "\n", txt) } + if (chomp == "-") sub(/\n+$/, "", txt) + return jstr(txt) +} +{ L[NR] = $0 } +END { + if (failed) exit 2 + N = NR; sp = 0; pending = 0; out = ""; started = 0 + for (i = 1; i <= N; i++) { + NR_cur = i; line = L[i] + if (line ~ /\t/ && line !~ /^[ ]*#/) { if (match(line, /^[ ]*\t/)) die("tab indentation") } + if (line ~ /^[ ]*(#.*)?$/) continue + if (line ~ /^---[ ]*$/ && i == 1) continue + if (line ~ /^(---|\.\.\.)/) die("multi-document stream") + n = match(line, /[^ ]/) - 1; c = substr(line, n + 1) + # resolve a key whose value is on the following lines + if (pending) { + if (c ~ /^-([ ]|$)/ && n >= pn) { openframe("a", n) } + else if (n > pn) { openframe("o", n) } + else { out = out "null" } + pending = 0 + } + isseq = 0 + while (c ~ /^-([ ]|$)/) { # sequence item (possibly nested "- - x" is refused below) + closeto(n) + if (sp == 0 || ind[sp] != n || typ[sp] != "a") { if (sp == 0 && out != "") die("second root"); openframe("a", n) } + sep() + rest = substr(c, 2); m = match(rest, /[^ ]/) + if (m == 0) die("empty sequence item") + c = substr(rest, m); n = n + 1 + m - 1; isseq = 1 + if (c ~ /^-([ ]|$)/) die("nested sequence on one line") + break + } + if (match(c, /^("[^"]*"|'[^']*'|[A-Za-z0-9_.\/-]+)[ ]*:([ ]|$)/)) { + kt = substr(c, 1, RLENGTH); sub(/[ ]*:([ ]|$)$/, "", kt) + v = substr(c, RLENGTH + 1) + if (kt ~ /^"/) kt = substr(kt, 2, length(kt) - 2); else if (kt ~ /^'/) kt = substr(kt, 2, length(kt) - 2) + closeto(n) + if (sp == 0 || ind[sp] != n || typ[sp] != "o") { if (sp == 0 && out != "") die("second root"); openframe("o", n) ; if (isseq) cnt[sp] = 0 } + if (!isseq || cnt[sp] > 0) sep(); else cnt[sp]++ + if ((fid[sp] SUBSEP kt) in seen) die("duplicate key " kt); seen[fid[sp] SUBSEP kt] = 1 + out = out jstr(kt) ":" + sub(/^[ ]+/, "", v) + if (v == "" || v ~ /^#/) { pending = 1; pn = (isseq ? n : n) } + else if (v ~ /^[|>][-+0-9]*[ ]*(#.*)?$/) { sub(/[ ]*#.*$/, "", v); out = out block(n, v) } + else out = out scalar(v) + } else { + if (!isseq) { if (sp == 0) { out = scalar(c); continue } die("not a key or a sequence item: " c) } + if (c ~ /^[|>][-+0-9]*[ ]*$/) out = out block(n - 2, c); else out = out scalar(c) + } + } + if (pending) out = out "null" + closeto(-1) + print out +}