From 1b057b085a05bc1b0b047a9f8951417cc5f6614c Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Tue, 8 Sep 2026 23:23:28 +0300 Subject: [PATCH 01/21] ci(release): pin release-critical CI dependencies --- .../scripts/ci-supply-chain-check.tests.sh | 129 ++++++++++++ .github/workflows/ci-browser-smoke.yml | 10 +- .github/workflows/ci-core.yml | 18 +- .github/workflows/ci-security.yml | 6 +- .github/workflows/ci-vscode.yml | 4 +- .github/workflows/ci-wasm-size.yml | 8 +- CHANGELOG.md | 4 + docs/ci.md | 31 ++- docs/release.md | 7 +- scripts/check.sh | 4 + scripts/ci-supply-chain-check.sh | 190 ++++++++++++++++++ 11 files changed, 385 insertions(+), 26 deletions(-) create mode 100755 .github/scripts/ci-supply-chain-check.tests.sh create mode 100755 scripts/ci-supply-chain-check.sh diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh new file mode 100755 index 00000000..65c69e81 --- /dev/null +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECKER="$ROOT_DIR/scripts/ci-supply-chain-check.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" +tests_run=0 + +write_tinygo_workflow() { + local path="$1" + local action_ref="$2" + local checksum="$3" + local verification_order="$4" + local include_checksum="$5" + + mkdir -p "$(dirname "$path")" + { + printf 'name: fixture\n' + printf 'env:\n' + printf ' TINYGO_VERSION: 0.42.0\n' + if [[ "$include_checksum" == "yes" ]]; then + printf ' TINYGO_SHA256: %s\n' "$checksum" + fi + printf 'jobs:\n' + printf ' fixture:\n' + printf ' steps:\n' + if [[ -n "$action_ref" ]]; then + printf ' - uses: %s\n' "$action_ref" + fi + printf ' # uses: actions/commented-out@v1\n' + printf ' - run: |\n' + printf ' curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"\n' + if [[ "$verification_order" == "after-install" ]]; then + printf ' sudo apt-get install -y /tmp/tinygo.deb\n' + fi + if [[ "$verification_order" == "commented" ]]; then + printf " # printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" + else + printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" + fi + if [[ "$verification_order" != "after-install" ]]; then + printf ' sudo apt-get install -y /tmp/tinygo.deb\n' + fi + printf ' tinygo version\n' + } > "$path" +} + +make_fixture() { + local name="$1" + local action_ref="$2" + local checksum="${3:-$EXPECTED_SHA}" + local verification_order="${4:-before-install}" + local include_checksum="${5:-yes}" + local fixture="$TMP_ROOT/$name" + + write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ + "$action_ref" "$checksum" "$verification_order" "$include_checksum" + write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ + "" "$checksum" "before-install" "$include_checksum" + write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ + "" "$checksum" "before-install" "$include_checksum" + printf '%s' "$fixture" +} + +expect_pass() { + local name="$1" + local fixture="$2" + tests_run=$((tests_run + 1)) + if ! output="$($CHECKER "$fixture" 2>&1)"; then + printf 'not ok %d - %s\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + +expect_fail() { + local name="$1" + local fixture="$2" + tests_run=$((tests_run + 1)) + if output="$($CHECKER "$fixture" 2>&1)"; then + printf 'not ok %d - %s unexpectedly passed\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + +expect_pass "full 40-character remote Action SHA" \ + "$(make_fixture full-sha "actions/checkout@$FULL_ACTION_SHA")" +expect_pass "local Action reference" \ + "$(make_fixture local-action './.github/actions/local')" +expect_fail "mutable major Action tag" \ + "$(make_fixture mutable-tag 'actions/checkout@v7')" +expect_fail "mutable branch Action ref" \ + "$(make_fixture mutable-branch 'actions/checkout@main')" +expect_fail "short Action SHA" \ + "$(make_fixture short-sha 'actions/checkout@3d3c42e5aac5')" +expect_fail "missing TinyGo checksum" \ + "$(make_fixture missing-checksum "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" before-install no)" +expect_fail "wrong TinyGo checksum" \ + "$(make_fixture wrong-checksum "actions/checkout@$FULL_ACTION_SHA" "$(printf '0%.0s' {1..64})")" +expect_pass "verified TinyGo install contract" \ + "$(make_fixture verified-tinygo "actions/checkout@$FULL_ACTION_SHA")" +expect_fail "TinyGo install before verification" \ + "$(make_fixture install-first "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" after-install)" +expect_fail "commented TinyGo verification" \ + "$(make_fixture commented-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" commented)" + +synthetic_artifact="$TMP_ROOT/synthetic-tinygo.deb" +printf 'synthetic TinyGo archive\n' > "$synthetic_artifact" +synthetic_sha="$(sha256sum "$synthetic_artifact" | awk '{print $1}')" +tests_run=$((tests_run + 1)) +if ! printf '%s %s\n' "$synthetic_sha" "$synthetic_artifact" | sha256sum --check --strict - >/dev/null; then + printf 'not ok %d - correct synthetic artifact checksum\n' "$tests_run" >&2 + exit 1 +fi +printf 'ok %d - correct synthetic artifact checksum\n' "$tests_run" + +tests_run=$((tests_run + 1)) +if printf '%064d %s\n' 0 "$synthetic_artifact" | sha256sum --check --strict - >/dev/null 2>&1; then + printf 'not ok %d - incorrect synthetic artifact checksum unexpectedly passed\n' "$tests_run" >&2 + exit 1 +fi +printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" + +printf 'ci supply-chain check tests: ok (%d cases)\n' "$tests_run" diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 90450443..f58f952c 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -12,6 +12,7 @@ permissions: env: TINYGO_VERSION: 0.42.0 + TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 jobs: browser-smoke: @@ -20,22 +21,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.26.6' cache: true - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24.18.1' - name: Setup Chrome id: setup-chrome - uses: browser-actions/setup-chrome@v2 + uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 - name: Install smoke dependencies run: | @@ -45,6 +46,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index e520759d..e0538ef3 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -36,10 +36,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: ${{ matrix.go }} cache: true @@ -67,10 +67,16 @@ jobs: - name: Setup Node.js if: matrix.full - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24.18.1' + - name: CI supply-chain gate + if: matrix.full + run: | + scripts/ci-supply-chain-check.sh + .github/scripts/ci-supply-chain-check.tests.sh + - name: Artifact gate if: matrix.full run: scripts/artifact-check.sh @@ -115,13 +121,14 @@ jobs: env: TINYGO_VERSION: 0.42.0 + TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: ${{ matrix.go }} cache: true @@ -129,6 +136,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-security.yml b/.github/workflows/ci-security.yml index 6b76cc6c..5ee872ea 100644 --- a/.github/workflows/ci-security.yml +++ b/.github/workflows/ci-security.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Go 1.26.6 - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.26.6' cache: true @@ -37,7 +37,7 @@ jobs: run: scripts/security-analysis.sh - name: Setup Go 1.27.0 - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.27.0' cache: true diff --git a/.github/workflows/ci-vscode.yml b/.github/workflows/ci-vscode.yml index a530ff2d..1e3e15ec 100644 --- a/.github/workflows/ci-vscode.yml +++ b/.github/workflows/ci-vscode.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24.18.1' cache: npm diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index ba1fe7df..93db9617 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -12,6 +12,7 @@ permissions: env: TINYGO_VERSION: 0.42.0 + TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 jobs: tinygo-size: @@ -20,10 +21,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.26.6' cache: true @@ -36,6 +37,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - sudo apt-get install -y /tmp/tinygo.deb tinygo version @@ -82,7 +84,7 @@ jobs: run: scripts/size-budget.sh | tee size-report.txt - name: Upload size report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: wasm-size-report diff --git a/CHANGELOG.md b/CHANGELOG.md index d68ae9e9..f6139871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Release-critical authored GitHub Actions are pinned to immutable commits, and + repository-authored TinyGo `0.42.0` package downloads verify the accepted + SHA-256 before installation. A fail-closed repository gate preserves those + boundaries without changing workflow topology or toolchain versions. - Adopted TinyGo `0.42.0` for the release compiler baseline, with a measured, deterministic WASM size rebaseline limited to failing absolute ceilings; compression ratios and production compiler flags are unchanged. diff --git a/docs/ci.md b/docs/ci.md index f3c01979..e415bc16 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -16,15 +16,15 @@ It uses an explicit Go and host matrix: | host | Go | tier | full-only gates | | --- | --- | --- | --- | -| `ubuntu-latest` | `1.26.6` | supported minimum | artifact/module/docs/race | -| `ubuntu-latest` | `1.27.0` | supported stable | artifact/module/docs/race | +| `ubuntu-latest` | `1.26.6` | supported minimum | supply-chain/artifact/module/docs/race | +| `ubuntu-latest` | `1.27.0` | supported stable | supply-chain/artifact/module/docs/race | | `macos-15-intel` | `1.26.6` | host evidence | none | | `windows-latest` | `1.26.6` | host evidence | none | Every matrix entry runs formatting, ordinary tests, vet, debug-tag tests, and selected GOX golden tests. The two supported Linux entries additionally run -the artifact, module-path, docs, and race gates. Every entry is required; no -matrix lane is advisory. +the supply-chain, artifact, module-path, docs, and race gates. Every entry is +required; no matrix lane is advisory. Before those commands, the Windows entry verifies that the exact selected Go version exposes its executable, compiler tools, standard-library source tree, @@ -36,6 +36,7 @@ full integrity probe before continuing. It checks: +- authored CI Action and direct-download integrity; - tracked artifact gate; - canonical module path gate; - docs/example consistency check; @@ -54,6 +55,10 @@ supports Go through `1.27`; local source-selection and build characterization also passes with Go `1.27.0`. The stable Go `1.27.0` Core row remains standard-Go evidence, without a second TinyGo matrix. +The repository-authored TinyGo install downloads the accepted `0.42.0` amd64 +Debian package and verifies its pinned SHA-256 before installation. The Browser +Smoke and WASM Size workflows apply the same check to their TinyGo installs. + The `cmd/goxc` test suite includes manifest/path/package/export/workspace regression tests, including root-aware symlink checks for app roots, entry directories, source files, manifest assets, package/export roots, generated @@ -473,10 +478,20 @@ Security alerts and security updates should be enabled from the GitHub repository security settings. Recommended labels are `dependencies`, `github-actions`, `go`, `vscode`, and `npm`. -Current supply-chain evidence is lightweight: +Current authored CI supply-chain controls are bounded: - GitHub Actions workflows use read-only repository contents permissions by default; +- every remote `uses:` reference in authored workflows and Actions is pinned to + a full Git commit SHA, with an adjacent comment recording the corresponding + upstream release version; +- repository-authored TinyGo `0.42.0` amd64 package downloads verify the + accepted SHA-256 before installation; +- the Windows Go fallback verifies its versioned archive SHA-256 before + extraction; +- `scripts/ci-supply-chain-check.sh`, run by the full Linux Core lanes and + `scripts/check.sh`, rejects mutable remote Action refs and missing, changed, + or misplaced TinyGo verification; - Dependabot checks GitHub Actions, Go modules, and VS Code extension npm dependencies; - the VS Code extension workflow installs from `package-lock.json` with @@ -485,8 +500,10 @@ Current supply-chain evidence is lightweight: standard-library-only dependency surface and runs pinned correctness, vulnerability, and advisory security analyzers. -No SBOM, package signing, license scanner, or Action/artifact pinning policy is -part of this security-analysis stage. +This does not claim that all transitive CI downloads are independently pinned. +Downloads performed internally by pinned Actions and ordinary package-manager +operations retain their existing integrity mechanisms. No SBOM, package +signing, attestation, or license scanner is part of this stage. ## Local Checks diff --git a/docs/release.md b/docs/release.md index f5048f3d..bc4fcb80 100644 --- a/docs/release.md +++ b/docs/release.md @@ -47,6 +47,8 @@ Before creating a tag: pass under the documented Node.js baseline (`npm test --prefix extensions/vscode-gox` covers compile and tests); - `actionlint` passes at the workflow baseline; +- `scripts/ci-supply-chain-check.sh` passes, with authored remote Actions pinned + to full commits and direct TinyGo downloads verified before installation; - `scripts/artifact-check.sh` passes; - `scripts/module-path-check.sh` passes; - the `Security Analysis` workflow passes on the exact release HEAD, including @@ -255,8 +257,9 @@ announcement. - experimental surfaces named rather than hidden; - compatibility/deprecation notes included; - security-analysis and supply-chain evidence stated explicitly, including the - blocking/advisory analyzer split and root Go module dependency check, with no - SBOM, signing, attestation, or complete supply-chain claim; + blocking/advisory analyzer split, root Go module dependency check, immutable + authored Action refs, and verified repository-authored toolchain downloads, + with no SBOM, signing, attestation, or complete supply-chain claim; - migration notes linked; - rollback/revert plan noted for preview users. diff --git a/scripts/check.sh b/scripts/check.sh index 8ab89ee6..2b670fe0 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,6 +10,10 @@ export GOCACHE="${GOCACHE:-/tmp/goframe-go-cache}" export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/goframe-tinygo-cache}" mkdir -p "$GOCACHE" "$XDG_CACHE_HOME" +echo "== CI supply-chain gate ==" +scripts/ci-supply-chain-check.sh +.github/scripts/ci-supply-chain-check.tests.sh + echo "== Artifact gate ==" scripts/artifact-check.sh diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh new file mode 100755 index 00000000..afcfbf6b --- /dev/null +++ b/scripts/ci-supply-chain-check.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_TINYGO_VERSION="0.42.0" +EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +EXPECTED_TINYGO_WORKFLOWS=( + ".github/workflows/ci-browser-smoke.yml" + ".github/workflows/ci-core.yml" + ".github/workflows/ci-wasm-size.yml" +) + +usage() { + printf 'usage: %s [repository-root]\n' "${0##*/}" >&2 +} + +if (( $# > 1 )); then + usage + exit 2 +fi + +if (( $# == 1 )); then + ROOT_DIR="$(cd "$1" && pwd)" +else + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fi + +failures=0 +remote_action_count=0 + +fail() { + printf 'ci supply-chain check: %s\n' "$*" >&2 + failures=1 +} + +trim_whitespace() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +active_fixed_lines() { + local needle="$1" + local file="$2" + grep -nF "$needle" "$file" 2>/dev/null | + grep -Ev '^[0-9]+:[[:space:]]*#' || true +} + +active_regex_lines() { + local pattern="$1" + local file="$2" + grep -nE "$pattern" "$file" 2>/dev/null | + grep -Ev '^[0-9]+:[[:space:]]*#' || true +} + +scan_roots=() +for candidate in "$ROOT_DIR/.github/workflows" "$ROOT_DIR/.github/actions"; do + if [[ -d "$candidate" ]]; then + scan_roots+=("$candidate") + fi +done +if (( ${#scan_roots[@]} == 0 )); then + fail "no authored GitHub workflow or Action directories found under $ROOT_DIR/.github" +fi + +workflow_files=() +if (( ${#scan_roots[@]} > 0 )); then + mapfile -d '' workflow_files < <( + find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | + sort -z + ) +fi + +for file in "${workflow_files[@]}"; do + line_number=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + if [[ ! "$line" =~ ^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*(.*)$ ]]; then + continue + fi + + value="${BASH_REMATCH[2]}" + value="${value%%#*}" + value="$(trim_whitespace "$value")" + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + + if [[ -z "$value" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" + continue + fi + if [[ "$value" == ./* ]]; then + continue + fi + + remote_action_count=$((remote_action_count + 1)) + if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" + fi + done < "$file" +done + +declare -A expected_tinygo_workflows=() +for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + expected_tinygo_workflows["$relative"]=1 +done + +direct_tinygo_workflows=() +for file in "${workflow_files[@]}"; do + if [[ -n "$(active_fixed_lines 'tinygo-org/tinygo/releases/download/' "$file")" ]]; then + direct_tinygo_workflows+=("${file#"$ROOT_DIR/"}") + fi +done + +if (( ${#direct_tinygo_workflows[@]} != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then + fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo workflow downloads, found ${#direct_tinygo_workflows[@]}" +fi + +for relative in "${direct_tinygo_workflows[@]}"; do + if [[ -z "${expected_tinygo_workflows[$relative]+present}" ]]; then + fail "unexpected direct TinyGo download in $relative" + fi +done + +download_url='https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb' +verify_pattern='printf[[:space:]]+'"'"'%s %s\\n'"'"'[[:space:]]+"\$TINYGO_SHA256"[[:space:]]+/tmp/tinygo\.deb[[:space:]]*\|[[:space:]]*sha256sum[[:space:]]+--check[[:space:]]+--strict[[:space:]]+-' + +for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + file="$ROOT_DIR/$relative" + if [[ ! -f "$file" ]]; then + fail "required TinyGo workflow is missing: $relative" + continue + fi + + version_count="$(grep -Ec "^[[:space:]]*TINYGO_VERSION:[[:space:]]*${EXPECTED_TINYGO_VERSION//./\\.}([[:space:]]*(#.*)?)?$" "$file" || true)" + if [[ "$version_count" != "1" ]]; then + fail "$relative must declare TINYGO_VERSION exactly once as $EXPECTED_TINYGO_VERSION" + fi + + sha_declaration_count="$(grep -Ec '^[[:space:]]*TINYGO_SHA256:' "$file" || true)" + sha_match_count="$(grep -Ec "^[[:space:]]*TINYGO_SHA256:[[:space:]]*$EXPECTED_TINYGO_SHA256([[:space:]]*(#.*)?)?$" "$file" || true)" + if [[ "$sha_declaration_count" != "1" || "$sha_match_count" != "1" ]]; then + fail "$relative must declare exactly one accepted TinyGo SHA-256: $EXPECTED_TINYGO_SHA256" + fi + + mapfile -t download_lines < <(active_fixed_lines "$download_url" "$file") + mapfile -t verify_lines < <(active_regex_lines "$verify_pattern" "$file") + mapfile -t install_lines < <(active_fixed_lines 'sudo apt-get install -y /tmp/tinygo.deb' "$file") + mapfile -t version_lines < <(active_fixed_lines 'tinygo version' "$file") + + if (( ${#download_lines[@]} != 1 )); then + fail "$relative must contain exactly one accepted TinyGo download" + continue + fi + if (( ${#verify_lines[@]} != 1 )); then + fail "$relative must verify /tmp/tinygo.deb with sha256sum --check --strict exactly once" + continue + fi + if (( ${#install_lines[@]} != 1 )); then + fail "$relative must install /tmp/tinygo.deb exactly once" + continue + fi + if (( ${#version_lines[@]} != 1 )); then + fail "$relative must report the selected TinyGo version exactly once" + continue + fi + + download_line="${download_lines[0]%%:*}" + verify_line="${verify_lines[0]%%:*}" + install_line="${install_lines[0]%%:*}" + version_line="${version_lines[0]%%:*}" + if ! (( download_line < verify_line && verify_line < install_line && install_line < version_line )); then + fail "$relative must download, verify, install, then report TinyGo in that order" + fi +done + +if (( failures != 0 )); then + exit 1 +fi + +printf 'ci supply-chain check: ok (%d remote Action refs, %d verified TinyGo downloads)\n' \ + "$remote_action_count" "${#direct_tinygo_workflows[@]}" From ae107c1b40d89df9f570d9f4352e53213fa31e1a Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Wed, 9 Sep 2026 01:25:01 +0300 Subject: [PATCH 02/21] ci(release): harden supply-chain verification gate --- .../scripts/ci-supply-chain-check.tests.sh | 13 +++ scripts/ci-supply-chain-check.sh | 82 +++++++++++++------ 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 65c69e81..6ef19e66 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -39,9 +39,16 @@ write_tinygo_workflow() { fi if [[ "$verification_order" == "commented" ]]; then printf " # printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" + elif [[ "$verification_order" == "ignored" ]]; then + printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || true\n" + elif [[ "$verification_order" == "skipped" ]]; then + printf " false && printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" else printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" fi + if [[ "$verification_order" == "replaced" ]]; then + printf ' cp /tmp/replacement.deb /tmp/tinygo.deb\n' + fi if [[ "$verification_order" != "after-install" ]]; then printf ' sudo apt-get install -y /tmp/tinygo.deb\n' fi @@ -108,6 +115,12 @@ expect_fail "TinyGo install before verification" \ "$(make_fixture install-first "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" after-install)" expect_fail "commented TinyGo verification" \ "$(make_fixture commented-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" commented)" +expect_fail "TinyGo verification with ignored failure" \ + "$(make_fixture ignored-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" ignored)" +expect_fail "TinyGo verification behind false condition" \ + "$(make_fixture skipped-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" skipped)" +expect_fail "TinyGo artifact replaced after verification" \ + "$(make_fixture replaced-artifact "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" replaced)" synthetic_artifact="$TMP_ROOT/synthetic-tinygo.deb" printf 'synthetic TinyGo archive\n' > "$synthetic_artifact" diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index afcfbf6b..02568369 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -46,13 +46,6 @@ active_fixed_lines() { grep -Ev '^[0-9]+:[[:space:]]*#' || true } -active_regex_lines() { - local pattern="$1" - local file="$2" - grep -nE "$pattern" "$file" 2>/dev/null | - grep -Ev '^[0-9]+:[[:space:]]*#' || true -} - scan_roots=() for candidate in "$ROOT_DIR/.github/workflows" "$ROOT_DIR/.github/actions"; do if [[ -d "$candidate" ]]; then @@ -130,8 +123,50 @@ for relative in "${direct_tinygo_workflows[@]}"; do fi done -download_url='https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb' -verify_pattern='printf[[:space:]]+'"'"'%s %s\\n'"'"'[[:space:]]+"\$TINYGO_SHA256"[[:space:]]+/tmp/tinygo\.deb[[:space:]]*\|[[:space:]]*sha256sum[[:space:]]+--check[[:space:]]+--strict[[:space:]]+-' +tinygo_download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"' +tinygo_verify_command="printf '%s %s\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -" +tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' +tinygo_version_command='tinygo version' + +count_tinygo_install_sequences() { + local file="$1" + local lines=() + local index run_header run_indent command_indent + local download_line verify_line install_line version_line + local count=0 + + mapfile -t lines < "$file" + for ((index = 0; index + 4 < ${#lines[@]}; index++)); do + run_header="$(trim_whitespace "${lines[index]}")" + if [[ "$run_header" != "run: |" && "$run_header" != "- run: |" ]]; then + continue + fi + + run_indent="${lines[index]%%[![:space:]]*}" + download_line="${lines[index + 1]}" + verify_line="${lines[index + 2]}" + install_line="${lines[index + 3]}" + version_line="${lines[index + 4]}" + command_indent="${download_line%%[![:space:]]*}" + + if (( ${#command_indent} <= ${#run_indent} )) || [[ "$command_indent" != "$run_indent"* ]]; then + continue + fi + if [[ "${verify_line%%[![:space:]]*}" != "$command_indent" || + "${install_line%%[![:space:]]*}" != "$command_indent" || + "${version_line%%[![:space:]]*}" != "$command_indent" ]]; then + continue + fi + if [[ "$(trim_whitespace "$download_line")" == "$tinygo_download_command" && + "$(trim_whitespace "$verify_line")" == "$tinygo_verify_command" && + "$(trim_whitespace "$install_line")" == "$tinygo_install_command" && + "$(trim_whitespace "$version_line")" == "$tinygo_version_command" ]]; then + count=$((count + 1)) + fi + done + + printf '%d' "$count" +} for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do file="$ROOT_DIR/$relative" @@ -151,34 +186,27 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do fail "$relative must declare exactly one accepted TinyGo SHA-256: $EXPECTED_TINYGO_SHA256" fi - mapfile -t download_lines < <(active_fixed_lines "$download_url" "$file") - mapfile -t verify_lines < <(active_regex_lines "$verify_pattern" "$file") - mapfile -t install_lines < <(active_fixed_lines 'sudo apt-get install -y /tmp/tinygo.deb' "$file") - mapfile -t version_lines < <(active_fixed_lines 'tinygo version' "$file") + mapfile -t download_lines < <(active_fixed_lines "$tinygo_download_command" "$file") + mapfile -t verify_lines < <(active_fixed_lines "$tinygo_verify_command" "$file") + mapfile -t install_lines < <(active_fixed_lines "$tinygo_install_command" "$file") + mapfile -t version_lines < <(active_fixed_lines "$tinygo_version_command" "$file") if (( ${#download_lines[@]} != 1 )); then - fail "$relative must contain exactly one accepted TinyGo download" - continue + fail "$relative must contain exactly one accepted TinyGo download command" fi if (( ${#verify_lines[@]} != 1 )); then - fail "$relative must verify /tmp/tinygo.deb with sha256sum --check --strict exactly once" - continue + fail "$relative must contain exactly one accepted TinyGo verification command" fi if (( ${#install_lines[@]} != 1 )); then - fail "$relative must install /tmp/tinygo.deb exactly once" - continue + fail "$relative must contain exactly one accepted TinyGo installation command" fi if (( ${#version_lines[@]} != 1 )); then - fail "$relative must report the selected TinyGo version exactly once" - continue + fail "$relative must contain exactly one accepted TinyGo version-report command" fi - download_line="${download_lines[0]%%:*}" - verify_line="${verify_lines[0]%%:*}" - install_line="${install_lines[0]%%:*}" - version_line="${version_lines[0]%%:*}" - if ! (( download_line < verify_line && verify_line < install_line && install_line < version_line )); then - fail "$relative must download, verify, install, then report TinyGo in that order" + sequence_count="$(count_tinygo_install_sequences "$file")" + if [[ "$sequence_count" != "1" ]]; then + fail "$relative must contain exactly one contiguous TinyGo download, verification, installation, and version-report sequence" fi done From 2ac10392f404a17d91669891c0ce3cc6411cb9a7 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Fri, 11 Sep 2026 17:31:13 +0300 Subject: [PATCH 03/21] ci(release): close supply-chain review gaps --- .../scripts/ci-supply-chain-check.tests.sh | 138 ++++++++++++- .github/workflows/ci-browser-smoke.yml | 2 +- .github/workflows/ci-core.yml | 11 +- .github/workflows/ci-wasm-size.yml | 2 +- scripts/ci-supply-chain-check.sh | 190 ++++++++++-------- 5 files changed, 251 insertions(+), 92 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 6ef19e66..a0fdc6aa 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -38,13 +38,17 @@ write_tinygo_workflow() { printf ' sudo apt-get install -y /tmp/tinygo.deb\n' fi if [[ "$verification_order" == "commented" ]]; then - printf " # printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" + printf " # printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" elif [[ "$verification_order" == "ignored" ]]; then printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || true\n" + elif [[ "$verification_order" == "warned" ]]; then + printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || echo warning\n" elif [[ "$verification_order" == "skipped" ]]; then - printf " false && printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" - else + printf " false && printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" + elif [[ "$verification_order" == "implicit" ]]; then printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" + else + printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" fi if [[ "$verification_order" == "replaced" ]]; then printf ' cp /tmp/replacement.deb /tmp/tinygo.deb\n' @@ -77,7 +81,7 @@ expect_pass() { local name="$1" local fixture="$2" tests_run=$((tests_run + 1)) - if ! output="$($CHECKER "$fixture" 2>&1)"; then + if ! output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then printf 'not ok %d - %s\n%s\n' "$tests_run" "$name" "$output" >&2 exit 1 fi @@ -88,7 +92,7 @@ expect_fail() { local name="$1" local fixture="$2" tests_run=$((tests_run + 1)) - if output="$($CHECKER "$fixture" 2>&1)"; then + if output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then printf 'not ok %d - %s unexpectedly passed\n%s\n' "$tests_run" "$name" "$output" >&2 exit 1 fi @@ -105,6 +109,75 @@ expect_fail "mutable branch Action ref" \ "$(make_fixture mutable-branch 'actions/checkout@main')" expect_fail "short Action SHA" \ "$(make_fixture short-sha 'actions/checkout@3d3c42e5aac5')" + +expect_pass "external reusable workflow with full SHA" \ + "$(make_fixture reusable-workflow "owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA")" + +for action_form in \ + '"uses": actions/checkout@v7' \ + "'uses': actions/checkout@v7" \ + '{ uses: actions/checkout@v7 }' \ + "\"uses\": actions/checkout@$FULL_ACTION_SHA" \ + "{ uses: actions/checkout@$FULL_ACTION_SHA }" \ + '"u\u0073es": actions/checkout@v7' \ + '? uses'; do + fixture="$(make_fixture alternate-action "actions/checkout@$FULL_ACTION_SHA")" + printf 'jobs:\n alternate:\n steps:\n - %s\n' "$action_form" \ + > "$fixture/.github/workflows/alternate.yml" + expect_fail "unsupported Action key: $action_form" "$fixture" +done + +fixture="$(make_fixture commented-action "actions/checkout@$FULL_ACTION_SHA")" +printf '# - "uses": actions/checkout@v7\n# - { uses: actions/checkout@v7 }\n' \ + > "$fixture/.github/workflows/comments.yml" +expect_pass "commented alternate Action keys" "$fixture" + +fixture="$(make_fixture scalar-content "actions/checkout@$FULL_ACTION_SHA")" +printf '%s\n' 'env:' ' VERSION: value' 'jobs:' ' quoted:' ' strategy:' ' matrix:' ' go:' " - '1.26.6'" \ + ' steps:' ' - run: |' ' echo "uses: actions/example@v7"' \ + > "$fixture/.github/workflows/scalars.yml" +expect_pass "quoted scalar list and literal script content" "$fixture" + +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a block-scalar step name" "$fixture" + +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a spaced block-scalar step name" "$fixture" + +fixture="$(make_fixture 'space in repository path' "actions/checkout@$FULL_ACTION_SHA")" +expect_pass "repository path containing spaces" "$fixture" +printf 'uses: actions/checkout@v7\n' > "$fixture/.github/workflows/"$'line\nbreak.yml' +expect_fail "workflow filename containing a newline" "$fixture" + +extra_download='curl -fsSL -o /tmp/extra.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' +for placement in before after same-line canonical hash-prefix unexpected; do + fixture="$(make_fixture extra-download "actions/checkout@$FULL_ACTION_SHA")" + file="$fixture/.github/workflows/ci-core.yml" + case "$placement" in + before) + awk -v command="$extra_download" ' + / - run: \|/ { print " - run: " command } + { print } + ' "$file" > "$TMP_ROOT/extra.yml" + mv "$TMP_ROOT/extra.yml" "$file" + ;; + after) printf ' - run: %s\n' "$extra_download" >> "$file" ;; + same-line) printf ' - run: %s; %s\n' "$extra_download" "$extra_download" >> "$file" ;; + canonical) + command="$(sed -n '/ curl -fsSL/p' "$file")" + printf '%s\n' "$command" >> "$file" + ;; + hash-prefix) printf ' - run: printf "# data"; %s\n' "$extra_download" >> "$file" ;; + unexpected) + printf 'jobs:\n extra:\n steps:\n - run: %s\n' "$extra_download" \ + > "$fixture/.github/workflows/extra.yml" + ;; + esac + expect_fail "additional TinyGo download: $placement" "$fixture" +done + expect_fail "missing TinyGo checksum" \ "$(make_fixture missing-checksum "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" before-install no)" expect_fail "wrong TinyGo checksum" \ @@ -117,6 +190,10 @@ expect_fail "commented TinyGo verification" \ "$(make_fixture commented-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" commented)" expect_fail "TinyGo verification with ignored failure" \ "$(make_fixture ignored-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" ignored)" +expect_fail "TinyGo verification with warning-only failure" \ + "$(make_fixture warned-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" warned)" +expect_fail "TinyGo verification relying on implicit errexit" \ + "$(make_fixture implicit-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" implicit)" expect_fail "TinyGo verification behind false condition" \ "$(make_fixture skipped-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" skipped)" expect_fail "TinyGo artifact replaced after verification" \ @@ -124,19 +201,64 @@ expect_fail "TinyGo artifact replaced after verification" \ synthetic_artifact="$TMP_ROOT/synthetic-tinygo.deb" printf 'synthetic TinyGo archive\n' > "$synthetic_artifact" -synthetic_sha="$(sha256sum "$synthetic_artifact" | awk '{print $1}')" +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" + else + shasum -a 256 "$1" + fi | awk '{print $1}' +} + +verify_synthetic_checksum() { + local digest="$1" + local artifact="$2" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$digest" "$artifact" | sha256sum --check --strict - + else + printf '%s %s\n' "$digest" "$artifact" | shasum -a 256 --check - + fi +} + +synthetic_sha="$(sha256_file "$synthetic_artifact")" tests_run=$((tests_run + 1)) -if ! printf '%s %s\n' "$synthetic_sha" "$synthetic_artifact" | sha256sum --check --strict - >/dev/null; then +if ! verify_synthetic_checksum "$synthetic_sha" "$synthetic_artifact" >/dev/null; then printf 'not ok %d - correct synthetic artifact checksum\n' "$tests_run" >&2 exit 1 fi printf 'ok %d - correct synthetic artifact checksum\n' "$tests_run" tests_run=$((tests_run + 1)) -if printf '%064d %s\n' 0 "$synthetic_artifact" | sha256sum --check --strict - >/dev/null 2>&1; then +if verify_synthetic_checksum "$(printf '%064d' 0)" "$synthetic_artifact" >/dev/null 2>&1; then printf 'not ok %d - incorrect synthetic artifact checksum unexpectedly passed\n' "$tests_run" >&2 exit 1 fi printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" +if command -v sha256sum >/dev/null 2>&1; then + # Execute the fixture's actual verification line without inherited errexit. + fixture="$(make_fixture plain-bash "actions/checkout@$FULL_ACTION_SHA")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification/\/tmp\/tinygo.deb/\"\$synthetic_artifact\"}" + printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + rm -f "$TMP_ROOT/continued" + tests_run=$((tests_run + 1)) + if TINYGO_SHA256="$digest" synthetic_artifact="$synthetic_artifact" continuation="$TMP_ROOT/continued" \ + "$BASH" --noprofile --norc "$TMP_ROOT/verify.sh" > "$TMP_ROOT/checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" || ! -f "$TMP_ROOT/continued" ]]; then + printf 'not ok %d - checksum continuation without errexit\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" || -f "$TMP_ROOT/continued" ]]; then + printf 'not ok %d - checksum failure without errexit\n' "$tests_run" >&2 + exit 1 + fi + fi + printf 'ok %d - explicit checksum status and continuation without errexit\n' "$tests_run" + done +else + printf 'skip - GNU workflow checksum execution; portable SHA-256 controls passed\n' +fi + printf 'ci supply-chain check tests: ok (%d cases)\n' "$tests_run" diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index f58f952c..80a1282c 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -46,7 +46,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index e0538ef3..5909b2fd 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -71,6 +71,15 @@ jobs: with: node-version: '24.18.1' + - name: Test supply-chain gate with system Bash + if: runner.os == 'macOS' + run: | + /bin/bash --version + /bin/bash -n scripts/ci-supply-chain-check.sh + /bin/bash -n .github/scripts/ci-supply-chain-check.tests.sh + /bin/bash scripts/ci-supply-chain-check.sh + /bin/bash .github/scripts/ci-supply-chain-check.tests.sh + - name: CI supply-chain gate if: matrix.full run: | @@ -136,7 +145,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index 93db9617..724dcf75 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -37,7 +37,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - + printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 02568369..91b66d7c 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -39,11 +39,22 @@ trim_whitespace() { printf '%s' "$value" } -active_fixed_lines() { +count_active_occurrences() { local needle="$1" local file="$2" - grep -nF "$needle" "$file" 2>/dev/null | - grep -Ev '^[0-9]+:[[:space:]]*#' || true + local line remainder count=0 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + # A # inside a shell string is data, not necessarily a comment. + remainder="$line" + while [[ "$remainder" == *"$needle"* ]]; do + count=$((count + 1)) + remainder="${remainder#*"$needle"}" + done + done < "$file" + printf '%d' "$count" } scan_roots=() @@ -56,28 +67,50 @@ if (( ${#scan_roots[@]} == 0 )); then fail "no authored GitHub workflow or Action directories found under $ROOT_DIR/.github" fi -workflow_files=() -if (( ${#scan_roots[@]} > 0 )); then - mapfile -d '' workflow_files < <( - find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | - sort -z - ) -fi - -for file in "${workflow_files[@]}"; do - line_number=0 +scan_action_refs() { + local file="$1" + local line line_number=0 indent syntax key value sequence_prefix + local scalar_indent=-1 + local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' + local scalar_re='^[|>][+-]?$' while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) - if [[ "$line" =~ ^[[:space:]]*# ]]; then + syntax="$(trim_whitespace "${line%%#*}")" + if [[ -z "$syntax" ]]; then + continue + fi + indent="${line%%[![:space:]]*}" + if (( scalar_indent >= 0 && ${#indent} > scalar_indent )); then continue fi - if [[ ! "$line" =~ ^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*(.*)$ ]]; then + scalar_indent=-1 + + # Only canonical block mapping keys are authored here. Reject YAML + # indirection and flow forms instead of letting them hide a uses key. + if [[ ! "$syntax" =~ $mapping_re ]]; then + case "${syntax#- }" in + *:*|\{*|\[*|\?*|\&*|\**|\!*) + fail "${file#"$ROOT_DIR/"}:$line_number unsupported workflow mapping syntax; use canonical block keys" + ;; + esac + continue + fi + sequence_prefix="${BASH_REMATCH[1]}" + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[4]}" + case "$value" in + \{*|\[*|\&*|\**|\!*) + fail "${file#"$ROOT_DIR/"}:$line_number unsupported workflow mapping value; use canonical block mappings" + continue + ;; + esac + if [[ "$key" != uses ]]; then + if [[ "$value" =~ $scalar_re ]]; then + scalar_indent=$((${#indent} + ${#sequence_prefix})) + fi continue fi - value="${BASH_REMATCH[2]}" - value="${value%%#*}" - value="$(trim_whitespace "$value")" if (( ${#value} >= 2 )); then if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then value="${value:1:${#value}-2}" @@ -99,71 +132,71 @@ for file in "${workflow_files[@]}"; do fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" fi done < "$file" -done - -declare -A expected_tinygo_workflows=() -for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do - expected_tinygo_workflows["$relative"]=1 -done - -direct_tinygo_workflows=() -for file in "${workflow_files[@]}"; do - if [[ -n "$(active_fixed_lines 'tinygo-org/tinygo/releases/download/' "$file")" ]]; then - direct_tinygo_workflows+=("${file#"$ROOT_DIR/"}") - fi -done +} -if (( ${#direct_tinygo_workflows[@]} != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then - fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo workflow downloads, found ${#direct_tinygo_workflows[@]}" +direct_tinygo_downloads=0 +if (( ${#scan_roots[@]} > 0 )); then + # Traversal order affects diagnostics only; keep filenames NUL-delimited. + while IFS= read -r -d '' file; do + scan_action_refs "$file" + download_count="$(count_active_occurrences 'tinygo-org/tinygo/releases/download/' "$file")" + direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) + if (( download_count > 0 )); then + relative="${file#"$ROOT_DIR/"}" + expected=false + for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + if [[ "$relative" == "$candidate" ]]; then + expected=true + fi + done + if [[ "$expected" == false ]]; then + fail "unexpected direct TinyGo download in $relative" + elif (( download_count != 1 )); then + fail "$relative must contain exactly one direct TinyGo release download, found $download_count" + fi + fi + done < <(find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) fi -for relative in "${direct_tinygo_workflows[@]}"; do - if [[ -z "${expected_tinygo_workflows[$relative]+present}" ]]; then - fail "unexpected direct TinyGo download in $relative" - fi -done +if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then + fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" +fi tinygo_download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"' -tinygo_verify_command="printf '%s %s\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -" +tinygo_verify_command="printf '%s %s\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1" tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' tinygo_version_command='tinygo version' count_tinygo_install_sequences() { local file="$1" - local lines=() - local index run_header run_indent command_indent - local download_line verify_line install_line version_line - local count=0 - - mapfile -t lines < "$file" - for ((index = 0; index + 4 < ${#lines[@]}; index++)); do - run_header="$(trim_whitespace "${lines[index]}")" - if [[ "$run_header" != "run: |" && "$run_header" != "- run: |" ]]; then - continue - fi - - run_indent="${lines[index]%%[![:space:]]*}" - download_line="${lines[index + 1]}" - verify_line="${lines[index + 2]}" - install_line="${lines[index + 3]}" - version_line="${lines[index + 4]}" - command_indent="${download_line%%[![:space:]]*}" - - if (( ${#command_indent} <= ${#run_indent} )) || [[ "$command_indent" != "$run_indent"* ]]; then - continue - fi - if [[ "${verify_line%%[![:space:]]*}" != "$command_indent" || - "${install_line%%[![:space:]]*}" != "$command_indent" || - "${version_line%%[![:space:]]*}" != "$command_indent" ]]; then - continue + local line command indent run_indent command_indent expected_command + local state=0 count=0 + while IFS= read -r line || [[ -n "$line" ]]; do + command="$(trim_whitespace "$line")" + indent="${line%%[![:space:]]*}" + if (( state > 0 )); then + case "$state" in + 1) expected_command="$tinygo_download_command"; command_indent="$indent" ;; + 2) expected_command="$tinygo_verify_command" ;; + 3) expected_command="$tinygo_install_command" ;; + 4) expected_command="$tinygo_version_command" ;; + esac + if [[ "$command" == "$expected_command" && "$indent" == "$command_indent" && + "$indent" == "$run_indent"* ]] && (( ${#indent} > ${#run_indent} )); then + state=$((state + 1)) + if (( state == 5 )); then + count=$((count + 1)) + state=0 + fi + else + state=0 + fi fi - if [[ "$(trim_whitespace "$download_line")" == "$tinygo_download_command" && - "$(trim_whitespace "$verify_line")" == "$tinygo_verify_command" && - "$(trim_whitespace "$install_line")" == "$tinygo_install_command" && - "$(trim_whitespace "$version_line")" == "$tinygo_version_command" ]]; then - count=$((count + 1)) + if [[ "$command" == 'run: |' || "$command" == '- run: |' ]]; then + run_indent="$indent" + state=1 fi - done + done < "$file" printf '%d' "$count" } @@ -186,21 +219,16 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do fail "$relative must declare exactly one accepted TinyGo SHA-256: $EXPECTED_TINYGO_SHA256" fi - mapfile -t download_lines < <(active_fixed_lines "$tinygo_download_command" "$file") - mapfile -t verify_lines < <(active_fixed_lines "$tinygo_verify_command" "$file") - mapfile -t install_lines < <(active_fixed_lines "$tinygo_install_command" "$file") - mapfile -t version_lines < <(active_fixed_lines "$tinygo_version_command" "$file") - - if (( ${#download_lines[@]} != 1 )); then + if [[ "$(count_active_occurrences "$tinygo_download_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo download command" fi - if (( ${#verify_lines[@]} != 1 )); then + if [[ "$(count_active_occurrences "$tinygo_verify_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo verification command" fi - if (( ${#install_lines[@]} != 1 )); then + if [[ "$(count_active_occurrences "$tinygo_install_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo installation command" fi - if (( ${#version_lines[@]} != 1 )); then + if [[ "$(count_active_occurrences "$tinygo_version_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo version-report command" fi @@ -215,4 +243,4 @@ if (( failures != 0 )); then fi printf 'ci supply-chain check: ok (%d remote Action refs, %d verified TinyGo downloads)\n' \ - "$remote_action_count" "${#direct_tinygo_workflows[@]}" + "$remote_action_count" "$direct_tinygo_downloads" From cc67a2c46c20f2dde6f0b5f2231c629a0fe4f966 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 12 Sep 2026 16:25:11 +0300 Subject: [PATCH 04/21] ci(release): bind supply-chain trust roots --- .../scripts/ci-supply-chain-check.tests.sh | 256 ++++++++++++++---- .github/workflows/ci-browser-smoke.yml | 8 +- .github/workflows/ci-core.yml | 8 +- .github/workflows/ci-wasm-size.yml | 8 +- scripts/ci-supply-chain-check.sh | 126 +++++++-- 5 files changed, 324 insertions(+), 82 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index a0fdc6aa..7ac57286 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -8,6 +8,8 @@ trap 'rm -rf "$TMP_ROOT"' EXIT EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" +FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" +FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" tests_run=0 write_tinygo_workflow() { @@ -16,13 +18,31 @@ write_tinygo_workflow() { local checksum="$3" local verification_order="$4" local include_checksum="$5" + local trust_root_mode="${6:-literal}" + local download_command verify_input mkdir -p "$(dirname "$path")" + case "$trust_root_mode" in + literal|variable-digest) + download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' + ;; + variable-url|variable-both) + download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"' + ;; + *) + printf 'unsupported trust-root mode: %s\n' "$trust_root_mode" >&2 + exit 1 + ;; + esac + case "$trust_root_mode" in + literal|variable-url) verify_input="'$checksum'" ;; + variable-digest|variable-both) verify_input='"$TINYGO_SHA256"' ;; + esac { printf 'name: fixture\n' - printf 'env:\n' - printf ' TINYGO_VERSION: 0.42.0\n' - if [[ "$include_checksum" == "yes" ]]; then + if [[ "$trust_root_mode" != "literal" ]]; then + printf 'env:\n' + printf ' TINYGO_VERSION: 0.42.0\n' printf ' TINYGO_SHA256: %s\n' "$checksum" fi printf 'jobs:\n' @@ -33,22 +53,24 @@ write_tinygo_workflow() { fi printf ' # uses: actions/commented-out@v1\n' printf ' - run: |\n' - printf ' curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"\n' + printf ' %s\n' "$download_command" if [[ "$verification_order" == "after-install" ]]; then printf ' sudo apt-get install -y /tmp/tinygo.deb\n' fi - if [[ "$verification_order" == "commented" ]]; then - printf " # printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" - elif [[ "$verification_order" == "ignored" ]]; then - printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || true\n" - elif [[ "$verification_order" == "warned" ]]; then - printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || echo warning\n" - elif [[ "$verification_order" == "skipped" ]]; then - printf " false && printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" - elif [[ "$verification_order" == "implicit" ]]; then - printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict -\n" - else - printf " printf '%%s %%s\\\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" + if [[ "$include_checksum" == "yes" ]]; then + if [[ "$verification_order" == "commented" ]]; then + printf " # printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + elif [[ "$verification_order" == "ignored" ]]; then + printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || true\n" "$verify_input" + elif [[ "$verification_order" == "warned" ]]; then + printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || echo warning\n" "$verify_input" + elif [[ "$verification_order" == "skipped" ]]; then + printf " false && printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + elif [[ "$verification_order" == "implicit" ]]; then + printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict -\n" "$verify_input" + else + printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + fi fi if [[ "$verification_order" == "replaced" ]]; then printf ' cp /tmp/replacement.deb /tmp/tinygo.deb\n' @@ -66,17 +88,77 @@ make_fixture() { local checksum="${3:-$EXPECTED_SHA}" local verification_order="${4:-before-install}" local include_checksum="${5:-yes}" + local trust_root_mode="${6:-literal}" local fixture="$TMP_ROOT/$name" write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ - "$action_ref" "$checksum" "$verification_order" "$include_checksum" + "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ - "" "$checksum" "before-install" "$include_checksum" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ - "" "$checksum" "before-install" "$include_checksum" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" printf '%s' "$fixture" } +write_local_action() { + local fixture="$1" + local path="$2" + local metadata="$3" + local nested_ref="${4:-}" + local target="$fixture/${path#./}" + + mkdir -p "$target" + { + printf 'name: local fixture\n' + printf 'runs:\n' + printf ' using: composite\n' + printf ' steps:\n' + if [[ -n "$nested_ref" ]]; then + printf ' - uses: %s\n' "$nested_ref" + else + printf ' - shell: bash\n' + printf ' run: echo local\n' + fi + } > "$target/$metadata" +} + +write_local_workflow() { + local fixture="$1" + local path="$2" + local nested_ref="${3:-}" + local target="$fixture/${path#./}" + + mkdir -p "$(dirname "$target")" + { + printf 'name: reusable fixture\n' + printf 'on:\n' + printf ' workflow_call:\n' + printf 'jobs:\n' + printf ' fixture:\n' + printf ' runs-on: ubuntu-latest\n' + printf ' steps:\n' + if [[ -n "$nested_ref" ]]; then + printf ' - uses: %s\n' "$nested_ref" + else + printf ' - run: echo local\n' + fi + } > "$target" +} + +add_runtime_override() { + local file="$1" + awk ' + / - run: \|/ && !inserted { + print " - run: |" + print " echo '\''TINYGO_VERSION=attacker-value'\'' >> \"$GITHUB_ENV\"" + print " echo '\''TINYGO_SHA256=attacker-digest'\'' >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/runtime-override.yml" + mv "$TMP_ROOT/runtime-override.yml" "$file" +} + expect_pass() { local name="$1" local fixture="$2" @@ -99,40 +181,45 @@ expect_fail() { printf 'ok %d - %s\n' "$tests_run" "$name" } -expect_pass "full 40-character remote Action SHA" \ - "$(make_fixture full-sha "actions/checkout@$FULL_ACTION_SHA")" -expect_pass "local Action reference" \ - "$(make_fixture local-action './.github/actions/local')" +expect_pass "full remote Action SHA with version annotation" \ + "$(make_fixture full-sha "$FULL_ACTION_REF")" expect_fail "mutable major Action tag" \ "$(make_fixture mutable-tag 'actions/checkout@v7')" expect_fail "mutable branch Action ref" \ "$(make_fixture mutable-branch 'actions/checkout@main')" expect_fail "short Action SHA" \ "$(make_fixture short-sha 'actions/checkout@3d3c42e5aac5')" - -expect_pass "external reusable workflow with full SHA" \ - "$(make_fixture reusable-workflow "owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA")" +expect_pass "external reusable workflow with annotated full SHA" \ + "$(make_fixture reusable-workflow "$FULL_REUSABLE_REF")" +expect_fail "full remote Action SHA without version annotation" \ + "$(make_fixture missing-version-annotation "actions/checkout@$FULL_ACTION_SHA")" +expect_fail "remote Action SHA with prose annotation" \ + "$(make_fixture prose-version-annotation "actions/checkout@$FULL_ACTION_SHA # pinned")" +expect_fail "remote Action SHA with major-only annotation" \ + "$(make_fixture major-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1")" +expect_fail "remote Action SHA with two-component annotation" \ + "$(make_fixture minor-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1.2")" for action_form in \ '"uses": actions/checkout@v7' \ "'uses': actions/checkout@v7" \ '{ uses: actions/checkout@v7 }' \ - "\"uses\": actions/checkout@$FULL_ACTION_SHA" \ - "{ uses: actions/checkout@$FULL_ACTION_SHA }" \ + "\"uses\": actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + "{ uses: actions/checkout@$FULL_ACTION_SHA } # v7.0.1" \ '"u\u0073es": actions/checkout@v7' \ '? uses'; do - fixture="$(make_fixture alternate-action "actions/checkout@$FULL_ACTION_SHA")" + fixture="$(make_fixture alternate-action "$FULL_ACTION_REF")" printf 'jobs:\n alternate:\n steps:\n - %s\n' "$action_form" \ > "$fixture/.github/workflows/alternate.yml" expect_fail "unsupported Action key: $action_form" "$fixture" done -fixture="$(make_fixture commented-action "actions/checkout@$FULL_ACTION_SHA")" +fixture="$(make_fixture commented-action "$FULL_ACTION_REF")" printf '# - "uses": actions/checkout@v7\n# - { uses: actions/checkout@v7 }\n' \ > "$fixture/.github/workflows/comments.yml" expect_pass "commented alternate Action keys" "$fixture" -fixture="$(make_fixture scalar-content "actions/checkout@$FULL_ACTION_SHA")" +fixture="$(make_fixture scalar-content "$FULL_ACTION_REF")" printf '%s\n' 'env:' ' VERSION: value' 'jobs:' ' quoted:' ' strategy:' ' matrix:' ' go:' " - '1.26.6'" \ ' steps:' ' - run: |' ' echo "uses: actions/example@v7"' \ > "$fixture/.github/workflows/scalars.yml" @@ -146,14 +233,74 @@ printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' m ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" expect_fail "Action following a spaced block-scalar step name" "$fixture" -fixture="$(make_fixture 'space in repository path' "actions/checkout@$FULL_ACTION_SHA")" +fixture="$(make_fixture 'space in repository path' "$FULL_ACTION_REF")" expect_pass "repository path containing spaces" "$fixture" printf 'uses: actions/checkout@v7\n' > "$fixture/.github/workflows/"$'line\nbreak.yml' expect_fail "workflow filename containing a newline" "$fixture" +fixture="$(make_fixture local-action-yml './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml +expect_pass "local Action with action.yml" "$fixture" + +fixture="$(make_fixture local-action-yaml './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yaml +expect_pass "local Action with action.yaml" "$fixture" + +fixture="$(make_fixture duplicate-local-metadata './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml +write_local_action "$fixture" './.github/actions/local' action.yaml +expect_fail "local Action with duplicate metadata entrypoints" "$fixture" + +fixture="$(make_fixture missing-local-metadata './.github/actions/local')" +mkdir -p "$fixture/.github/actions/local" +expect_fail "local Action missing metadata" "$fixture" + +fixture="$(make_fixture symlinked-local-action './.github/actions/local')" +write_local_action "$fixture" './outside-action' action.yml 'owner/remote@v1' +mkdir -p "$fixture/.github/actions" +ln -s ../../outside-action "$fixture/.github/actions/local" +expect_fail "symlinked local Action outside scanned tree" "$fixture" + +fixture="$(make_fixture symlinked-local-metadata './.github/actions/local')" +write_local_action "$fixture" './outside-action' action.yml 'owner/remote@v1' +mkdir -p "$fixture/.github/actions/local" +ln -s ../../../outside-action/action.yml "$fixture/.github/actions/local/action.yml" +expect_fail "symlinked local Action metadata outside scanned tree" "$fixture" + +fixture="$(make_fixture nested-mutable-action './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml 'owner/remote@v1' +expect_fail "local Action with mutable nested remote Action" "$fixture" + +fixture="$(make_fixture nested-pinned-action './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml \ + "owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "local Action with pinned annotated nested Action" "$fixture" + +fixture="$(make_fixture local-reusable './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' +expect_pass "local reusable workflow" "$fixture" + +fixture="$(make_fixture local-reusable-mutable './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' 'owner/remote@main' +expect_fail "local reusable workflow with mutable nested Action" "$fixture" + +fixture="$(make_fixture local-reusable-pinned './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' \ + "owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "local reusable workflow with pinned annotated nested Action" "$fixture" + +fixture="$(make_fixture outside-local-action './custom-action')" +write_local_action "$fixture" './custom-action' action.yml 'owner/remote@v1' +expect_fail "local Action outside scanned roots" "$fixture" + +expect_fail "local Action path traversal" \ + "$(make_fixture local-action-traversal './.github/actions/../../custom-action')" +expect_fail "backslash local Action path" \ + "$(make_fixture local-action-backslash '.\.github\actions\local')" + extra_download='curl -fsSL -o /tmp/extra.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' for placement in before after same-line canonical hash-prefix unexpected; do - fixture="$(make_fixture extra-download "actions/checkout@$FULL_ACTION_SHA")" + fixture="$(make_fixture extra-download "$FULL_ACTION_REF")" file="$fixture/.github/workflows/ci-core.yml" case "$placement" in before) @@ -179,25 +326,38 @@ for placement in before after same-line canonical hash-prefix unexpected; do done expect_fail "missing TinyGo checksum" \ - "$(make_fixture missing-checksum "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" before-install no)" + "$(make_fixture missing-checksum "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install no)" expect_fail "wrong TinyGo checksum" \ - "$(make_fixture wrong-checksum "actions/checkout@$FULL_ACTION_SHA" "$(printf '0%.0s' {1..64})")" + "$(make_fixture wrong-checksum "$FULL_ACTION_REF" "$(printf '0%.0s' {1..64})")" expect_pass "verified TinyGo install contract" \ - "$(make_fixture verified-tinygo "actions/checkout@$FULL_ACTION_SHA")" + "$(make_fixture verified-tinygo "$FULL_ACTION_REF")" +expect_fail "runtime-variable TinyGo download URL" \ + "$(make_fixture variable-tinygo-url "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-url)" +expect_fail "runtime-variable TinyGo checksum input" \ + "$(make_fixture variable-tinygo-digest "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-digest)" + +fixture="$(make_fixture runtime-trust-root-override "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-both)" +add_runtime_override "$fixture/.github/workflows/ci-core.yml" +expect_fail "runtime-variable TinyGo trust roots after GITHUB_ENV override" "$fixture" + +fixture="$(make_fixture literal-trust-root-override "$FULL_ACTION_REF")" +add_runtime_override "$fixture/.github/workflows/ci-core.yml" +expect_pass "GITHUB_ENV override cannot alter literal TinyGo trust roots" "$fixture" + expect_fail "TinyGo install before verification" \ - "$(make_fixture install-first "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" after-install)" + "$(make_fixture install-first "$FULL_ACTION_REF" "$EXPECTED_SHA" after-install)" expect_fail "commented TinyGo verification" \ - "$(make_fixture commented-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" commented)" + "$(make_fixture commented-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" commented)" expect_fail "TinyGo verification with ignored failure" \ - "$(make_fixture ignored-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" ignored)" + "$(make_fixture ignored-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" ignored)" expect_fail "TinyGo verification with warning-only failure" \ - "$(make_fixture warned-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" warned)" + "$(make_fixture warned-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" warned)" expect_fail "TinyGo verification relying on implicit errexit" \ - "$(make_fixture implicit-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" implicit)" + "$(make_fixture implicit-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" implicit)" expect_fail "TinyGo verification behind false condition" \ - "$(make_fixture skipped-verification "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" skipped)" + "$(make_fixture skipped-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" skipped)" expect_fail "TinyGo artifact replaced after verification" \ - "$(make_fixture replaced-artifact "actions/checkout@$FULL_ACTION_SHA" "$EXPECTED_SHA" replaced)" + "$(make_fixture replaced-artifact "$FULL_ACTION_REF" "$EXPECTED_SHA" replaced)" synthetic_artifact="$TMP_ROOT/synthetic-tinygo.deb" printf 'synthetic TinyGo archive\n' > "$synthetic_artifact" @@ -236,14 +396,14 @@ printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" if command -v sha256sum >/dev/null 2>&1; then # Execute the fixture's actual verification line without inherited errexit. - fixture="$(make_fixture plain-bash "actions/checkout@$FULL_ACTION_SHA")" - verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" - verification="${verification/\/tmp\/tinygo.deb/\"\$synthetic_artifact\"}" - printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + fixture="$(make_fixture plain-bash "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification/\/tmp\/tinygo.deb/\"\$synthetic_artifact\"}" + printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" rm -f "$TMP_ROOT/continued" tests_run=$((tests_run + 1)) - if TINYGO_SHA256="$digest" synthetic_artifact="$synthetic_artifact" continuation="$TMP_ROOT/continued" \ + if synthetic_artifact="$synthetic_artifact" continuation="$TMP_ROOT/continued" \ "$BASH" --noprofile --norc "$TMP_ROOT/verify.sh" > "$TMP_ROOT/checksum.log" 2>&1; then if [[ "$digest" != "$synthetic_sha" || ! -f "$TMP_ROOT/continued" ]]; then printf 'not ok %d - checksum continuation without errexit\n' "$tests_run" >&2 diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 80a1282c..18874473 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -10,10 +10,6 @@ on: permissions: contents: read -env: - TINYGO_VERSION: 0.42.0 - TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 - jobs: browser-smoke: name: Browser smoke @@ -45,8 +41,8 @@ jobs: - name: Install TinyGo run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 5909b2fd..f1049786 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -128,10 +128,6 @@ jobs: go: - '1.26.6' - env: - TINYGO_VERSION: 0.42.0 - TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 - steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -144,8 +140,8 @@ jobs: - name: Install TinyGo run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index 724dcf75..d99322e3 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -10,10 +10,6 @@ on: permissions: contents: read -env: - TINYGO_VERSION: 0.42.0 - TINYGO_SHA256: 2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 - jobs: tinygo-size: name: TinyGo packages and size budgets @@ -36,8 +32,8 @@ jobs: - name: Install TinyGo run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" - printf '%s %s\n' "$TINYGO_SHA256" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 91b66d7c..69045fc2 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -39,6 +39,87 @@ trim_whitespace() { printf '%s' "$value" } +path_has_symlink_component() { + local current="$1" + local remainder="$2" + local component + + while [[ -n "$remainder" ]]; do + component="${remainder%%/*}" + current="$current/$component" + if [[ -L "$current" ]]; then + return 0 + fi + if [[ "$remainder" == */* ]]; then + remainder="${remainder#*/}" + else + remainder="" + fi + done + return 1 +} + +validate_local_uses_ref() { + local file="$1" + local line_number="$2" + local value="$3" + local kind tail padded target scan_root metadata_count=0 + + case "$value" in + ./.github/actions/*) + kind=action + scan_root="$ROOT_DIR/.github/actions" + tail="${value#./.github/actions/}" + ;; + ./.github/workflows/*) + kind=workflow + scan_root="$ROOT_DIR/.github/workflows" + tail="${value#./.github/workflows/}" + ;; + *) + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference is outside the scanned .github/actions and .github/workflows roots: $value" + return + ;; + esac + + padded="/$tail/" + if [[ -z "$tail" || "$tail" == *\\* || "$padded" == *'//'* || + "$padded" == *'/./'* || "$padded" == *'/../'* ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference is not a canonical in-root path: $value" + return + fi + if [[ -L "$scan_root" ]] || path_has_symlink_component "$scan_root" "$tail"; then + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference traverses a symbolic link outside the file scan: $value" + return + fi + + target="$ROOT_DIR/${value#./}" + if [[ "$kind" == action ]]; then + if [[ ! -d "$target" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local Action directory does not exist: $value" + return + fi + if [[ -L "$target/action.yml" || -L "$target/action.yaml" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local Action metadata must be a regular file inside the scanned tree: $value" + return + fi + if [[ -f "$target/action.yml" ]]; then + metadata_count=$((metadata_count + 1)) + fi + if [[ -f "$target/action.yaml" ]]; then + metadata_count=$((metadata_count + 1)) + fi + if (( metadata_count != 1 )); then + fail "${file#"$ROOT_DIR/"}:$line_number local Action must contain exactly one action.yml or action.yaml: $value" + fi + return + fi + + if [[ ! "$tail" =~ \.ya?ml$ || ! -f "$target" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local reusable workflow must name an existing .yml or .yaml file: $value" + fi +} + count_active_occurrences() { local needle="$1" local file="$2" @@ -60,7 +141,11 @@ count_active_occurrences() { scan_roots=() for candidate in "$ROOT_DIR/.github/workflows" "$ROOT_DIR/.github/actions"; do if [[ -d "$candidate" ]]; then - scan_roots+=("$candidate") + if [[ -L "$candidate" ]]; then + fail "authored GitHub workflow and Action scan root must not be a symbolic link: ${candidate#"$ROOT_DIR/"}" + else + scan_roots+=("$candidate") + fi fi done if (( ${#scan_roots[@]} == 0 )); then @@ -69,13 +154,28 @@ fi scan_action_refs() { local file="$1" - local line line_number=0 indent syntax key value sequence_prefix + local line line_number=0 indent syntax key value sequence_prefix content + local comment comment_is_yaml local scalar_indent=-1 local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' local scalar_re='^[|>][+-]?$' + local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' + local full_comment_re='^[[:space:]]*#(.*)$' while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) - syntax="$(trim_whitespace "${line%%#*}")" + content="$line" + comment="" + comment_is_yaml=false + if [[ "$line" =~ $inline_comment_re ]]; then + content="${BASH_REMATCH[1]}" + comment="${BASH_REMATCH[2]}" + comment_is_yaml=true + elif [[ "$line" =~ $full_comment_re ]]; then + content="" + comment="${BASH_REMATCH[1]}" + comment_is_yaml=true + fi + syntax="$(trim_whitespace "$content")" if [[ -z "$syntax" ]]; then continue fi @@ -124,6 +224,7 @@ scan_action_refs() { continue fi if [[ "$value" == ./* ]]; then + validate_local_uses_ref "$file" "$line_number" "$value" continue fi @@ -131,6 +232,10 @@ scan_action_refs() { if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" fi + comment="$(trim_whitespace "$comment")" + if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" + fi done < "$file" } @@ -162,8 +267,8 @@ if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" fi -tinygo_download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"' -tinygo_verify_command="printf '%s %s\\n' \"\$TINYGO_SHA256\" /tmp/tinygo.deb | sha256sum --check --strict - || exit 1" +tinygo_download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/tinygo-org/tinygo/releases/download/v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" +tinygo_verify_command="printf '%s %s\\n' '$EXPECTED_TINYGO_SHA256' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1" tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' tinygo_version_command='tinygo version' @@ -208,17 +313,6 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do continue fi - version_count="$(grep -Ec "^[[:space:]]*TINYGO_VERSION:[[:space:]]*${EXPECTED_TINYGO_VERSION//./\\.}([[:space:]]*(#.*)?)?$" "$file" || true)" - if [[ "$version_count" != "1" ]]; then - fail "$relative must declare TINYGO_VERSION exactly once as $EXPECTED_TINYGO_VERSION" - fi - - sha_declaration_count="$(grep -Ec '^[[:space:]]*TINYGO_SHA256:' "$file" || true)" - sha_match_count="$(grep -Ec "^[[:space:]]*TINYGO_SHA256:[[:space:]]*$EXPECTED_TINYGO_SHA256([[:space:]]*(#.*)?)?$" "$file" || true)" - if [[ "$sha_declaration_count" != "1" || "$sha_match_count" != "1" ]]; then - fail "$relative must declare exactly one accepted TinyGo SHA-256: $EXPECTED_TINYGO_SHA256" - fi - if [[ "$(count_active_occurrences "$tinygo_download_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo download command" fi From 5222df3cf3b097292e39fa3e2bed7333080e98d2 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 12 Sep 2026 18:17:21 +0300 Subject: [PATCH 05/21] ci(release): bound TinyGo download surface --- .../scripts/ci-supply-chain-check.tests.sh | 43 ++++++++++++ scripts/ci-supply-chain-check.sh | 67 ++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 7ac57286..1f53a2ad 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -159,6 +159,23 @@ add_runtime_override() { mv "$TMP_ROOT/runtime-override.yml" "$file" } +write_repository_file() { + local fixture="$1" + local path="$2" + local content="$3" + local target="$fixture/$path" + + mkdir -p "$(dirname "$target")" + printf '%s\n' "$content" > "$target" +} + +add_workflow_script_invocation() { + local file="$1" + local command="$2" + + printf '\n delegated-script:\n steps:\n - run: %s\n' "$command" >> "$file" +} + expect_pass() { local name="$1" local fixture="$2" @@ -325,6 +342,32 @@ for placement in before after same-line canonical hash-prefix unexpected; do expect_fail "additional TinyGo download: $placement" "$fixture" done +fixture="$(make_fixture delegated-tinygo-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.sh "$extra_download" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/install-tinygo.sh' +expect_fail "invoked repository helper with direct TinyGo download" "$fixture" + +fixture="$(make_fixture uninvoked-tinygo-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.sh "$extra_download" +expect_fail "uninvoked repository helper with direct TinyGo download" "$fixture" + +fixture="$(make_fixture non-shell-tinygo-source "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install.py "$extra_download" +expect_fail "non-shell repository source with direct TinyGo download" "$fixture" + +fixture="$(make_fixture benign-invoked-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/helper.sh 'printf "benign helper\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/helper.sh' +expect_pass "invoked benign repository helper" "$fixture" + +fixture="$(make_fixture allowlisted-policy-sources "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh "$extra_download" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + "$extra_download" +expect_pass "allowlisted checker and test policy literals" "$fixture" + expect_fail "missing TinyGo checksum" \ "$(make_fixture missing-checksum "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install no)" expect_fail "wrong TinyGo checksum" \ diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 69045fc2..8c70734d 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -3,6 +3,7 @@ set -euo pipefail EXPECTED_TINYGO_VERSION="0.42.0" EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +TINYGO_RELEASE_NAMESPACE="tinygo-org/tinygo/releases/download/" EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-browser-smoke.yml" ".github/workflows/ci-core.yml" @@ -138,6 +139,70 @@ count_active_occurrences() { printf '%d' "$count" } +tinygo_release_url_owner_allowed() { + local path="$1" + local candidate + + for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + if [[ "$path" == "$candidate" ]]; then + return 0 + fi + done + case "$path" in + scripts/ci-supply-chain-check.sh | .github/scripts/ci-supply-chain-check.tests.sh) + return 0 + ;; + esac + return 1 +} + +enumerate_repository_files() { + if [[ "$repository_uses_git_inventory" == true ]]; then + git -C "$ROOT_DIR" ls-files -z --cached --others --exclude-standard || return + else + find "$ROOT_DIR" -path "$ROOT_DIR/.git" -prune -o -type f -print0 || return + fi + # An empty record proves that the producer completed successfully. + printf '\0' +} + +repository_uses_git_inventory=false +if command -v git >/dev/null 2>&1 && + [[ "$(git -C "$ROOT_DIR" rev-parse --is-inside-work-tree 2>/dev/null || true)" == true ]]; then + repository_uses_git_inventory=true +fi + +repository_inventory_complete=false +while IFS= read -r -d '' repository_path; do + if [[ -z "$repository_path" ]]; then + repository_inventory_complete=true + continue + fi + if [[ "$repository_path" == "$ROOT_DIR/"* ]]; then + relative="${repository_path#"$ROOT_DIR/"}" + file="$repository_path" + else + relative="$repository_path" + file="$ROOT_DIR/$repository_path" + fi + if [[ ! -f "$file" ]]; then + continue + fi + if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$file"; then + if ! tinygo_release_url_owner_allowed "$relative"; then + fail "TinyGo release-download namespace is not owned by $relative" + fi + else + grep_status=$? + if (( grep_status > 1 )); then + fail "could not inspect repository file for TinyGo release downloads: $relative" + fi + fi +done < <(enumerate_repository_files) +if [[ "$repository_inventory_complete" != true ]]; then + fail "could not enumerate repository-owned files for TinyGo release downloads" +fi + scan_roots=() for candidate in "$ROOT_DIR/.github/workflows" "$ROOT_DIR/.github/actions"; do if [[ -d "$candidate" ]]; then @@ -244,7 +309,7 @@ if (( ${#scan_roots[@]} > 0 )); then # Traversal order affects diagnostics only; keep filenames NUL-delimited. while IFS= read -r -d '' file; do scan_action_refs "$file" - download_count="$(count_active_occurrences 'tinygo-org/tinygo/releases/download/' "$file")" + download_count="$(count_active_occurrences "$TINYGO_RELEASE_NAMESPACE" "$file")" direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) if (( download_count > 0 )); then relative="${file#"$ROOT_DIR/"}" From 8fbadcf836ae3b805898751a4a45f662a7c004ee Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 12 Sep 2026 18:54:17 +0300 Subject: [PATCH 06/21] ci(release): remove TinyGo scan exemptions --- .../scripts/ci-supply-chain-check.tests.sh | 35 ++++++++++++++++--- scripts/ci-supply-chain-check.sh | 10 ++---- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 1f53a2ad..6b69e625 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -7,6 +7,8 @@ TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +TINYGO_RELEASE_REPOSITORY="tinygo-org/tinygo" +TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" @@ -24,10 +26,10 @@ write_tinygo_workflow() { mkdir -p "$(dirname "$path")" case "$trust_root_mode" in literal|variable-digest) - download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' + download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" ;; variable-url|variable-both) - download_command='curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"' + download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" ;; *) printf 'unsupported trust-root mode: %s\n' "$trust_root_mode" >&2 @@ -315,7 +317,7 @@ expect_fail "local Action path traversal" \ expect_fail "backslash local Action path" \ "$(make_fixture local-action-backslash '.\.github\actions\local')" -extra_download='curl -fsSL -o /tmp/extra.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' +extra_download="curl -fsSL -o /tmp/extra.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" for placement in before after same-line canonical hash-prefix unexpected; do fixture="$(make_fixture extra-download "$FULL_ACTION_REF")" file="$fixture/.github/workflows/ci-core.yml" @@ -362,11 +364,34 @@ add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ 'scripts/helper.sh' expect_pass "invoked benign repository helper" "$fixture" -fixture="$(make_fixture allowlisted-policy-sources "$FULL_ACTION_REF")" +fixture="$(make_fixture executable-checker-direct-download "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/ci-supply-chain-check.sh "$extra_download" +expect_fail "executable checker script with direct TinyGo download" "$fixture" + +fixture="$(make_fixture executable-test-harness-direct-download "$FULL_ACTION_REF")" write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ "$extra_download" -expect_pass "allowlisted checker and test policy literals" "$fixture" +expect_fail "executable test harness with direct TinyGo download" "$fixture" + +if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$CHECKER"; then + printf 'not ok - checker source contains raw TinyGo release namespace\n' >&2 + exit 1 +fi +printf 'ok - checker source contains no raw TinyGo release namespace\n' + +if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$BASH_SOURCE"; then + printf 'not ok - test harness source contains raw TinyGo release namespace\n' >&2 + exit 1 +fi +printf 'ok - test harness source contains no raw TinyGo release namespace\n' + +runtime_namespace="$TINYGO_RELEASE_NAMESPACE" +expected_runtime_namespace="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +if [[ "$runtime_namespace" != "$expected_runtime_namespace" ]]; then + printf 'not ok - runtime TinyGo release namespace construction\n' >&2 + exit 1 +fi +printf 'ok - runtime TinyGo release namespace construction\n' expect_fail "missing TinyGo checksum" \ "$(make_fixture missing-checksum "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install no)" diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 8c70734d..df00257d 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -3,7 +3,8 @@ set -euo pipefail EXPECTED_TINYGO_VERSION="0.42.0" EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" -TINYGO_RELEASE_NAMESPACE="tinygo-org/tinygo/releases/download/" +TINYGO_RELEASE_REPOSITORY="tinygo-org/tinygo" +TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-browser-smoke.yml" ".github/workflows/ci-core.yml" @@ -148,11 +149,6 @@ tinygo_release_url_owner_allowed() { return 0 fi done - case "$path" in - scripts/ci-supply-chain-check.sh | .github/scripts/ci-supply-chain-check.tests.sh) - return 0 - ;; - esac return 1 } @@ -332,7 +328,7 @@ if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" fi -tinygo_download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/tinygo-org/tinygo/releases/download/v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" +tinygo_download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" tinygo_verify_command="printf '%s %s\\n' '$EXPECTED_TINYGO_SHA256' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1" tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' tinygo_version_command='tinygo version' From 49db574d41dc8f2efd62a2765775aa2e9e13e183 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 13 Sep 2026 12:19:34 +0300 Subject: [PATCH 07/21] ci(release): harden checksum execution --- .../scripts/ci-supply-chain-check.tests.sh | 219 ++++++++++++++++-- .github/workflows/ci-browser-smoke.yml | 2 +- .github/workflows/ci-core.yml | 2 +- .github/workflows/ci-wasm-size.yml | 2 +- scripts/ci-supply-chain-check.sh | 4 +- 5 files changed, 210 insertions(+), 19 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 6b69e625..4b047957 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -21,7 +21,8 @@ write_tinygo_workflow() { local verification_order="$4" local include_checksum="$5" local trust_root_mode="${6:-literal}" - local download_command verify_input + local verifier_mode="${7:-trusted}" + local download_command verify_command verify_digest verify_input mkdir -p "$(dirname "$path")" case "$trust_root_mode" in @@ -37,8 +38,26 @@ write_tinygo_workflow() { ;; esac case "$trust_root_mode" in - literal|variable-url) verify_input="'$checksum'" ;; - variable-digest|variable-both) verify_input='"$TINYGO_SHA256"' ;; + literal|variable-url) + verify_digest="'$checksum'" + verify_input="'$checksum /tmp/tinygo.deb'" + ;; + variable-digest|variable-both) + verify_digest='"$TINYGO_SHA256"' + verify_input='"$TINYGO_SHA256 /tmp/tinygo.deb"' + ;; + esac + case "$verifier_mode" in + unqualified) + verify_command="printf '%s %s\\n' $verify_digest /tmp/tinygo.deb | sha256sum --check --strict -" + ;; + trusted) + verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< $verify_input" + ;; + *) + printf 'unsupported verifier mode: %s\n' "$verifier_mode" >&2 + exit 1 + ;; esac { printf 'name: fixture\n' @@ -61,17 +80,17 @@ write_tinygo_workflow() { fi if [[ "$include_checksum" == "yes" ]]; then if [[ "$verification_order" == "commented" ]]; then - printf " # printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + printf ' # %s || exit 1\n' "$verify_command" elif [[ "$verification_order" == "ignored" ]]; then - printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || true\n" "$verify_input" + printf ' %s || true\n' "$verify_command" elif [[ "$verification_order" == "warned" ]]; then - printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || echo warning\n" "$verify_input" + printf ' %s || echo warning\n' "$verify_command" elif [[ "$verification_order" == "skipped" ]]; then - printf " false && printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + printf ' false && %s || exit 1\n' "$verify_command" elif [[ "$verification_order" == "implicit" ]]; then - printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict -\n" "$verify_input" + printf ' %s\n' "$verify_command" else - printf " printf '%%s %%s\\\\n' %s /tmp/tinygo.deb | sha256sum --check --strict - || exit 1\n" "$verify_input" + printf ' %s || exit 1\n' "$verify_command" fi fi if [[ "$verification_order" == "replaced" ]]; then @@ -91,14 +110,15 @@ make_fixture() { local verification_order="${4:-before-install}" local include_checksum="${5:-yes}" local trust_root_mode="${6:-literal}" + local verifier_mode="${7:-trusted}" local fixture="$TMP_ROOT/$name" write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ - "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" + "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" printf '%s' "$fixture" } @@ -161,6 +181,50 @@ add_runtime_override() { mv "$TMP_ROOT/runtime-override.yml" "$file" } +add_path_poisoning() { + local file="$1" + awk ' + / - run: \|/ && !inserted { + print " - run: |" + print " mkdir -p \"$RUNNER_TEMP/fake-bin\"" + print " cp /bin/true \"$RUNNER_TEMP/fake-bin/sha256sum\"" + print " echo \"$RUNNER_TEMP/fake-bin\" >> \"$GITHUB_PATH\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/path-poison.yml" + mv "$TMP_ROOT/path-poison.yml" "$file" +} + +add_bash_env_shadowing() { + local file="$1" + awk ' + / - run: \|/ && !inserted { + print " - run: |" + print " printf '\''%s\\n'\'' '\''sha256sum() { return 0; }'\'' '\''printf() { return 0; }'\'' > \"$RUNNER_TEMP/bash-env\"" + print " echo \"BASH_ENV=$RUNNER_TEMP/bash-env\" >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/bash-env-shadow.yml" + mv "$TMP_ROOT/bash-env-shadow.yml" "$file" +} + +write_block_scalar_workflow() { + local path="$1" + local scalar="$2" + + mkdir -p "$(dirname "$path")" + { + printf 'jobs:\n' + printf ' scalar:\n' + printf ' steps:\n' + printf ' - run: %s\n' "$scalar" + printf ' echo "https://example.invalid"\n' + printf ' echo "uses: actions/example@v1"\n' + } > "$path" +} + write_repository_file() { local fixture="$1" local path="$2" @@ -219,6 +283,21 @@ expect_fail "remote Action SHA with major-only annotation" \ expect_fail "remote Action SHA with two-component annotation" \ "$(make_fixture minor-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1.2")" +expect_fail "old unqualified TinyGo checksum verifier" \ + "$(make_fixture old-unqualified-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal unqualified)" + +fixture="$(make_fixture path-poisoned-unqualified-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal unqualified)" +add_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_fail "PATH-poisoned workflow with unqualified checksum verifier" "$fixture" + +fixture="$(make_fixture path-poisoned-trusted-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted)" +add_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_pass "PATH-poisoned workflow with trusted checksum verifier" "$fixture" + +fixture="$(make_fixture bash-env-trusted-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted)" +add_bash_env_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_pass "BASH_ENV-shadowed workflow with trusted checksum verifier" "$fixture" + for action_form in \ '"uses": actions/checkout@v7' \ "'uses': actions/checkout@v7" \ @@ -244,10 +323,30 @@ printf '%s\n' 'env:' ' VERSION: value' 'jobs:' ' quoted:' ' strategy:' ' > "$fixture/.github/workflows/scalars.yml" expect_pass "quoted scalar list and literal script content" "$fixture" +for scalar in '|' '|-' '|+' '>' '>-' '>+' '|2' '|2-' '|-2' '>2+' '>+2'; do + fixture="$(make_fixture "block-scalar-$tests_run" "$FULL_ACTION_REF")" + write_block_scalar_workflow "$fixture/.github/workflows/scalars.yml" "$scalar" + expect_pass "block scalar $scalar with mapping-like body content" "$fixture" +done + +for scalar in '|0' '|22' '|+-' '>-+'; do + fixture="$(make_fixture "invalid-block-scalar-$tests_run" "$FULL_ACTION_REF")" + write_block_scalar_workflow "$fixture/.github/workflows/scalars.yml" "$scalar" + expect_fail "invalid block scalar indicator $scalar" "$fixture" +done + printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" expect_fail "Action following a block-scalar step name" "$fixture" +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |2' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following an explicit-indent block-scalar step name" "$fixture" + +printf '%s\n' 'jobs:' ' scalar:' ' steps:' ' - run: >+2' ' scalar body' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a folded explicit-indent block scalar" "$fixture" + printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" expect_fail "Action following a spaced block-scalar step name" "$fixture" @@ -465,9 +564,14 @@ printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" if command -v sha256sum >/dev/null 2>&1; then # Execute the fixture's actual verification line without inherited errexit. for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum continues without inherited errexit" + else + case_name="wrong checksum stops without inherited errexit" + fi fixture="$(make_fixture plain-bash "$FULL_ACTION_REF" "$digest")" verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" - verification="${verification/\/tmp\/tinygo.deb/\"\$synthetic_artifact\"}" + verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" rm -f "$TMP_ROOT/continued" tests_run=$((tests_run + 1)) @@ -483,7 +587,94 @@ if command -v sha256sum >/dev/null 2>&1; then exit 1 fi fi - printf 'ok %d - explicit checksum status and continuation without errexit\n' "$tests_run" + printf 'ok %d - %s\n' "$tests_run" "$case_name" + done + + fake_bin="$TMP_ROOT/fake-bin" + fake_marker="$TMP_ROOT/fake-sha256sum-ran" + mkdir -p "$fake_bin" + { + printf '#!/usr/bin/env bash\n' + printf ': > "$FAKE_SHA256SUM_MARKER"\n' + printf 'exit 0\n' + } > "$fake_bin/sha256sum" + chmod +x "$fake_bin/sha256sum" + + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum passes under poisoned PATH" + else + case_name="wrong checksum fails under poisoned PATH" + fi + fixture="$(make_fixture poisoned-path-execution "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" + printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/path-verify.sh" + rm -f "$fake_marker" + tests_run=$((tests_run + 1)) + if FAKE_SHA256SUM_MARKER="$fake_marker" PATH="$fake_bin:$PATH" \ + "$BASH" --noprofile --norc "$TMP_ROOT/path-verify.sh" > "$TMP_ROOT/path-checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" ]]; then + printf 'not ok %d - wrong checksum passed under poisoned PATH\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" ]]; then + printf 'not ok %d - correct checksum failed under poisoned PATH\n' "$tests_run" >&2 + exit 1 + fi + fi + if [[ -e "$fake_marker" ]]; then + printf 'not ok %d - fake PATH checksum verifier executed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$case_name" + done + + checksum_function_marker="$TMP_ROOT/checksum-function-ran" + printf_function_marker="$TMP_ROOT/printf-function-ran" + { + printf 'sha256sum() {\n' + printf ' : > "$FAKE_SHA256SUM_FUNCTION_MARKER"\n' + printf ' return 0\n' + printf '}\n' + printf 'printf() {\n' + printf ' : > "$FAKE_PRINTF_FUNCTION_MARKER"\n' + printf ' builtin printf "attacker\\n"\n' + printf '}\n' + } > "$TMP_ROOT/bash-env" + + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum passes with BASH_ENV function overrides" + else + case_name="wrong checksum fails with BASH_ENV function overrides" + fi + fixture="$(make_fixture bash-env-execution "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" + printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/bash-env-verify.sh" + rm -f "$checksum_function_marker" "$printf_function_marker" + tests_run=$((tests_run + 1)) + if BASH_ENV="$TMP_ROOT/bash-env" \ + FAKE_SHA256SUM_FUNCTION_MARKER="$checksum_function_marker" \ + FAKE_PRINTF_FUNCTION_MARKER="$printf_function_marker" \ + "$BASH" --noprofile --norc "$TMP_ROOT/bash-env-verify.sh" > "$TMP_ROOT/bash-env-checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" ]]; then + printf 'not ok %d - wrong checksum passed with BASH_ENV function overrides\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" ]]; then + printf 'not ok %d - correct checksum failed with BASH_ENV function overrides\n' "$tests_run" >&2 + exit 1 + fi + fi + if [[ -e "$checksum_function_marker" || -e "$printf_function_marker" ]]; then + printf 'not ok %d - BASH_ENV checksum or printf override executed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$case_name" done else printf 'skip - GNU workflow checksum execution; portable SHA-256 controls passed\n' diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 18874473..b2ec6841 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -42,7 +42,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index f1049786..a26b4681 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -141,7 +141,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index d99322e3..469b82a1 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -33,7 +33,7 @@ jobs: - name: Install TinyGo run: | curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - printf '%s %s\n' '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1 + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 sudo apt-get install -y /tmp/tinygo.deb tinygo version diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index df00257d..4a8d0c30 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -219,7 +219,7 @@ scan_action_refs() { local comment comment_is_yaml local scalar_indent=-1 local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' - local scalar_re='^[|>][+-]?$' + local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?|)$' local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' local full_comment_re='^[[:space:]]*#(.*)$' while IFS= read -r line || [[ -n "$line" ]]; do @@ -329,7 +329,7 @@ if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then fi tinygo_download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" -tinygo_verify_command="printf '%s %s\\n' '$EXPECTED_TINYGO_SHA256' /tmp/tinygo.deb | sha256sum --check --strict - || exit 1" +tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 /tmp/tinygo.deb' || exit 1" tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' tinygo_version_command='tinygo version' From b1589879ca33964f1c9bc31002b6bb5a26833665 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 13 Sep 2026 16:59:50 +0300 Subject: [PATCH 08/21] ci(release): restore Bash 3.2 scalar parsing --- scripts/ci-supply-chain-check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 4a8d0c30..d46a764d 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -219,7 +219,7 @@ scan_action_refs() { local comment comment_is_yaml local scalar_indent=-1 local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' - local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?|)$' + local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' local full_comment_re='^[[:space:]]*#(.*)$' while IFS= read -r line || [[ -n "$line" ]]; do From 35c1d70858022e91399681e60db3ed205564fd75 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 13 Sep 2026 17:35:54 +0300 Subject: [PATCH 09/21] ci(release): fix checksum oracle portability --- .github/scripts/ci-supply-chain-check.tests.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 4b047957..ef2125a0 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -561,7 +561,7 @@ if verify_synthetic_checksum "$(printf '%064d' 0)" "$synthetic_artifact" >/dev/n fi printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" -if command -v sha256sum >/dev/null 2>&1; then +if [[ -x /usr/bin/env && -x /usr/bin/sha256sum ]]; then # Execute the fixture's actual verification line without inherited errexit. for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do if [[ "$digest" == "$synthetic_sha" ]]; then @@ -677,7 +677,7 @@ if command -v sha256sum >/dev/null 2>&1; then printf 'ok %d - %s\n' "$tests_run" "$case_name" done else - printf 'skip - GNU workflow checksum execution; portable SHA-256 controls passed\n' + printf 'skip - exact production verifier (/usr/bin/env -i /usr/bin/sha256sum) unavailable; portable SHA-256 controls passed\n' fi printf 'ci supply-chain check tests: ok (%d cases)\n' "$tests_run" From cfad7ee572f921d50f5c159a2ff6ac6a25da1a0c Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 13 Sep 2026 22:16:15 +0300 Subject: [PATCH 10/21] ci(release): bind TinyGo install boundary --- .../scripts/ci-supply-chain-check.tests.sh | 342 ++++++++++++++++-- .github/workflows/ci-browser-smoke.yml | 9 +- .github/workflows/ci-core.yml | 9 +- .github/workflows/ci-wasm-size.yml | 9 +- scripts/ci-supply-chain-check.sh | 245 ++++++++++++- 5 files changed, 566 insertions(+), 48 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index ef2125a0..9d90fb36 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -9,6 +9,12 @@ trap 'rm -rf "$TMP_ROOT"' EXIT EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" TINYGO_RELEASE_REPOSITORY="tinygo-org/tinygo" TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +VERIFIED_DEB="/tmp/goframe-tinygo-0.42.0-verified.deb" +CLEAN_PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' +CLEAN_SHELL="/usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail {0}" +CLEANUP_COMMAND="trap '/usr/bin/sudo /usr/bin/rm -f $VERIFIED_DEB' EXIT" +STAGE_COMMAND="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $VERIFIED_DEB" +CURL_COMMAND_NAME='cur''l' FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" @@ -22,34 +28,70 @@ write_tinygo_workflow() { local include_checksum="$5" local trust_root_mode="${6:-literal}" local verifier_mode="${7:-trusted}" - local download_command verify_command verify_digest verify_input + local boundary_mode="${8:-protected}" + local download_command verify_command verify_digest verify_input verify_target + local shell_template="$CLEAN_SHELL" + local cleanup_command="$CLEANUP_COMMAND" + local stage_command="$STAGE_COMMAND" + local install_target="$VERIFIED_DEB" mkdir -p "$(dirname "$path")" case "$trust_root_mode" in literal|variable-digest) - download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" + download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" ;; variable-url|variable-both) - download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" + download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" ;; *) printf 'unsupported trust-root mode: %s\n' "$trust_root_mode" >&2 exit 1 ;; esac + case "$boundary_mode" in + protected) ;; + legacy) + shell_template="" + cleanup_command="" + stage_command="" + install_target=/tmp/tinygo.deb + ;; + no-staging) + stage_command="" + install_target=/tmp/tinygo.deb + ;; + checksum-original) ;; + install-original) + install_target=/tmp/tinygo.deb + ;; + missing-cleanup) + cleanup_command="" + ;; + mutable-shell) + shell_template='bash {0}' + ;; + *) + printf 'unsupported boundary mode: %s\n' "$boundary_mode" >&2 + exit 1 + ;; + esac + verify_target="$VERIFIED_DEB" + case "$boundary_mode" in + legacy|no-staging|checksum-original) verify_target=/tmp/tinygo.deb ;; + esac case "$trust_root_mode" in literal|variable-url) verify_digest="'$checksum'" - verify_input="'$checksum /tmp/tinygo.deb'" + verify_input="'$checksum $verify_target'" ;; variable-digest|variable-both) verify_digest='"$TINYGO_SHA256"' - verify_input='"$TINYGO_SHA256 /tmp/tinygo.deb"' + verify_input="\"\$TINYGO_SHA256 $verify_target\"" ;; esac case "$verifier_mode" in unqualified) - verify_command="printf '%s %s\\n' $verify_digest /tmp/tinygo.deb | sha256sum --check --strict -" + verify_command="printf '%s %s\\n' $verify_digest $verify_target | sha256sum --check --strict -" ;; trusted) verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< $verify_input" @@ -73,10 +115,22 @@ write_tinygo_workflow() { printf ' - uses: %s\n' "$action_ref" fi printf ' # uses: actions/commented-out@v1\n' - printf ' - run: |\n' + if [[ -n "$shell_template" ]]; then + printf ' - name: Install TinyGo\n' + printf ' shell: %s\n' "$shell_template" + printf ' run: |\n' + else + printf ' - run: |\n' + fi + if [[ -n "$cleanup_command" ]]; then + printf ' %s\n' "$cleanup_command" + fi printf ' %s\n' "$download_command" + if [[ -n "$stage_command" ]]; then + printf ' %s\n' "$stage_command" + fi if [[ "$verification_order" == "after-install" ]]; then - printf ' sudo apt-get install -y /tmp/tinygo.deb\n' + printf ' /usr/bin/sudo /usr/bin/apt-get install -y %s\n' "$install_target" fi if [[ "$include_checksum" == "yes" ]]; then if [[ "$verification_order" == "commented" ]]; then @@ -94,10 +148,10 @@ write_tinygo_workflow() { fi fi if [[ "$verification_order" == "replaced" ]]; then - printf ' cp /tmp/replacement.deb /tmp/tinygo.deb\n' + printf ' /usr/bin/cp /tmp/replacement.deb %s\n' "$install_target" fi if [[ "$verification_order" != "after-install" ]]; then - printf ' sudo apt-get install -y /tmp/tinygo.deb\n' + printf ' /usr/bin/sudo /usr/bin/apt-get install -y %s\n' "$install_target" fi printf ' tinygo version\n' } > "$path" @@ -111,14 +165,15 @@ make_fixture() { local include_checksum="${5:-yes}" local trust_root_mode="${6:-literal}" local verifier_mode="${7:-trusted}" + local boundary_mode="${8:-protected}" local fixture="$TMP_ROOT/$name" write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ - "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" + "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" printf '%s' "$fixture" } @@ -170,7 +225,7 @@ write_local_workflow() { add_runtime_override() { local file="$1" awk ' - / - run: \|/ && !inserted { + / - name: Install TinyGo/ && !inserted { print " - run: |" print " echo '\''TINYGO_VERSION=attacker-value'\'' >> \"$GITHUB_ENV\"" print " echo '\''TINYGO_SHA256=attacker-digest'\'' >> \"$GITHUB_ENV\"" @@ -184,7 +239,7 @@ add_runtime_override() { add_path_poisoning() { local file="$1" awk ' - / - run: \|/ && !inserted { + / - name: Install TinyGo/ && !inserted { print " - run: |" print " mkdir -p \"$RUNNER_TEMP/fake-bin\"" print " cp /bin/true \"$RUNNER_TEMP/fake-bin/sha256sum\"" @@ -199,7 +254,7 @@ add_path_poisoning() { add_bash_env_shadowing() { local file="$1" awk ' - / - run: \|/ && !inserted { + / - name: Install TinyGo/ && !inserted { print " - run: |" print " printf '\''%s\\n'\'' '\''sha256sum() { return 0; }'\'' '\''printf() { return 0; }'\'' > \"$RUNNER_TEMP/bash-env\"" print " echo \"BASH_ENV=$RUNNER_TEMP/bash-env\" >> \"$GITHUB_ENV\"" @@ -210,6 +265,20 @@ add_bash_env_shadowing() { mv "$TMP_ROOT/bash-env-shadow.yml" "$file" } +add_bash_env_sudo_shadowing() { + local file="$1" + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " printf '\''%s\\n'\'' '\''sudo() { cp /tmp/replacement.deb /tmp/tinygo.deb; /usr/bin/sudo \"$@\"; }'\'' > \"$RUNNER_TEMP/bash-env\"" + print " echo \"BASH_ENV=$RUNNER_TEMP/bash-env\" >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/bash-env-sudo-shadow.yml" + mv "$TMP_ROOT/bash-env-sudo-shadow.yml" "$file" +} + write_block_scalar_workflow() { local path="$1" local scalar="$2" @@ -235,6 +304,19 @@ write_repository_file() { printf '%s\n' "$content" > "$target" } +write_shell_source() { + local fixture="$1" + local path="$2" + shift 2 + local target="$fixture/$path" + + mkdir -p "$(dirname "$target")" + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$@" + } > "$target" +} + add_workflow_script_invocation() { local file="$1" local command="$2" @@ -264,6 +346,111 @@ expect_fail() { printf 'ok %d - %s\n' "$tests_run" "$name" } +test_clean_shell_boundary() { + local marker_dir="$TMP_ROOT/clean-shell-markers" + local fake_bin="$TMP_ROOT/clean-shell-fake-bin" + local bash_env="$TMP_ROOT/clean-shell-bash-env" + local child_script="$TMP_ROOT/clean-shell-child.sh" + local driver_script="$TMP_ROOT/clean-shell-driver.sh" + local failing_script="$TMP_ROOT/clean-shell-failing.sh" + local command_name + + mkdir -p "$marker_dir" "$fake_bin" + printf '%s\n' \ + 'mark_override() { : > "$P1_MARKER_DIR/$1"; }' \ + 'sudo() { mark_override sudo; return 99; }' \ + 'curl() { mark_override curl; return 99; }' \ + 'sha256sum() { mark_override sha256sum; return 99; }' \ + 'printf() { mark_override printf; return 99; }' \ + 'function /usr/bin/env() { mark_override env; return 99; }' \ + > "$bash_env" + for command_name in sudo curl sha256sum printf; do + printf '%s\n' '#!/bin/sh' ": > \"$marker_dir/path-$command_name\"" 'exit 99' \ + > "$fake_bin/$command_name" + chmod +x "$fake_bin/$command_name" + done + printf '%s\n' \ + '[[ -z "${BASH_ENV+x}" ]]' \ + "[[ \"\$PATH\" == '$CLEAN_PATH' ]]" \ + 'for function_name in sudo curl sha256sum printf /usr/bin/env; do' \ + ' ! declare -F "$function_name" >/dev/null' \ + 'done' \ + > "$child_script" + printf '%s\n' \ + "command /usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail \"$child_script\"" \ + > "$driver_script" + + tests_run=$((tests_run + 1)) + if ! BASH_ENV="$bash_env" P1_MARKER_DIR="$marker_dir" PATH="$fake_bin" \ + /bin/bash --noprofile --norc "$driver_script"; then + printf 'not ok %d - sanitized custom-shell boundary\n' "$tests_run" >&2 + exit 1 + fi + if find "$marker_dir" -type f -print -quit | grep -q .; then + printf 'not ok %d - parent BASH_ENV or PATH override entered clean shell\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - sanitized custom-shell boundary excludes parent BASH_ENV and PATH\n' "$tests_run" + + printf 'false\n' > "$failing_script" + printf '%s\n' \ + "command /usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail \"$failing_script\"" \ + > "$driver_script" + tests_run=$((tests_run + 1)) + if BASH_ENV="$bash_env" P1_MARKER_DIR="$marker_dir" PATH="$fake_bin" \ + /bin/bash --noprofile --norc "$driver_script"; then + printf 'not ok %d - sanitized custom-shell failure unexpectedly passed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - sanitized custom-shell failure propagates\n' "$tests_run" +} + +test_ambient_sudo_interposition() { + local artifact="$TMP_ROOT/ambient-tinygo.deb" + local replacement="$TMP_ROOT/ambient-replacement.deb" + local consumed="$TMP_ROOT/ambient-consumed.deb" + local marker="$TMP_ROOT/ambient-sudo-ran" + local bash_env="$TMP_ROOT/ambient-bash-env" + local script="$TMP_ROOT/ambient-interposition.sh" + local digest verify_command + + printf 'verified artifact\n' > "$artifact" + printf 'replacement artifact\n' > "$replacement" + if [[ -x /usr/bin/sha256sum ]]; then + digest="$(/usr/bin/sha256sum "$artifact" | awk '{print $1}')" + verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$digest $artifact' || exit 1" + elif [[ -x /usr/bin/shasum ]]; then + digest="$(/usr/bin/shasum -a 256 "$artifact" | awk '{print $1}')" + verify_command="/usr/bin/printf '%s %s\\n' '$digest' '$artifact' | /usr/bin/shasum -a 256 --check - || exit 1" + else + printf 'skip - ambient sudo interposition oracle requires sha256sum or shasum\n' + return + fi + printf '%s\n' \ + 'sudo() {' \ + ' : > "$P1_MARKER"' \ + ' /usr/bin/cp "$P1_REPLACEMENT" "$P1_ARTIFACT"' \ + ' "$@"' \ + '}' \ + > "$bash_env" + printf '%s\n' "$verify_command" \ + 'sudo /usr/bin/cp "$P1_ARTIFACT" "$P1_CONSUMED"' \ + > "$script" + + tests_run=$((tests_run + 1)) + if ! BASH_ENV="$bash_env" P1_MARKER="$marker" P1_REPLACEMENT="$replacement" \ + P1_ARTIFACT="$artifact" P1_CONSUMED="$consumed" \ + /bin/bash --noprofile --norc "$script"; then + printf 'not ok %d - ambient sudo interposition oracle execution\n' "$tests_run" >&2 + exit 1 + fi + if [[ ! -f "$marker" ]] || ! /usr/bin/cmp -s "$replacement" "$consumed"; then + printf 'not ok %d - ambient sudo did not replace verified bytes\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - ambient sudo can replace bytes after verification\n' "$tests_run" +} + expect_pass "full remote Action SHA with version annotation" \ "$(make_fixture full-sha "$FULL_ACTION_REF")" expect_fail "mutable major Action tag" \ @@ -298,6 +485,119 @@ fixture="$(make_fixture bash-env-trusted-verifier "$FULL_ACTION_REF" "$EXPECTED_ add_bash_env_shadowing "$fixture/.github/workflows/ci-core.yml" expect_pass "BASH_ENV-shadowed workflow with trusted checksum verifier" "$fixture" +dynamic_base="https://github.com/${TINYGO_RELEASE_REPOSITORY}" +fixture="$(make_fixture dynamic-workflow-download "$FULL_ACTION_REF")" +printf '%s\n' '' ' dynamic-download:' ' steps:' ' - run: |' \ + " base=$dynamic_base" \ + ' curl -fsSL -o /tmp/extra.deb "$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "runtime-composed TinyGo download in workflow" "$fixture" + +fixture="$(make_fixture dynamic-helper-download "$FULL_ACTION_REF")" +dynamic_helper="$(printf '%s\n' "base=$dynamic_base" \ + 'curl -fsSL -o /tmp/extra.deb "$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"')" +write_repository_file "$fixture" scripts/install-tinygo.sh "$dynamic_helper" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/install-tinygo.sh' +expect_fail "runtime-composed TinyGo download in invoked helper" "$fixture" + +fixture="$(make_fixture continued-dynamic-download "$FULL_ACTION_REF")" +printf '%s\n' '' ' continued-download:' ' steps:' ' - run: |' \ + " base=$dynamic_base" \ + ' url="$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' \ + ' curl \' ' "$url"' \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "continued runtime-composed TinyGo download" "$fixture" + +fixture="$(make_fixture dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' 'curl "$url"' +expect_fail 'curl with runtime URL' "$fixture" + +fixture="$(make_fixture absolute-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' '/usr/bin/curl "$url"' +expect_fail 'absolute curl with runtime URL' "$fixture" + +fixture="$(make_fixture command-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' 'command curl "$url"' +expect_fail 'command curl with runtime URL' "$fixture" + +fixture="$(make_fixture conditional-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' 'if curl "$url"; then printf success; fi' +expect_fail 'conditional curl with runtime URL' "$fixture" + +fixture="$(make_fixture substitution-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "result=\"\$($CURL_COMMAND_NAME \"\$url\")\"" +expect_fail 'command-substitution curl with runtime URL' "$fixture" + +fixture="$(make_fixture literal-external-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + 'curl https://example.com/file' +expect_fail 'literal external curl' "$fixture" + +fixture="$(make_fixture external-wget "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + 'wget https://example.com/file' +expect_fail 'literal external wget' "$fixture" + +fixture="$(make_fixture simple-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + 'curl -fsS "http://127.0.0.1:$port/" >/dev/null 2>&1' +expect_pass 'literal-rooted loopback port probe' "$fixture" + +fixture="$(make_fixture path-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + 'curl -fsS "http://127.0.0.1:$port/$wasm_path" >/dev/null 2>&1' +expect_pass 'literal-rooted loopback path probe' "$fixture" + +fixture="$(make_fixture substitution-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + "headers=\"\$($CURL_COMMAND_NAME -fsSI \"http://127.0.0.1:\$port/\$wasm_path?smoke=\$(date +%s%N)\" || true)\"" +expect_pass 'literal-rooted loopback command-substitution probe' "$fixture" + +fixture="$(make_fixture nested-external-loopback-download "$FULL_ACTION_REF")" +nested_loopback_command="$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\$($CURL_COMMAND_NAME https://example.com/file)\" >/dev/null 2>&1" +write_shell_source "$fixture" scripts/loopback-probe.sh "$nested_loopback_command" +expect_fail 'external downloader nested in loopback probe' "$fixture" + +fixture="$(make_fixture benign-curl-string "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + 'message='"'"'curl "$url" is not executed'"'"'' 'printf "%s\\n" "$message"' +expect_pass 'benign string containing curl' "$fixture" + +fixture="$(make_fixture benign-url-variable "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + 'url=https://example.com/file' 'printf "%s\\n" "$url"' +expect_pass 'benign URL assignment without downloader' "$fixture" + +expect_fail "old TinyGo sequence without sanitized shell" \ + "$(make_fixture old-install-boundary "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted legacy)" +expect_fail "sanitized shell without protected staging" \ + "$(make_fixture no-protected-staging "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted no-staging)" +expect_fail "protected staging with checksum of original candidate" \ + "$(make_fixture checksum-original-candidate "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted checksum-original)" +expect_fail "protected checksum with install of original candidate" \ + "$(make_fixture install-original-candidate "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted install-original)" +expect_fail "protected boundary without cleanup" \ + "$(make_fixture missing-protected-cleanup "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted missing-cleanup)" +expect_fail "mutable TinyGo custom shell" \ + "$(make_fixture mutable-install-shell "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted mutable-shell)" + +fixture="$(make_fixture bash-env-sudo-interposition-old "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted legacy)" +add_bash_env_sudo_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_fail "BASH_ENV sudo override with old install boundary" "$fixture" + +fixture="$(make_fixture bash-env-sudo-interposition-protected "$FULL_ACTION_REF")" +add_bash_env_sudo_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_pass "BASH_ENV sudo override cannot enter protected install boundary" "$fixture" + +test_ambient_sudo_interposition +test_clean_shell_boundary + for action_form in \ '"uses": actions/checkout@v7' \ "'uses': actions/checkout@v7" \ @@ -423,7 +723,7 @@ for placement in before after same-line canonical hash-prefix unexpected; do case "$placement" in before) awk -v command="$extra_download" ' - / - run: \|/ { print " - run: " command } + / - name: Install TinyGo/ { print " - run: " command } { print } ' "$file" > "$TMP_ROOT/extra.yml" mv "$TMP_ROOT/extra.yml" "$file" @@ -431,7 +731,7 @@ for placement in before after same-line canonical hash-prefix unexpected; do after) printf ' - run: %s\n' "$extra_download" >> "$file" ;; same-line) printf ' - run: %s; %s\n' "$extra_download" "$extra_download" >> "$file" ;; canonical) - command="$(sed -n '/ curl -fsSL/p' "$file")" + command="$(sed -n '/\/usr\/bin\/curl -fsSL/p' "$file")" printf '%s\n' "$command" >> "$file" ;; hash-prefix) printf ' - run: printf "# data"; %s\n' "$extra_download" >> "$file" ;; @@ -571,7 +871,7 @@ if [[ -x /usr/bin/env && -x /usr/bin/sha256sum ]]; then fi fixture="$(make_fixture plain-bash "$FULL_ACTION_REF" "$digest")" verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" - verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" rm -f "$TMP_ROOT/continued" tests_run=$((tests_run + 1)) @@ -608,7 +908,7 @@ if [[ -x /usr/bin/env && -x /usr/bin/sha256sum ]]; then fi fixture="$(make_fixture poisoned-path-execution "$FULL_ACTION_REF" "$digest")" verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" - verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/path-verify.sh" rm -f "$fake_marker" tests_run=$((tests_run + 1)) @@ -652,7 +952,7 @@ if [[ -x /usr/bin/env && -x /usr/bin/sha256sum ]]; then fi fixture="$(make_fixture bash-env-execution "$FULL_ACTION_REF" "$digest")" verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" - verification="${verification//\/tmp\/tinygo.deb/$synthetic_artifact}" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/bash-env-verify.sh" rm -f "$checksum_function_marker" "$printf_function_marker" tests_run=$((tests_run + 1)) diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index b2ec6841..34a72117 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -40,10 +40,13 @@ jobs: sudo apt-get install -y brotli zstd curl - name: Install TinyGo + shell: /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0} run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 - sudo apt-get install -y /tmp/tinygo.deb + trap '/usr/bin/sudo /usr/bin/rm -f /tmp/goframe-tinygo-0.42.0-verified.deb' EXIT + /usr/bin/curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 + /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb tinygo version - name: Install goxc diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index a26b4681..f8411ead 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -139,10 +139,13 @@ jobs: cache: true - name: Install TinyGo + shell: /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0} run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 - sudo apt-get install -y /tmp/tinygo.deb + trap '/usr/bin/sudo /usr/bin/rm -f /tmp/goframe-tinygo-0.42.0-verified.deb' EXIT + /usr/bin/curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 + /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb tinygo version - name: TinyGo source-selection parity diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index 469b82a1..c7f6e3a1 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -31,10 +31,13 @@ jobs: sudo apt-get install -y brotli zstd - name: Install TinyGo + shell: /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0} run: | - curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" - /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/tinygo.deb' || exit 1 - sudo apt-get install -y /tmp/tinygo.deb + trap '/usr/bin/sudo /usr/bin/rm -f /tmp/goframe-tinygo-0.42.0-verified.deb' EXIT + /usr/bin/curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb" + /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb + /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 + /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb tinygo version - name: Install goxc diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index d46a764d..3a550cfe 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -10,6 +10,14 @@ EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-core.yml" ".github/workflows/ci-wasm-size.yml" ) +TINYGO_VERIFIED_DEB="/tmp/goframe-tinygo-${EXPECTED_TINYGO_VERSION}-verified.deb" +tinygo_shell_template='/usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0}' +tinygo_cleanup_command="trap '/usr/bin/sudo /usr/bin/rm -f $TINYGO_VERIFIED_DEB' EXIT" +tinygo_download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" +tinygo_stage_command="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $TINYGO_VERIFIED_DEB" +tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 $TINYGO_VERIFIED_DEB' || exit 1" +tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" +tinygo_version_command='tinygo version' usage() { printf 'usage: %s [repository-root]\n' "${0##*/}" >&2 @@ -28,6 +36,7 @@ fi failures=0 remote_action_count=0 +loopback_download_count=0 fail() { printf 'ci supply-chain check: %s\n' "$*" >&2 @@ -152,6 +161,183 @@ tinygo_release_url_owner_allowed() { return 1 } +shell_downloader_re='(^|;|&&|\|\||\||\(|\{|\$\(|`)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*((if|elif|while|until|then|do|!)[[:space:]]+)*(command[[:space:]]+)?(/usr/bin/)?(curl|wget)([[:space:]]|$)' + +shell_command_has_active_downloader() { + local command="$1" + # This is an authored-shell boundary, not a shell interpreter. Match command + # positions used by repository CI and scripts, including substitutions. + [[ "$command" =~ $shell_downloader_re ]] +} + +shell_command_has_multiple_active_downloaders() { + local remainder="$1" + local first_match + + if [[ ! "$remainder" =~ $shell_downloader_re ]]; then + return 1 + fi + first_match="${BASH_REMATCH[0]}" + remainder="${remainder#*"$first_match"}" + [[ "$remainder" =~ $shell_downloader_re ]] +} + +shell_command_is_loopback_probe() { + local command="$1" + local direct_re='^(if[[:space:]]+)?(command[[:space:]]+)?(/usr/bin/)?curl([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(>/dev/null[[:space:]]+2>&1)?[[:space:]]*(;[[:space:]]*then)?$' + local substitution_re='^[A-Za-z_][A-Za-z0-9_]*="\$\([[:space:]]*(command[[:space:]]+)?(/usr/bin/)?curl([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(\|\|[[:space:]]+true)?[[:space:]]*\)"$' + if shell_command_has_multiple_active_downloaders "$command"; then + return 1 + fi + [[ "$command" =~ $direct_re || "$command" =~ $substitution_re ]] +} + +inspect_shell_logical_command() { + local relative="$1" + local line_number="$2" + local command="$3" + + command="$(trim_whitespace "$command")" + if [[ -z "$command" ]] || ! shell_command_has_active_downloader "$command"; then + return + fi + if tinygo_release_url_owner_allowed "$relative" && + [[ "$command" == "$tinygo_download_command" ]]; then + return + fi + if shell_command_is_loopback_probe "$command"; then + loopback_download_count=$((loopback_download_count + 1)) + return + fi + fail "$relative:$line_number active curl/wget command is neither the accepted TinyGo fetch nor a literal-rooted loopback probe" +} + +pending_shell_command="" +pending_shell_line=0 + +reset_shell_logical_command() { + pending_shell_command="" + pending_shell_line=0 +} + +consume_shell_physical_line() { + local relative="$1" + local line_number="$2" + local line="$3" + local command + + command="$(trim_whitespace "$line")" + if [[ -z "$pending_shell_command" && ( -z "$command" || "$command" == \#* ) ]]; then + return + fi + if [[ -z "$pending_shell_command" ]]; then + pending_shell_line="$line_number" + fi + if [[ "$command" == *\\ ]]; then + command="${command%\\}" + pending_shell_command="${pending_shell_command}${pending_shell_command:+ }$command" + return + fi + pending_shell_command="${pending_shell_command}${pending_shell_command:+ }$command" + inspect_shell_logical_command "$relative" "$pending_shell_line" "$pending_shell_command" + reset_shell_logical_command +} + +flush_shell_logical_command() { + local relative="$1" + if [[ -n "$pending_shell_command" ]]; then + inspect_shell_logical_command "$relative" "$pending_shell_line" "$pending_shell_command" + fi + reset_shell_logical_command +} + +scan_shell_downloaders() { + local file="$1" + local relative="$2" + local line line_number=0 + + reset_shell_logical_command + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + consume_shell_physical_line "$relative" "$line_number" "$line" + done < "$file" + flush_shell_logical_command "$relative" +} + +scan_workflow_downloaders() { + local file="$1" + local relative="${file#"$ROOT_DIR/"}" + local line line_number=0 indent syntax key value sequence_prefix content + local scalar_indent=-1 run_scalar=false + local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' + local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' + + reset_shell_logical_command + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + indent="${line%%[![:space:]]*}" + syntax="$(trim_whitespace "$line")" + if (( scalar_indent >= 0 )) && + [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then + if [[ "$run_scalar" == true ]]; then + consume_shell_physical_line "$relative" "$line_number" "$line" + fi + continue + fi + if [[ "$run_scalar" == true ]]; then + flush_shell_logical_command "$relative" + fi + scalar_indent=-1 + run_scalar=false + content="$syntax" + if [[ "$syntax" == \#* ]]; then + continue + fi + if [[ ! "$content" =~ $mapping_re ]]; then + continue + fi + sequence_prefix="${BASH_REMATCH[1]}" + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[4]}" + if [[ "$value" =~ $scalar_re ]]; then + scalar_indent=$((${#indent} + ${#sequence_prefix})) + if [[ "$key" == run ]]; then + run_scalar=true + fi + continue + fi + if [[ "$key" != run || -z "$value" ]]; then + continue + fi + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + consume_shell_physical_line "$relative" "$line_number" "$value" + done < "$file" + if [[ "$run_scalar" == true ]]; then + flush_shell_logical_command "$relative" + fi +} + +repository_file_is_shell_source() { + local relative="$1" + local file="$2" + local first_line + + case "$relative" in + *.sh|*.bash) return 0 ;; + esac + if [[ "${relative##*/}" == *.* ]]; then + return 1 + fi + IFS= read -r first_line < "$file" || true + [[ "$first_line" =~ ^\#!.*(bash|/sh)([[:space:]]|$) ]] +} + enumerate_repository_files() { if [[ "$repository_uses_git_inventory" == true ]]; then git -C "$ROOT_DIR" ls-files -z --cached --others --exclude-standard || return @@ -194,6 +380,9 @@ while IFS= read -r -d '' repository_path; do fail "could not inspect repository file for TinyGo release downloads: $relative" fi fi + if repository_file_is_shell_source "$relative" "$file"; then + scan_shell_downloaders "$file" "$relative" + fi done < <(enumerate_repository_files) if [[ "$repository_inventory_complete" != true ]]; then fail "could not enumerate repository-owned files for TinyGo release downloads" @@ -305,6 +494,7 @@ if (( ${#scan_roots[@]} > 0 )); then # Traversal order affects diagnostics only; keep filenames NUL-delimited. while IFS= read -r -d '' file; do scan_action_refs "$file" + scan_workflow_downloaders "$file" download_count="$(count_active_occurrences "$TINYGO_RELEASE_NAMESPACE" "$file")" direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) if (( download_count > 0 )); then @@ -328,29 +518,38 @@ if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" fi -tinygo_download_command="curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" -tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 /tmp/tinygo.deb' || exit 1" -tinygo_install_command='sudo apt-get install -y /tmp/tinygo.deb' -tinygo_version_command='tinygo version' - count_tinygo_install_sequences() { local file="$1" - local line command indent run_indent command_indent expected_command + local line command indent shell_indent command_indent expected_command valid_indent local state=0 count=0 while IFS= read -r line || [[ -n "$line" ]]; do command="$(trim_whitespace "$line")" indent="${line%%[![:space:]]*}" if (( state > 0 )); then case "$state" in - 1) expected_command="$tinygo_download_command"; command_indent="$indent" ;; - 2) expected_command="$tinygo_verify_command" ;; - 3) expected_command="$tinygo_install_command" ;; - 4) expected_command="$tinygo_version_command" ;; + 1) expected_command='run: |' ;; + 2) expected_command="$tinygo_cleanup_command"; command_indent="$indent" ;; + 3) expected_command="$tinygo_download_command" ;; + 4) expected_command="$tinygo_stage_command" ;; + 5) expected_command="$tinygo_verify_command" ;; + 6) expected_command="$tinygo_install_command" ;; + 7) expected_command="$tinygo_version_command" ;; esac - if [[ "$command" == "$expected_command" && "$indent" == "$command_indent" && - "$indent" == "$run_indent"* ]] && (( ${#indent} > ${#run_indent} )); then + if (( state == 1 )); then + valid_indent=false + if [[ "$indent" == "$shell_indent" ]]; then + valid_indent=true + fi + else + valid_indent=false + if [[ "$indent" == "$command_indent" && "$indent" == "$shell_indent"* ]] && + (( ${#indent} > ${#shell_indent} )); then + valid_indent=true + fi + fi + if [[ "$command" == "$expected_command" && "$valid_indent" == true ]]; then state=$((state + 1)) - if (( state == 5 )); then + if (( state == 8 )); then count=$((count + 1)) state=0 fi @@ -358,8 +557,8 @@ count_tinygo_install_sequences() { state=0 fi fi - if [[ "$command" == 'run: |' || "$command" == '- run: |' ]]; then - run_indent="$indent" + if [[ "$command" == "shell: $tinygo_shell_template" ]]; then + shell_indent="$indent" state=1 fi done < "$file" @@ -367,6 +566,7 @@ count_tinygo_install_sequences() { printf '%d' "$count" } +accepted_tinygo_install_sequences=0 for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do file="$ROOT_DIR/$relative" if [[ ! -f "$file" ]]; then @@ -377,6 +577,12 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do if [[ "$(count_active_occurrences "$tinygo_download_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo download command" fi + if [[ "$(count_active_occurrences "$tinygo_cleanup_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo cleanup command" + fi + if [[ "$(count_active_occurrences "$tinygo_stage_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo staging command" + fi if [[ "$(count_active_occurrences "$tinygo_verify_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo verification command" fi @@ -389,7 +595,9 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do sequence_count="$(count_tinygo_install_sequences "$file")" if [[ "$sequence_count" != "1" ]]; then - fail "$relative must contain exactly one contiguous TinyGo download, verification, installation, and version-report sequence" + fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, cleanup, and version-report sequence" + else + accepted_tinygo_install_sequences=$((accepted_tinygo_install_sequences + 1)) fi done @@ -397,5 +605,6 @@ if (( failures != 0 )); then exit 1 fi -printf 'ci supply-chain check: ok (%d remote Action refs, %d verified TinyGo downloads)\n' \ - "$remote_action_count" "$direct_tinygo_downloads" +printf 'ci supply-chain check: ok (%d remote Action refs, %d verified TinyGo downloads, %d protected install sequences, %d loopback probes)\n' \ + "$remote_action_count" "$direct_tinygo_downloads" \ + "$accepted_tinygo_install_sequences" "$loopback_download_count" From 8995ea063c826002ee56f8ba7312ed7a3f130e87 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 13 Sep 2026 23:14:34 +0300 Subject: [PATCH 11/21] ci(release): separate TinyGo install smoke --- .../scripts/ci-supply-chain-check.tests.sh | 61 ++++++++++++++++--- .github/workflows/ci-browser-smoke.yml | 4 +- .github/workflows/ci-core.yml | 4 +- .github/workflows/ci-wasm-size.yml | 4 +- scripts/ci-supply-chain-check.sh | 46 +++++++++----- 5 files changed, 94 insertions(+), 25 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 9d90fb36..f3a8ec9c 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -29,6 +29,7 @@ write_tinygo_workflow() { local trust_root_mode="${6:-literal}" local verifier_mode="${7:-trusted}" local boundary_mode="${8:-protected}" + local smoke_mode="${9:-separate}" local download_command verify_command verify_digest verify_input verify_target local shell_template="$CLEAN_SHELL" local cleanup_command="$CLEANUP_COMMAND" @@ -115,6 +116,10 @@ write_tinygo_workflow() { printf ' - uses: %s\n' "$action_ref" fi printf ' # uses: actions/commented-out@v1\n' + if [[ "$smoke_mode" == "before-install" ]]; then + printf ' - name: Verify TinyGo\n' + printf ' run: tinygo version\n' + fi if [[ -n "$shell_template" ]]; then printf ' - name: Install TinyGo\n' printf ' shell: %s\n' "$shell_template" @@ -153,7 +158,29 @@ write_tinygo_workflow() { if [[ "$verification_order" != "after-install" ]]; then printf ' /usr/bin/sudo /usr/bin/apt-get install -y %s\n' "$install_target" fi - printf ' tinygo version\n' + case "$smoke_mode" in + inside) + printf ' tinygo version\n' + ;; + separate) + printf ' - name: Verify TinyGo\n' + printf ' run: tinygo version\n' + ;; + missing|before-install) ;; + malformed) + printf ' - name: Verify TinyGo\n' + printf ' run: echo wrong command\n' + ;; + custom-shell) + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$CLEAN_SHELL" + printf ' run: tinygo version\n' + ;; + *) + printf 'unsupported TinyGo smoke mode: %s\n' "$smoke_mode" >&2 + exit 1 + ;; + esac } > "$path" } @@ -166,14 +193,15 @@ make_fixture() { local trust_root_mode="${6:-literal}" local verifier_mode="${7:-trusted}" local boundary_mode="${8:-protected}" + local smoke_mode="${9:-separate}" local fixture="$TMP_ROOT/$name" write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ - "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" + "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ - "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" printf '%s' "$fixture" } @@ -412,7 +440,13 @@ test_ambient_sudo_interposition() { local marker="$TMP_ROOT/ambient-sudo-ran" local bash_env="$TMP_ROOT/ambient-bash-env" local script="$TMP_ROOT/ambient-interposition.sh" - local digest verify_command + local cp_bin digest verify_command + + cp_bin="$(command -v cp || true)" + if [[ "$cp_bin" != /* || ! -x "$cp_bin" ]]; then + printf 'not ok - ambient sudo interposition oracle requires an absolute executable cp\n' >&2 + exit 1 + fi printf 'verified artifact\n' > "$artifact" printf 'replacement artifact\n' > "$replacement" @@ -429,12 +463,12 @@ test_ambient_sudo_interposition() { printf '%s\n' \ 'sudo() {' \ ' : > "$P1_MARKER"' \ - ' /usr/bin/cp "$P1_REPLACEMENT" "$P1_ARTIFACT"' \ + " \"$cp_bin\" \"\$P1_REPLACEMENT\" \"\$P1_ARTIFACT\"" \ ' "$@"' \ '}' \ > "$bash_env" printf '%s\n' "$verify_command" \ - 'sudo /usr/bin/cp "$P1_ARTIFACT" "$P1_CONSUMED"' \ + "sudo \"$cp_bin\" \"\$P1_ARTIFACT\" \"\$P1_CONSUMED\"" \ > "$script" tests_run=$((tests_run + 1)) @@ -451,6 +485,19 @@ test_ambient_sudo_interposition() { printf 'ok %d - ambient sudo can replace bytes after verification\n' "$tests_run" } +expect_pass "protected install followed by separate TinyGo version smoke" \ + "$(make_fixture split-version-smoke "$FULL_ACTION_REF")" +expect_fail "TinyGo version smoke inside sanitized install" \ + "$(make_fixture in-boundary-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected inside)" +expect_fail "protected install without TinyGo version smoke" \ + "$(make_fixture missing-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected missing)" +expect_fail "TinyGo version smoke before protected install" \ + "$(make_fixture early-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected before-install)" +expect_fail "malformed TinyGo version smoke" \ + "$(make_fixture malformed-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected malformed)" +expect_fail "sanitized TinyGo version smoke" \ + "$(make_fixture sanitized-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected custom-shell)" + expect_pass "full remote Action SHA with version annotation" \ "$(make_fixture full-sha "$FULL_ACTION_REF")" expect_fail "mutable major Action tag" \ diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 34a72117..2f07a5b4 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -47,7 +47,9 @@ jobs: /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - tinygo version + + - name: Verify TinyGo + run: tinygo version - name: Install goxc run: go install ./cmd/goxc diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index f8411ead..8bbd3404 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -146,7 +146,9 @@ jobs: /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - tinygo version + + - name: Verify TinyGo + run: tinygo version - name: TinyGo source-selection parity run: | diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index c7f6e3a1..e42e0bdc 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -38,7 +38,9 @@ jobs: /usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb /usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1 /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - tinygo version + + - name: Verify TinyGo + run: tinygo version - name: Install goxc run: go install ./cmd/goxc diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 3a550cfe..a8a6c21b 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -520,36 +520,52 @@ fi count_tinygo_install_sequences() { local file="$1" - local line command indent shell_indent command_indent expected_command valid_indent + local line command indent step_indent property_indent command_indent expected_command valid_indent local state=0 count=0 while IFS= read -r line || [[ -n "$line" ]]; do command="$(trim_whitespace "$line")" indent="${line%%[![:space:]]*}" if (( state > 0 )); then + if (( state == 8 )) && [[ -z "$command" ]]; then + continue + fi case "$state" in - 1) expected_command='run: |' ;; - 2) expected_command="$tinygo_cleanup_command"; command_indent="$indent" ;; - 3) expected_command="$tinygo_download_command" ;; - 4) expected_command="$tinygo_stage_command" ;; - 5) expected_command="$tinygo_verify_command" ;; - 6) expected_command="$tinygo_install_command" ;; - 7) expected_command="$tinygo_version_command" ;; + 1) expected_command="shell: $tinygo_shell_template"; property_indent="$indent" ;; + 2) expected_command='run: |' ;; + 3) expected_command="$tinygo_cleanup_command"; command_indent="$indent" ;; + 4) expected_command="$tinygo_download_command" ;; + 5) expected_command="$tinygo_stage_command" ;; + 6) expected_command="$tinygo_verify_command" ;; + 7) expected_command="$tinygo_install_command" ;; + 8) expected_command='- name: Verify TinyGo' ;; + 9) expected_command="run: $tinygo_version_command" ;; esac if (( state == 1 )); then valid_indent=false - if [[ "$indent" == "$shell_indent" ]]; then + if [[ "$indent" == "$step_indent"* ]] && + (( ${#indent} > ${#step_indent} )); then + valid_indent=true + fi + elif (( state == 2 || state == 9 )); then + valid_indent=false + if [[ "$indent" == "$property_indent" ]]; then + valid_indent=true + fi + elif (( state == 8 )); then + valid_indent=false + if [[ "$indent" == "$step_indent" ]]; then valid_indent=true fi else valid_indent=false - if [[ "$indent" == "$command_indent" && "$indent" == "$shell_indent"* ]] && - (( ${#indent} > ${#shell_indent} )); then + if [[ "$indent" == "$command_indent" && "$indent" == "$property_indent"* ]] && + (( ${#indent} > ${#property_indent} )); then valid_indent=true fi fi if [[ "$command" == "$expected_command" && "$valid_indent" == true ]]; then state=$((state + 1)) - if (( state == 8 )); then + if (( state == 10 )); then count=$((count + 1)) state=0 fi @@ -557,8 +573,8 @@ count_tinygo_install_sequences() { state=0 fi fi - if [[ "$command" == "shell: $tinygo_shell_template" ]]; then - shell_indent="$indent" + if [[ "$command" == '- name: Install TinyGo' ]]; then + step_indent="$indent" state=1 fi done < "$file" @@ -595,7 +611,7 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do sequence_count="$(count_tinygo_install_sequences "$file")" if [[ "$sequence_count" != "1" ]]; then - fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, cleanup, and version-report sequence" + fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, and cleanup sequence immediately followed by an ordinary TinyGo version smoke" else accepted_tinygo_install_sequences=$((accepted_tinygo_install_sequences + 1)) fi From 7c795717dacd6ea08077dab05595864ffbd04c4e Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Tue, 15 Sep 2026 12:14:49 +0300 Subject: [PATCH 12/21] ci(release): close authored CI policy gaps --- .../scripts/ci-supply-chain-check.tests.sh | 403 ++++++++++++- .github/workflows/ci-browser-smoke.yml | 2 +- .github/workflows/ci-core.yml | 2 +- .github/workflows/ci-wasm-size.yml | 2 +- scripts/ci-supply-chain-check.sh | 546 +++++++++++++++--- 5 files changed, 854 insertions(+), 101 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index f3a8ec9c..74e54c5d 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -7,8 +7,11 @@ TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" -TINYGO_RELEASE_REPOSITORY="tinygo-org/tinygo" +TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' +TINYGO_RELEASE_REPOSITORY_CASE_VARIANT="TinyGo-Org/"'TinyGo' TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +TINYGO_BINARY="/usr/local/bin/tinygo" +TINYGO_VERSION_COMMAND="$TINYGO_BINARY version" VERIFIED_DEB="/tmp/goframe-tinygo-0.42.0-verified.deb" CLEAN_PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' CLEAN_SHELL="/usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail {0}" @@ -118,7 +121,7 @@ write_tinygo_workflow() { printf ' # uses: actions/commented-out@v1\n' if [[ "$smoke_mode" == "before-install" ]]; then printf ' - name: Verify TinyGo\n' - printf ' run: tinygo version\n' + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" fi if [[ -n "$shell_template" ]]; then printf ' - name: Install TinyGo\n' @@ -160,9 +163,13 @@ write_tinygo_workflow() { fi case "$smoke_mode" in inside) - printf ' tinygo version\n' + printf ' %s\n' "$TINYGO_VERSION_COMMAND" ;; separate) + printf ' - name: Verify TinyGo\n' + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" + ;; + ambient) printf ' - name: Verify TinyGo\n' printf ' run: tinygo version\n' ;; @@ -174,7 +181,7 @@ write_tinygo_workflow() { custom-shell) printf ' - name: Verify TinyGo\n' printf ' shell: %s\n' "$CLEAN_SHELL" - printf ' run: tinygo version\n' + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" ;; *) printf 'unsupported TinyGo smoke mode: %s\n' "$smoke_mode" >&2 @@ -279,6 +286,22 @@ add_path_poisoning() { mv "$TMP_ROOT/path-poison.yml" "$file" } +add_tinygo_path_poisoning() { + local file="$1" + + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " mkdir -p \"$RUNNER_TEMP/fake-tinygo-bin\"" + print " cp /bin/true \"$RUNNER_TEMP/fake-tinygo-bin/tinygo\"" + print " echo \"$RUNNER_TEMP/fake-tinygo-bin\" >> \"$GITHUB_PATH\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/tinygo-path-poison.yml" + mv "$TMP_ROOT/tinygo-path-poison.yml" "$file" +} + add_bash_env_shadowing() { local file="$1" awk ' @@ -325,11 +348,11 @@ write_block_scalar_workflow() { write_repository_file() { local fixture="$1" local path="$2" - local content="$3" local target="$fixture/$path" + shift 2 mkdir -p "$(dirname "$target")" - printf '%s\n' "$content" > "$target" + printf '%s\n' "$@" > "$target" } write_shell_source() { @@ -352,6 +375,97 @@ add_workflow_script_invocation() { printf '\n delegated-script:\n steps:\n - run: %s\n' "$command" >> "$file" } +add_verify_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + awk -v position="$position" -v property="$property" -v child="$child" ' + function emit_property() { + print " " property + if (child != "") { + print " " child + } + } + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && position == "before" && $0 ~ /^ run:/ { + emit_property() + } + { print } + in_verify && position == "after" && $0 ~ /^ run:/ { + emit_property() + in_verify = 0 + } + ' "$file" > "$TMP_ROOT/verify-property.yml" + mv "$TMP_ROOT/verify-property.yml" "$file" +} + +add_install_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + awk -v position="$position" -v property="$property" -v child="$child" ' + function emit_property() { + print " " property + if (child != "") { + print " " child + } + } + $0 == " - name: Install TinyGo" { in_install = 1 } + in_install && position == "before" && $0 == " run: |" { + emit_property() + } + in_install && $0 == " - name: Verify TinyGo" { + if (position == "after") { + emit_property() + } + in_install = 0 + } + { print } + ' "$file" > "$TMP_ROOT/install-property.yml" + mv "$TMP_ROOT/install-property.yml" "$file" +} + +reorder_verify_properties() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { + name = " name: Verify TinyGo" + if ((getline run_line) <= 0) { + exit 1 + } + sub(/^ run:/, " - run:", run_line) + print run_line + print name + next + } + { print } + ' "$file" > "$TMP_ROOT/reordered-verify.yml" + mv "$TMP_ROOT/reordered-verify.yml" "$file" +} + +move_install_shell_after_run() { + local file="$1" + + awk ' + $0 == " - name: Install TinyGo" { in_install = 1 } + in_install && $0 ~ /^ shell:/ { + shell_line = $0 + next + } + in_install && $0 == " - name: Verify TinyGo" { + print shell_line + in_install = 0 + } + { print } + ' "$file" > "$TMP_ROOT/reordered-install.yml" + mv "$TMP_ROOT/reordered-install.yml" "$file" +} + expect_pass() { local name="$1" local fixture="$2" @@ -485,8 +599,125 @@ test_ambient_sudo_interposition() { printf 'ok %d - ambient sudo can replace bytes after verification\n' "$tests_run" } +fixture="$(make_fixture python-helper-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" \ + 'subprocess.run(["curl", base + "/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"], check=True)' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 scripts/install-tinygo.py' +expect_fail "invoked Python helper with TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture python-helper-direct-tinygo-url "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "subprocess.run([\"curl\", \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" +expect_fail "Python helper with direct TinyGo release URL" "$fixture" + +fixture="$(make_fixture python-helper-case-variant-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY_CASE_VARIANT\"" \ + 'subprocess.run(["curl", base + "/releases/download/artifact"], check=True)' +expect_fail "Python helper with case-variant TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture javascript-helper-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.mjs \ + 'import { spawnSync } from "node:child_process";' \ + "const base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\";" \ + 'spawnSync("curl", [base + "/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"]);' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node scripts/install-tinygo.mjs' +expect_fail "invoked JavaScript helper with TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture workflow-provided-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import os' \ + 'import subprocess' \ + 'subprocess.run(["curl", os.environ["TINYGO_BASE"] + "/releases/download/artifact"]);' +printf '%s\n' '' ' delegated-python:' ' runs-on: ubuntu-latest' \ + ' env:' \ + " TINYGO_BASE: https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + ' steps:' \ + ' - run: python3 scripts/install-tinygo.py' \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "workflow cannot supply TinyGo provenance to a non-shell helper" "$fixture" + +fixture="$(make_fixture benign-python-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.py \ + 'print("benign helper")' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 scripts/benign.py' +expect_pass "invoked Python helper without TinyGo provenance" "$fixture" + +fixture="$(make_fixture loopback-javascript-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/loopback.mjs \ + 'const target = "http://127.0.0.1:8080/";' \ + 'console.log(target);' +expect_pass "JavaScript helper with loopback-only data" "$fixture" + +fixture="$(make_fixture documentation-tinygo-link "$FULL_ACTION_REF")" +write_repository_file "$fixture" docs/tinygo-reference.md \ + "Upstream: https://github.com/$TINYGO_RELEASE_REPOSITORY/releases/tag/v0.42.0" +expect_pass "documentation may reference the TinyGo repository" "$fixture" + +fixture="$(make_fixture uninvoked-python-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/uninvoked.py \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +expect_fail "uninvoked executable-source helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture outside-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install-tinygo.py \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 tools/install-tinygo.py' +expect_fail "invoked helper outside standard CI source roots" "$fixture" + +fixture="$(make_fixture outside-go-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install-tinygo.go \ + 'package main' \ + 'import "fmt"' \ + "func main() { fmt.Println(\"https://github.com/$TINYGO_RELEASE_REPOSITORY\") }" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'go run tools/install-tinygo.go' +expect_fail "directly referenced Go helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture traversed-go-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" outside/install-tinygo.go \ + 'package main' \ + 'import "fmt"' \ + "func main() { fmt.Println(\"https://github.com/$TINYGO_RELEASE_REPOSITORY\") }" +parent_path_component=.. +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "go run tools/$parent_path_component/outside/install-tinygo.go" +expect_fail "traversed Go helper path cannot evade TinyGo provenance" "$fixture" + +fixture="$(make_fixture extensionless-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo \ + '#!/usr/bin/env python3' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +chmod +x "$fixture/scripts/install-tinygo" +expect_fail "extensionless executable helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture checker-short-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh \ + '#!/usr/bin/env bash' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +expect_fail "checker source is not exempt from TinyGo provenance policy" "$fixture" + +fixture="$(make_fixture harness-short-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + '#!/usr/bin/env bash' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +expect_fail "test harness is not exempt from TinyGo provenance policy" "$fixture" + expect_pass "protected install followed by separate TinyGo version smoke" \ "$(make_fixture split-version-smoke "$FULL_ACTION_REF")" +expect_fail "ambient PATH TinyGo version smoke" \ + "$(make_fixture ambient-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected ambient)" +fixture="$(make_fixture path-poisoned-version-smoke "$FULL_ACTION_REF")" +add_tinygo_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_pass "PATH poisoning cannot redirect package-owned TinyGo smoke" "$fixture" expect_fail "TinyGo version smoke inside sanitized install" \ "$(make_fixture in-boundary-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected inside)" expect_fail "protected install without TinyGo version smoke" \ @@ -498,6 +729,59 @@ expect_fail "malformed TinyGo version smoke" \ expect_fail "sanitized TinyGo version smoke" \ "$(make_fixture sanitized-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected custom-shell)" +for position in before after; do + fixture="$(make_fixture "verify-continue-on-error-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'continue-on-error: true' + expect_fail "Verify TinyGo continue-on-error $position run" "$fixture" + + fixture="$(make_fixture "verify-if-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" 'if: false' + expect_fail "Verify TinyGo if false $position run" "$fixture" + + fixture="$(make_fixture "verify-env-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'env:' 'TINYGO_FAKE: attacker' + expect_fail "Verify TinyGo step env $position run" "$fixture" + + fixture="$(make_fixture "install-continue-on-error-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'continue-on-error: true' + expect_fail "Install TinyGo continue-on-error $position run" "$fixture" + + fixture="$(make_fixture "install-if-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" 'if: false' + expect_fail "Install TinyGo if false $position run" "$fixture" + + fixture="$(make_fixture "install-env-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'env:' 'TINYGO_FAKE: attacker' + expect_fail "Install TinyGo step env $position run" "$fixture" +done + +fixture="$(make_fixture install-unexpected-property "$FULL_ACTION_REF")" +add_install_property "$fixture/.github/workflows/ci-core.yml" after \ + 'timeout-minutes: 1' +expect_fail "Install TinyGo unexpected step property" "$fixture" + +fixture="$(make_fixture verify-duplicate-run "$FULL_ACTION_REF")" +add_verify_property "$fixture/.github/workflows/ci-core.yml" after \ + 'run: /usr/local/bin/tinygo version' +expect_fail "Verify TinyGo duplicate run property" "$fixture" + +fixture="$(make_fixture install-duplicate-shell "$FULL_ACTION_REF")" +add_install_property "$fixture/.github/workflows/ci-core.yml" after \ + "shell: $CLEAN_SHELL" +expect_fail "Install TinyGo duplicate shell property" "$fixture" + +fixture="$(make_fixture reordered-verify-properties "$FULL_ACTION_REF")" +reorder_verify_properties "$fixture/.github/workflows/ci-core.yml" +expect_pass "Verify TinyGo valid properties in reordered mapping" "$fixture" + +fixture="$(make_fixture reordered-install-properties "$FULL_ACTION_REF")" +move_install_shell_after_run "$fixture/.github/workflows/ci-core.yml" +expect_pass "Install TinyGo valid properties in reordered mapping" "$fixture" + expect_pass "full remote Action SHA with version annotation" \ "$(make_fixture full-sha "$FULL_ACTION_REF")" expect_fail "mutable major Action tag" \ @@ -517,6 +801,97 @@ expect_fail "remote Action SHA with major-only annotation" \ expect_fail "remote Action SHA with two-component annotation" \ "$(make_fixture minor-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1.2")" +fixture="$(make_fixture job-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' env:' \ + ' uses: harmless-data' \ + ' steps:' \ + ' - run: echo ok' +expect_pass "job env key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture step-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo ok' \ + ' env:' \ + ' uses: harmless-data' +expect_pass "step env key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture with-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + ' uses: harmless-data' +expect_pass "with key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture flow-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' env: { uses: harmless-data }' \ + ' steps:' \ + ' - run: echo ok' +expect_pass "flow env data named uses is not an Action reference" "$fixture" + +fixture="$(make_fixture action-input-output-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/data/action.yml \ + 'name: data' \ + 'inputs:' \ + ' uses:' \ + ' default: harmless-data' \ + 'outputs:' \ + ' uses:' \ + ' value: harmless-data' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - run: echo ok' \ + ' shell: bash' +expect_pass "Action input and output keys named uses are ordinary data" "$fixture" + +fixture="$(make_fixture job-reusable-pinned "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/reusable-job.yml \ + 'jobs:' \ + ' call:' \ + " uses: $FULL_REUSABLE_REF" +expect_pass "job-level reusable workflow uses is validated" "$fixture" + +fixture="$(make_fixture job-reusable-mutable "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/reusable-job.yml \ + 'jobs:' \ + ' call:' \ + ' uses: owner/repo/.github/workflows/build.yml@main' +expect_fail "mutable job-level reusable workflow uses" "$fixture" + +fixture="$(make_fixture composite-action-pinned "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/direct/action.yml \ + 'name: direct' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + " - uses: owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "composite Action step uses is validated" "$fixture" + +fixture="$(make_fixture composite-action-mutable "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/direct/action.yml \ + 'name: direct' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - uses: owner/remote@main' +expect_fail "mutable composite Action step uses" "$fixture" + expect_fail "old unqualified TinyGo checksum verifier" \ "$(make_fixture old-unqualified-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal unqualified)" @@ -700,7 +1075,9 @@ expect_fail "Action following a spaced block-scalar step name" "$fixture" fixture="$(make_fixture 'space in repository path' "$FULL_ACTION_REF")" expect_pass "repository path containing spaces" "$fixture" -printf 'uses: actions/checkout@v7\n' > "$fixture/.github/workflows/"$'line\nbreak.yml' +printf '%s\n' 'jobs:' ' newline:' ' steps:' \ + ' - uses: actions/checkout@v7' \ + > "$fixture/.github/workflows/"$'line\nbreak.yml' expect_fail "workflow filename containing a newline" "$fixture" fixture="$(make_fixture local-action-yml './.github/actions/local')" @@ -831,6 +1208,18 @@ if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$BASH_SOURCE"; then fi printf 'ok - test harness source contains no raw TinyGo release namespace\n' +if grep -Fq -- "$TINYGO_RELEASE_REPOSITORY" "$CHECKER"; then + printf 'not ok - checker source contains raw TinyGo repository identity\n' >&2 + exit 1 +fi +printf 'ok - checker source contains no raw TinyGo repository identity\n' + +if grep -Fq -- "$TINYGO_RELEASE_REPOSITORY" "$BASH_SOURCE"; then + printf 'not ok - test harness source contains raw TinyGo repository identity\n' >&2 + exit 1 +fi +printf 'ok - test harness source contains no raw TinyGo repository identity\n' + runtime_namespace="$TINYGO_RELEASE_NAMESPACE" expected_runtime_namespace="${TINYGO_RELEASE_REPOSITORY}/releases/download/" if [[ "$runtime_namespace" != "$expected_runtime_namespace" ]]; then diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 2f07a5b4..099953e7 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -49,7 +49,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo - run: tinygo version + run: /usr/local/bin/tinygo version - name: Install goxc run: go install ./cmd/goxc diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 8bbd3404..9f9e6a24 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -148,7 +148,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo - run: tinygo version + run: /usr/local/bin/tinygo version - name: TinyGo source-selection parity run: | diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index e42e0bdc..033cff04 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -40,7 +40,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo - run: tinygo version + run: /usr/local/bin/tinygo version - name: Install goxc run: go install ./cmd/goxc diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index a8a6c21b..63176058 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -3,7 +3,7 @@ set -euo pipefail EXPECTED_TINYGO_VERSION="0.42.0" EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" -TINYGO_RELEASE_REPOSITORY="tinygo-org/tinygo" +TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-browser-smoke.yml" @@ -17,7 +17,7 @@ tinygo_download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github tinygo_stage_command="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $TINYGO_VERIFIED_DEB" tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 $TINYGO_VERIFIED_DEB' || exit 1" tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" -tinygo_version_command='tinygo version' +tinygo_version_command='/usr/local/bin/tinygo version' usage() { printf 'usage: %s [repository-root]\n' "${0##*/}" >&2 @@ -198,6 +198,7 @@ inspect_shell_logical_command() { local command="$3" command="$(trim_whitespace "$command")" + inspect_referenced_ci_helpers "$relative" "$line_number" "$command" if [[ -z "$command" ]] || ! shell_command_has_active_downloader "$command"; then return fi @@ -338,6 +339,141 @@ repository_file_is_shell_source() { [[ "$first_line" =~ ^\#!.*(bash|/sh)([[:space:]]|$) ]] } +repository_file_is_helper_source() { + local relative="$1" + local file="$2" + local first_line + + case "$relative" in + *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb|*.go|*.ts|*.tsx) + return 0 + ;; + esac + if [[ -x "$file" ]]; then + return 0 + fi + IFS= read -r first_line < "$file" || true + [[ "$first_line" == '#!'* ]] +} + +repository_file_is_ci_executable_source() { + local relative="$1" + local file="$2" + + # This is a bounded authored-CI provenance inventory, not language parsing. + # Common helper-language files and executable files are inspected regardless + # of invocation; additional source types are included under CI helper roots. + case "$relative" in + *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb) return 0 ;; + esac + case "$relative" in + .github/scripts/*|.github/actions/*|scripts/*) + repository_file_is_helper_source "$relative" "$file" + return + ;; + esac + [[ -x "$file" ]] +} + +repository_file_is_authored_ci_yaml() { + local relative="$1" + + case "$relative" in + .github/workflows/*.yml|.github/workflows/*.yaml|\ + .github/actions/*.yml|.github/actions/*.yaml|\ + .github/actions/*/*.yml|.github/actions/*/*.yaml) + return 0 + ;; + esac + return 1 +} + +scan_ci_executable_tinygo_provenance() { + local relative="$1" + local file="$2" + local forced="${3:-false}" + local grep_status line syntax + + # A literal repository identity is the bounded, language-agnostic marker. + # Deliberately encoded or character-by-character construction is outside this + # authored-CI policy; arbitrary source-language data flow is not interpreted. + if [[ "$forced" != true ]] && + ! repository_file_is_ci_executable_source "$relative" "$file" && + ! repository_file_is_authored_ci_yaml "$relative"; then + return + fi + if grep -Fiq -- "$TINYGO_RELEASE_REPOSITORY" "$file"; then + if tinygo_release_url_owner_allowed "$relative"; then + while IFS= read -r line || [[ -n "$line" ]]; do + if ! grep -Fiq -- "$TINYGO_RELEASE_REPOSITORY" <<< "$line"; then + continue + fi + syntax="$(trim_whitespace "$line")" + if [[ "$syntax" != "$tinygo_download_command" ]]; then + fail "TinyGo repository provenance in $relative is outside the accepted fetch command" + fi + done < "$file" + else + fail "TinyGo repository provenance is not allowed in executable CI source: $relative" + fi + else + grep_status=$? + if (( grep_status > 1 )); then + fail "could not inspect executable CI source for TinyGo provenance: $relative" + fi + fi +} + +inspect_referenced_ci_helpers() { + local source_relative="$1" + local line_number="$2" + local command="$3" + local token relative file + local words=() + + read -r -a words <<< "$command" + for token in "${words[@]}"; do + token="${token#\"}" + token="${token%\"}" + token="${token#\'}" + token="${token%\'}" + token="${token#(}" + token="${token%)}" + token="${token%;}" + token="${token%|}" + token="${token%&}" + case "$token" in + http://*|https://*|/*|*'$'*|*'`'*) continue ;; + esac + relative="${token#./}" + case "/$relative/" in + *'/../'*|*'/./'*|*'//'*) + file="$ROOT_DIR/$relative" + case "$relative" in + *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb|*.go|*.ts|*.tsx) + fail "$source_relative:$line_number referenced CI helper path is not canonical: $token" + ;; + *) + if [[ -f "$file" && -x "$file" ]]; then + fail "$source_relative:$line_number referenced executable CI helper path is not canonical: $token" + fi + ;; + esac + continue + ;; + esac + file="$ROOT_DIR/$relative" + if [[ ! -f "$file" ]] || + ! repository_file_is_helper_source "$relative" "$file"; then + continue + fi + if repository_file_is_ci_executable_source "$relative" "$file"; then + continue + fi + scan_ci_executable_tinygo_provenance "$relative" "$file" true + done +} + enumerate_repository_files() { if [[ "$repository_uses_git_inventory" == true ]]; then git -C "$ROOT_DIR" ls-files -z --cached --others --exclude-standard || return @@ -383,6 +519,7 @@ while IFS= read -r -d '' repository_path; do if repository_file_is_shell_source "$relative" "$file"; then scan_shell_downloaders "$file" "$relative" fi + scan_ci_executable_tinygo_provenance "$relative" "$file" done < <(enumerate_repository_files) if [[ "$repository_inventory_complete" != true ]]; then fail "could not enumerate repository-owned files for TinyGo release downloads" @@ -402,15 +539,55 @@ if (( ${#scan_roots[@]} == 0 )); then fail "no authored GitHub workflow or Action directories found under $ROOT_DIR/.github" fi +validate_action_uses_ref() { + local file="$1" + local line_number="$2" + local value="$3" + local comment="$4" + local comment_is_yaml="$5" + + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + + if [[ -z "$value" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" + return + fi + if [[ "$value" == ./* ]]; then + validate_local_uses_ref "$file" "$line_number" "$value" + return + fi + + remote_action_count=$((remote_action_count + 1)) + if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" + fi + comment="$(trim_whitespace "$comment")" + if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" + fi +} + scan_action_refs() { local file="$1" + local relative="${file#"$ROOT_DIR/"}" local line line_number=0 indent syntax key value sequence_prefix content - local comment comment_is_yaml + local comment comment_is_yaml key_indent stack_count stack_index parent_kind + local is_action_uses=false + local action_step_active=false action_step_indent=-1 action_step_kind="" local scalar_indent=-1 local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' local full_comment_re='^[[:space:]]*#(.*)$' + local path_keys=() + local path_indents=() + while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) content="$line" @@ -435,56 +612,101 @@ scan_action_refs() { fi scalar_indent=-1 - # Only canonical block mapping keys are authored here. Reject YAML - # indirection and flow forms instead of letting them hide a uses key. + if [[ "$syntax" =~ $mapping_re ]]; then + sequence_prefix="${BASH_REMATCH[1]}" + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[4]}" + key_indent=$((${#indent} + ${#sequence_prefix})) + else + sequence_prefix="" + key_indent=${#indent} + if [[ "$syntax" == '- '* ]]; then + key_indent=$((key_indent + 2)) + fi + fi + + while (( ${#path_indents[@]} > 0 )); do + stack_index=$((${#path_indents[@]} - 1)) + if (( path_indents[stack_index] < key_indent )); then + break + fi + unset 'path_indents[stack_index]' 'path_keys[stack_index]' + done + stack_count=${#path_keys[@]} + parent_kind="" + if [[ "$relative" == .github/workflows/* ]] && + (( stack_count == 3 )) && + [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then + parent_kind=workflow-step + elif [[ "$relative" == .github/actions/* ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == runs && "${path_keys[1]}" == steps ]]; then + parent_kind=composite-step + fi + + if [[ "$action_step_active" == true ]] && + (( key_indent < action_step_indent )); then + action_step_active=false + action_step_kind="" + fi + + # Only canonical block mapping keys are authored for security-relevant + # structure. Ordinary scalar/list data remains outside Action semantics. if [[ ! "$syntax" =~ $mapping_re ]]; then case "${syntax#- }" in *:*|\{*|\[*|\?*|\&*|\**|\!*) - fail "${file#"$ROOT_DIR/"}:$line_number unsupported workflow mapping syntax; use canonical block keys" + fail "$relative:$line_number unsupported workflow mapping syntax; use canonical block keys" ;; esac continue fi - sequence_prefix="${BASH_REMATCH[1]}" - key="${BASH_REMATCH[2]}" - value="${BASH_REMATCH[4]}" + + if [[ -n "$sequence_prefix" && -n "$parent_kind" ]]; then + action_step_active=true + action_step_indent=$key_indent + action_step_kind="$parent_kind" + elif [[ -n "$sequence_prefix" && "$action_step_active" == true ]] && + (( key_indent <= action_step_indent )); then + action_step_active=false + action_step_kind="" + fi + case "$value" in \{*|\[*|\&*|\**|\!*) - fail "${file#"$ROOT_DIR/"}:$line_number unsupported workflow mapping value; use canonical block mappings" - continue - ;; - esac - if [[ "$key" != uses ]]; then - if [[ "$value" =~ $scalar_re ]]; then - scalar_indent=$((${#indent} + ${#sequence_prefix})) - fi + case "$key" in + env|with|inputs|outputs) ;; + *) + fail "$relative:$line_number unsupported workflow mapping value; use canonical block mappings" + ;; + esac continue - fi + ;; + esac - if (( ${#value} >= 2 )); then - if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then - value="${value:1:${#value}-2}" - elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then - value="${value:1:${#value}-2}" + is_action_uses=false + if [[ "$key" == uses ]]; then + if [[ "$relative" == .github/workflows/* ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == jobs ]]; then + is_action_uses=true + elif [[ "$action_step_active" == true && + "$action_step_kind" == "$parent_kind" ]] && + (( key_indent == action_step_indent )); then + is_action_uses=true fi fi - - if [[ -z "$value" ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" - continue - fi - if [[ "$value" == ./* ]]; then - validate_local_uses_ref "$file" "$line_number" "$value" - continue + if [[ "$is_action_uses" == true ]]; then + validate_action_uses_ref "$file" "$line_number" "$value" \ + "$comment" "$comment_is_yaml" fi - remote_action_count=$((remote_action_count + 1)) - if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" + if [[ "$value" =~ $scalar_re ]]; then + scalar_indent=$key_indent + continue fi - comment="$(trim_whitespace "$comment")" - if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" + if [[ -z "$value" ]]; then + path_indents[stack_count]=$key_indent + path_keys[stack_count]="$key" fi done < "$file" } @@ -518,68 +740,209 @@ if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" fi -count_tinygo_install_sequences() { +reset_tinygo_step() { + tinygo_step_active=false + tinygo_step_indent=-1 + tinygo_step_line=0 + tinygo_step_property_count=0 + tinygo_step_name_count=0 + tinygo_step_shell_count=0 + tinygo_step_run_count=0 + tinygo_step_install_name=false + tinygo_step_verify_name=false + tinygo_step_shell_value="" + tinygo_step_run_value="" + tinygo_step_run_lines=() +} + +record_tinygo_step_property() { + local key="$1" + local value="$2" + + tinygo_step_property_count=$((tinygo_step_property_count + 1)) + case "$key" in + name) + tinygo_step_name_count=$((tinygo_step_name_count + 1)) + if [[ "$value" == 'Install TinyGo' ]]; then + tinygo_step_install_name=true + elif [[ "$value" == 'Verify TinyGo' ]]; then + tinygo_step_verify_name=true + fi + ;; + shell) + tinygo_step_shell_count=$((tinygo_step_shell_count + 1)) + tinygo_step_shell_value="$value" + ;; + run) + tinygo_step_run_count=$((tinygo_step_run_count + 1)) + tinygo_step_run_value="$value" + ;; + esac +} + +tinygo_install_body_is_exact() { + (( ${#tinygo_step_run_lines[@]} == 5 )) || return 1 + [[ "${tinygo_step_run_lines[0]}" == "$tinygo_cleanup_command" ]] || return 1 + [[ "${tinygo_step_run_lines[1]}" == "$tinygo_download_command" ]] || return 1 + [[ "${tinygo_step_run_lines[2]}" == "$tinygo_stage_command" ]] || return 1 + [[ "${tinygo_step_run_lines[3]}" == "$tinygo_verify_command" ]] || return 1 + [[ "${tinygo_step_run_lines[4]}" == "$tinygo_install_command" ]] +} + +finalize_tinygo_step() { + local relative="$1" + local valid=true + + if [[ "$tinygo_step_active" != true ]]; then + return + fi + if [[ "$tinygo_step_install_name" == true && + "$tinygo_step_verify_name" == true ]]; then + fail "$relative:$tinygo_step_line TinyGo step has conflicting duplicate names" + tinygo_pending_install=false + reset_tinygo_step + return + fi + + if [[ "$tinygo_step_install_name" == true ]]; then + if [[ "$tinygo_pending_install" == true ]]; then + fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" + fi + if (( tinygo_step_property_count != 3 || + tinygo_step_name_count != 1 || + tinygo_step_shell_count != 1 || + tinygo_step_run_count != 1 )); then + valid=false + fi + if [[ "$tinygo_step_shell_value" != "$tinygo_shell_template" || + "$tinygo_step_run_value" != '|' ]] || + ! tinygo_install_body_is_exact; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:$tinygo_step_line Install TinyGo must contain only the exact sanitized shell and protected run transaction" + tinygo_pending_install=false + else + tinygo_pending_install=true + fi + elif [[ "$tinygo_step_verify_name" == true ]]; then + if (( tinygo_step_property_count != 2 || + tinygo_step_name_count != 1 || + tinygo_step_shell_count != 0 || + tinygo_step_run_count != 1 )) || + [[ "$tinygo_step_run_value" != "$tinygo_version_command" ]]; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:$tinygo_step_line Verify TinyGo must contain only the package-owned version command" + tinygo_pending_install=false + elif [[ "$tinygo_pending_install" != true ]]; then + fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" + else + tinygo_sequence_count=$((tinygo_sequence_count + 1)) + tinygo_pending_install=false + fi + elif [[ "$tinygo_pending_install" == true ]]; then + fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" + tinygo_pending_install=false + fi + reset_tinygo_step +} + +scan_tinygo_install_steps() { local file="$1" - local line command indent step_indent property_indent command_indent expected_command valid_indent - local state=0 count=0 + local relative="${file#"$ROOT_DIR/"}" + local line line_number=0 content syntax indent key value sequence_prefix + local key_indent stack_count stack_index parent_is_steps=false + local scalar_indent=-1 scalar_is_step_run=false + local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' + local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' + local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' + local full_comment_re='^[[:space:]]*#(.*)$' + local path_keys=() + local path_indents=() + + tinygo_sequence_count=0 + tinygo_pending_install=false + reset_tinygo_step while IFS= read -r line || [[ -n "$line" ]]; do - command="$(trim_whitespace "$line")" + line_number=$((line_number + 1)) indent="${line%%[![:space:]]*}" - if (( state > 0 )); then - if (( state == 8 )) && [[ -z "$command" ]]; then - continue + syntax="$(trim_whitespace "$line")" + if (( scalar_indent >= 0 )) && + [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then + if [[ "$scalar_is_step_run" == true && + -n "$syntax" && "$syntax" != \#* ]]; then + tinygo_step_run_lines[${#tinygo_step_run_lines[@]}]="$syntax" fi - case "$state" in - 1) expected_command="shell: $tinygo_shell_template"; property_indent="$indent" ;; - 2) expected_command='run: |' ;; - 3) expected_command="$tinygo_cleanup_command"; command_indent="$indent" ;; - 4) expected_command="$tinygo_download_command" ;; - 5) expected_command="$tinygo_stage_command" ;; - 6) expected_command="$tinygo_verify_command" ;; - 7) expected_command="$tinygo_install_command" ;; - 8) expected_command='- name: Verify TinyGo' ;; - 9) expected_command="run: $tinygo_version_command" ;; - esac - if (( state == 1 )); then - valid_indent=false - if [[ "$indent" == "$step_indent"* ]] && - (( ${#indent} > ${#step_indent} )); then - valid_indent=true - fi - elif (( state == 2 || state == 9 )); then - valid_indent=false - if [[ "$indent" == "$property_indent" ]]; then - valid_indent=true - fi - elif (( state == 8 )); then - valid_indent=false - if [[ "$indent" == "$step_indent" ]]; then - valid_indent=true - fi - else - valid_indent=false - if [[ "$indent" == "$command_indent" && "$indent" == "$property_indent"* ]] && - (( ${#indent} > ${#property_indent} )); then - valid_indent=true - fi + continue + fi + scalar_indent=-1 + scalar_is_step_run=false + + content="$line" + if [[ "$line" =~ $inline_comment_re ]]; then + content="${BASH_REMATCH[1]}" + elif [[ "$line" =~ $full_comment_re ]]; then + content="" + fi + syntax="$(trim_whitespace "$content")" + if [[ -z "$syntax" || ! "$syntax" =~ $mapping_re ]]; then + continue + fi + sequence_prefix="${BASH_REMATCH[1]}" + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[4]}" + key_indent=$((${#indent} + ${#sequence_prefix})) + + while (( ${#path_indents[@]} > 0 )); do + stack_index=$((${#path_indents[@]} - 1)) + if (( path_indents[stack_index] < key_indent )); then + break fi - if [[ "$command" == "$expected_command" && "$valid_indent" == true ]]; then - state=$((state + 1)) - if (( state == 10 )); then - count=$((count + 1)) - state=0 - fi - else - state=0 + unset 'path_indents[stack_index]' 'path_keys[stack_index]' + done + stack_count=${#path_keys[@]} + parent_is_steps=false + if (( stack_count == 3 )) && + [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then + parent_is_steps=true + fi + + if [[ "$tinygo_step_active" == true ]] && + (( key_indent < tinygo_step_indent )); then + finalize_tinygo_step "$relative" + fi + if [[ -n "$sequence_prefix" && "$parent_is_steps" == true ]]; then + finalize_tinygo_step "$relative" + tinygo_step_active=true + tinygo_step_indent=$key_indent + tinygo_step_line=$line_number + fi + + if [[ "$tinygo_step_active" == true ]] && + (( key_indent == tinygo_step_indent )); then + record_tinygo_step_property "$key" "$value" + fi + if [[ "$value" =~ $scalar_re ]]; then + scalar_indent=$key_indent + if [[ "$tinygo_step_active" == true && + "$key" == run ]] && + (( key_indent == tinygo_step_indent )); then + scalar_is_step_run=true fi + continue fi - if [[ "$command" == '- name: Install TinyGo' ]]; then - step_indent="$indent" - state=1 + if [[ -z "$value" ]]; then + path_indents[stack_count]=$key_indent + path_keys[stack_count]="$key" fi done < "$file" - - printf '%d' "$count" + finalize_tinygo_step "$relative" + if [[ "$tinygo_pending_install" == true ]]; then + fail "$relative Verify TinyGo must immediately follow Install TinyGo" + tinygo_pending_install=false + fi } accepted_tinygo_install_sequences=0 @@ -609,7 +972,8 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do fail "$relative must contain exactly one accepted TinyGo version-report command" fi - sequence_count="$(count_tinygo_install_sequences "$file")" + scan_tinygo_install_steps "$file" + sequence_count="$tinygo_sequence_count" if [[ "$sequence_count" != "1" ]]; then fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, and cleanup sequence immediately followed by an ordinary TinyGo version smoke" else From 6dc44d281bb0e610789a1675366a903c0f83d441 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Tue, 15 Sep 2026 14:38:25 +0300 Subject: [PATCH 13/21] ci(release): bind TinyGo job execution --- .../scripts/ci-supply-chain-check.tests.sh | 297 +++++++++++++++++- .github/workflows/ci-browser-smoke.yml | 1 + .github/workflows/ci-core.yml | 1 + .github/workflows/ci-wasm-size.yml | 1 + scripts/ci-supply-chain-check.sh | 99 +++++- 5 files changed, 393 insertions(+), 6 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 74e54c5d..58797529 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -15,6 +15,7 @@ TINYGO_VERSION_COMMAND="$TINYGO_BINARY version" VERIFIED_DEB="/tmp/goframe-tinygo-0.42.0-verified.deb" CLEAN_PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' CLEAN_SHELL="/usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail {0}" +VERIFY_SHELL='/bin/bash --noprofile --norc -p -e -o pipefail {0}' CLEANUP_COMMAND="trap '/usr/bin/sudo /usr/bin/rm -f $VERIFIED_DEB' EXIT" STAGE_COMMAND="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $VERIFIED_DEB" CURL_COMMAND_NAME='cur''l' @@ -121,6 +122,7 @@ write_tinygo_workflow() { printf ' # uses: actions/commented-out@v1\n' if [[ "$smoke_mode" == "before-install" ]]; then printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" fi if [[ -n "$shell_template" ]]; then @@ -167,15 +169,18 @@ write_tinygo_workflow() { ;; separate) printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" ;; ambient) printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" printf ' run: tinygo version\n' ;; missing|before-install) ;; malformed) printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" printf ' run: echo wrong command\n' ;; custom-shell) @@ -429,18 +434,151 @@ add_install_property() { mv "$TMP_ROOT/install-property.yml" "$file" } +replace_verify_shell() { + local file="$1" + local shell="$2" + + awk -v shell="$shell" ' + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && $0 ~ /^ shell:/ { + print " shell: " shell + in_verify = 0 + next + } + { print } + ' "$file" > "$TMP_ROOT/verify-shell.yml" + mv "$TMP_ROOT/verify-shell.yml" "$file" +} + +remove_verify_shell() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && $0 ~ /^ shell:/ { + in_verify = 0 + next + } + { print } + ' "$file" > "$TMP_ROOT/verify-shell.yml" + mv "$TMP_ROOT/verify-shell.yml" "$file" +} + +add_job_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + if [[ "$position" == before ]]; then + awk -v property="$property" -v child="$child" ' + { print } + $0 == " fixture:" { + print " " property + if (child != "") { + print " " child + } + } + ' "$file" > "$TMP_ROOT/job-property.yml" + mv "$TMP_ROOT/job-property.yml" "$file" + return + fi + + printf ' %s\n' "$property" >> "$file" + if [[ -n "$child" ]]; then + printf ' %s\n' "$child" >> "$file" + fi +} + +add_workflow_run_default() { + local file="$1" + + awk ' + $0 == "jobs:" { + print "defaults:" + print " run:" + print " shell: sh" + } + { print } + ' "$file" > "$TMP_ROOT/workflow-default.yml" + mv "$TMP_ROOT/workflow-default.yml" "$file" +} + +add_job_run_default() { + local file="$1" + + awk ' + { print } + $0 == " fixture:" { + print " defaults:" + print " run:" + print " shell: sh" + } + ' "$file" > "$TMP_ROOT/job-default.yml" + mv "$TMP_ROOT/job-default.yml" "$file" +} + +add_job_strategy_matrix() { + local file="$1" + + awk ' + { print } + $0 == " fixture:" { + print " strategy:" + print " fail-fast: false" + print " matrix:" + print " go:" + print " - 1.26.6" + } + ' "$file" > "$TMP_ROOT/job-strategy.yml" + mv "$TMP_ROOT/job-strategy.yml" "$file" +} + +add_unrelated_control_jobs() { + local file="$1" + + printf '%s\n' \ + ' prerequisite:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo prerequisite' \ + ' unrelated:' \ + ' if: true' \ + ' continue-on-error: false' \ + ' needs: prerequisite' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo unrelated' \ + >> "$file" +} + +split_verify_into_new_job() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { + print " verify-tinygo:" + print " runs-on: ubuntu-latest" + print " steps:" + } + { print } + ' "$file" > "$TMP_ROOT/cross-job-verify.yml" + mv "$TMP_ROOT/cross-job-verify.yml" "$file" +} + reorder_verify_properties() { local file="$1" awk ' $0 == " - name: Verify TinyGo" { name = " name: Verify TinyGo" - if ((getline run_line) <= 0) { + if ((getline shell_line) <= 0 || (getline run_line) <= 0) { exit 1 } sub(/^ run:/, " - run:", run_line) print run_line print name + print shell_line next } { print } @@ -599,6 +737,144 @@ test_ambient_sudo_interposition() { printf 'ok %d - ambient sudo can replace bytes after verification\n' "$tests_run" } +test_verify_shell_boundary() { + local oracle_dir="$TMP_ROOT/verify-shell-oracle" + local target="$oracle_dir/tinygo" + local marker="$oracle_dir/interposed" + local bash_env="$oracle_dir/bash-env" + local target_script="$oracle_dir/run-target.sh" + local environment_script="$oracle_dir/check-environment.sh" + local output + + mkdir -p "$oracle_dir" + printf '%s\n' '#!/bin/sh' 'printf "real tinygo\\n"' > "$target" + chmod +x "$target" + printf 'function %s {\n' "$target" > "$bash_env" + printf '%s\n' \ + ' printf "fake tinygo\n"' \ + ' : > "$VERIFY_MARKER"' \ + ' return 0' \ + '}' \ + >> "$bash_env" + printf '%s\n' '"$VERIFY_TARGET" version' > "$target_script" + printf '%s\n' \ + '[[ "$VERIFY_ENV_SENTINEL" == "setup-go-visible" ]]' \ + 'printf "%s\n" "$VERIFY_ENV_SENTINEL"' \ + > "$environment_script" + + rm -f "$marker" + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_MARKER="$marker" \ + VERIFY_TARGET="$target" /bin/bash --noprofile --norc -e -o pipefail \ + "$target_script")"; then + printf 'not ok %d - ordinary Bash BASH_ENV interposition oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ ! -f "$marker" || "$output" != 'fake tinygo' ]]; then + printf 'not ok %d - ordinary Bash did not load slash-named BASH_ENV function\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - ordinary Bash loads slash-named BASH_ENV function\n' "$tests_run" + + rm -f "$marker" + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_MARKER="$marker" \ + VERIFY_TARGET="$target" /bin/bash --noprofile --norc -p -e -o pipefail \ + "$target_script")"; then + printf 'not ok %d - privileged Verify shell oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ -e "$marker" || "$output" != 'real tinygo' ]]; then + printf 'not ok %d - privileged Verify shell allowed BASH_ENV interposition\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - privileged Verify shell blocks BASH_ENV interposition\n' \ + "$tests_run" + + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_ENV_SENTINEL=setup-go-visible \ + /bin/bash --noprofile --norc -p -e -o pipefail "$environment_script")"; then + printf 'not ok %d - privileged Verify shell environment oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ "$output" != setup-go-visible ]]; then + printf 'not ok %d - privileged Verify shell lost normal environment\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - privileged Verify shell preserves normal environment\n' \ + "$tests_run" +} + +expect_pass "protected TinyGo job without conditional controls" \ + "$(make_fixture protected-job-controls "$FULL_ACTION_REF")" + +fixture="$(make_fixture protected-job-if-before "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: false' +expect_fail "protected TinyGo job if false before ordinary properties" "$fixture" + +fixture="$(make_fixture protected-job-if-after "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after 'if: false' +expect_fail "protected TinyGo job if false after steps" "$fixture" + +fixture="$(make_fixture protected-job-if-true "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: true' +expect_fail "protected TinyGo job if true" "$fixture" + +fixture="$(make_fixture protected-job-if-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: ${{ always() }}' +expect_fail "protected TinyGo job if expression" "$fixture" + +fixture="$(make_fixture protected-job-continue-true "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'continue-on-error: true' +expect_fail "protected TinyGo job continue-on-error true" "$fixture" + +fixture="$(make_fixture protected-job-continue-false "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after \ + 'continue-on-error: false' +expect_fail "protected TinyGo job continue-on-error false" "$fixture" + +fixture="$(make_fixture protected-job-continue-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'continue-on-error: ${{ matrix.experimental }}' +expect_fail "protected TinyGo job continue-on-error expression" "$fixture" + +fixture="$(make_fixture protected-job-needs-scalar "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'needs: prerequisite' +expect_fail "protected TinyGo job scalar needs" "$fixture" + +fixture="$(make_fixture protected-job-needs-list "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after 'needs:' \ + '- prerequisite' +expect_fail "protected TinyGo job sequence needs" "$fixture" + +fixture="$(make_fixture cross-job-tinygo-sequence "$FULL_ACTION_REF")" +split_verify_into_new_job "$fixture/.github/workflows/ci-core.yml" +expect_fail "Install and Verify TinyGo must share one containing job" "$fixture" + +fixture="$(make_fixture unrelated-job-controls "$FULL_ACTION_REF")" +add_unrelated_control_jobs "$fixture/.github/workflows/ci-core.yml" +expect_pass "unrelated jobs may use if continue-on-error and needs" "$fixture" + +fixture="$(make_fixture protected-job-matrix "$FULL_ACTION_REF")" +add_job_strategy_matrix "$fixture/.github/workflows/ci-core.yml" +expect_pass "protected TinyGo job preserves strategy and matrix" "$fixture" + +fixture="$(make_fixture workflow-run-default "$FULL_ACTION_REF")" +add_workflow_run_default "$fixture/.github/workflows/ci-core.yml" +expect_pass "workflow run shell default does not invalidate protected steps" "$fixture" + +fixture="$(make_fixture job-run-default "$FULL_ACTION_REF")" +add_job_run_default "$fixture/.github/workflows/ci-core.yml" +expect_pass "job run shell default does not invalidate protected steps" "$fixture" + fixture="$(make_fixture python-helper-tinygo-provenance "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.py \ 'import subprocess' \ @@ -713,11 +989,30 @@ expect_fail "test harness is not exempt from TinyGo provenance policy" "$fixture expect_pass "protected install followed by separate TinyGo version smoke" \ "$(make_fixture split-version-smoke "$FULL_ACTION_REF")" + +fixture="$(make_fixture missing-verify-shell "$FULL_ACTION_REF")" +remove_verify_shell "$fixture/.github/workflows/ci-core.yml" +expect_fail "Verify TinyGo without explicit shell" "$fixture" + +fixture="$(make_fixture ambient-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" bash +expect_fail "Verify TinyGo with PATH-resolved Bash shell" "$fixture" + +fixture="$(make_fixture incomplete-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash -e {0}' +expect_fail "Verify TinyGo with incomplete Bash shell" "$fixture" + +fixture="$(make_fixture sanitized-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" "$CLEAN_SHELL" +expect_fail "Verify TinyGo with install boundary shell" "$fixture" + expect_fail "ambient PATH TinyGo version smoke" \ "$(make_fixture ambient-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected ambient)" fixture="$(make_fixture path-poisoned-version-smoke "$FULL_ACTION_REF")" add_tinygo_path_poisoning "$fixture/.github/workflows/ci-core.yml" expect_pass "PATH poisoning cannot redirect package-owned TinyGo smoke" "$fixture" +test_verify_shell_boundary expect_fail "TinyGo version smoke inside sanitized install" \ "$(make_fixture in-boundary-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected inside)" expect_fail "protected install without TinyGo version smoke" \ diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 099953e7..749a8e36 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -49,6 +49,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo + shell: /bin/bash --noprofile --norc -p -e -o pipefail {0} run: /usr/local/bin/tinygo version - name: Install goxc diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 9f9e6a24..c1c3d1d7 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -148,6 +148,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo + shell: /bin/bash --noprofile --norc -p -e -o pipefail {0} run: /usr/local/bin/tinygo version - name: TinyGo source-selection parity diff --git a/.github/workflows/ci-wasm-size.yml b/.github/workflows/ci-wasm-size.yml index 033cff04..f6e8bc77 100644 --- a/.github/workflows/ci-wasm-size.yml +++ b/.github/workflows/ci-wasm-size.yml @@ -40,6 +40,7 @@ jobs: /usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb - name: Verify TinyGo + shell: /bin/bash --noprofile --norc -p -e -o pipefail {0} run: /usr/local/bin/tinygo version - name: Install goxc diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 63176058..7b89da7c 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -17,6 +17,7 @@ tinygo_download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github tinygo_stage_command="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $TINYGO_VERIFIED_DEB" tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 $TINYGO_VERIFIED_DEB' || exit 1" tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" +tinygo_verify_shell_template='/bin/bash --noprofile --norc -p -e -o pipefail {0}' tinygo_version_command='/usr/local/bin/tinygo version' usage() { @@ -755,6 +756,64 @@ reset_tinygo_step() { tinygo_step_run_lines=() } +reset_tinygo_job() { + tinygo_job_active=false + tinygo_job_indent=-1 + tinygo_job_line=0 + tinygo_job_id="" + tinygo_job_sequence_count=0 + tinygo_job_if_line=0 + tinygo_job_continue_on_error_line=0 + tinygo_job_needs_line=0 +} + +record_tinygo_job_property() { + local key="$1" + local line_number="$2" + + case "$key" in + if) + if (( tinygo_job_if_line == 0 )); then + tinygo_job_if_line=$line_number + fi + ;; + continue-on-error) + if (( tinygo_job_continue_on_error_line == 0 )); then + tinygo_job_continue_on_error_line=$line_number + fi + ;; + needs) + if (( tinygo_job_needs_line == 0 )); then + tinygo_job_needs_line=$line_number + fi + ;; + esac +} + +finalize_tinygo_job() { + local relative="$1" + + if [[ "$tinygo_job_active" != true ]]; then + return + fi + if [[ "$tinygo_pending_install" == true ]]; then + fail "$relative:$tinygo_job_line protected TinyGo job $tinygo_job_id must keep Verify TinyGo immediately after Install TinyGo" + tinygo_pending_install=false + fi + if (( tinygo_job_sequence_count > 0 )); then + if (( tinygo_job_if_line > 0 )); then + fail "$relative:$tinygo_job_if_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level if" + fi + if (( tinygo_job_continue_on_error_line > 0 )); then + fail "$relative:$tinygo_job_continue_on_error_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level continue-on-error" + fi + if (( tinygo_job_needs_line > 0 )); then + fail "$relative:$tinygo_job_needs_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level needs" + fi + fi + reset_tinygo_job +} + record_tinygo_step_property() { local key="$1" local value="$2" @@ -826,20 +885,25 @@ finalize_tinygo_step() { tinygo_pending_install=true fi elif [[ "$tinygo_step_verify_name" == true ]]; then - if (( tinygo_step_property_count != 2 || + if (( tinygo_step_property_count != 3 || tinygo_step_name_count != 1 || - tinygo_step_shell_count != 0 || + tinygo_step_shell_count != 1 || tinygo_step_run_count != 1 )) || - [[ "$tinygo_step_run_value" != "$tinygo_version_command" ]]; then + [[ "$tinygo_step_shell_value" != "$tinygo_verify_shell_template" || + "$tinygo_step_run_value" != "$tinygo_version_command" ]]; then valid=false fi if [[ "$valid" != true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo must contain only the package-owned version command" + fail "$relative:$tinygo_step_line Verify TinyGo must contain only the exact privileged shell and package-owned version command" tinygo_pending_install=false elif [[ "$tinygo_pending_install" != true ]]; then fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" + elif [[ "$tinygo_job_active" != true ]]; then + fail "$relative:$tinygo_step_line Verify TinyGo is outside a workflow job" + tinygo_pending_install=false else tinygo_sequence_count=$((tinygo_sequence_count + 1)) + tinygo_job_sequence_count=$((tinygo_job_sequence_count + 1)) tinygo_pending_install=false fi elif [[ "$tinygo_pending_install" == true ]]; then @@ -853,7 +917,7 @@ scan_tinygo_install_steps() { local file="$1" local relative="${file#"$ROOT_DIR/"}" local line line_number=0 content syntax indent key value sequence_prefix - local key_indent stack_count stack_index parent_is_steps=false + local key_indent stack_count stack_index parent_is_steps=false new_job=false local scalar_indent=-1 scalar_is_step_run=false local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' @@ -865,6 +929,7 @@ scan_tinygo_install_steps() { tinygo_sequence_count=0 tinygo_pending_install=false reset_tinygo_step + reset_tinygo_job while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) indent="${line%%[![:space:]]*}" @@ -908,11 +973,34 @@ scan_tinygo_install_steps() { [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then parent_is_steps=true fi + new_job=false + if [[ -z "$sequence_prefix" ]] && + (( stack_count == 1 )) && + [[ "${path_keys[0]}" == jobs ]]; then + new_job=true + fi if [[ "$tinygo_step_active" == true ]] && (( key_indent < tinygo_step_indent )); then finalize_tinygo_step "$relative" fi + if [[ "$tinygo_job_active" == true ]] && + (( key_indent <= tinygo_job_indent )); then + finalize_tinygo_job "$relative" + fi + if [[ "$new_job" == true ]]; then + tinygo_job_active=true + tinygo_job_indent=$key_indent + tinygo_job_line=$line_number + tinygo_job_id="$key" + fi + if [[ "$tinygo_job_active" == true ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == jobs && + "${path_keys[1]}" == "$tinygo_job_id" ]] && + (( key_indent > tinygo_job_indent )); then + record_tinygo_job_property "$key" "$line_number" + fi if [[ -n "$sequence_prefix" && "$parent_is_steps" == true ]]; then finalize_tinygo_step "$relative" tinygo_step_active=true @@ -939,6 +1027,7 @@ scan_tinygo_install_steps() { fi done < "$file" finalize_tinygo_step "$relative" + finalize_tinygo_job "$relative" if [[ "$tinygo_pending_install" == true ]]; then fail "$relative Verify TinyGo must immediately follow Install TinyGo" tinygo_pending_install=false From 15b4458d66fc474e5601f10beab4fe3c43b364e9 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 19 Sep 2026 17:30:13 +0300 Subject: [PATCH 14/21] ci(release): close final supply-chain blind spots --- .../scripts/ci-supply-chain-check.tests.sh | 363 ++++++++++++++++-- scripts/ci-supply-chain-check.sh | 141 ++++++- 2 files changed, 449 insertions(+), 55 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 58797529..5700bfa5 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -19,6 +19,7 @@ VERIFY_SHELL='/bin/bash --noprofile --norc -p -e -o pipefail {0}' CLEANUP_COMMAND="trap '/usr/bin/sudo /usr/bin/rm -f $VERIFIED_DEB' EXIT" STAGE_COMMAND="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $VERIFIED_DEB" CURL_COMMAND_NAME='cur''l' +WGET_COMMAND_NAME='wg''et' FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" @@ -43,10 +44,10 @@ write_tinygo_workflow() { mkdir -p "$(dirname "$path")" case "$trust_root_mode" in literal|variable-digest) - download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" + download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" ;; variable-url|variable-both) - download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" + download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" ;; *) printf 'unsupported trust-root mode: %s\n' "$trust_root_mode" >&2 @@ -115,6 +116,7 @@ write_tinygo_workflow() { fi printf 'jobs:\n' printf ' fixture:\n' + printf ' runs-on: ubuntu-latest\n' printf ' steps:\n' if [[ -n "$action_ref" ]]; then printf ' - uses: %s\n' "$action_ref" @@ -490,6 +492,56 @@ add_job_property() { fi } +replace_fixture_runs_on() { + local file="$1" + local value="$2" + local child="${3:-}" + + awk -v value="$value" -v child="$child" ' + $0 == " fixture:" { in_fixture = 1 } + in_fixture && !replaced && $0 ~ /^ runs-on:/ { + if (value == "") { + print " runs-on:" + } else { + print " runs-on: " value + } + if (child != "") { + print " " child + } + replaced = 1 + next + } + { print } + ' "$file" > "$TMP_ROOT/runs-on.yml" + mv "$TMP_ROOT/runs-on.yml" "$file" +} + +remove_fixture_runs_on() { + local file="$1" + + awk ' + $0 == " fixture:" { in_fixture = 1 } + in_fixture && !removed && $0 ~ /^ runs-on:/ { + removed = 1 + next + } + { print } + ' "$file" > "$TMP_ROOT/runs-on.yml" + mv "$TMP_ROOT/runs-on.yml" "$file" +} + +add_unrelated_runner_jobs() { + local file="$1" + + printf '%s\n' \ + ' unrelated-container:' \ + ' runs-on: self-hosted' \ + ' container: attacker/unrelated:latest' \ + ' steps:' \ + ' - run: echo unrelated' \ + >> "$file" +} + add_workflow_run_default() { local file="$1" @@ -639,12 +691,12 @@ test_clean_shell_boundary() { printf '%s\n' \ 'mark_override() { : > "$P1_MARKER_DIR/$1"; }' \ 'sudo() { mark_override sudo; return 99; }' \ - 'curl() { mark_override curl; return 99; }' \ + "$CURL_COMMAND_NAME() { mark_override $CURL_COMMAND_NAME; return 99; }" \ 'sha256sum() { mark_override sha256sum; return 99; }' \ 'printf() { mark_override printf; return 99; }' \ 'function /usr/bin/env() { mark_override env; return 99; }' \ > "$bash_env" - for command_name in sudo curl sha256sum printf; do + for command_name in sudo "$CURL_COMMAND_NAME" sha256sum printf; do printf '%s\n' '#!/bin/sh' ": > \"$marker_dir/path-$command_name\"" 'exit 99' \ > "$fake_bin/$command_name" chmod +x "$fake_bin/$command_name" @@ -652,7 +704,7 @@ test_clean_shell_boundary() { printf '%s\n' \ '[[ -z "${BASH_ENV+x}" ]]' \ "[[ \"\$PATH\" == '$CLEAN_PATH' ]]" \ - 'for function_name in sudo curl sha256sum printf /usr/bin/env; do' \ + "for function_name in sudo $CURL_COMMAND_NAME sha256sum printf /usr/bin/env; do" \ ' ! declare -F "$function_name" >/dev/null' \ 'done' \ > "$child_script" @@ -814,6 +866,80 @@ test_verify_shell_boundary() { expect_pass "protected TinyGo job without conditional controls" \ "$(make_fixture protected-job-controls "$FULL_ACTION_REF")" +fixture="$(make_fixture workflow-standalone-mutable-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + ' uses: actions/checkout@v7' +expect_fail "workflow standalone step indicator with mutable Action" "$fixture" + +fixture="$(make_fixture workflow-standalone-pinned-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + " uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" +expect_fail "workflow standalone step indicator with pinned Action" "$fixture" + +fixture="$(make_fixture workflow-commented-standalone-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - # comment' \ + ' uses: actions/checkout@v7' +expect_fail "workflow commented standalone step indicator" "$fixture" + +fixture="$(make_fixture workflow-standalone-run-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + ' run: echo unsupported' +expect_fail "workflow standalone run step indicator" "$fixture" + +fixture="$(make_fixture composite-standalone-mutable-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/standalone/action.yml \ + 'name: standalone' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' -' \ + ' uses: actions/checkout@v7' +expect_fail "composite standalone step indicator with mutable Action" "$fixture" + +fixture="$(make_fixture composite-standalone-pinned-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/standalone/action.yml \ + 'name: standalone' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' -' \ + " uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" +expect_fail "composite standalone step indicator with pinned Action" "$fixture" + +fixture="$(make_fixture ordinary-standalone-sequence "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data-sequence.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' strategy:' \ + ' matrix:' \ + ' include:' \ + ' -' \ + ' value: ordinary' \ + ' steps:' \ + ' - run: echo ordinary' +expect_pass "standalone sequence data outside steps" "$fixture" + fixture="$(make_fixture protected-job-if-before "$FULL_ACTION_REF")" add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: false' expect_fail "protected TinyGo job if false before ordinary properties" "$fixture" @@ -855,6 +981,64 @@ add_job_property "$fixture/.github/workflows/ci-core.yml" after 'needs:' \ '- prerequisite' expect_fail "protected TinyGo job sequence needs" "$fixture" +fixture="$(make_fixture protected-job-self-hosted "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" self-hosted +expect_fail "protected TinyGo job self-hosted runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-expression "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + '${{ matrix.runner }}' +expect_fail "protected TinyGo job runner expression" "$fixture" + +fixture="$(make_fixture protected-job-quoted-runner "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + "'ubuntu-latest'" +expect_fail "protected TinyGo job quoted runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-flow-sequence "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + '[ubuntu-latest]' +expect_fail "protected TinyGo job flow-sequence runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-block-sequence "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" '' \ + '- ubuntu-latest' +expect_fail "protected TinyGo job block-sequence runner" "$fixture" + +fixture="$(make_fixture protected-job-missing-runner "$FULL_ACTION_REF")" +remove_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" +expect_fail "protected TinyGo job missing runs-on" "$fixture" + +fixture="$(make_fixture protected-job-duplicate-runner "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'runs-on: ubuntu-latest' +expect_fail "protected TinyGo job duplicate runs-on" "$fixture" + +fixture="$(make_fixture protected-job-container-scalar "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container scalar" "$fixture" + +fixture="$(make_fixture protected-job-container-mapping "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'container:' \ + 'image: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container mapping" "$fixture" + +fixture="$(make_fixture protected-job-container-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: ${{ matrix.container }}' +expect_fail "protected TinyGo job container expression" "$fixture" + +fixture="$(make_fixture protected-job-container-null "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: null' +expect_fail "protected TinyGo job container null" "$fixture" + +fixture="$(make_fixture protected-job-container-after-steps "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after \ + 'container: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container after steps" "$fixture" + fixture="$(make_fixture cross-job-tinygo-sequence "$FULL_ACTION_REF")" split_verify_into_new_job "$fixture/.github/workflows/ci-core.yml" expect_fail "Install and Verify TinyGo must share one containing job" "$fixture" @@ -863,6 +1047,10 @@ fixture="$(make_fixture unrelated-job-controls "$FULL_ACTION_REF")" add_unrelated_control_jobs "$fixture/.github/workflows/ci-core.yml" expect_pass "unrelated jobs may use if continue-on-error and needs" "$fixture" +fixture="$(make_fixture unrelated-job-runner-container "$FULL_ACTION_REF")" +add_unrelated_runner_jobs "$fixture/.github/workflows/ci-core.yml" +expect_pass "unrelated jobs may use self-hosted runners and containers" "$fixture" + fixture="$(make_fixture protected-job-matrix "$FULL_ACTION_REF")" add_job_strategy_matrix "$fixture/.github/workflows/ci-core.yml" expect_pass "protected TinyGo job preserves strategy and matrix" "$fixture" @@ -879,7 +1067,7 @@ fixture="$(make_fixture python-helper-tinygo-provenance "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.py \ 'import subprocess' \ "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" \ - 'subprocess.run(["curl", base + "/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"], check=True)' + "subprocess.run([\"$CURL_COMMAND_NAME\", base + \"/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ 'python3 scripts/install-tinygo.py' expect_fail "invoked Python helper with TinyGo repository provenance" "$fixture" @@ -887,21 +1075,21 @@ expect_fail "invoked Python helper with TinyGo repository provenance" "$fixture" fixture="$(make_fixture python-helper-direct-tinygo-url "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.py \ 'import subprocess' \ - "subprocess.run([\"curl\", \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" + "subprocess.run([\"$CURL_COMMAND_NAME\", \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" expect_fail "Python helper with direct TinyGo release URL" "$fixture" fixture="$(make_fixture python-helper-case-variant-provenance "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.py \ 'import subprocess' \ "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY_CASE_VARIANT\"" \ - 'subprocess.run(["curl", base + "/releases/download/artifact"], check=True)' + "subprocess.run([\"$CURL_COMMAND_NAME\", base + \"/releases/download/artifact\"], check=True)" expect_fail "Python helper with case-variant TinyGo repository provenance" "$fixture" fixture="$(make_fixture javascript-helper-tinygo-provenance "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.mjs \ 'import { spawnSync } from "node:child_process";' \ "const base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\";" \ - 'spawnSync("curl", [base + "/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"]);' + "spawnSync(\"$CURL_COMMAND_NAME\", [base + \"/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"]);" add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ 'node scripts/install-tinygo.mjs' expect_fail "invoked JavaScript helper with TinyGo repository provenance" "$fixture" @@ -910,7 +1098,7 @@ fixture="$(make_fixture workflow-provided-tinygo-provenance "$FULL_ACTION_REF")" write_repository_file "$fixture" scripts/install-tinygo.py \ 'import os' \ 'import subprocess' \ - 'subprocess.run(["curl", os.environ["TINYGO_BASE"] + "/releases/download/artifact"]);' + "subprocess.run([\"$CURL_COMMAND_NAME\", os.environ[\"TINYGO_BASE\"] + \"/releases/download/artifact\"]);" printf '%s\n' '' ' delegated-python:' ' runs-on: ubuntu-latest' \ ' env:' \ " TINYGO_BASE: https://github.com/$TINYGO_RELEASE_REPOSITORY" \ @@ -987,6 +1175,18 @@ write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" expect_fail "test harness is not exempt from TinyGo provenance policy" "$fixture" +fixture="$(make_fixture checker-active-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh \ + '#!/usr/bin/env bash' \ + "$CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "checker source is not exempt from downloader policy" "$fixture" + +fixture="$(make_fixture harness-active-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + '#!/usr/bin/env bash' \ + "$CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "test harness is not exempt from downloader policy" "$fixture" + expect_pass "protected install followed by separate TinyGo version smoke" \ "$(make_fixture split-version-smoke "$FULL_ACTION_REF")" @@ -1206,13 +1406,13 @@ dynamic_base="https://github.com/${TINYGO_RELEASE_REPOSITORY}" fixture="$(make_fixture dynamic-workflow-download "$FULL_ACTION_REF")" printf '%s\n' '' ' dynamic-download:' ' steps:' ' - run: |' \ " base=$dynamic_base" \ - ' curl -fsSL -o /tmp/extra.deb "$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' \ + " $CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"\$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"" \ >> "$fixture/.github/workflows/ci-core.yml" expect_fail "runtime-composed TinyGo download in workflow" "$fixture" fixture="$(make_fixture dynamic-helper-download "$FULL_ACTION_REF")" dynamic_helper="$(printf '%s\n' "base=$dynamic_base" \ - 'curl -fsSL -o /tmp/extra.deb "$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"')" + "$CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"\$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"")" write_repository_file "$fixture" scripts/install-tinygo.sh "$dynamic_helper" add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ 'scripts/install-tinygo.sh' @@ -1222,53 +1422,125 @@ fixture="$(make_fixture continued-dynamic-download "$FULL_ACTION_REF")" printf '%s\n' '' ' continued-download:' ' steps:' ' - run: |' \ " base=$dynamic_base" \ ' url="$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' \ - ' curl \' ' "$url"' \ + " $CURL_COMMAND_NAME \\" ' "$url"' \ >> "$fixture/.github/workflows/ci-core.yml" expect_fail "continued runtime-composed TinyGo download" "$fixture" fixture="$(make_fixture dynamic-url-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/dynamic-download.sh \ - 'url=https://example.com/file' 'curl "$url"' -expect_fail 'curl with runtime URL' "$fixture" + 'url=https://example.com/file' "$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'downloader with runtime URL' "$fixture" fixture="$(make_fixture absolute-dynamic-url-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/dynamic-download.sh \ - 'url=https://example.com/file' '/usr/bin/curl "$url"' -expect_fail 'absolute curl with runtime URL' "$fixture" + 'url=https://example.com/file' "/usr/bin/$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'absolute downloader with runtime URL' "$fixture" + +fixture="$(make_fixture alternate-absolute-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "/usr/local/bin/$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'alternate absolute downloader path with runtime URL' "$fixture" + +fixture="$(make_fixture relative-path-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "./$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'relative downloader path with runtime URL' "$fixture" fixture="$(make_fixture command-dynamic-url-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/dynamic-download.sh \ - 'url=https://example.com/file' 'command curl "$url"' -expect_fail 'command curl with runtime URL' "$fixture" + 'url=https://example.com/file' "command $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'command-prefixed downloader with runtime URL' "$fixture" fixture="$(make_fixture conditional-dynamic-url-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/dynamic-download.sh \ - 'url=https://example.com/file' 'if curl "$url"; then printf success; fi' -expect_fail 'conditional curl with runtime URL' "$fixture" + 'url=https://example.com/file' "if $CURL_COMMAND_NAME \"\$url\"; then printf success; fi" +expect_fail 'conditional downloader with runtime URL' "$fixture" fixture="$(make_fixture substitution-dynamic-url-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/dynamic-download.sh \ 'url=https://example.com/file' "result=\"\$($CURL_COMMAND_NAME \"\$url\")\"" -expect_fail 'command-substitution curl with runtime URL' "$fixture" +expect_fail 'command-substitution downloader with runtime URL' "$fixture" fixture="$(make_fixture literal-external-download "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/external-download.sh \ - 'curl https://example.com/file' -expect_fail 'literal external curl' "$fixture" + "$CURL_COMMAND_NAME https://example.com/file" +expect_fail 'literal external downloader' "$fixture" -fixture="$(make_fixture external-wget "$FULL_ACTION_REF")" +fixture="$(make_fixture external-alternate-downloader "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/external-download.sh \ - 'wget https://example.com/file' -expect_fail 'literal external wget' "$fixture" + "$WGET_COMMAND_NAME https://example.com/file" +expect_fail 'literal external alternate downloader' "$fixture" + +fixture="$(make_fixture env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'env-wrapped downloader' "$fixture" + +fixture="$(make_fixture absolute-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "/usr/bin/env $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'absolute env-wrapped downloader' "$fixture" + +fixture="$(make_fixture clean-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env -i $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'clean-env-wrapped downloader' "$fixture" + +fixture="$(make_fixture assigned-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env FOO=bar $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'assigned-env-wrapped downloader' "$fixture" + +for wrapper in exec sudo nohup 'timeout 10' 'nice -n 5' 'stdbuf -oL' xargs; do + fixture="$(make_fixture "${wrapper%% *}-wrapped-downloader" "$FULL_ACTION_REF")" + write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "$wrapper $CURL_COMMAND_NAME \"\$url\"" + expect_fail "$wrapper wrapped downloader" "$fixture" +done + +fixture="$(make_fixture bash-nested-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "bash -c '$CURL_COMMAND_NAME \"\$url\"'" +expect_fail 'Bash-nested downloader' "$fixture" + +fixture="$(make_fixture sh-nested-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "sh -c '$WGET_COMMAND_NAME \"\$url\"'" +expect_fail 'POSIX-shell-nested downloader' "$fixture" + +fixture="$(make_fixture variable-selected-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "downloader=$CURL_COMMAND_NAME" \ + 'url=https://example.com/file' \ + '"$downloader" "$url"' +expect_fail 'variable-selected downloader identity' "$fixture" + +fixture="$(make_fixture evaluated-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "cmd='$CURL_COMMAND_NAME https://example.com/file'" \ + 'eval "$cmd"' +expect_fail 'evaluated downloader string' "$fixture" + +fixture="$(make_fixture aliased-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "alias fetch='$CURL_COMMAND_NAME'" \ + 'fetch https://example.com/file' +expect_fail 'aliased downloader identity' "$fixture" + +fixture="$(make_fixture function-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "fetch() { $CURL_COMMAND_NAME \"\$url\"; }" \ + 'fetch' +expect_fail 'function-contained downloader identity' "$fixture" fixture="$(make_fixture simple-loopback-probe "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/loopback-probe.sh \ - 'curl -fsS "http://127.0.0.1:$port/" >/dev/null 2>&1' + "$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\" >/dev/null 2>&1" expect_pass 'literal-rooted loopback port probe' "$fixture" fixture="$(make_fixture path-loopback-probe "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/loopback-probe.sh \ - 'curl -fsS "http://127.0.0.1:$port/$wasm_path" >/dev/null 2>&1' + "$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\$wasm_path\" >/dev/null 2>&1" expect_pass 'literal-rooted loopback path probe' "$fixture" fixture="$(make_fixture substitution-loopback-probe "$FULL_ACTION_REF")" @@ -1276,21 +1548,44 @@ write_shell_source "$fixture" scripts/loopback-probe.sh \ "headers=\"\$($CURL_COMMAND_NAME -fsSI \"http://127.0.0.1:\$port/\$wasm_path?smoke=\$(date +%s%N)\" || true)\"" expect_pass 'literal-rooted loopback command-substitution probe' "$fixture" +fixture="$(make_fixture loopback-tool-requirement "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/browser-smoke.sh \ + "require_command $CURL_COMMAND_NAME \"Install $CURL_COMMAND_NAME for smoke server readiness checks.\"" +expect_pass 'exact loopback tool requirement' "$fixture" + +fixture="$(make_fixture loopback-tool-package "$FULL_ACTION_REF")" +printf ' - run: sudo apt-get install -y brotli zstd %s\n' \ + "$CURL_COMMAND_NAME" >> "$fixture/.github/workflows/ci-browser-smoke.yml" +expect_pass 'exact loopback tool package install' "$fixture" + fixture="$(make_fixture nested-external-loopback-download "$FULL_ACTION_REF")" nested_loopback_command="$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\$($CURL_COMMAND_NAME https://example.com/file)\" >/dev/null 2>&1" write_shell_source "$fixture" scripts/loopback-probe.sh "$nested_loopback_command" expect_fail 'external downloader nested in loopback probe' "$fixture" -fixture="$(make_fixture benign-curl-string "$FULL_ACTION_REF")" +fixture="$(make_fixture multiple-downloaders "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + "$CURL_COMMAND_NAME https://example.com/file || $WGET_COMMAND_NAME https://example.com/fallback" +expect_fail 'multiple external downloaders' "$fixture" + +fixture="$(make_fixture benign-downloader-string "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/benign.sh \ - 'message='"'"'curl "$url" is not executed'"'"'' 'printf "%s\\n" "$message"' -expect_pass 'benign string containing curl' "$fixture" + "message='$CURL_COMMAND_NAME \"\$url\" is not executed'" \ + 'printf "%s\\n" "$message"' +expect_fail 'downloader-shaped inert string is fail-closed' "$fixture" fixture="$(make_fixture benign-url-variable "$FULL_ACTION_REF")" write_shell_source "$fixture" scripts/benign.sh \ 'url=https://example.com/file' 'printf "%s\\n" "$url"' expect_pass 'benign URL assignment without downloader' "$fixture" +fixture="$(make_fixture downloader-comments "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + "# $CURL_COMMAND_NAME and $WGET_COMMAND_NAME are comments only" \ + "printf 'inline comment ignored\\n' # $CURL_COMMAND_NAME and $WGET_COMMAND_NAME" \ + 'printf "comments ignored\\n"' +expect_pass 'comments may mention downloader tools' "$fixture" + expect_fail "old TinyGo sequence without sanitized shell" \ "$(make_fixture old-install-boundary "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted legacy)" expect_fail "sanitized shell without protected staging" \ @@ -1435,7 +1730,7 @@ expect_fail "local Action path traversal" \ expect_fail "backslash local Action path" \ "$(make_fixture local-action-backslash '.\.github\actions\local')" -extra_download="curl -fsSL -o /tmp/extra.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" +extra_download="$CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" for placement in before after same-line canonical hash-prefix unexpected; do fixture="$(make_fixture extra-download "$FULL_ACTION_REF")" file="$fixture/.github/workflows/ci-core.yml" @@ -1450,7 +1745,7 @@ for placement in before after same-line canonical hash-prefix unexpected; do after) printf ' - run: %s\n' "$extra_download" >> "$file" ;; same-line) printf ' - run: %s; %s\n' "$extra_download" "$extra_download" >> "$file" ;; canonical) - command="$(sed -n '/\/usr\/bin\/curl -fsSL/p' "$file")" + command="$(sed -n "/\\/usr\\/bin\\/$CURL_COMMAND_NAME -fsSL/p" "$file")" printf '%s\n' "$command" >> "$file" ;; hash-prefix) printf ' - run: printf "# data"; %s\n' "$extra_download" >> "$file" ;; diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 7b89da7c..8371b90c 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -5,6 +5,8 @@ EXPECTED_TINYGO_VERSION="0.42.0" EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +CURL_COMMAND_NAME='cur''l' +WGET_COMMAND_NAME='wg''et' EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-browser-smoke.yml" ".github/workflows/ci-core.yml" @@ -13,7 +15,7 @@ EXPECTED_TINYGO_WORKFLOWS=( TINYGO_VERIFIED_DEB="/tmp/goframe-tinygo-${EXPECTED_TINYGO_VERSION}-verified.deb" tinygo_shell_template='/usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0}' tinygo_cleanup_command="trap '/usr/bin/sudo /usr/bin/rm -f $TINYGO_VERIFIED_DEB' EXIT" -tinygo_download_command="/usr/bin/curl -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" +tinygo_download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" tinygo_stage_command="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $TINYGO_VERIFIED_DEB" tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 $TINYGO_VERIFIED_DEB' || exit 1" tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" @@ -162,37 +164,49 @@ tinygo_release_url_owner_allowed() { return 1 } -shell_downloader_re='(^|;|&&|\|\||\||\(|\{|\$\(|`)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*((if|elif|while|until|then|do|!)[[:space:]]+)*(command[[:space:]]+)?(/usr/bin/)?(curl|wget)([[:space:]]|$)' +shell_downloader_token_re="(^|[^[:alnum:]_.-])(${CURL_COMMAND_NAME}|${WGET_COMMAND_NAME})([^[:alnum:]_./-]|$)" -shell_command_has_active_downloader() { +shell_command_has_downloader_token() { local command="$1" - # This is an authored-shell boundary, not a shell interpreter. Match command - # positions used by repository CI and scripts, including substitutions. - [[ "$command" =~ $shell_downloader_re ]] + # This is a bounded authored-shell policy, not a shell interpreter. Any + # textual downloader identity is security-relevant regardless of wrappers. + [[ "$command" =~ $shell_downloader_token_re ]] } -shell_command_has_multiple_active_downloaders() { +shell_command_downloader_token_count() { local remainder="$1" - local first_match + local match count=0 - if [[ ! "$remainder" =~ $shell_downloader_re ]]; then - return 1 - fi - first_match="${BASH_REMATCH[0]}" - remainder="${remainder#*"$first_match"}" - [[ "$remainder" =~ $shell_downloader_re ]] + while [[ "$remainder" =~ $shell_downloader_token_re ]]; do + match="${BASH_REMATCH[0]}" + count=$((count + 1)) + remainder="${remainder#*"$match"}" + done + printf '%d' "$count" } shell_command_is_loopback_probe() { local command="$1" - local direct_re='^(if[[:space:]]+)?(command[[:space:]]+)?(/usr/bin/)?curl([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(>/dev/null[[:space:]]+2>&1)?[[:space:]]*(;[[:space:]]*then)?$' - local substitution_re='^[A-Za-z_][A-Za-z0-9_]*="\$\([[:space:]]*(command[[:space:]]+)?(/usr/bin/)?curl([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(\|\|[[:space:]]+true)?[[:space:]]*\)"$' - if shell_command_has_multiple_active_downloaders "$command"; then + local direct_re='^(if[[:space:]]+)?(command[[:space:]]+)?(/usr/bin/)?'"$CURL_COMMAND_NAME"'([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(>/dev/null[[:space:]]+2>&1)?[[:space:]]*(;[[:space:]]*then)?$' + local substitution_re='^[A-Za-z_][A-Za-z0-9_]*="\$\([[:space:]]*(command[[:space:]]+)?(/usr/bin/)?'"$CURL_COMMAND_NAME"'([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(\|\|[[:space:]]+true)?[[:space:]]*\)"$' + if (( $(shell_command_downloader_token_count "$command") != 1 )); then return 1 fi [[ "$command" =~ $direct_re || "$command" =~ $substitution_re ]] } +shell_command_is_loopback_support_command() { + local relative="$1" + local command="$2" + + if [[ "$relative" == scripts/browser-smoke.sh ]] && + [[ "$command" == "require_command $CURL_COMMAND_NAME \"Install $CURL_COMMAND_NAME for smoke server readiness checks.\"" ]]; then + return 0 + fi + [[ "$relative" == .github/workflows/ci-browser-smoke.yml ]] && + [[ "$command" == "sudo apt-get install -y brotli zstd $CURL_COMMAND_NAME" ]] +} + inspect_shell_logical_command() { local relative="$1" local line_number="$2" @@ -200,7 +214,7 @@ inspect_shell_logical_command() { command="$(trim_whitespace "$command")" inspect_referenced_ci_helpers "$relative" "$line_number" "$command" - if [[ -z "$command" ]] || ! shell_command_has_active_downloader "$command"; then + if [[ -z "$command" ]] || ! shell_command_has_downloader_token "$command"; then return fi if tinygo_release_url_owner_allowed "$relative" && @@ -211,7 +225,10 @@ inspect_shell_logical_command() { loopback_download_count=$((loopback_download_count + 1)) return fi - fail "$relative:$line_number active curl/wget command is neither the accepted TinyGo fetch nor a literal-rooted loopback probe" + if shell_command_is_loopback_support_command "$relative" "$command"; then + return + fi + fail "$relative:$line_number downloader token is outside the accepted TinyGo fetch and literal-rooted loopback contract" } pending_shell_command="" @@ -222,12 +239,63 @@ reset_shell_logical_command() { pending_shell_line=0 } +strip_shell_comment() { + local line="$1" + local index character previous quote="" escaped=false + + for ((index = 0; index < ${#line}; index++)); do + character="${line:index:1}" + if [[ "$escaped" == true ]]; then + escaped=false + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + fi + continue + fi + if [[ "$quote" == '"' ]]; then + if [[ "$character" == \\ ]]; then + escaped=true + elif [[ "$character" == '"' ]]; then + quote="" + fi + continue + fi + if [[ "$character" == \\ ]]; then + escaped=true + continue + fi + if [[ "$character" == "'" || "$character" == '"' ]]; then + quote="$character" + continue + fi + if [[ "$character" != \# ]]; then + continue + fi + if (( index == 0 )); then + printf '%s' "${line:0:index}" + return + fi + previous="${line:index-1:1}" + case "$previous" in + [[:space:]]|';'|'|'|'&') + printf '%s' "${line:0:index}" + return + ;; + esac + done + printf '%s' "$line" +} + consume_shell_physical_line() { local relative="$1" local line_number="$2" local line="$3" local command + line="$(strip_shell_comment "$line")" command="$(trim_whitespace "$line")" if [[ -z "$pending_shell_command" && ( -z "$command" || "$command" == \#* ) ]]; then return @@ -654,6 +722,12 @@ scan_action_refs() { # Only canonical block mapping keys are authored for security-relevant # structure. Ordinary scalar/list data remains outside Action semantics. if [[ ! "$syntax" =~ $mapping_re ]]; then + if [[ "$syntax" == - && -n "$parent_kind" ]]; then + fail "$relative:$line_number unsupported standalone step sequence indicator; use canonical '- key:' step mappings" + action_step_active=false + action_step_kind="" + continue + fi case "${syntax#- }" in *:*|\{*|\[*|\?*|\&*|\**|\!*) fail "$relative:$line_number unsupported workflow mapping syntax; use canonical block keys" @@ -765,11 +839,16 @@ reset_tinygo_job() { tinygo_job_if_line=0 tinygo_job_continue_on_error_line=0 tinygo_job_needs_line=0 + tinygo_job_runs_on_count=0 + tinygo_job_runs_on_line=0 + tinygo_job_runs_on_value="" + tinygo_job_container_line=0 } record_tinygo_job_property() { local key="$1" - local line_number="$2" + local value="$2" + local line_number="$3" case "$key" in if) @@ -787,6 +866,18 @@ record_tinygo_job_property() { tinygo_job_needs_line=$line_number fi ;; + runs-on) + tinygo_job_runs_on_count=$((tinygo_job_runs_on_count + 1)) + if (( tinygo_job_runs_on_count == 1 )); then + tinygo_job_runs_on_line=$line_number + tinygo_job_runs_on_value="$value" + fi + ;; + container) + if (( tinygo_job_container_line == 0 )); then + tinygo_job_container_line=$line_number + fi + ;; esac } @@ -810,6 +901,14 @@ finalize_tinygo_job() { if (( tinygo_job_needs_line > 0 )); then fail "$relative:$tinygo_job_needs_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level needs" fi + if (( tinygo_job_runs_on_count != 1 )); then + fail "$relative:$tinygo_job_line protected TinyGo job $tinygo_job_id must contain exactly one canonical runs-on: ubuntu-latest" + elif [[ "$tinygo_job_runs_on_value" != ubuntu-latest ]]; then + fail "$relative:$tinygo_job_runs_on_line protected TinyGo job $tinygo_job_id must use canonical runs-on: ubuntu-latest" + fi + if (( tinygo_job_container_line > 0 )); then + fail "$relative:$tinygo_job_container_line protected TinyGo job $tinygo_job_id must run directly on the trusted hosted runner without a job container" + fi fi reset_tinygo_job } @@ -999,7 +1098,7 @@ scan_tinygo_install_steps() { [[ "${path_keys[0]}" == jobs && "${path_keys[1]}" == "$tinygo_job_id" ]] && (( key_indent > tinygo_job_indent )); then - record_tinygo_job_property "$key" "$line_number" + record_tinygo_job_property "$key" "$value" "$line_number" fi if [[ -n "$sequence_prefix" && "$parent_is_steps" == true ]]; then finalize_tinygo_step "$relative" From d0422f13bf9065a749df30fd9fe085b06c3eeee0 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 20 Sep 2026 14:48:56 +0300 Subject: [PATCH 15/21] ci(release): unify authored CI execution policy --- .../scripts/ci-supply-chain-check.tests.sh | 408 +++++ scripts/ci-supply-chain-check.sh | 1615 ++++++++++++----- 2 files changed, 1540 insertions(+), 483 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 5700bfa5..70d32195 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -866,6 +866,414 @@ test_verify_shell_boundary() { expect_pass "protected TinyGo job without conditional controls" \ "$(make_fixture protected-job-controls "$FULL_ACTION_REF")" +fixture="$(make_fixture execution-data-contexts "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution-data.yml \ + 'name: execution data' \ + 'env:' \ + " run: $CURL_COMMAND_NAME" \ + " shell: $CURL_COMMAND_NAME" \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + " run: $CURL_COMMAND_NAME" \ + " shell: $CURL_COMMAND_NAME" +expect_pass "run and shell keys in env and with are ordinary data" "$fixture" + +fixture="$(make_fixture action-execution-data-contexts "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/data/action.yml \ + 'name: data' \ + 'inputs:' \ + ' run:' \ + " default: $CURL_COMMAND_NAME" \ + 'outputs:' \ + ' shell:' \ + " value: $CURL_COMMAND_NAME" \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: bash' \ + ' run: echo ok' +expect_pass "run and shell keys in Action inputs and outputs are ordinary data" "$fixture" + +fixture="$(make_fixture workflow-step-run-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' run:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - run: $CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "workflow step run receives downloader policy" "$fixture" + +fixture="$(make_fixture workflow-step-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "workflow step shell receives downloader policy" "$fixture" + +fixture="$(make_fixture workflow-default-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' \ + 'defaults:' \ + ' run:' \ + " shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" +expect_fail "workflow defaults run shell receives downloader policy" "$fixture" + +fixture="$(make_fixture job-default-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' \ + ' defaults:' \ + ' run:' \ + " shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" +expect_fail "job defaults run shell receives downloader policy" "$fixture" + +fixture="$(make_fixture composite-run-downloader './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: bash' \ + " run: $CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "composite step run receives downloader policy" "$fixture" + +fixture="$(make_fixture composite-shell-downloader './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + " - shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "composite step shell receives downloader policy" "$fixture" + +fixture="$(make_fixture local-docker-action './.github/actions/docker')" +write_repository_file "$fixture" .github/actions/docker/action.yml \ + 'name: docker' \ + 'runs:' \ + ' using: docker' \ + ' image: Dockerfile' +write_repository_file "$fixture" .github/actions/docker/Dockerfile \ + 'FROM ubuntu:latest' \ + "RUN base=https://github.com/$TINYGO_RELEASE_REPOSITORY; \\" \ + " $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" +expect_fail "local Docker Action runtime is unsupported" "$fixture" + +fixture="$(make_fixture local-docker-image-action './.github/actions/docker-image')" +write_repository_file "$fixture" .github/actions/docker-image/action.yml \ + 'name: docker image' \ + 'runs:' \ + ' using: docker' \ + ' image: docker://alpine:latest' +expect_fail "local Docker image Action runtime is unsupported" "$fixture" + +for runtime in node20 node24; do + fixture="$(make_fixture "local-$runtime-action" './.github/actions/javascript')" + write_repository_file "$fixture" .github/actions/javascript/action.yml \ + 'name: javascript' \ + 'runs:' \ + " using: $runtime" \ + ' main: dist/action' + write_repository_file "$fixture" .github/actions/javascript/dist/action \ + "const base = 'https://github.com/$TINYGO_RELEASE_REPOSITORY';" \ + "spawn('$CURL_COMMAND_NAME', [base + '/releases/download/v0.42.0/file']);" + expect_fail "local $runtime Action runtime is unsupported" "$fixture" +done + +for mode in unknown expression quoted missing duplicate; do + fixture="$(make_fixture "local-action-runtime-$mode" './.github/actions/runtime')" + case "$mode" in + unknown) + lines=('name: runtime' 'runs:' ' using: custom-runtime') + ;; + expression) + lines=('name: runtime' 'runs:' ' using: ${{ inputs.runtime }}') + ;; + quoted) + lines=('name: runtime' 'runs:' ' using: "composite"' ' steps:' ' - shell: bash' ' run: echo local') + ;; + missing) + lines=('name: runtime' 'runs:' ' steps:' ' - shell: bash' ' run: echo local') + ;; + duplicate) + lines=('name: runtime' 'runs:' ' using: composite' ' using: composite' ' steps:' ' - shell: bash' ' run: echo local') + ;; + esac + write_repository_file "$fixture" .github/actions/runtime/action.yml "${lines[@]}" + expect_fail "local Action $mode runs using contract" "$fixture" +done + +fixture="$(make_fixture direct-docker-action "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/docker.yml \ + 'name: docker' \ + 'jobs:' \ + ' docker:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - uses: docker://alpine:latest' +expect_fail "direct docker Action runtime is unsupported" "$fixture" + +for primitive in source dot bash bash-option sh; do + fixture="$(make_fixture "unknown-extension-$primitive" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$primitive" in + source) command='source scripts/bootstrap.inc' ;; + dot) command='. scripts/bootstrap.inc' ;; + bash) command='bash scripts/bootstrap.inc' ;; + bash-option) command='bash -e scripts/bootstrap.inc' ;; + sh) command='sh scripts/bootstrap.inc' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$primitive force-scans an unknown-extension shell target" "$fixture" +done + +for wrapper in command env env-assignment exec; do + fixture="$(make_fixture "wrapped-helper-$wrapper" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$wrapper" in + command) command='command bash scripts/bootstrap.inc' ;; + env) command='env bash scripts/bootstrap.inc' ;; + env-assignment) command='env FOO=bar bash scripts/bootstrap.inc' ;; + exec) command='exec bash scripts/bootstrap.inc' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$wrapper wrapper preserves helper discovery" "$fixture" +done + +fixture="$(make_fixture env-chdir-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'env -C tools bash bootstrap.inc' +expect_fail "env chdir option cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture exec-argv0-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'exec -a bootstrap bash scripts/bootstrap.inc' +expect_fail "exec argv0 option cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture node-preload-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/preload.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node --require ./tools/preload.resource -e "console.log(1)"' +expect_fail "Node preload force-scans a repository helper" "$fixture" + +for primitive in python python3 node pwsh powershell; do + fixture="$(make_fixture "interpreted-helper-$primitive" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" + case "$primitive" in + pwsh) command='pwsh -File tools/bootstrap.resource' ;; + powershell) command='powershell -File tools/bootstrap.resource' ;; + *) command="$primitive tools/bootstrap.resource" ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$primitive force-scans an unknown-extension target" "$fixture" +done + +fixture="$(make_fixture direct-unknown-extension "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/runner.data \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './tools/runner.data' +expect_fail "direct repository path force-scans an unknown-extension target" "$fixture" + +fixture="$(make_fixture direct-unknown-extension-without-dot "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/runner.data \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'tools/runner.data' +expect_fail "direct repository path without dot prefix is force-scanned" "$fixture" + +fixture="$(make_fixture quoted-helper-path "$FULL_ACTION_REF")" +write_repository_file "$fixture" 'tools/path with spaces.inc' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash "tools/path with spaces.inc"' +expect_fail "quoted repository helper path with spaces is resolved" "$fixture" + +for scope in workflow job step; do + fixture="$(make_fixture "working-directory-$scope" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$scope" in + workflow) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools' + ;; + job) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' defaults:' \ + ' run:' \ + ' working-directory: tools' + ;; + step) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' working-directory: tools' + ;; + esac + expect_fail "$scope working-directory resolves the executed helper" "$fixture" +done + +fixture="$(make_fixture working-directory-precedence "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/malicious/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" tools/benign/bootstrap.inc 'printf "benign\\n"' +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory precedence' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools/malicious' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' defaults:' \ + ' run:' \ + ' working-directory: tools/benign' \ + ' steps:' \ + ' - run: bash bootstrap.inc' +expect_pass "job working-directory overrides workflow default" "$fixture" + +fixture="$(make_fixture step-working-directory-precedence "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/malicious/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" tools/benign/bootstrap.inc 'printf "benign\\n"' +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory precedence' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools/malicious' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' working-directory: tools/benign' +expect_pass "step working-directory overrides inherited defaults" "$fixture" + +fixture="$(make_fixture dynamic-working-directory-no-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: dynamic working directory' \ + 'jobs:' \ + ' harmless:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - working-directory: ${{ matrix.directory }}' \ + ' run: echo harmless' +expect_pass "dynamic working-directory without local helper remains supported" "$fixture" + +fixture="$(make_fixture dynamic-step-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/dynamic-shell.yml \ + 'name: dynamic shell' \ + 'jobs:' \ + ' dynamic:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - shell: ${{ matrix.shell }}' \ + ' run: echo harmless' +expect_fail "dynamic executable step shell fails closed" "$fixture" + +for mode in traversal backslash expression; do + fixture="$(make_fixture "unsafe-working-directory-$mode" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' + case "$mode" in + traversal) directory='tools/../tools' ;; + backslash) directory='tools\subdir' ;; + expression) directory='${{ matrix.directory }}' ;; + esac + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: unsafe working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - working-directory: $directory" \ + ' run: bash bootstrap.inc' + expect_fail "$mode working-directory fails closed for helper resolution" "$fixture" +done + +fixture="$(make_fixture traversed-helper-target "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools/../tools/bootstrap.inc' +expect_fail "helper target traversal is rejected" "$fixture" + +fixture="$(make_fixture backslash-helper-target "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools\bootstrap.inc' +expect_fail "helper target backslash spelling is rejected" "$fixture" + +outside_helper="$TMP_ROOT/outside-execution-target" +printf '%s\n' 'printf "outside\\n"' > "$outside_helper" +for mode in direct bash; do + fixture="$(make_fixture "symlink-helper-$mode" "$FULL_ACTION_REF")" + mkdir -p "$fixture/scripts" + ln -s "$outside_helper" "$fixture/scripts/helper" + if [[ "$mode" == direct ]]; then + command='scripts/helper' + else + command='bash scripts/helper' + fi + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$mode helper symlink escape is rejected" "$fixture" +done + +fixture="$(make_fixture symlink-helper-component "$FULL_ACTION_REF")" +mkdir -p "$fixture/real-tools" +write_repository_file "$fixture" real-tools/helper.inc 'printf "benign\\n"' +ln -s real-tools "$fixture/tools" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools/helper.inc' +expect_fail "helper target with a symlink path component is rejected" "$fixture" + fixture="$(make_fixture workflow-standalone-mutable-step "$FULL_ACTION_REF")" write_repository_file "$fixture" .github/workflows/standalone.yml \ 'jobs:' \ diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 8371b90c..ccca5346 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -77,7 +77,7 @@ validate_local_uses_ref() { local file="$1" local line_number="$2" local value="$3" - local kind tail padded target scan_root metadata_count=0 + local kind tail padded target scan_root metadata_count=0 metadata="" case "$value" in ./.github/actions/*) @@ -119,13 +119,17 @@ validate_local_uses_ref() { fi if [[ -f "$target/action.yml" ]]; then metadata_count=$((metadata_count + 1)) + metadata="$target/action.yml" fi if [[ -f "$target/action.yaml" ]]; then metadata_count=$((metadata_count + 1)) + metadata="$target/action.yaml" fi if (( metadata_count != 1 )); then fail "${file#"$ROOT_DIR/"}:$line_number local Action must contain exactly one action.yml or action.yaml: $value" + return fi + validate_local_action_runtime "$file" "$line_number" "$metadata" "$value" return fi @@ -211,9 +215,13 @@ inspect_shell_logical_command() { local relative="$1" local line_number="$2" local command="$3" + local working_directory="${4:-}" + local strict_helpers="${5:-false}" command="$(trim_whitespace "$command")" - inspect_referenced_ci_helpers "$relative" "$line_number" "$command" + inspect_referenced_ci_helpers \ + "$relative" "$line_number" "$command" "$working_directory" \ + "$strict_helpers" if [[ -z "$command" ]] || ! shell_command_has_downloader_token "$command"; then return fi @@ -293,6 +301,8 @@ consume_shell_physical_line() { local relative="$1" local line_number="$2" local line="$3" + local working_directory="${4:-}" + local strict_helpers="${5:-false}" local command line="$(strip_shell_comment "$line")" @@ -309,14 +319,20 @@ consume_shell_physical_line() { return fi pending_shell_command="${pending_shell_command}${pending_shell_command:+ }$command" - inspect_shell_logical_command "$relative" "$pending_shell_line" "$pending_shell_command" + inspect_shell_logical_command \ + "$relative" "$pending_shell_line" "$pending_shell_command" \ + "$working_directory" "$strict_helpers" reset_shell_logical_command } flush_shell_logical_command() { local relative="$1" + local working_directory="${2:-}" + local strict_helpers="${3:-false}" if [[ -n "$pending_shell_command" ]]; then - inspect_shell_logical_command "$relative" "$pending_shell_line" "$pending_shell_command" + inspect_shell_logical_command \ + "$relative" "$pending_shell_line" "$pending_shell_command" \ + "$working_directory" "$strict_helpers" fi reset_shell_logical_command } @@ -324,73 +340,19 @@ flush_shell_logical_command() { scan_shell_downloaders() { local file="$1" local relative="$2" + local working_directory="${3:-}" + local strict_helpers="${4:-false}" local line line_number=0 reset_shell_logical_command while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) - consume_shell_physical_line "$relative" "$line_number" "$line" + consume_shell_physical_line \ + "$relative" "$line_number" "$line" "$working_directory" \ + "$strict_helpers" done < "$file" - flush_shell_logical_command "$relative" -} - -scan_workflow_downloaders() { - local file="$1" - local relative="${file#"$ROOT_DIR/"}" - local line line_number=0 indent syntax key value sequence_prefix content - local scalar_indent=-1 run_scalar=false - local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' - local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' - - reset_shell_logical_command - while IFS= read -r line || [[ -n "$line" ]]; do - line_number=$((line_number + 1)) - indent="${line%%[![:space:]]*}" - syntax="$(trim_whitespace "$line")" - if (( scalar_indent >= 0 )) && - [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then - if [[ "$run_scalar" == true ]]; then - consume_shell_physical_line "$relative" "$line_number" "$line" - fi - continue - fi - if [[ "$run_scalar" == true ]]; then - flush_shell_logical_command "$relative" - fi - scalar_indent=-1 - run_scalar=false - content="$syntax" - if [[ "$syntax" == \#* ]]; then - continue - fi - if [[ ! "$content" =~ $mapping_re ]]; then - continue - fi - sequence_prefix="${BASH_REMATCH[1]}" - key="${BASH_REMATCH[2]}" - value="${BASH_REMATCH[4]}" - if [[ "$value" =~ $scalar_re ]]; then - scalar_indent=$((${#indent} + ${#sequence_prefix})) - if [[ "$key" == run ]]; then - run_scalar=true - fi - continue - fi - if [[ "$key" != run || -z "$value" ]]; then - continue - fi - if (( ${#value} >= 2 )); then - if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then - value="${value:1:${#value}-2}" - elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then - value="${value:1:${#value}-2}" - fi - fi - consume_shell_physical_line "$relative" "$line_number" "$value" - done < "$file" - if [[ "$run_scalar" == true ]]; then - flush_shell_logical_command "$relative" - fi + flush_shell_logical_command \ + "$relative" "$working_directory" "$strict_helpers" } repository_file_is_shell_source() { @@ -493,53 +455,450 @@ scan_ci_executable_tinygo_provenance() { fi } -inspect_referenced_ci_helpers() { +shell_tokens=() + +tokenize_shell_command() { + local command="$1" + local token="" quote="" character next escaped=false index + + shell_tokens=() + for ((index = 0; index < ${#command}; index++)); do + character="${command:index:1}" + if [[ "$escaped" == true ]]; then + token+="$character" + escaped=false + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + else + token+="$character" + fi + continue + fi + if [[ "$quote" == '"' ]]; then + if [[ "$character" == \\ ]]; then + escaped=true + elif [[ "$character" == '"' ]]; then + quote="" + else + token+="$character" + fi + continue + fi + case "$character" in + \\) + escaped=true + ;; + "'"|'"') + quote="$character" + ;; + [[:space:]]) + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + token="" + fi + ;; + ';'|'|'|'&'|'('|')') + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + token="" + fi + next="" + if (( index + 1 < ${#command} )); then + next="${command:index+1:1}" + fi + if [[ ( "$character" == '|' || "$character" == '&' ) && + "$next" == "$character" ]]; then + shell_tokens[${#shell_tokens[@]}]="$character$next" + index=$((index + 1)) + else + shell_tokens[${#shell_tokens[@]}]="$character" + fi + ;; + *) + token+="$character" + ;; + esac + done + if [[ "$escaped" == true || -n "$quote" ]]; then + return 1 + fi + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + fi +} + +shell_token_is_operator() { + case "$1" in + ';'|'|'|'||'|'&'|'&&'|'('|')') return 0 ;; + esac + return 1 +} + +execution_target_is_dynamic() { + case "$1" in + *'$'*|*'`'*|*'${{'*) return 0 ;; + esac + return 1 +} + +resolve_execution_working_directory() { local source_relative="$1" local line_number="$2" - local command="$3" - local token relative file - local words=() - - read -r -a words <<< "$command" - for token in "${words[@]}"; do - token="${token#\"}" - token="${token%\"}" - token="${token#\'}" - token="${token%\'}" - token="${token#(}" - token="${token%)}" - token="${token%;}" - token="${token%|}" - token="${token%&}" - case "$token" in - http://*|https://*|/*|*'$'*|*'`'*) continue ;; - esac - relative="${token#./}" - case "/$relative/" in - *'/../'*|*'/./'*|*'//'*) - file="$ROOT_DIR/$relative" - case "$relative" in - *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb|*.go|*.ts|*.tsx) - fail "$source_relative:$line_number referenced CI helper path is not canonical: $token" - ;; - *) - if [[ -f "$file" && -x "$file" ]]; then - fail "$source_relative:$line_number referenced executable CI helper path is not canonical: $token" + local working_directory="$3" + local strict="$4" + local padded + + resolved_execution_directory="" + if [[ -z "$working_directory" || "$working_directory" == . ]]; then + return 0 + fi + if execution_target_is_dynamic "$working_directory"; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot statically resolve a repository helper under dynamic working-directory: $working_directory" + fi + return 1 + fi + working_directory="${working_directory#./}" + padded="/$working_directory/" + if [[ "$working_directory" == /* || "$working_directory" == *\\* || + "$padded" == *'//'* || "$padded" == *'/./'* || + "$padded" == *'/../'* ]]; then + fail "$source_relative:$line_number execution working-directory is not a canonical repository-relative path: $working_directory" + return 1 + fi + if path_has_symlink_component "$ROOT_DIR" "$working_directory"; then + fail "$source_relative:$line_number execution working-directory traverses a symbolic link: $working_directory" + return 1 + fi + if [[ ! -d "$ROOT_DIR/$working_directory" ]]; then + fail "$source_relative:$line_number execution working-directory does not name a repository directory: $working_directory" + return 1 + fi + resolved_execution_directory="$working_directory" +} + +scan_execution_target() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local target="$4" + local language="$5" + local strict="$6" + local relative padded file shell_source=false + + if [[ -z "$target" || "$target" == '{0}' ]]; then + return + fi + if execution_target_is_dynamic "$target"; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot statically resolve repository execution target: $target" + fi + return + fi + if [[ "$target" == /* ]]; then + fail "$source_relative:$line_number repository execution target must be repository-relative: $target" + return + fi + if ! resolve_execution_working_directory \ + "$source_relative" "$line_number" "$working_directory" "$strict"; then + return + fi + target="${target#./}" + if [[ -n "$resolved_execution_directory" ]]; then + relative="$resolved_execution_directory/$target" + else + relative="$target" + fi + padded="/$relative/" + if [[ -z "$relative" || "$relative" == *\\* || + "$padded" == *'//'* || "$padded" == *'/./'* || + "$padded" == *'/../'* ]]; then + fail "$source_relative:$line_number repository execution target is not canonical: $target" + return + fi + if path_has_symlink_component "$ROOT_DIR" "$relative"; then + fail "$source_relative:$line_number repository execution target traverses a symbolic link: $target" + return + fi + file="$ROOT_DIR/$relative" + if [[ ! -f "$file" ]]; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number repository execution target does not name an existing regular file: $target" + fi + return + fi + + scan_ci_executable_tinygo_provenance "$relative" "$file" true + if [[ "$language" == shell ]]; then + shell_source=true + elif [[ "$language" == direct ]] && + repository_file_is_shell_source "$relative" "$file"; then + shell_source=true + fi + if [[ "$shell_source" == true ]] && + ! repository_file_is_shell_source "$relative" "$file"; then + scan_shell_downloaders "$file" "$relative" + fi +} + +inspect_interpreter_target() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local interpreter="$5" + local start="$6" + local end="$7" + local index=$start token target="" language=non-shell + + case "$interpreter" in + bash|sh) language=shell ;; + esac + if [[ "$interpreter" == pwsh || "$interpreter" == powershell ]]; then + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + -[Ff]ile) + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + ;; + -[Cc]ommand|-CommandWithArgs) return ;; + -*) index=$((index + 1)); continue ;; + *) target="$token"; break ;; + esac + done + else + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + case "$interpreter:$token" in + bash:-c|bash:--command|sh:-c|sh:--command|python:-c|python:-m|python3:-c|python3:-m|node:-e|node:--eval|node:-p|node:--print) + return + ;; + bash:-o|bash:-O|sh:-o|sh:-O|python:-W|python:-X|python3:-W|python3:-X) + index=$((index + 2)) + continue + ;; + node:-r|node:--require|node:--import|node:--loader|node:--experimental-loader) + index=$((index + 1)) + if (( index >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number Node preload option is missing its repository execution target" fi - ;; - esac + return + fi + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" non-shell "$strict" + index=$((index + 1)) + continue + ;; + node:--require=*|node:--import=*|node:--loader=*|node:--experimental-loader=*) + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${token#*=}" non-shell "$strict" + index=$((index + 1)) + continue + ;; + esac + if [[ "$token" == -* ]]; then + index=$((index + 1)) continue + fi + target="$token" + break + done + fi + if [[ -n "$target" ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$target" "$language" "$strict" + fi +} + +inspect_shell_command_segment() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local start="$5" + local end="$6" + local index=$start token command_name + + while (( index < end )) && + [[ "${shell_tokens[index]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do + index=$((index + 1)) + done + while (( index < end )); do + token="${shell_tokens[index]}" + command_name="${token##*/}" + case "$command_name" in + command) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -p) index=$((index + 1)) ;; + -v|-V) return ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported command wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) break ;; + esac + done + ;; + exec) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -c|-l) index=$((index + 1)) ;; + -a) + if (( index + 1 >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number exec -a is missing its argv0 argument" + fi + return + fi + index=$((index + 2)) + ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported exec wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) break ;; + esac + done ;; + env) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -i|--ignore-environment) index=$((index + 1)) ;; + -u|--unset) + if (( index + 1 >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number env unset option is missing its variable name" + fi + return + fi + index=$((index + 2)) + ;; + --unset=*) index=$((index + 1)) ;; + -C|--chdir|-S|--split-string|--chdir=*|--split-string=*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number env option changes bounded execution-target semantics: $token" + fi + return + ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported env wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) + if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then + index=$((index + 1)) + else + break + fi + ;; + esac + done + ;; + *) break ;; esac - file="$ROOT_DIR/$relative" - if [[ ! -f "$file" ]] || - ! repository_file_is_helper_source "$relative" "$file"; then - continue + done + if (( index >= end )); then + return + fi + token="${shell_tokens[index]}" + command_name="${token##*/}" + case "$command_name" in + source|.) + index=$((index + 1)) + if (( index < end )); then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" shell "$strict" + fi + ;; + bash|sh|python|python3|node|pwsh|powershell) + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" + ;; + go) + index=$((index + 1)) + if (( index < end )) && [[ "${shell_tokens[index]}" == run ]]; then + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == -* ]]; then + index=$((index + 1)) + continue + fi + case "$token" in + *.go|./*.go|*/*.go) + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" non-shell "$strict" + ;; + esac + index=$((index + 1)) + done + fi + ;; + *) + if [[ "$token" == ./* || + ( "$token" != /* && "$token" == */* ) ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" direct "$strict" + fi + ;; + esac +} + +inspect_referenced_ci_helpers() { + local source_relative="$1" + local line_number="$2" + local command="$3" + local working_directory="${4:-}" + local strict="${5:-false}" + local start=0 index + + if ! tokenize_shell_command "$command"; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot safely tokenize executable command for repository helper resolution" fi - if repository_file_is_ci_executable_source "$relative" "$file"; then - continue + return + fi + for ((index = 0; index <= ${#shell_tokens[@]}; index++)); do + if (( index == ${#shell_tokens[@]} )) || + shell_token_is_operator "${shell_tokens[index]}"; then + if (( start < index )); then + inspect_shell_command_segment "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$start" "$index" + fi + start=$((index + 1)) fi - scan_ci_executable_tinygo_provenance "$relative" "$file" true done } @@ -608,48 +967,250 @@ if (( ${#scan_roots[@]} == 0 )); then fail "no authored GitHub workflow or Action directories found under $ROOT_DIR/.github" fi -validate_action_uses_ref() { - local file="$1" - local line_number="$2" - local value="$3" - local comment="$4" - local comment_is_yaml="$5" +ci_files=() +ci_file_types=() +ci_file_default_shell_count=() +ci_file_default_shell_value=() +ci_file_default_shell_line=() +ci_file_default_directory_count=() +ci_file_default_directory_value=() +ci_file_default_directory_line=() +ci_file_action_using_count=() +ci_file_action_using_value=() +ci_file_action_using_line=() + +ci_job_file=() +ci_job_id=() +ci_job_line=() +ci_job_if_line=() +ci_job_continue_line=() +ci_job_needs_line=() +ci_job_runs_on_count=() +ci_job_runs_on_value=() +ci_job_runs_on_line=() +ci_job_container_line=() +ci_job_uses_count=() +ci_job_uses_value=() +ci_job_uses_line=() +ci_job_uses_comment=() +ci_job_uses_comment_is_yaml=() +ci_job_default_shell_count=() +ci_job_default_shell_value=() +ci_job_default_shell_line=() +ci_job_default_directory_count=() +ci_job_default_directory_value=() +ci_job_default_directory_line=() + +ci_step_file=() +ci_step_kind=() +ci_step_job=() +ci_step_line=() +ci_step_property_count=() +ci_step_name_count=() +ci_step_name_value=() +ci_step_install_name=() +ci_step_verify_name=() +ci_step_shell_count=() +ci_step_shell_value=() +ci_step_shell_line=() +ci_step_run_count=() +ci_step_run_value=() +ci_step_run_line=() +ci_step_directory_count=() +ci_step_directory_value=() +ci_step_directory_line=() +ci_step_uses_count=() +ci_step_uses_value=() +ci_step_uses_line=() +ci_step_uses_comment=() +ci_step_uses_comment_is_yaml=() +ci_run_step=() +ci_run_line=() +ci_run_text=() + +register_ci_file() { + local relative="$1" + ci_current_file=${#ci_files[@]} + ci_files[ci_current_file]="$relative" + case "$relative" in + .github/workflows/*) ci_file_types[ci_current_file]=workflow ;; + *) ci_file_types[ci_current_file]=action ;; + esac + ci_file_default_shell_count[ci_current_file]=0 + ci_file_default_shell_value[ci_current_file]="" + ci_file_default_shell_line[ci_current_file]=0 + ci_file_default_directory_count[ci_current_file]=0 + ci_file_default_directory_value[ci_current_file]="" + ci_file_default_directory_line[ci_current_file]=0 + ci_file_action_using_count[ci_current_file]=0 + ci_file_action_using_value[ci_current_file]="" + ci_file_action_using_line[ci_current_file]=0 +} - if (( ${#value} >= 2 )); then - if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then - value="${value:1:${#value}-2}" - elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then - value="${value:1:${#value}-2}" +register_ci_job() { + local file_index="$1" + local id="$2" + local line_number="$3" + ci_current_job=${#ci_job_id[@]} + ci_job_file[ci_current_job]="$file_index" + ci_job_id[ci_current_job]="$id" + ci_job_line[ci_current_job]="$line_number" + ci_job_if_line[ci_current_job]=0 + ci_job_continue_line[ci_current_job]=0 + ci_job_needs_line[ci_current_job]=0 + ci_job_runs_on_count[ci_current_job]=0 + ci_job_runs_on_value[ci_current_job]="" + ci_job_runs_on_line[ci_current_job]=0 + ci_job_container_line[ci_current_job]=0 + ci_job_uses_count[ci_current_job]=0 + ci_job_uses_value[ci_current_job]="" + ci_job_uses_line[ci_current_job]=0 + ci_job_uses_comment[ci_current_job]="" + ci_job_uses_comment_is_yaml[ci_current_job]=false + ci_job_default_shell_count[ci_current_job]=0 + ci_job_default_shell_value[ci_current_job]="" + ci_job_default_shell_line[ci_current_job]=0 + ci_job_default_directory_count[ci_current_job]=0 + ci_job_default_directory_value[ci_current_job]="" + ci_job_default_directory_line[ci_current_job]=0 +} + +find_ci_job() { + local file_index="$1" + local id="$2" + local index + ci_found_job=-1 + for ((index = 0; index < ${#ci_job_id[@]}; index++)); do + if [[ "${ci_job_file[index]}" == "$file_index" && + "${ci_job_id[index]}" == "$id" ]]; then + ci_found_job=$index + return fi - fi + done +} - if [[ -z "$value" ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" - return - fi - if [[ "$value" == ./* ]]; then - validate_local_uses_ref "$file" "$line_number" "$value" - return - fi +register_ci_step() { + local file_index="$1" + local kind="$2" + local job_index="$3" + local line_number="$4" + ci_current_step=${#ci_step_file[@]} + ci_step_file[ci_current_step]="$file_index" + ci_step_kind[ci_current_step]="$kind" + ci_step_job[ci_current_step]="$job_index" + ci_step_line[ci_current_step]="$line_number" + ci_step_property_count[ci_current_step]=0 + ci_step_name_count[ci_current_step]=0 + ci_step_name_value[ci_current_step]="" + ci_step_install_name[ci_current_step]=false + ci_step_verify_name[ci_current_step]=false + ci_step_shell_count[ci_current_step]=0 + ci_step_shell_value[ci_current_step]="" + ci_step_shell_line[ci_current_step]=0 + ci_step_run_count[ci_current_step]=0 + ci_step_run_value[ci_current_step]="" + ci_step_run_line[ci_current_step]=0 + ci_step_directory_count[ci_current_step]=0 + ci_step_directory_value[ci_current_step]="" + ci_step_directory_line[ci_current_step]=0 + ci_step_uses_count[ci_current_step]=0 + ci_step_uses_value[ci_current_step]="" + ci_step_uses_line[ci_current_step]=0 + ci_step_uses_comment[ci_current_step]="" + ci_step_uses_comment_is_yaml[ci_current_step]=false +} - remote_action_count=$((remote_action_count + 1)) - if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" - fi - comment="$(trim_whitespace "$comment")" - if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" - fi +record_ci_job_property() { + local job_index="$1" + local key="$2" + local value="$3" + local line_number="$4" + local comment="$5" + local comment_is_yaml="$6" + + case "$key" in + if) ci_job_if_line[job_index]="$line_number" ;; + continue-on-error) ci_job_continue_line[job_index]="$line_number" ;; + needs) ci_job_needs_line[job_index]="$line_number" ;; + runs-on) + ci_job_runs_on_count[job_index]=$((ci_job_runs_on_count[job_index] + 1)) + if (( ci_job_runs_on_count[job_index] == 1 )); then + ci_job_runs_on_value[job_index]="$value" + ci_job_runs_on_line[job_index]="$line_number" + fi + ;; + container) ci_job_container_line[job_index]="$line_number" ;; + uses) + ci_job_uses_count[job_index]=$((ci_job_uses_count[job_index] + 1)) + if (( ci_job_uses_count[job_index] == 1 )); then + ci_job_uses_value[job_index]="$value" + ci_job_uses_line[job_index]="$line_number" + ci_job_uses_comment[job_index]="$comment" + ci_job_uses_comment_is_yaml[job_index]="$comment_is_yaml" + fi + ;; + esac +} + +record_ci_step_property() { + local step_index="$1" + local key="$2" + local value="$3" + local line_number="$4" + local comment="$5" + local comment_is_yaml="$6" + + ci_step_property_count[step_index]=$((ci_step_property_count[step_index] + 1)) + case "$key" in + name) + ci_step_name_count[step_index]=$((ci_step_name_count[step_index] + 1)) + ci_step_name_value[step_index]="$value" + if [[ "$value" == 'Install TinyGo' ]]; then + ci_step_install_name[step_index]=true + elif [[ "$value" == 'Verify TinyGo' ]]; then + ci_step_verify_name[step_index]=true + fi + ;; + shell) + ci_step_shell_count[step_index]=$((ci_step_shell_count[step_index] + 1)) + if (( ci_step_shell_count[step_index] == 1 )); then + ci_step_shell_value[step_index]="$value" + ci_step_shell_line[step_index]="$line_number" + fi + ;; + run) + ci_step_run_count[step_index]=$((ci_step_run_count[step_index] + 1)) + if (( ci_step_run_count[step_index] == 1 )); then + ci_step_run_value[step_index]="$value" + ci_step_run_line[step_index]="$line_number" + fi + ;; + working-directory) + ci_step_directory_count[step_index]=$((ci_step_directory_count[step_index] + 1)) + if (( ci_step_directory_count[step_index] == 1 )); then + ci_step_directory_value[step_index]="$value" + ci_step_directory_line[step_index]="$line_number" + fi + ;; + uses) + ci_step_uses_count[step_index]=$((ci_step_uses_count[step_index] + 1)) + if (( ci_step_uses_count[step_index] == 1 )); then + ci_step_uses_value[step_index]="$value" + ci_step_uses_line[step_index]="$line_number" + ci_step_uses_comment[step_index]="$comment" + ci_step_uses_comment_is_yaml[step_index]="$comment_is_yaml" + fi + ;; + esac } -scan_action_refs() { +parse_authored_ci_file() { local file="$1" local relative="${file#"$ROOT_DIR/"}" - local line line_number=0 indent syntax key value sequence_prefix content + local line line_number=0 content syntax indent key value sequence_prefix local comment comment_is_yaml key_indent stack_count stack_index parent_kind - local is_action_uses=false - local action_step_active=false action_step_indent=-1 action_step_kind="" - local scalar_indent=-1 + local active_step=-1 active_step_indent=-1 job_index=-1 + local scalar_indent=-1 scalar_step=-1 scalar_key="" local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' @@ -657,8 +1218,25 @@ scan_action_refs() { local path_keys=() local path_indents=() + register_ci_file "$relative" + local file_index=$ci_current_file while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) + indent="${line%%[![:space:]]*}" + syntax="$(trim_whitespace "$line")" + if (( scalar_indent >= 0 )) && + [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then + if (( scalar_step >= 0 )) && [[ "$scalar_key" == run ]]; then + ci_run_step[${#ci_run_step[@]}]="$scalar_step" + ci_run_line[${#ci_run_line[@]}]="$line_number" + ci_run_text[${#ci_run_text[@]}]="$syntax" + fi + continue + fi + scalar_indent=-1 + scalar_step=-1 + scalar_key="" + content="$line" comment="" comment_is_yaml=false @@ -675,12 +1253,6 @@ scan_action_refs() { if [[ -z "$syntax" ]]; then continue fi - indent="${line%%[![:space:]]*}" - if (( scalar_indent >= 0 && ${#indent} > scalar_indent )); then - continue - fi - scalar_indent=-1 - if [[ "$syntax" =~ $mapping_re ]]; then sequence_prefix="${BASH_REMATCH[1]}" key="${BASH_REMATCH[2]}" @@ -703,29 +1275,25 @@ scan_action_refs() { done stack_count=${#path_keys[@]} parent_kind="" - if [[ "$relative" == .github/workflows/* ]] && + if [[ "${ci_file_types[file_index]}" == workflow ]] && (( stack_count == 3 )) && [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then parent_kind=workflow-step - elif [[ "$relative" == .github/actions/* ]] && + elif [[ "${ci_file_types[file_index]}" == action ]] && (( stack_count == 2 )) && [[ "${path_keys[0]}" == runs && "${path_keys[1]}" == steps ]]; then parent_kind=composite-step fi - - if [[ "$action_step_active" == true ]] && - (( key_indent < action_step_indent )); then - action_step_active=false - action_step_kind="" + if (( active_step >= 0 && key_indent < active_step_indent )); then + active_step=-1 + active_step_indent=-1 fi - # Only canonical block mapping keys are authored for security-relevant - # structure. Ordinary scalar/list data remains outside Action semantics. if [[ ! "$syntax" =~ $mapping_re ]]; then if [[ "$syntax" == - && -n "$parent_kind" ]]; then fail "$relative:$line_number unsupported standalone step sequence indicator; use canonical '- key:' step mappings" - action_step_active=false - action_step_kind="" + active_step=-1 + active_step_indent=-1 continue fi case "${syntax#- }" in @@ -737,13 +1305,18 @@ scan_action_refs() { fi if [[ -n "$sequence_prefix" && -n "$parent_kind" ]]; then - action_step_active=true - action_step_indent=$key_indent - action_step_kind="$parent_kind" - elif [[ -n "$sequence_prefix" && "$action_step_active" == true ]] && - (( key_indent <= action_step_indent )); then - action_step_active=false - action_step_kind="" + job_index=-1 + if [[ "$parent_kind" == workflow-step ]]; then + find_ci_job "$file_index" "${path_keys[1]}" + job_index=$ci_found_job + fi + register_ci_step "$file_index" "$parent_kind" "$job_index" "$line_number" + active_step=$ci_current_step + active_step_indent=$key_indent + elif [[ -n "$sequence_prefix" && active_step -ge 0 ]] && + (( key_indent <= active_step_indent )); then + active_step=-1 + active_step_indent=-1 fi case "$value" in @@ -754,29 +1327,75 @@ scan_action_refs() { fail "$relative:$line_number unsupported workflow mapping value; use canonical block mappings" ;; esac - continue - ;; + continue + ;; esac - is_action_uses=false - if [[ "$key" == uses ]]; then - if [[ "$relative" == .github/workflows/* ]] && - (( stack_count == 2 )) && - [[ "${path_keys[0]}" == jobs ]]; then - is_action_uses=true - elif [[ "$action_step_active" == true && - "$action_step_kind" == "$parent_kind" ]] && - (( key_indent == action_step_indent )); then - is_action_uses=true + if [[ "${ci_file_types[file_index]}" == workflow && + -z "$sequence_prefix" ]] && + (( stack_count == 1 )) && [[ "${path_keys[0]}" == jobs ]] && + [[ -z "$value" ]]; then + register_ci_job "$file_index" "$key" "$line_number" + fi + + if [[ "${ci_file_types[file_index]}" == workflow ]] && + (( stack_count >= 2 )) && [[ "${path_keys[0]}" == jobs ]]; then + find_ci_job "$file_index" "${path_keys[1]}" + job_index=$ci_found_job + if (( job_index >= 0 && stack_count == 2 )); then + record_ci_job_property "$job_index" "$key" "$value" \ + "$line_number" "$comment" "$comment_is_yaml" fi + if (( job_index >= 0 && stack_count == 4 )) && + [[ "${path_keys[2]}" == defaults && "${path_keys[3]}" == run ]]; then + case "$key" in + shell) + ci_job_default_shell_count[job_index]=$((ci_job_default_shell_count[job_index] + 1)) + ci_job_default_shell_value[job_index]="$value" + ci_job_default_shell_line[job_index]="$line_number" + ;; + working-directory) + ci_job_default_directory_count[job_index]=$((ci_job_default_directory_count[job_index] + 1)) + ci_job_default_directory_value[job_index]="$value" + ci_job_default_directory_line[job_index]="$line_number" + ;; + esac + fi + fi + if [[ "${ci_file_types[file_index]}" == workflow ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == defaults && "${path_keys[1]}" == run ]]; then + case "$key" in + shell) + ci_file_default_shell_count[file_index]=$((ci_file_default_shell_count[file_index] + 1)) + ci_file_default_shell_value[file_index]="$value" + ci_file_default_shell_line[file_index]="$line_number" + ;; + working-directory) + ci_file_default_directory_count[file_index]=$((ci_file_default_directory_count[file_index] + 1)) + ci_file_default_directory_value[file_index]="$value" + ci_file_default_directory_line[file_index]="$line_number" + ;; + esac + fi + if [[ "${ci_file_types[file_index]}" == action ]] && + (( stack_count == 1 )) && [[ "${path_keys[0]}" == runs ]] && + [[ "$key" == using ]]; then + ci_file_action_using_count[file_index]=$((ci_file_action_using_count[file_index] + 1)) + ci_file_action_using_value[file_index]="$value" + ci_file_action_using_line[file_index]="$line_number" fi - if [[ "$is_action_uses" == true ]]; then - validate_action_uses_ref "$file" "$line_number" "$value" \ - "$comment" "$comment_is_yaml" + if (( active_step >= 0 && key_indent == active_step_indent )); then + record_ci_step_property "$active_step" "$key" "$value" \ + "$line_number" "$comment" "$comment_is_yaml" fi if [[ "$value" =~ $scalar_re ]]; then scalar_indent=$key_indent + if (( active_step >= 0 && key_indent == active_step_indent )); then + scalar_step=$active_step + scalar_key="$key" + fi continue fi if [[ -z "$value" ]]; then @@ -786,352 +1405,374 @@ scan_action_refs() { done < "$file" } -direct_tinygo_downloads=0 -if (( ${#scan_roots[@]} > 0 )); then - # Traversal order affects diagnostics only; keep filenames NUL-delimited. - while IFS= read -r -d '' file; do - scan_action_refs "$file" - scan_workflow_downloaders "$file" - download_count="$(count_active_occurrences "$TINYGO_RELEASE_NAMESPACE" "$file")" - direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) - if (( download_count > 0 )); then - relative="${file#"$ROOT_DIR/"}" - expected=false - for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do - if [[ "$relative" == "$candidate" ]]; then - expected=true - fi - done - if [[ "$expected" == false ]]; then - fail "unexpected direct TinyGo download in $relative" - elif (( download_count != 1 )); then - fail "$relative must contain exactly one direct TinyGo release download, found $download_count" - fi +unquote_yaml_scalar() { + local value="$1" + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" fi - done < <(find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) -fi - -if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then - fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" -fi - -reset_tinygo_step() { - tinygo_step_active=false - tinygo_step_indent=-1 - tinygo_step_line=0 - tinygo_step_property_count=0 - tinygo_step_name_count=0 - tinygo_step_shell_count=0 - tinygo_step_run_count=0 - tinygo_step_install_name=false - tinygo_step_verify_name=false - tinygo_step_shell_value="" - tinygo_step_run_value="" - tinygo_step_run_lines=() -} - -reset_tinygo_job() { - tinygo_job_active=false - tinygo_job_indent=-1 - tinygo_job_line=0 - tinygo_job_id="" - tinygo_job_sequence_count=0 - tinygo_job_if_line=0 - tinygo_job_continue_on_error_line=0 - tinygo_job_needs_line=0 - tinygo_job_runs_on_count=0 - tinygo_job_runs_on_line=0 - tinygo_job_runs_on_value="" - tinygo_job_container_line=0 + fi + printf '%s' "$value" } -record_tinygo_job_property() { - local key="$1" - local value="$2" - local line_number="$3" +validate_local_action_runtime() { + local source_file="$1" + local line_number="$2" + local metadata="$3" + local value="$4" + local relative="${metadata#"$ROOT_DIR/"}" + local index found=-1 count runtime runtime_line - case "$key" in - if) - if (( tinygo_job_if_line == 0 )); then - tinygo_job_if_line=$line_number - fi - ;; - continue-on-error) - if (( tinygo_job_continue_on_error_line == 0 )); then - tinygo_job_continue_on_error_line=$line_number - fi - ;; - needs) - if (( tinygo_job_needs_line == 0 )); then - tinygo_job_needs_line=$line_number - fi - ;; - runs-on) - tinygo_job_runs_on_count=$((tinygo_job_runs_on_count + 1)) - if (( tinygo_job_runs_on_count == 1 )); then - tinygo_job_runs_on_line=$line_number - tinygo_job_runs_on_value="$value" - fi - ;; - container) - if (( tinygo_job_container_line == 0 )); then - tinygo_job_container_line=$line_number - fi - ;; - esac + for ((index = 0; index < ${#ci_files[@]}; index++)); do + if [[ "${ci_files[index]}" == "$relative" ]]; then + found=$index + break + fi + done + if (( found < 0 )); then + fail "${source_file#"$ROOT_DIR/"}:$line_number could not structurally inspect local Action metadata: $value" + return + fi + count=${ci_file_action_using_count[found]} + runtime=${ci_file_action_using_value[found]} + runtime_line=${ci_file_action_using_line[found]} + if (( count != 1 )); then + fail "$relative:$runtime_line local Action must contain exactly one canonical runs.using: composite" + elif [[ "$runtime" != composite ]]; then + fail "$relative:$runtime_line unsupported local Action runtime '$runtime'; A3 permits composite Actions only" + fi } -finalize_tinygo_job() { - local relative="$1" +validate_action_uses_ref() { + local file="$1" + local line_number="$2" + local value="$3" + local comment="$4" + local comment_is_yaml="$5" - if [[ "$tinygo_job_active" != true ]]; then + value="$(unquote_yaml_scalar "$value")" + if [[ -z "$value" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" return fi - if [[ "$tinygo_pending_install" == true ]]; then - fail "$relative:$tinygo_job_line protected TinyGo job $tinygo_job_id must keep Verify TinyGo immediately after Install TinyGo" - tinygo_pending_install=false + if [[ "$value" == docker://* ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number direct Docker Actions are unsupported in the A3 authored-CI contract: $value" + return fi - if (( tinygo_job_sequence_count > 0 )); then - if (( tinygo_job_if_line > 0 )); then - fail "$relative:$tinygo_job_if_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level if" - fi - if (( tinygo_job_continue_on_error_line > 0 )); then - fail "$relative:$tinygo_job_continue_on_error_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level continue-on-error" - fi - if (( tinygo_job_needs_line > 0 )); then - fail "$relative:$tinygo_job_needs_line protected TinyGo job $tinygo_job_id must be unconditional and blocking: contains job-level needs" - fi - if (( tinygo_job_runs_on_count != 1 )); then - fail "$relative:$tinygo_job_line protected TinyGo job $tinygo_job_id must contain exactly one canonical runs-on: ubuntu-latest" - elif [[ "$tinygo_job_runs_on_value" != ubuntu-latest ]]; then - fail "$relative:$tinygo_job_runs_on_line protected TinyGo job $tinygo_job_id must use canonical runs-on: ubuntu-latest" - fi - if (( tinygo_job_container_line > 0 )); then - fail "$relative:$tinygo_job_container_line protected TinyGo job $tinygo_job_id must run directly on the trusted hosted runner without a job container" - fi + if [[ "$value" == ./* ]]; then + validate_local_uses_ref "$file" "$line_number" "$value" + return fi - reset_tinygo_job -} -record_tinygo_step_property() { - local key="$1" - local value="$2" + remote_action_count=$((remote_action_count + 1)) + if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" + fi + comment="$(trim_whitespace "$comment")" + if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" + fi +} - tinygo_step_property_count=$((tinygo_step_property_count + 1)) - case "$key" in - name) - tinygo_step_name_count=$((tinygo_step_name_count + 1)) - if [[ "$value" == 'Install TinyGo' ]]; then - tinygo_step_install_name=true - elif [[ "$value" == 'Verify TinyGo' ]]; then - tinygo_step_verify_name=true - fi - ;; - shell) - tinygo_step_shell_count=$((tinygo_step_shell_count + 1)) - tinygo_step_shell_value="$value" - ;; - run) - tinygo_step_run_count=$((tinygo_step_run_count + 1)) - tinygo_step_run_value="$value" - ;; - esac +validate_authored_action_refs() { + local index file + for ((index = 0; index < ${#ci_job_id[@]}; index++)); do + if (( ci_job_uses_count[index] > 1 )); then + file="$ROOT_DIR/${ci_files[${ci_job_file[index]}]}" + fail "${ci_files[${ci_job_file[index]}]}:${ci_job_line[index]} reusable-workflow job contains duplicate uses properties" + elif (( ci_job_uses_count[index] == 1 )); then + file="$ROOT_DIR/${ci_files[${ci_job_file[index]}]}" + validate_action_uses_ref "$file" "${ci_job_uses_line[index]}" \ + "${ci_job_uses_value[index]}" "${ci_job_uses_comment[index]}" \ + "${ci_job_uses_comment_is_yaml[index]}" + fi + done + for ((index = 0; index < ${#ci_step_file[@]}; index++)); do + if (( ci_step_uses_count[index] > 1 )); then + fail "${ci_files[${ci_step_file[index]}]}:${ci_step_line[index]} Action step contains duplicate uses properties" + elif (( ci_step_uses_count[index] == 1 )); then + file="$ROOT_DIR/${ci_files[${ci_step_file[index]}]}" + validate_action_uses_ref "$file" "${ci_step_uses_line[index]}" \ + "${ci_step_uses_value[index]}" "${ci_step_uses_comment[index]}" \ + "${ci_step_uses_comment_is_yaml[index]}" + fi + done } -tinygo_install_body_is_exact() { - (( ${#tinygo_step_run_lines[@]} == 5 )) || return 1 - [[ "${tinygo_step_run_lines[0]}" == "$tinygo_cleanup_command" ]] || return 1 - [[ "${tinygo_step_run_lines[1]}" == "$tinygo_download_command" ]] || return 1 - [[ "${tinygo_step_run_lines[2]}" == "$tinygo_stage_command" ]] || return 1 - [[ "${tinygo_step_run_lines[3]}" == "$tinygo_verify_command" ]] || return 1 - [[ "${tinygo_step_run_lines[4]}" == "$tinygo_install_command" ]] +effective_step_directory() { + local step_index="$1" + local file_index=${ci_step_file[step_index]} + local job_index=${ci_step_job[step_index]} + + effective_directory="" + if (( ci_file_default_directory_count[file_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_file_default_directory_line[file_index]} workflow defaults.run contains duplicate working-directory properties" + fi + if (( ci_file_default_directory_count[file_index] == 1 )); then + effective_directory=${ci_file_default_directory_value[file_index]} + fi + if (( job_index >= 0 )); then + if (( ci_job_default_directory_count[job_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_job_default_directory_line[job_index]} job defaults.run contains duplicate working-directory properties" + fi + if (( ci_job_default_directory_count[job_index] == 1 )); then + effective_directory=${ci_job_default_directory_value[job_index]} + fi + fi + if (( ci_step_directory_count[step_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_step_directory_line[step_index]} step contains duplicate working-directory properties" + fi + if (( ci_step_directory_count[step_index] == 1 )); then + effective_directory=${ci_step_directory_value[step_index]} + fi + effective_directory="$(unquote_yaml_scalar "$effective_directory")" } -finalize_tinygo_step() { - local relative="$1" - local valid=true +scan_ci_step_run() { + local step_index="$1" + local relative="${ci_files[${ci_step_file[step_index]}]}" + local directory="$2" + local value index + local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' - if [[ "$tinygo_step_active" != true ]]; then + if (( ci_step_run_count[step_index] != 1 )); then return fi - if [[ "$tinygo_step_install_name" == true && - "$tinygo_step_verify_name" == true ]]; then - fail "$relative:$tinygo_step_line TinyGo step has conflicting duplicate names" - tinygo_pending_install=false - reset_tinygo_step - return + value=${ci_step_run_value[step_index]} + reset_shell_logical_command + if [[ "$value" =~ $scalar_re ]]; then + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" != "$step_index" ]]; then + continue + fi + consume_shell_physical_line "$relative" "${ci_run_line[index]}" \ + "${ci_run_text[index]}" "$directory" true + done + flush_shell_logical_command "$relative" "$directory" true + else + value="$(unquote_yaml_scalar "$value")" + consume_shell_physical_line "$relative" "${ci_step_run_line[step_index]}" \ + "$value" "$directory" true + flush_shell_logical_command "$relative" "$directory" true fi +} - if [[ "$tinygo_step_install_name" == true ]]; then - if [[ "$tinygo_pending_install" == true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" +apply_authored_execution_policy() { + local index file_index job_index relative directory shell + + for ((file_index = 0; file_index < ${#ci_files[@]}; file_index++)); do + if (( ci_file_default_shell_count[file_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} workflow defaults.run contains duplicate shell properties" + elif (( ci_file_default_shell_count[file_index] == 1 )); then + shell=${ci_file_default_shell_value[file_index]} + directory="" + if (( ci_file_default_directory_count[file_index] == 1 )); then + directory=${ci_file_default_directory_value[file_index]} + fi + if [[ "$shell" == *'${{'* ]]; then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} dynamic workflow defaults.run.shell is outside the bounded execution contract" + else + inspect_shell_logical_command "${ci_files[file_index]}" \ + "${ci_file_default_shell_line[file_index]}" "$shell" "$directory" true + fi fi - if (( tinygo_step_property_count != 3 || - tinygo_step_name_count != 1 || - tinygo_step_shell_count != 1 || - tinygo_step_run_count != 1 )); then - valid=false + done + for ((job_index = 0; job_index < ${#ci_job_id[@]}; job_index++)); do + file_index=${ci_job_file[job_index]} + if (( ci_job_default_shell_count[job_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} job defaults.run contains duplicate shell properties" + elif (( ci_job_default_shell_count[job_index] == 1 )); then + shell=${ci_job_default_shell_value[job_index]} + directory="" + if (( ci_file_default_directory_count[file_index] == 1 )); then + directory=${ci_file_default_directory_value[file_index]} + fi + if (( ci_job_default_directory_count[job_index] == 1 )); then + directory=${ci_job_default_directory_value[job_index]} + fi + if [[ "$shell" == *'${{'* ]]; then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} dynamic job defaults.run.shell is outside the bounded execution contract" + else + inspect_shell_logical_command "${ci_files[file_index]}" \ + "${ci_job_default_shell_line[job_index]}" "$shell" "$directory" true + fi fi - if [[ "$tinygo_step_shell_value" != "$tinygo_shell_template" || - "$tinygo_step_run_value" != '|' ]] || - ! tinygo_install_body_is_exact; then - valid=false + done + for ((index = 0; index < ${#ci_step_file[@]}; index++)); do + file_index=${ci_step_file[index]} + relative=${ci_files[file_index]} + effective_step_directory "$index" + directory=$effective_directory + if (( ci_step_shell_count[index] > 1 )); then + fail "$relative:${ci_step_shell_line[index]} step contains duplicate shell properties" + elif (( ci_step_shell_count[index] == 1 )); then + shell=${ci_step_shell_value[index]} + if [[ "$shell" == *'${{'* ]]; then + fail "$relative:${ci_step_shell_line[index]} dynamic step shell is outside the bounded execution contract" + else + inspect_shell_logical_command "$relative" \ + "${ci_step_shell_line[index]}" "$shell" "$directory" true + fi fi - if [[ "$valid" != true ]]; then - fail "$relative:$tinygo_step_line Install TinyGo must contain only the exact sanitized shell and protected run transaction" - tinygo_pending_install=false + if (( ci_step_run_count[index] > 1 )); then + fail "$relative:${ci_step_run_line[index]} step contains duplicate run properties" else - tinygo_pending_install=true + scan_ci_step_run "$index" "$directory" fi - elif [[ "$tinygo_step_verify_name" == true ]]; then - if (( tinygo_step_property_count != 3 || - tinygo_step_name_count != 1 || - tinygo_step_shell_count != 1 || - tinygo_step_run_count != 1 )) || - [[ "$tinygo_step_shell_value" != "$tinygo_verify_shell_template" || - "$tinygo_step_run_value" != "$tinygo_version_command" ]]; then - valid=false + done +} + +tinygo_step_body_is_exact() { + local step_index="$1" + local index syntax body=() + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" != "$step_index" ]]; then + continue fi - if [[ "$valid" != true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo must contain only the exact privileged shell and package-owned version command" - tinygo_pending_install=false - elif [[ "$tinygo_pending_install" != true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" - elif [[ "$tinygo_job_active" != true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo is outside a workflow job" - tinygo_pending_install=false - else - tinygo_sequence_count=$((tinygo_sequence_count + 1)) - tinygo_job_sequence_count=$((tinygo_job_sequence_count + 1)) - tinygo_pending_install=false + syntax="$(trim_whitespace "${ci_run_text[index]}")" + if [[ -n "$syntax" && "$syntax" != \#* ]]; then + body[${#body[@]}]="$syntax" fi - elif [[ "$tinygo_pending_install" == true ]]; then - fail "$relative:$tinygo_step_line Verify TinyGo must immediately follow Install TinyGo" - tinygo_pending_install=false - fi - reset_tinygo_step + done + (( ${#body[@]} == 5 )) || return 1 + [[ "${body[0]}" == "$tinygo_cleanup_command" ]] || return 1 + [[ "${body[1]}" == "$tinygo_download_command" ]] || return 1 + [[ "${body[2]}" == "$tinygo_stage_command" ]] || return 1 + [[ "${body[3]}" == "$tinygo_verify_command" ]] || return 1 + [[ "${body[4]}" == "$tinygo_install_command" ]] } -scan_tinygo_install_steps() { - local file="$1" - local relative="${file#"$ROOT_DIR/"}" - local line line_number=0 content syntax indent key value sequence_prefix - local key_indent stack_count stack_index parent_is_steps=false new_job=false - local scalar_indent=-1 scalar_is_step_run=false - local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' - local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' - local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' - local full_comment_re='^[[:space:]]*#(.*)$' - local path_keys=() - local path_indents=() +validate_tinygo_job_controls() { + local job_index="$1" + local relative="${ci_files[${ci_job_file[job_index]}]}" + local id="${ci_job_id[job_index]}" + if (( ci_job_if_line[job_index] > 0 )); then + fail "$relative:${ci_job_if_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level if" + fi + if (( ci_job_continue_line[job_index] > 0 )); then + fail "$relative:${ci_job_continue_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level continue-on-error" + fi + if (( ci_job_needs_line[job_index] > 0 )); then + fail "$relative:${ci_job_needs_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level needs" + fi + if (( ci_job_runs_on_count[job_index] != 1 )); then + fail "$relative:${ci_job_line[job_index]} protected TinyGo job $id must contain exactly one canonical runs-on: ubuntu-latest" + elif [[ "${ci_job_runs_on_value[job_index]}" != ubuntu-latest ]]; then + fail "$relative:${ci_job_runs_on_line[job_index]} protected TinyGo job $id must use canonical runs-on: ubuntu-latest" + fi + if (( ci_job_container_line[job_index] > 0 )); then + fail "$relative:${ci_job_container_line[job_index]} protected TinyGo job $id must run directly on the trusted hosted runner without a job container" + fi +} +evaluate_tinygo_workflow() { + local file_index="$1" + local relative="${ci_files[file_index]}" + local job_index step_index pending=false job_sequences valid tinygo_sequence_count=0 - tinygo_pending_install=false - reset_tinygo_step - reset_tinygo_job - while IFS= read -r line || [[ -n "$line" ]]; do - line_number=$((line_number + 1)) - indent="${line%%[![:space:]]*}" - syntax="$(trim_whitespace "$line")" - if (( scalar_indent >= 0 )) && - [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then - if [[ "$scalar_is_step_run" == true && - -n "$syntax" && "$syntax" != \#* ]]; then - tinygo_step_run_lines[${#tinygo_step_run_lines[@]}]="$syntax" - fi + for ((job_index = 0; job_index < ${#ci_job_id[@]}; job_index++)); do + if [[ "${ci_job_file[job_index]}" != "$file_index" ]]; then continue fi - scalar_indent=-1 - scalar_is_step_run=false - - content="$line" - if [[ "$line" =~ $inline_comment_re ]]; then - content="${BASH_REMATCH[1]}" - elif [[ "$line" =~ $full_comment_re ]]; then - content="" - fi - syntax="$(trim_whitespace "$content")" - if [[ -z "$syntax" || ! "$syntax" =~ $mapping_re ]]; then - continue - fi - sequence_prefix="${BASH_REMATCH[1]}" - key="${BASH_REMATCH[2]}" - value="${BASH_REMATCH[4]}" - key_indent=$((${#indent} + ${#sequence_prefix})) - - while (( ${#path_indents[@]} > 0 )); do - stack_index=$((${#path_indents[@]} - 1)) - if (( path_indents[stack_index] < key_indent )); then - break + pending=false + job_sequences=0 + for ((step_index = 0; step_index < ${#ci_step_file[@]}; step_index++)); do + if [[ "${ci_step_job[step_index]}" != "$job_index" ]]; then + continue + fi + if [[ "${ci_step_install_name[step_index]}" == true && + "${ci_step_verify_name[step_index]}" == true ]]; then + fail "$relative:${ci_step_line[step_index]} TinyGo step has conflicting duplicate names" + pending=false + continue + fi + if [[ "${ci_step_install_name[step_index]}" == true ]]; then + if [[ "$pending" == true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + fi + valid=true + if (( ci_step_property_count[step_index] != 3 || + ci_step_name_count[step_index] != 1 || + ci_step_shell_count[step_index] != 1 || + ci_step_run_count[step_index] != 1 )) || + [[ "${ci_step_shell_value[step_index]}" != "$tinygo_shell_template" || + "${ci_step_run_value[step_index]}" != '|' ]] || + ! tinygo_step_body_is_exact "$step_index"; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Install TinyGo must contain only the exact sanitized shell and protected run transaction" + pending=false + else + pending=true + fi + elif [[ "${ci_step_verify_name[step_index]}" == true ]]; then + valid=true + if (( ci_step_property_count[step_index] != 3 || + ci_step_name_count[step_index] != 1 || + ci_step_shell_count[step_index] != 1 || + ci_step_run_count[step_index] != 1 )) || + [[ "${ci_step_shell_value[step_index]}" != "$tinygo_verify_shell_template" || + "${ci_step_run_value[step_index]}" != "$tinygo_version_command" ]]; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must contain only the exact privileged shell and package-owned version command" + pending=false + elif [[ "$pending" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + else + job_sequences=$((job_sequences + 1)) + tinygo_sequence_count=$((tinygo_sequence_count + 1)) + pending=false + fi + elif [[ "$pending" == true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + pending=false fi - unset 'path_indents[stack_index]' 'path_keys[stack_index]' done - stack_count=${#path_keys[@]} - parent_is_steps=false - if (( stack_count == 3 )) && - [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then - parent_is_steps=true + if [[ "$pending" == true ]]; then + fail "$relative:${ci_job_line[job_index]} protected TinyGo job ${ci_job_id[job_index]} must keep Verify TinyGo immediately after Install TinyGo" fi - new_job=false - if [[ -z "$sequence_prefix" ]] && - (( stack_count == 1 )) && - [[ "${path_keys[0]}" == jobs ]]; then - new_job=true + if (( job_sequences > 0 )); then + validate_tinygo_job_controls "$job_index" fi + done +} - if [[ "$tinygo_step_active" == true ]] && - (( key_indent < tinygo_step_indent )); then - finalize_tinygo_step "$relative" - fi - if [[ "$tinygo_job_active" == true ]] && - (( key_indent <= tinygo_job_indent )); then - finalize_tinygo_job "$relative" - fi - if [[ "$new_job" == true ]]; then - tinygo_job_active=true - tinygo_job_indent=$key_indent - tinygo_job_line=$line_number - tinygo_job_id="$key" - fi - if [[ "$tinygo_job_active" == true ]] && - (( stack_count == 2 )) && - [[ "${path_keys[0]}" == jobs && - "${path_keys[1]}" == "$tinygo_job_id" ]] && - (( key_indent > tinygo_job_indent )); then - record_tinygo_job_property "$key" "$value" "$line_number" - fi - if [[ -n "$sequence_prefix" && "$parent_is_steps" == true ]]; then - finalize_tinygo_step "$relative" - tinygo_step_active=true - tinygo_step_indent=$key_indent - tinygo_step_line=$line_number - fi +authored_ci_files=() +while IFS= read -r -d '' file; do + authored_ci_files[${#authored_ci_files[@]}]="$file" + parse_authored_ci_file "$file" +done < <(find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) - if [[ "$tinygo_step_active" == true ]] && - (( key_indent == tinygo_step_indent )); then - record_tinygo_step_property "$key" "$value" - fi - if [[ "$value" =~ $scalar_re ]]; then - scalar_indent=$key_indent - if [[ "$tinygo_step_active" == true && - "$key" == run ]] && - (( key_indent == tinygo_step_indent )); then - scalar_is_step_run=true +validate_authored_action_refs +apply_authored_execution_policy + +direct_tinygo_downloads=0 +for file in "${authored_ci_files[@]}"; do + download_count="$(count_active_occurrences "$TINYGO_RELEASE_NAMESPACE" "$file")" + direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) + if (( download_count > 0 )); then + relative="${file#"$ROOT_DIR/"}" + expected=false + for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + if [[ "$relative" == "$candidate" ]]; then + expected=true fi - continue - fi - if [[ -z "$value" ]]; then - path_indents[stack_count]=$key_indent - path_keys[stack_count]="$key" + done + if [[ "$expected" == false ]]; then + fail "unexpected direct TinyGo download in $relative" + elif (( download_count != 1 )); then + fail "$relative must contain exactly one direct TinyGo release download, found $download_count" fi - done < "$file" - finalize_tinygo_step "$relative" - finalize_tinygo_job "$relative" - if [[ "$tinygo_pending_install" == true ]]; then - fail "$relative Verify TinyGo must immediately follow Install TinyGo" - tinygo_pending_install=false fi -} +done +if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then + fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" +fi accepted_tinygo_install_sequences=0 for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do @@ -1140,7 +1781,6 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do fail "required TinyGo workflow is missing: $relative" continue fi - if [[ "$(count_active_occurrences "$tinygo_download_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo download command" fi @@ -1159,10 +1799,19 @@ for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do if [[ "$(count_active_occurrences "$tinygo_version_command" "$file")" != 1 ]]; then fail "$relative must contain exactly one accepted TinyGo version-report command" fi - - scan_tinygo_install_steps "$file" - sequence_count="$tinygo_sequence_count" - if [[ "$sequence_count" != "1" ]]; then + file_index=-1 + for ((index = 0; index < ${#ci_files[@]}; index++)); do + if [[ "${ci_files[index]}" == "$relative" ]]; then + file_index=$index + break + fi + done + if (( file_index < 0 )); then + fail "$relative was not structurally inspected" + continue + fi + evaluate_tinygo_workflow "$file_index" + if (( tinygo_sequence_count != 1 )); then fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, and cleanup sequence immediately followed by an ordinary TinyGo version smoke" else accepted_tinygo_install_sequences=$((accepted_tinygo_install_sequences + 1)) From ce03c522e006d184f4dfcc98a8d3144bd31f51b3 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 20 Sep 2026 16:23:00 +0300 Subject: [PATCH 16/21] ci(release): close execution indirection gaps --- .../scripts/ci-supply-chain-check.tests.sh | 188 ++++++++++++++ scripts/ci-supply-chain-check.sh | 240 +++++++++++++++++- 2 files changed, 414 insertions(+), 14 deletions(-) diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 70d32195..30d2872e 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -965,6 +965,86 @@ write_repository_file "$fixture" .github/actions/composite/action.yml \ ' run: echo harmless' expect_fail "composite step shell receives downloader policy" "$fixture" +for scalar in '|' '>' '|-' '>+' '|2-'; do + fixture="$(make_fixture "workflow-step-block-shell-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - shell: $scalar" \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' + expect_fail "workflow step executable shell block scalar $scalar" "$fixture" +done + +fixture="$(make_fixture workflow-default-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'defaults:' \ + ' run:' \ + ' shell: |' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' +expect_fail "workflow defaults executable shell block scalar" "$fixture" + +fixture="$(make_fixture job-default-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' defaults:' \ + ' run:' \ + ' shell: >' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' steps:' \ + ' - run: echo harmless' +expect_fail "job defaults executable shell folded scalar" "$fixture" + +fixture="$(make_fixture composite-block-shell './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: |-' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "composite executable shell block scalar" "$fixture" + +fixture="$(make_fixture data-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data-shell.yml \ + 'name: data shell' \ + 'env:' \ + ' shell: |' \ + ' harmless environment data' \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + ' shell: >' \ + ' harmless input data' +expect_pass "block scalar shell keys in env and with remain ordinary data" "$fixture" + +fixture="$(make_fixture inline-pwsh-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/inline-shell.yml \ + 'name: inline shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: windows-latest' \ + ' steps:' \ + ' - shell: pwsh' \ + " run: Write-Output 'ok'" +expect_pass "canonical inline executable shell remains accepted" "$fixture" + fixture="$(make_fixture local-docker-action './.github/actions/docker')" write_repository_file "$fixture" .github/actions/docker/action.yml \ 'name: docker' \ @@ -1062,6 +1142,114 @@ for wrapper in command env env-assignment exec; do expect_fail "$wrapper wrapper preserves helper discovery" "$fixture" done +for command in \ + 'sudo bash scripts/bootstrap.inc' \ + 'timeout 30 bash scripts/bootstrap.inc' \ + 'nice bash scripts/bootstrap.inc' \ + 'nohup bash scripts/bootstrap.inc' \ + 'stdbuf -oL bash scripts/bootstrap.inc' \ + 'xargs bash scripts/bootstrap.inc' \ + 'arbitrary-wrapper bash scripts/bootstrap.inc'; do + fixture="$(make_fixture "unsupported-helper-indirection-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "unsupported repository helper indirection: $command" "$fixture" +done + +for command in \ + 'sudo python3 tools/bootstrap.resource' \ + 'arbitrary-wrapper node tools/bootstrap.resource'; do + fixture="$(make_fixture "unsupported-interpreter-indirection-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "unsupported non-shell helper indirection: $command" "$fixture" +done + +fixture="$(make_fixture nested-unsupported-helper-indirection "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/outer.sh \ + '#!/usr/bin/env bash' \ + 'arbitrary-wrapper bash scripts/bootstrap.inc' +write_repository_file "$fixture" scripts/bootstrap.inc \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash scripts/outer.sh' +expect_fail "unsupported helper indirection inside a scanned shell source" "$fixture" + +for command in \ + 'sudo ./scripts/executable-helper' \ + 'timeout 10 scripts/executable-helper'; do + fixture="$(make_fixture "wrapped-executable-helper-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/executable-helper 'printf "benign\\n"' + chmod +x "$fixture/scripts/executable-helper" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "wrapped executable repository helper: $command" "$fixture" +done + +fixture="$(make_fixture wrapped-working-directory-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" .github/workflows/wrapped-working-directory.yml \ + 'name: wrapped working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - working-directory: tools' \ + ' run: sudo bash bootstrap.inc' +expect_fail "working-directory resolves a helper behind unsupported indirection" "$fixture" + +fixture="$(make_fixture wrapped-symlink-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" real-tools/helper.inc 'printf "benign\\n"' +ln -s real-tools "$fixture/tools" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'sudo bash tools/helper.inc' +expect_fail "wrapped helper target with a symlink path component is rejected" "$fixture" + +fixture="$(make_fixture wrapped-traversed-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/helper.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'sudo bash tools/../tools/helper.inc' +expect_fail "wrapped helper target traversal is rejected" "$fixture" + +fixture="$(make_fixture command-substitution-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'result="$(bash scripts/bootstrap.inc)"' +expect_fail "command substitution cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture backtick-substitution-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'result=`bash scripts/bootstrap.inc`' +expect_fail "backtick substitution cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture literal-substitution-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "echo '\$(bash scripts/bootstrap.inc)'" +expect_pass "single-quoted substitution-shaped text remains ordinary data" "$fixture" + +fixture="$(make_fixture subshell-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '( bash scripts/bootstrap.inc )' +expect_fail "subshell preserves repository helper discovery" "$fixture" + +fixture="$(make_fixture repository-data-arguments "$FULL_ACTION_REF")" +write_repository_file "$fixture" docs/file.txt 'harmless data' +write_repository_file "$fixture" scripts/data.txt 'pattern' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'cat docs/file.txt' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'grep pattern scripts/data.txt' +expect_pass "non-executable repository data arguments remain supported" "$fixture" + fixture="$(make_fixture env-chdir-helper "$FULL_ACTION_REF")" write_repository_file "$fixture" tools/bootstrap.inc \ "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index ccca5346..1b51f87f 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -21,6 +21,7 @@ tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" tinygo_verify_shell_template='/bin/bash --noprofile --norc -p -e -o pipefail {0}' tinygo_version_command='/usr/local/bin/tinygo version' +yaml_block_scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' usage() { printf 'usage: %s [repository-root]\n' "${0##*/}" >&2 @@ -587,19 +588,23 @@ scan_execution_target() { local target="$4" local language="$5" local strict="$6" + local mode="${7:-scan}" local relative padded file shell_source=false if [[ -z "$target" || "$target" == '{0}' ]]; then return fi if execution_target_is_dynamic "$target"; then - if [[ "$strict" == true ]]; then + if [[ "$strict" == true && + "$mode" != reject-indirect-executable ]]; then fail "$source_relative:$line_number cannot statically resolve repository execution target: $target" fi return fi if [[ "$target" == /* ]]; then - fail "$source_relative:$line_number repository execution target must be repository-relative: $target" + if [[ "$mode" == scan || "$strict" == true ]]; then + fail "$source_relative:$line_number repository execution target must be repository-relative: $target" + fi return fi if ! resolve_execution_working_directory \ @@ -616,7 +621,9 @@ scan_execution_target() { if [[ -z "$relative" || "$relative" == *\\* || "$padded" == *'//'* || "$padded" == *'/./'* || "$padded" == *'/../'* ]]; then - fail "$source_relative:$line_number repository execution target is not canonical: $target" + if [[ "$mode" == scan || "$strict" == true ]]; then + fail "$source_relative:$line_number repository execution target is not canonical: $target" + fi return fi if path_has_symlink_component "$ROOT_DIR" "$relative"; then @@ -625,11 +632,24 @@ scan_execution_target() { fi file="$ROOT_DIR/$relative" if [[ ! -f "$file" ]]; then - if [[ "$strict" == true ]]; then + if [[ "$strict" == true && + "$mode" != reject-indirect-executable ]]; then fail "$source_relative:$line_number repository execution target does not name an existing regular file: $target" fi return fi + if [[ "$mode" == reject-indirect-executable && ! -x "$file" ]]; then + return + fi + if [[ "$mode" == reject-indirect || + "$mode" == reject-indirect-executable ]]; then + if [[ "$strict" != true ]] && + repository_file_is_ci_executable_source "$relative" "$file"; then + return + fi + fail "$source_relative:$line_number repository execution target appears behind unsupported execution indirection: $target" + return + fi scan_ci_executable_tinygo_provenance "$relative" "$file" true if [[ "$language" == shell ]]; then @@ -652,6 +672,7 @@ inspect_interpreter_target() { local interpreter="$5" local start="$6" local end="$7" + local mode="${8:-scan}" local index=$start token target="" language=non-shell case "$interpreter" in @@ -700,13 +721,13 @@ inspect_interpreter_target() { return fi scan_execution_target "$source_relative" "$line_number" \ - "$working_directory" "${shell_tokens[index]}" non-shell "$strict" + "$working_directory" "${shell_tokens[index]}" non-shell "$strict" "$mode" index=$((index + 1)) continue ;; node:--require=*|node:--import=*|node:--loader=*|node:--experimental-loader=*) scan_execution_target "$source_relative" "$line_number" \ - "$working_directory" "${token#*=}" non-shell "$strict" + "$working_directory" "${token#*=}" non-shell "$strict" "$mode" index=$((index + 1)) continue ;; @@ -721,10 +742,52 @@ inspect_interpreter_target() { fi if [[ -n "$target" ]]; then scan_execution_target "$source_relative" "$line_number" \ - "$working_directory" "$target" "$language" "$strict" + "$working_directory" "$target" "$language" "$strict" "$mode" fi } +inspect_unsupported_execution_indirection() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local start="$5" + local end="$6" + local index=$((start + 1)) token command_name + + # The command head is unsupported, so inspect later tokens only for the + # repository execution shapes that the canonical grammar already owns. + while (( index < end )); do + token="${shell_tokens[index]}" + command_name="${token##*/}" + case "$command_name" in + source|.) + index=$((index + 1)) + if (( index < end )); then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" shell "$strict" \ + reject-indirect + fi + return + ;; + bash|sh|python|python3|node|pwsh|powershell) + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" reject-indirect + return + ;; + esac + if [[ "$token" != *://* && "$token" != *[[:space:]]* ]] && + [[ "$token" == ./* || + ( "$token" != /* && "$token" == */* ) ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" direct "$strict" \ + reject-indirect-executable + fi + index=$((index + 1)) + done +} + inspect_shell_command_segment() { local source_relative="$1" local line_number="$2" @@ -872,10 +935,153 @@ inspect_shell_command_segment() { scan_execution_target "$source_relative" "$line_number" \ "$working_directory" "$token" direct "$strict" fi + inspect_unsupported_execution_indirection "$source_relative" \ + "$line_number" "$working_directory" "$strict" "$index" "$end" ;; esac } +inspect_static_command_substitutions() { + local source_relative="$1" + local line_number="$2" + local command="$3" + local working_directory="$4" + local strict="$5" + local index=0 length=${#command} character next quote="" escaped=false + local inner_start inner_end depth inner_quote inner_escaped + + # Substitutions are nested executable command segments hidden from the flat + # token stream. Inspect their static bodies through the same bounded grammar. + while (( index < length )); do + character="${command:index:1}" + if [[ "$escaped" == true ]]; then + escaped=false + index=$((index + 1)) + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + fi + index=$((index + 1)) + continue + fi + if [[ "$character" == \\ ]]; then + escaped=true + index=$((index + 1)) + continue + fi + if [[ "$character" == "'" && "$quote" != '"' ]]; then + quote="'" + index=$((index + 1)) + continue + fi + if [[ "$character" == '"' ]]; then + if [[ "$quote" == '"' ]]; then + quote="" + else + quote='"' + fi + index=$((index + 1)) + continue + fi + next="" + if (( index + 1 < length )); then + next="${command:index+1:1}" + fi + if [[ "$character" == '$' && "$next" == '(' ]]; then + inner_start=$((index + 2)) + inner_end=$inner_start + depth=1 + inner_quote="" + inner_escaped=false + while (( inner_end < length )); do + character="${command:inner_end:1}" + if [[ "$inner_escaped" == true ]]; then + inner_escaped=false + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$inner_quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + inner_quote="" + fi + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == \\ ]]; then + inner_escaped=true + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == "'" && "$inner_quote" != '"' ]]; then + inner_quote="'" + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == '"' ]]; then + if [[ "$inner_quote" == '"' ]]; then + inner_quote="" + else + inner_quote='"' + fi + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$inner_quote" != '"' && "$character" == '$' ]] && + (( inner_end + 1 < length )) && + [[ "${command:inner_end+1:1}" == '(' ]]; then + depth=$((depth + 1)) + inner_end=$((inner_end + 2)) + continue + fi + if [[ -z "$inner_quote" && "$character" == ')' ]]; then + depth=$((depth - 1)) + if (( depth == 0 )); then + break + fi + fi + inner_end=$((inner_end + 1)) + done + if (( depth != 0 )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot safely inspect command substitution for repository helper resolution" + fi + return + fi + inspect_referenced_ci_helpers "$source_relative" "$line_number" \ + "${command:inner_start:inner_end-inner_start}" \ + "$working_directory" "$strict" + index=$((inner_end + 1)) + continue + fi + if [[ "$character" == '`' ]]; then + inner_start=$((index + 1)) + inner_end=$inner_start + inner_escaped=false + while (( inner_end < length )); do + character="${command:inner_end:1}" + if [[ "$inner_escaped" == true ]]; then + inner_escaped=false + elif [[ "$character" == \\ ]]; then + inner_escaped=true + elif [[ "$character" == '`' ]]; then + break + fi + inner_end=$((inner_end + 1)) + done + if (( inner_end < length )); then + inspect_referenced_ci_helpers "$source_relative" "$line_number" \ + "${command:inner_start:inner_end-inner_start}" \ + "$working_directory" "$strict" + index=$((inner_end + 1)) + continue + fi + fi + index=$((index + 1)) + done +} + inspect_referenced_ci_helpers() { local source_relative="$1" local line_number="$2" @@ -884,6 +1090,8 @@ inspect_referenced_ci_helpers() { local strict="${5:-false}" local start=0 index + inspect_static_command_substitutions "$source_relative" "$line_number" \ + "$command" "$working_directory" "$strict" if ! tokenize_shell_command "$command"; then if [[ "$strict" == true ]]; then fail "$source_relative:$line_number cannot safely tokenize executable command for repository helper resolution" @@ -1212,7 +1420,6 @@ parse_authored_ci_file() { local active_step=-1 active_step_indent=-1 job_index=-1 local scalar_indent=-1 scalar_step=-1 scalar_key="" local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' - local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' local full_comment_re='^[[:space:]]*#(.*)$' local path_keys=() @@ -1390,7 +1597,7 @@ parse_authored_ci_file() { "$line_number" "$comment" "$comment_is_yaml" fi - if [[ "$value" =~ $scalar_re ]]; then + if [[ "$value" =~ $yaml_block_scalar_re ]]; then scalar_indent=$key_indent if (( active_step >= 0 && key_indent == active_step_indent )); then scalar_step=$active_step @@ -1535,14 +1742,13 @@ scan_ci_step_run() { local relative="${ci_files[${ci_step_file[step_index]}]}" local directory="$2" local value index - local scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' if (( ci_step_run_count[step_index] != 1 )); then return fi value=${ci_step_run_value[step_index]} reset_shell_logical_command - if [[ "$value" =~ $scalar_re ]]; then + if [[ "$value" =~ $yaml_block_scalar_re ]]; then for ((index = 0; index < ${#ci_run_step[@]}; index++)); do if [[ "${ci_run_step[index]}" != "$step_index" ]]; then continue @@ -1571,7 +1777,9 @@ apply_authored_execution_policy() { if (( ci_file_default_directory_count[file_index] == 1 )); then directory=${ci_file_default_directory_value[file_index]} fi - if [[ "$shell" == *'${{'* ]]; then + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} dynamic workflow defaults.run.shell is outside the bounded execution contract" else inspect_shell_logical_command "${ci_files[file_index]}" \ @@ -1592,7 +1800,9 @@ apply_authored_execution_policy() { if (( ci_job_default_directory_count[job_index] == 1 )); then directory=${ci_job_default_directory_value[job_index]} fi - if [[ "$shell" == *'${{'* ]]; then + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} dynamic job defaults.run.shell is outside the bounded execution contract" else inspect_shell_logical_command "${ci_files[file_index]}" \ @@ -1609,7 +1819,9 @@ apply_authored_execution_policy() { fail "$relative:${ci_step_shell_line[index]} step contains duplicate shell properties" elif (( ci_step_shell_count[index] == 1 )); then shell=${ci_step_shell_value[index]} - if [[ "$shell" == *'${{'* ]]; then + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "$relative:${ci_step_shell_line[index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then fail "$relative:${ci_step_shell_line[index]} dynamic step shell is outside the bounded execution contract" else inspect_shell_logical_command "$relative" \ From a39b8b21ce8a768031ff0498456b5c3ec41f705b Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Mon, 21 Sep 2026 18:13:59 +0300 Subject: [PATCH 17/21] ci(release): eliminate opaque interpreter execution --- .../scripts/ci-supply-chain-check.tests.sh | 193 ++++++++++++++ .github/workflows/ci-vscode.yml | 10 +- scripts/browser-smoke.sh | 21 +- scripts/ci-node-tools.mjs | 88 +++++++ scripts/ci-supply-chain-check.sh | 238 +++++++++++++++--- 5 files changed, 487 insertions(+), 63 deletions(-) create mode 100644 scripts/ci-node-tools.mjs diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index 30d2872e..f0ff565a 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -23,6 +23,18 @@ WGET_COMMAND_NAME='wg''et' FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" +BASH_COMMAND_NAME='ba''sh' +SH_COMMAND_NAME='s''h' +PYTHON_COMMAND_NAME='python''3' +NODE_COMMAND_NAME='no''de' +PWSH_COMMAND_NAME='pw''sh' +POWERSHELL_COMMAND_NAME='power''shell' +EVAL_COMMAND_NAME='ev''al' +SHELL_CODE_OPTION='-''c' +SHELL_STDIN_OPTION='-''s' +PYTHON_MODULE_OPTION='-''m' +NODE_EVAL_OPTION='-''e' +NODE_PRINT_OPTION='-''p' tests_run=0 write_tinygo_workflow() { @@ -1271,6 +1283,187 @@ add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ 'node --require ./tools/preload.resource -e "console.log(1)"' expect_fail "Node preload force-scans a repository helper" "$fixture" +fixture="$(make_fixture benign-node-preload "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/preload.resource \ + 'console.log("benign preload");' +write_repository_file "$fixture" scripts/example.mjs \ + 'console.log("benign helper");' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node --require ./tools/preload.resource scripts/example.mjs' +expect_pass "Node preload and direct script targets remain statically resolved" "$fixture" + +for subcommand in validate-json pick-free-port manifest-wasm-path; do + fixture="$(make_fixture "direct-node-helper-$subcommand" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/ci-node-tools.mjs \ + 'console.log("benign helper");' + case "$subcommand" in + validate-json) arguments='package.json' ;; + pick-free-port) arguments='' ;; + manifest-wasm-path) arguments='examples/app' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME scripts/ci-node-tools.mjs $subcommand${arguments:+ $arguments}" + expect_pass "direct tracked Node helper subcommand: $subcommand" "$fixture" +done + +fixture="$(make_fixture node-transparent-runtime-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.mjs \ + 'console.log("benign helper");' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME --experimental-websocket scripts/example.mjs" +expect_pass "Node experimental WebSocket option preserves tracked target resolution" "$fixture" + +fixture="$(make_fixture node-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.mjs \ + "const provenance = 'https://github.com/$TINYGO_RELEASE_REPOSITORY';" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME scripts/example.mjs" +expect_fail "tracked Node helper remains under TinyGo provenance inspection" "$fixture" + +for shell_case in \ + "$BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" \ + "$SH_COMMAND_NAME $SHELL_CODE_OPTION 'source scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME -l${SHELL_CODE_OPTION#-} 'bash scripts/bootstrap.inc'" \ + "$SH_COMMAND_NAME -x${SHELL_CODE_OPTION#-} 'source scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME --command 'bash scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME $SHELL_STDIN_OPTION" \ + "$SH_COMMAND_NAME $SHELL_STDIN_OPTION" \ + "$SH_COMMAND_NAME -x${SHELL_STDIN_OPTION#-}"; do + fixture="$(make_fixture "opaque-shell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$shell_case" + expect_fail "opaque shell execution mode: $shell_case" "$fixture" +done + +for shell_name in "$BASH_COMMAND_NAME" "$SH_COMMAND_NAME"; do + fixture="$(make_fixture "pipeline-shell-$shell_name" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'echo ok\\n' | $shell_name" + expect_fail "pipeline-fed $shell_name stdin execution" "$fixture" +done + +fixture="$(make_fixture pipeline-stderr-shell "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'echo ok\\n' |& $BASH_COMMAND_NAME" +expect_fail "stdout-and-stderr pipeline cannot feed opaque shell stdin" "$fixture" + +fixture="$(make_fixture opaque-eval "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$EVAL_COMMAND_NAME 'bash scripts/bootstrap.inc'" +expect_fail "shell eval is opaque execution" "$fixture" + +for python_case in \ + "python $SHELL_CODE_OPTION 'print(1)'" \ + "python $PYTHON_MODULE_OPTION fixture_module" \ + "$PYTHON_COMMAND_NAME $SHELL_CODE_OPTION 'print(1)'" \ + "$PYTHON_COMMAND_NAME $PYTHON_MODULE_OPTION fixture_module" \ + "$PYTHON_COMMAND_NAME -" \ + "printf 'print(1)\\n' | $PYTHON_COMMAND_NAME"; do + fixture="$(make_fixture "opaque-python-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$python_case" + expect_fail "opaque Python execution mode: $python_case" "$fixture" +done + +for node_case in \ + "$NODE_COMMAND_NAME $NODE_EVAL_OPTION 'console.log(1)'" \ + "$NODE_COMMAND_NAME --eval 'console.log(1)'" \ + "$NODE_COMMAND_NAME --eval='console.log(1)'" \ + "$NODE_COMMAND_NAME $NODE_PRINT_OPTION '1 + 1'" \ + "$NODE_COMMAND_NAME --print '1 + 1'" \ + "$NODE_COMMAND_NAME --print='1 + 1'" \ + "$NODE_COMMAND_NAME -pe '1 + 1'" \ + "$NODE_COMMAND_NAME --run fixture" \ + "$NODE_COMMAND_NAME --run=fixture" \ + "$NODE_COMMAND_NAME -" \ + "printf 'console.log(1)\\n' | $NODE_COMMAND_NAME"; do + fixture="$(make_fixture "opaque-node-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$node_case" + expect_fail "opaque Node execution mode: $node_case" "$fixture" +done + +for powershell_case in \ + "$PWSH_COMMAND_NAME -Command 'Write-Output ok'" \ + "$PWSH_COMMAND_NAME -CommandWithArgs 'Write-Output ok'" \ + "$PWSH_COMMAND_NAME -EncodedCommand ZgBvAG8A" \ + "$POWERSHELL_COMMAND_NAME -Command 'Write-Output ok'" \ + "$POWERSHELL_COMMAND_NAME -EncodedCommand ZgBvAG8A"; do + fixture="$(make_fixture "opaque-powershell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$powershell_case" + expect_fail "opaque PowerShell execution mode: $powershell_case" "$fixture" +done + +for powershell_name in "$PWSH_COMMAND_NAME" "$POWERSHELL_COMMAND_NAME"; do + fixture="$(make_fixture "pipeline-powershell-$powershell_name" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'Write-Output ok\\n' | $powershell_name" + expect_fail "pipeline-fed $powershell_name stdin execution" "$fixture" +done + +for wrapper in command env exec sudo 'timeout 30' arbitrary-wrapper; do + fixture="$(make_fixture "wrapped-opaque-shell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$wrapper $BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" + expect_fail "$wrapper cannot restore opaque shell execution" "$fixture" +done + +fixture="$(make_fixture wrapped-opaque-sh "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "sudo $SH_COMMAND_NAME $SHELL_CODE_OPTION 'source scripts/bootstrap.inc'" +expect_fail "sudo cannot restore opaque sh execution" "$fixture" + +for nesting in substitution backticks subshell; do + fixture="$(make_fixture "nested-opaque-shell-$nesting" "$FULL_ACTION_REF")" + case "$nesting" in + substitution) + command="result=\"\$($BASH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok')\"" + ;; + backticks) + command="result=\`$SH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok'\`" + ;; + subshell) + command="( $BASH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok' )" + ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$nesting cannot restore opaque shell execution" "$fixture" +done + +fixture="$(make_fixture nested-helper-opaque-shell "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested repository shell helper cannot restore opaque execution" "$fixture" + +fixture="$(make_fixture nested-helper-shell-stdin "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME <<< 'printf ok'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested repository shell helper cannot consume opaque stdin" "$fixture" + +fixture="$(make_fixture inert-opaque-text "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "echo '$BASH_COMMAND_NAME $SHELL_CODE_OPTION \"bash scripts/bootstrap.inc\"'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf '%s\\n' '$NODE_COMMAND_NAME $NODE_EVAL_OPTION \"console.log(1)\"'" +expect_pass "single-quoted opaque-interpreter text remains ordinary data" "$fixture" + +fixture="$(make_fixture supported-interpreter-file-modes "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "ok\\n"' +write_repository_file "$fixture" tools/example.resource 'print("ok")' +write_repository_file "$fixture" tools/example.ps1 'Write-Output ok' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash --version' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash -n scripts/example.sh' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -e scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$PYTHON_COMMAND_NAME tools/example.resource" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$PWSH_COMMAND_NAME -fIlE tools/example.ps1" +expect_pass "supported interpreter file and introspection modes remain accepted" "$fixture" + for primitive in python python3 node pwsh powershell; do fixture="$(make_fixture "interpreted-helper-$primitive" "$FULL_ACTION_REF")" write_repository_file "$fixture" tools/bootstrap.resource \ diff --git a/.github/workflows/ci-vscode.yml b/.github/workflows/ci-vscode.yml index 1e3e15ec..ca85c8ea 100644 --- a/.github/workflows/ci-vscode.yml +++ b/.github/workflows/ci-vscode.yml @@ -30,11 +30,13 @@ jobs: cache-dependency-path: extensions/vscode-gox/package-lock.json - name: Validate JSON + working-directory: . run: | - node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))" - node -e "JSON.parse(require('fs').readFileSync('syntaxes/gox.tmLanguage.json','utf8'))" - node -e "JSON.parse(require('fs').readFileSync('snippets/gox.code-snippets','utf8'))" - node -e "JSON.parse(require('fs').readFileSync('language-configuration.json','utf8'))" + node scripts/ci-node-tools.mjs validate-json \ + extensions/vscode-gox/package.json \ + extensions/vscode-gox/syntaxes/gox.tmLanguage.json \ + extensions/vscode-gox/snippets/gox.code-snippets \ + extensions/vscode-gox/language-configuration.json - name: Install dependencies run: npm ci diff --git a/scripts/browser-smoke.sh b/scripts/browser-smoke.sh index 62ca9bb7..c55883df 100755 --- a/scripts/browser-smoke.sh +++ b/scripts/browser-smoke.sh @@ -38,17 +38,7 @@ require_command() { } pick_free_port() { - node -e 'const net = require("node:net"); -const server = net.createServer(); -server.on("error", (error) => { - console.error(error.message); - process.exit(1); -}); -server.listen(0, "127.0.0.1", () => { - const address = server.address(); - console.log(address.port); - server.close(); -});' + node scripts/ci-node-tools.mjs pick-free-port } resolve_port() { @@ -77,14 +67,7 @@ wait_for_server() { manifest_wasm_path() { local app="$1" - node -e 'const fs = require("node:fs"); -const manifestPath = process.argv[1] + "/.goframe/package/standalone/asset-manifest.json"; -const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); -if (!manifest.entrypoints || !manifest.entrypoints.wasm) { - console.error(`missing entrypoints.wasm in ${manifestPath}`); - process.exit(1); -} -console.log(manifest.entrypoints.wasm);' "$app" + node scripts/ci-node-tools.mjs manifest-wasm-path "$app" } build_smoke_url() { diff --git a/scripts/ci-node-tools.mjs b/scripts/ci-node-tools.mjs new file mode 100644 index 00000000..9f260669 --- /dev/null +++ b/scripts/ci-node-tools.mjs @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; + +function requireArguments(command, args, minimum, maximum = minimum) { + if (args.length < minimum || args.length > maximum) { + const expected = minimum === maximum ? `${minimum}` : `${minimum} or more`; + const suffix = minimum === 1 && maximum === 1 ? "" : "s"; + throw new Error(`${command} requires ${expected} argument${suffix}`); + } +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function validateJSON(files) { + requireArguments("validate-json", files, 1, Number.POSITIVE_INFINITY); + for (const file of files) { + const contents = await readFile(file, "utf8"); + try { + JSON.parse(contents); + } catch (error) { + throw new Error(`${file}: ${errorMessage(error)}`); + } + } +} + +async function pickFreePort(args) { + requireArguments("pick-free-port", args, 0); + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not determine the selected TCP port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); + console.log(port); +} + +async function manifestWASMPath(args) { + requireArguments("manifest-wasm-path", args, 1); + const manifestPath = join( + args[0], + ".goframe", + "package", + "standalone", + "asset-manifest.json", + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if (!manifest.entrypoints || !manifest.entrypoints.wasm) { + throw new Error(`missing entrypoints.wasm in ${manifestPath}`); + } + console.log(manifest.entrypoints.wasm); +} + +const [command, ...args] = process.argv.slice(2); + +try { + switch (command) { + case "validate-json": + await validateJSON(args); + break; + case "pick-free-port": + await pickFreePort(args); + break; + case "manifest-wasm-path": + await manifestWASMPath(args); + break; + default: + throw new Error(`unknown command: ${command || ""}`); + } +} catch (error) { + console.error(`ci-node-tools: ${errorMessage(error)}`); + process.exitCode = 1; +} diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh index 1b51f87f..cf0ec77d 100755 --- a/scripts/ci-supply-chain-check.sh +++ b/scripts/ci-supply-chain-check.sh @@ -7,6 +7,7 @@ TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" CURL_COMMAND_NAME='cur''l' WGET_COMMAND_NAME='wg''et' +EVAL_COMMAND_NAME='ev''al' EXPECTED_TINYGO_WORKFLOWS=( ".github/workflows/ci-browser-smoke.yml" ".github/workflows/ci-core.yml" @@ -368,7 +369,8 @@ repository_file_is_shell_source() { return 1 fi IFS= read -r first_line < "$file" || true - [[ "$first_line" =~ ^\#!.*(bash|/sh)([[:space:]]|$) ]] + [[ "$first_line" =~ ^\#!.*bash([[:space:]]|$) || + "$first_line" =~ ^\#!.*/sh([[:space:]]|$) ]] } repository_file_is_helper_source() { @@ -511,7 +513,8 @@ tokenize_shell_command() { next="${command:index+1:1}" fi if [[ ( "$character" == '|' || "$character" == '&' ) && - "$next" == "$character" ]]; then + "$next" == "$character" ]] || + [[ "$character" == '|' && "$next" == '&' ]]; then shell_tokens[${#shell_tokens[@]}]="$character$next" index=$((index + 1)) else @@ -533,7 +536,7 @@ tokenize_shell_command() { shell_token_is_operator() { case "$1" in - ';'|'|'|'||'|'&'|'&&'|'('|')') return 0 ;; + ';'|'|'|'|&'|'||'|'&'|'&&'|'('|')') return 0 ;; esac return 1 } @@ -673,30 +676,68 @@ inspect_interpreter_target() { local start="$6" local end="$7" local mode="${8:-scan}" - local index=$start token target="" language=non-shell + local stdin_from_pipe="${9:-false}" + local index=$start token target="" language=non-shell option_flags - case "$interpreter" in - bash|sh) language=shell ;; - esac - if [[ "$interpreter" == pwsh || "$interpreter" == powershell ]]; then + if [[ "$interpreter" == bash || "$interpreter" == sh ]]; then + language=shell while (( index < end )); do token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return + fi + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + if [[ "$token" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi case "$token" in - -[Ff]ile) - index=$((index + 1)) - if (( index < end )); then - target="${shell_tokens[index]}" + --command) + fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract" + return + ;; + -o|-O) + if (( index + 1 >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number $interpreter option is missing its argument: $token" + fi + return fi - break + index=$((index + 2)) + continue + ;; + -?*) + if [[ "$token" != --* ]]; then + option_flags="${token#-}" + if [[ "$option_flags" == *c* ]]; then + fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$option_flags" == *s* ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract: $token" + return + fi + fi + index=$((index + 1)) + continue ;; - -[Cc]ommand|-CommandWithArgs) return ;; - -*) index=$((index + 1)); continue ;; *) target="$token"; break ;; esac done - else + elif [[ "$interpreter" == python || "$interpreter" == python3 ]]; then while (( index < end )); do token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return + fi if [[ "$token" == -- ]]; then index=$((index + 1)) if (( index < end )); then @@ -704,20 +745,64 @@ inspect_interpreter_target() { fi break fi - case "$interpreter:$token" in - bash:-c|bash:--command|sh:-c|sh:--command|python:-c|python:-m|python3:-c|python3:-m|node:-e|node:--eval|node:-p|node:--print) + case "$token" in + -c|-m) + fail "$source_relative:$line_number $interpreter opaque code or module execution is outside the bounded authored-CI contract: $token" + return + ;; + -) + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" return ;; - bash:-o|bash:-O|sh:-o|sh:-O|python:-W|python:-X|python3:-W|python3:-X) + -W|-X) + if (( index + 1 >= end )); then + fail "$source_relative:$line_number $interpreter option is missing its argument: $token" + return + fi index=$((index + 2)) continue ;; - node:-r|node:--require|node:--import|node:--loader|node:--experimental-loader) + -W?*|-X?*|-B|-E|-I|-O|-OO|-q|-s|-S|-u|-v|-x) + index=$((index + 1)) + continue + ;; + -V|--version|-h|--help) + return + ;; + -*) + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return + ;; + *) target="$token"; break ;; + esac + done + elif [[ "$interpreter" == node ]]; then + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number Node redirected stdin execution is outside the bounded authored-CI contract" + return + fi + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + case "$token" in + -|--eval|--eval=*|--print|--print=*|--run|--run=*) + fail "$source_relative:$line_number Node opaque code, script-graph, or stdin execution is outside the bounded authored-CI contract: $token" + return + ;; + -e|-p) + fail "$source_relative:$line_number Node inline code execution is outside the bounded authored-CI contract: $token" + return + ;; + -r|--require|--import|--loader|--experimental-loader) index=$((index + 1)) if (( index >= end )); then - if [[ "$strict" == true ]]; then - fail "$source_relative:$line_number Node preload option is missing its repository execution target" - fi + fail "$source_relative:$line_number Node preload option is missing its repository execution target" return fi scan_execution_target "$source_relative" "$line_number" \ @@ -725,27 +810,82 @@ inspect_interpreter_target() { index=$((index + 1)) continue ;; - node:--require=*|node:--import=*|node:--loader=*|node:--experimental-loader=*) + --require=*|--import=*|--loader=*|--experimental-loader=*) scan_execution_target "$source_relative" "$line_number" \ "$working_directory" "${token#*=}" non-shell "$strict" "$mode" index=$((index + 1)) continue ;; + --experimental-websocket) + index=$((index + 1)) + continue + ;; + -v|--version|-h|--help) + return + ;; + -?*) + if [[ "$token" != --* ]]; then + option_flags="${token#-}" + if [[ "$option_flags" == *e* || "$option_flags" == *p* ]]; then + fail "$source_relative:$line_number Node inline code execution is outside the bounded authored-CI contract: $token" + return + fi + fi + fail "$source_relative:$line_number unsupported Node option prevents bounded execution-target resolution: $token" + return + ;; + *) target="$token"; break ;; esac - if [[ "$token" == -* ]]; then - index=$((index + 1)) - continue + done + else + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return fi - target="$token" - break + case "$token" in + -[Ff][Ii][Ll][Ee]) + index=$((index + 1)) + if (( index >= end )); then + fail "$source_relative:$line_number $interpreter -File is missing its repository execution target" + return + fi + target="${shell_tokens[index]}" + if [[ "$target" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi + break + ;; + -*) + fail "$source_relative:$line_number $interpreter command or encoded execution mode is outside the bounded authored-CI contract: $token" + return + ;; + *) target="$token"; break ;; + esac done fi if [[ -n "$target" ]]; then scan_execution_target "$source_relative" "$line_number" \ "$working_directory" "$target" "$language" "$strict" "$mode" + elif [[ "$stdin_from_pipe" == true ]]; then + fail "$source_relative:$line_number $interpreter cannot consume executable stdin from a pipeline in the bounded authored-CI contract" fi } +supported_interpreter_name() { + local candidate="$1" + + [[ "$candidate" == bash ]] || + [[ "$candidate" == sh ]] || + [[ "$candidate" == python ]] || + [[ "$candidate" == python3 ]] || + [[ "$candidate" == node ]] || + [[ "$candidate" == pwsh ]] || + [[ "$candidate" == powershell ]] +} + inspect_unsupported_execution_indirection() { local source_relative="$1" local line_number="$2" @@ -753,6 +893,7 @@ inspect_unsupported_execution_indirection() { local strict="$4" local start="$5" local end="$6" + local stdin_from_pipe="${7:-false}" local index=$((start + 1)) token command_name # The command head is unsupported, so inspect later tokens only for the @@ -760,6 +901,12 @@ inspect_unsupported_execution_indirection() { while (( index < end )); do token="${shell_tokens[index]}" command_name="${token##*/}" + if supported_interpreter_name "$command_name"; then + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" reject-indirect "$stdin_from_pipe" + return + fi case "$command_name" in source|.) index=$((index + 1)) @@ -770,10 +917,8 @@ inspect_unsupported_execution_indirection() { fi return ;; - bash|sh|python|python3|node|pwsh|powershell) - inspect_interpreter_target "$source_relative" "$line_number" \ - "$working_directory" "$strict" "$command_name" \ - "$((index + 1))" "$end" reject-indirect + $EVAL_COMMAND_NAME) + fail "$source_relative:$line_number shell eval is outside the bounded authored-CI execution contract" return ;; esac @@ -795,6 +940,7 @@ inspect_shell_command_segment() { local strict="$4" local start="$5" local end="$6" + local stdin_from_pipe="${7:-false}" local index=$start token command_name while (( index < end )) && @@ -896,6 +1042,12 @@ inspect_shell_command_segment() { fi token="${shell_tokens[index]}" command_name="${token##*/}" + if supported_interpreter_name "$command_name"; then + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" scan "$stdin_from_pipe" + return + fi case "$command_name" in source|.) index=$((index + 1)) @@ -904,10 +1056,8 @@ inspect_shell_command_segment() { "$working_directory" "${shell_tokens[index]}" shell "$strict" fi ;; - bash|sh|python|python3|node|pwsh|powershell) - inspect_interpreter_target "$source_relative" "$line_number" \ - "$working_directory" "$strict" "$command_name" \ - "$((index + 1))" "$end" + $EVAL_COMMAND_NAME) + fail "$source_relative:$line_number shell eval is outside the bounded authored-CI execution contract" ;; go) index=$((index + 1)) @@ -936,7 +1086,8 @@ inspect_shell_command_segment() { "$working_directory" "$token" direct "$strict" fi inspect_unsupported_execution_indirection "$source_relative" \ - "$line_number" "$working_directory" "$strict" "$index" "$end" + "$line_number" "$working_directory" "$strict" "$index" "$end" \ + "$stdin_from_pipe" ;; esac } @@ -1088,7 +1239,7 @@ inspect_referenced_ci_helpers() { local command="$3" local working_directory="${4:-}" local strict="${5:-false}" - local start=0 index + local start=0 index stdin_from_pipe=false inspect_static_command_substitutions "$source_relative" "$line_number" \ "$command" "$working_directory" "$strict" @@ -1103,7 +1254,14 @@ inspect_referenced_ci_helpers() { shell_token_is_operator "${shell_tokens[index]}"; then if (( start < index )); then inspect_shell_command_segment "$source_relative" "$line_number" \ - "$working_directory" "$strict" "$start" "$index" + "$working_directory" "$strict" "$start" "$index" \ + "$stdin_from_pipe" + fi + stdin_from_pipe=false + if (( index < ${#shell_tokens[@]} )) && + [[ "${shell_tokens[index]}" == '|' || + "${shell_tokens[index]}" == '|&' ]]; then + stdin_from_pipe=true fi start=$((index + 1)) fi From 1a2d8e11d25b0d4b1e8263bd92ace12c060e4e39 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sun, 27 Sep 2026 18:42:37 +0300 Subject: [PATCH 18/21] ci(release): analyze shell execution context --- .../scripts/ci-supply-chain-check.tests.sh | 294 +++++++++++++ .github/tools/shell-context/go.mod | 5 + .github/tools/shell-context/go.sum | 2 + .github/tools/shell-context/main.go | 240 +++++++++++ .github/tools/shell-context/main_test.go | 88 ++++ scripts/ci-supply-chain-check.sh | 387 ++++++++++++++++-- 6 files changed, 972 insertions(+), 44 deletions(-) create mode 100644 .github/tools/shell-context/go.mod create mode 100644 .github/tools/shell-context/go.sum create mode 100644 .github/tools/shell-context/main.go create mode 100644 .github/tools/shell-context/main_test.go diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh index f0ff565a..d169e107 100755 --- a/.github/scripts/ci-supply-chain-check.tests.sh +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -387,6 +387,20 @@ write_shell_source() { } > "$target" } +write_extensionless_downloader_helper() { + local fixture="$1" + local shebang="$2" + local downloader="${3:-$CURL_COMMAND_NAME}" + + write_repository_file "$fixture" scripts/helper \ + "$shebang" \ + 'org=tinygo-org' \ + 'repo=tinygo' \ + 'url="https://github.com/$org/$repo/releases/download/v0.42.0/file"' \ + "$downloader \"\$url\"" + chmod +x "$fixture/scripts/helper" +} + add_workflow_script_invocation() { local file="$1" local command="$2" @@ -690,6 +704,24 @@ expect_fail() { printf 'ok %d - %s\n' "$tests_run" "$name" } +expect_fail_in_helper() { + local name="$1" + local fixture="$2" + local output + tests_run=$((tests_run + 1)) + if output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then + printf 'not ok %d - %s unexpectedly passed\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + if [[ "$output" != *'scripts/outer.sh:'* || + "$output" != *'execution without a bounded script target'* ]]; then + printf 'not ok %d - %s failed outside the nested-shell boundary\n%s\n' \ + "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + test_clean_shell_boundary() { local marker_dir="$TMP_ROOT/clean-shell-markers" local fake_bin="$TMP_ROOT/clean-shell-fake-bin" @@ -1441,6 +1473,164 @@ add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ "$BASH_COMMAND_NAME scripts/outer.sh" expect_fail "nested repository shell helper cannot consume opaque stdin" "$fixture" +for context in simple sh conditional wrapper sudo timeout nice nohup stdbuf substitution backticks subshell pipeline heredoc; do + fixture="$(make_fixture "nested-targetless-$context" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/payload.txt 'printf "nested-stdin-executed\n"' + case "$context" in + simple) lines=("$BASH_COMMAND_NAME") ;; + sh) lines=("$SH_COMMAND_NAME") ;; + conditional) lines=("if $BASH_COMMAND_NAME; then" ':' 'fi') ;; + wrapper) lines=("env $BASH_COMMAND_NAME") ;; + sudo) lines=("sudo $BASH_COMMAND_NAME") ;; + timeout) lines=("timeout 30 $BASH_COMMAND_NAME") ;; + nice) lines=("nice $BASH_COMMAND_NAME") ;; + nohup) lines=("nohup $BASH_COMMAND_NAME") ;; + stdbuf) lines=("stdbuf -oL $BASH_COMMAND_NAME") ;; + substitution) lines=("result=\"\$($BASH_COMMAND_NAME)\"") ;; + backticks) lines=("result=\`$BASH_COMMAND_NAME\`") ;; + subshell) lines=("( $BASH_COMMAND_NAME )") ;; + pipeline) lines=('printf "printf ok\\n" |' "$BASH_COMMAND_NAME") ;; + heredoc) lines=('cat <&1)"; then + printf 'not ok %d - recursively scanned unknown-extension shell target unexpectedly passed\n%s\n' \ + "$tests_run" "$output" >&2 + exit 1 +fi +if [[ "$output" != *'scripts/middle.inc:'* || + "$output" != *'execution without a bounded script target'* ]]; then + printf 'not ok %d - unknown-extension shell target failed outside its nested boundary\n%s\n' \ + "$tests_run" "$output" >&2 + exit 1 +fi +printf 'ok %d - recursively scanned unknown-extension shell target\n' "$tests_run" + +fixture="$(make_fixture bounded-shell-with-stdin-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/payload.txt 'ordinary fixture data' +write_shell_source "$fixture" scripts/reader.sh 'cat' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/reader.sh < scripts/payload.txt" +expect_pass "explicit shell script target may receive ordinary stdin data" "$fixture" + +fixture="$(make_fixture inert-targetless-shell-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/payload.txt 'ordinary fixture data' +write_shell_source "$fixture" scripts/outer.sh \ + "printf '%s\\n' '$BASH_COMMAND_NAME'" \ + "cat <<'EOF'" \ + "$BASH_COMMAND_NAME" \ + "$CURL_COMMAND_NAME" \ + 'EOF' \ + 'cat < scripts/payload.txt' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_pass "quoted names, quoted heredocs, and benign stdin readers remain data" "$fixture" + +fixture="$(make_fixture malformed-repository-shell "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/invalid.sh 'if then' +expect_fail "malformed tracked shell source blocks parser coverage" "$fixture" + +fixture="$(make_fixture malformed-workflow-shell "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'if then' +expect_fail "malformed executable run body blocks parser coverage" "$fixture" + fixture="$(make_fixture inert-opaque-text "$FULL_ACTION_REF")" add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ "echo '$BASH_COMMAND_NAME $SHELL_CODE_OPTION \"bash scripts/bootstrap.inc\"'" @@ -1464,6 +1654,110 @@ add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ "$PWSH_COMMAND_NAME -fIlE tools/example.ps1" expect_pass "supported interpreter file and introspection modes remain accepted" "$fixture" +for option in --rcfile --init-file; do + fixture="$(make_fixture "bash-option-arity-$option" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" + expect_fail "$option cannot hide later command-string execution" "$fixture" +done + +fixture="$(make_fixture wrapped-bash-option-arity "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "env $BASH_COMMAND_NAME --rcfile scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" +expect_fail "env wrapper cannot hide Bash command-string execution" "$fixture" + +fixture="$(make_fixture nested-bash-option-arity "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME --init-file scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested shell helper cannot hide Bash command-string execution" "$fixture" + +for option in -i -l --login --debugger -eil; do + fixture="$(make_fixture "bash-startup-mode-$option" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option scripts/example.sh" + expect_fail "unsupported Bash startup mode $option" "$fixture" +done + +fixture="$(make_fixture unknown-bash-long-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME --something-unknown scripts/example.sh $SHELL_CODE_OPTION 'printf ok'" +expect_fail "unknown Bash long option cannot hide later execution" "$fixture" + +for option in \ + '--rcfile=scripts/benign.sh' \ + '--init-file=scripts/benign.sh' \ + '-O extglob'; do + fixture="$(make_fixture "bash-option-variant-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option $SHELL_CODE_OPTION 'printf ok'" + expect_fail "unsupported Bash option variant $option" "$fixture" +done + +fixture="$(make_fixture sh-rejects-bash-long-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$SH_COMMAND_NAME --noprofile scripts/example.sh" +expect_fail "sh does not accept Bash-only long options" "$fixture" + +for interpreter in "$BASH_COMMAND_NAME" "$SH_COMMAND_NAME"; do + fixture="$(make_fixture "bare-interpreter-$interpreter" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$interpreter" + expect_fail "$interpreter without a bounded target is rejected" "$fixture" +done + +fixture="$(make_fixture supported-shell-option-forms "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -eu scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -- scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$SH_COMMAND_NAME -e scripts/example.sh" +expect_pass "bounded Bash and sh target options remain accepted" "$fixture" + +for shebang in \ + '#!/usr/bin/env sh' \ + '#!/usr/bin/env -S sh -e' \ + '#!/bin/sh' \ + '#!/usr/bin/sh' \ + '#!/bin/bash' \ + '#!/usr/bin/bash' \ + '#!/usr/bin/env bash' \ + '#!/usr/bin/env -S bash -e'; do + fixture="$(make_fixture "extensionless-shell-$tests_run" "$FULL_ACTION_REF")" + write_extensionless_downloader_helper "$fixture" "$shebang" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './scripts/helper' + expect_fail "extensionless shell helper with $shebang is inspected" "$fixture" +done + +fixture="$(make_fixture extensionless-env-sh-wrapped "$FULL_ACTION_REF")" +write_extensionless_downloader_helper "$fixture" '#!/usr/bin/env sh' "$WGET_COMMAND_NAME" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'env ./scripts/helper' +expect_fail "env-wrapped extensionless sh helper is inspected" "$fixture" + +for shebang in \ + '#!/usr/bin/nobash' \ + '#!/usr/bin/env nobash' \ + '#!/usr/bin/env bashful' \ + '#!/usr/bin/env sh-wrapper'; do + fixture="$(make_fixture "extensionless-non-shell-$tests_run" "$FULL_ACTION_REF")" + write_extensionless_downloader_helper "$fixture" "$shebang" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './scripts/helper' + expect_pass "near-match interpreter $shebang remains non-shell" "$fixture" +done + for primitive in python python3 node pwsh powershell; do fixture="$(make_fixture "interpreted-helper-$primitive" "$FULL_ACTION_REF")" write_repository_file "$fixture" tools/bootstrap.resource \ diff --git a/.github/tools/shell-context/go.mod b/.github/tools/shell-context/go.mod new file mode 100644 index 00000000..523d3b55 --- /dev/null +++ b/.github/tools/shell-context/go.mod @@ -0,0 +1,5 @@ +module github.com/graybuton/goframe/.github/tools/shell-context + +go 1.26.0 + +require mvdan.cc/sh/v3 v3.14.1 diff --git a/.github/tools/shell-context/go.sum b/.github/tools/shell-context/go.sum new file mode 100644 index 00000000..967dc910 --- /dev/null +++ b/.github/tools/shell-context/go.sum @@ -0,0 +1,2 @@ +mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= diff --git a/.github/tools/shell-context/main.go b/.github/tools/shell-context/main.go new file mode 100644 index 00000000..f2aa8ac5 --- /dev/null +++ b/.github/tools/shell-context/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +type commandEvent struct { + line uint + pipeInput bool + redirectInput bool + assigns int + words []string + command string +} + +func sourceText(src []byte, node syntax.Node) (string, error) { + start, end := int(node.Pos().Offset()), int(node.End().Offset()) + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell source span %d:%d", start, end) + } + return string(src[start:end]), nil +} + +func policyPartsText(src []byte, start, end int, parts []syntax.WordPart) (string, error) { + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell word span %d:%d", start, end) + } + cursor := start + var text strings.Builder + var literal strings.Builder + // Bash removes continued newlines from literal syntax, but not single-quoted data. + flushLiteral := func() { + text.WriteString(strings.ReplaceAll(literal.String(), "\\\n", "")) + literal.Reset() + } + for _, part := range parts { + partStart, partEnd := int(part.Pos().Offset()), int(part.End().Offset()) + if partStart < cursor || partEnd < partStart || partEnd > end { + return "", fmt.Errorf("invalid shell word part span %d:%d", partStart, partEnd) + } + literal.Write(src[cursor:partStart]) + raw := src[partStart:partEnd] + switch node := part.(type) { + case *syntax.Lit: + literal.Write(raw) + case *syntax.SglQuoted: + flushLiteral() + if node.Dollar && !strings.Contains(node.Value, "\\") { + if !bytes.HasPrefix(raw, []byte("$'")) { + return "", fmt.Errorf("invalid ANSI-C quoted word span %d:%d", partStart, partEnd) + } + text.Write(raw[1:]) + } else { + text.Write(raw) + } + case *syntax.DblQuoted: + flushLiteral() + if node.Dollar { + text.Write(raw) + break + } + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return "", fmt.Errorf("invalid double-quoted word span %d:%d", partStart, partEnd) + } + inner, err := policyPartsText(src, partStart+1, partEnd-1, node.Parts) + if err != nil { + return "", err + } + text.WriteByte('"') + text.WriteString(inner) + text.WriteByte('"') + default: + flushLiteral() + text.Write(raw) + } + cursor = partEnd + } + literal.Write(src[cursor:end]) + flushLiteral() + return text.String(), nil +} + +func policyWordText(src []byte, word *syntax.Word) (string, error) { + return policyPartsText(src, int(word.Pos().Offset()), int(word.End().Offset()), word.Parts) +} + +func directCommandText(src []byte, call *syntax.CallExpr) (string, error) { + start, end := int(call.Pos().Offset()), int(call.End().Offset()) + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell command source span %d:%d", start, end) + } + type span struct{ start, end int } + var substitutions []span + syntax.Walk(call, func(node syntax.Node) bool { + switch node.(type) { + case *syntax.CmdSubst, *syntax.ProcSubst: + substitutions = append(substitutions, span{int(node.Pos().Offset()), int(node.End().Offset())}) + return false + } + return true + }) + sort.Slice(substitutions, func(i, j int) bool { return substitutions[i].start < substitutions[j].start }) + var text bytes.Buffer + for _, substitution := range substitutions { + if substitution.start < start || substitution.end < substitution.start || substitution.end > end { + return "", fmt.Errorf("invalid nested shell source span %d:%d", substitution.start, substitution.end) + } + text.Write(src[start:substitution.start]) + text.WriteString("$SUBSTITUTION") + start = substitution.end + } + text.Write(src[start:end]) + return text.String(), nil +} + +func analyze(src []byte, name string) ([]commandEvent, error) { + if bytes.IndexByte(src, 0) >= 0 { + return nil, fmt.Errorf("%s: NUL byte in shell source", name) + } + file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(bytes.NewReader(src), name) + if err != nil { + return nil, err + } + + piped := make(map[*syntax.CallExpr]bool) + redirected := make(map[*syntax.CallExpr]bool) + syntax.Walk(file, func(node syntax.Node) bool { + switch n := node.(type) { + case *syntax.BinaryCmd: + if n.Op == syntax.Pipe || n.Op == syntax.PipeAll { + syntax.Walk(n.Y, func(child syntax.Node) bool { + if call, ok := child.(*syntax.CallExpr); ok { + piped[call] = true + } + return true + }) + } + case *syntax.Stmt: + if call, ok := n.Cmd.(*syntax.CallExpr); ok { + for _, redir := range n.Redirs { + switch redir.Op { + case syntax.RdrIn, syntax.RdrInOut, syntax.DplIn, + syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc: + redirected[call] = true + } + } + } + } + return true + }) + + var events []commandEvent + var spanErr error + syntax.Walk(file, func(node syntax.Node) bool { + if spanErr != nil { + return false + } + call, ok := node.(*syntax.CallExpr) + if !ok || (len(call.Args) == 0 && len(call.Assigns) == 0) { + return true + } + event := commandEvent{ + line: call.Pos().Line(), + pipeInput: piped[call], + redirectInput: redirected[call], + assigns: len(call.Assigns), + } + for _, assign := range call.Assigns { + word, err := sourceText(src, assign) + if err != nil { + spanErr = err + return false + } + event.words = append(event.words, word) + } + for _, arg := range call.Args { + word, err := policyWordText(src, arg) + if err != nil { + spanErr = err + return false + } + event.words = append(event.words, word) + } + event.command, spanErr = directCommandText(src, call) + events = append(events, event) + return true + }) + return events, spanErr +} + +func writeEvents(w io.Writer, events []commandEvent) error { + field := func(value string) error { + _, err := io.WriteString(w, value+"\x00") + return err + } + for _, event := range events { + values := []string{"C", strconv.FormatUint(uint64(event.line), 10), + strconv.FormatBool(event.pipeInput), strconv.FormatBool(event.redirectInput), + strconv.Itoa(event.assigns), strconv.Itoa(len(event.words))} + values = append(values, event.words...) + values = append(values, event.command) + for _, value := range values { + if err := field(value); err != nil { + return err + } + } + } + if err := field("E"); err != nil { + return err + } + return field(strconv.Itoa(len(events))) +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: shell-context ") + os.Exit(2) + } + src, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: read shell source: %v\n", os.Args[1], err) + os.Exit(1) + } + events, err := analyze(src, os.Args[1]) + if err == nil { + err = writeEvents(os.Stdout, events) + } + if err != nil { + fmt.Fprintf(os.Stderr, "%s: inspect shell source: %v\n", os.Args[1], err) + os.Exit(1) + } +} diff --git a/.github/tools/shell-context/main_test.go b/.github/tools/shell-context/main_test.go new file mode 100644 index 00000000..907c3f9e --- /dev/null +++ b/.github/tools/shell-context/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestExecutableContext(t *testing.T) { + src := []byte("printf '%s\\n' 'bash'\ncat <<'EOF'\nbash\nEOF\n" + + "printf x |\nbash\nif sh; then :; fi\n" + + "cat <= end )); then - if [[ "$strict" == true ]]; then - fail "$source_relative:$line_number $interpreter option is missing its argument: $token" - fi + --rcfile|--rcfile=*|--init-file|--init-file=*|--debugger|--login) + fail "$source_relative:$line_number $interpreter startup mode is outside the bounded authored-CI contract: $token" + return + ;; + --noprofile|--norc) + if [[ "$interpreter" != bash ]]; then + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return + fi + index=$((index + 1)) + continue + ;; + --version|--help) + if [[ "$interpreter" != bash ]] || (( index + 1 < end )); then + fail "$source_relative:$line_number unsupported $interpreter introspection form: $token" + fi + return + ;; + -o) + if [[ "$interpreter" != bash ]] || (( index + 1 >= end )) || + [[ "${shell_tokens[index+1]}" != pipefail ]]; then + fail "$source_relative:$line_number unsupported $interpreter option or missing pipefail argument: $token" return fi index=$((index + 2)) continue ;; + -O) + fail "$source_relative:$line_number unsupported $interpreter startup option: $token" + return + ;; -?*) - if [[ "$token" != --* ]]; then - option_flags="${token#-}" - if [[ "$option_flags" == *c* ]]; then - fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract: $token" - return - fi - if [[ "$option_flags" == *s* ]]; then - fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract: $token" - return - fi + option_flags="${token#-}" + if [[ "$option_flags" == *c* ]]; then + fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract: $token" + return fi - index=$((index + 1)) - continue + if [[ "$option_flags" == *s* ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$option_flags" == *i* || "$option_flags" == *l* ]]; then + fail "$source_relative:$line_number $interpreter startup mode is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$interpreter" == bash && "$option_flags" =~ ^[eunpxv]+$ ]] || + [[ "$interpreter" == sh && "$option_flags" =~ ^[eunxv]+$ ]]; then + index=$((index + 1)) + continue + fi + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return ;; *) target="$token"; break ;; esac done + if [[ -z "$target" ]]; then + if [[ "$mode" == scan || "$known_wrapper" == true || + "$stdin_from_pipe" == true ]]; then + fail "$source_relative:$line_number $interpreter execution without a bounded script target is unsupported" + fi + return + fi + if [[ "$target" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi elif [[ "$interpreter" == python || "$interpreter" == python3 ]]; then while (( index < end )); do token="${shell_tokens[index]}" @@ -894,7 +957,17 @@ inspect_unsupported_execution_indirection() { local start="$5" local end="$6" local stdin_from_pipe="${7:-false}" - local index=$((start + 1)) token command_name + local index=$((start + 1)) token command_name known_wrapper=false + + case "${shell_tokens[start]##*/}" in + printf|echo|grep) + # These data consumers do not execute their arguments as shell targets. + return + ;; + sudo|timeout|nice|nohup|stdbuf|xargs) + known_wrapper=true + ;; + esac # The command head is unsupported, so inspect later tokens only for the # repository execution shapes that the canonical grammar already owns. @@ -904,7 +977,8 @@ inspect_unsupported_execution_indirection() { if supported_interpreter_name "$command_name"; then inspect_interpreter_target "$source_relative" "$line_number" \ "$working_directory" "$strict" "$command_name" \ - "$((index + 1))" "$end" reject-indirect "$stdin_from_pipe" + "$((index + 1))" "$end" reject-indirect "$stdin_from_pipe" \ + "$known_wrapper" return fi case "$command_name" in @@ -1268,6 +1342,114 @@ inspect_referenced_ci_helpers() { done } +inspect_syntax_aware_shell_source() { + local relative="$1" + local base_line="$2" + local working_directory="$3" + local strict="$4" + local events record line pipe_input redirect_input assign_count word_count word command + local expected count=0 completed=false index + local -a raw_words=() parsed_words=() shell_tokens=() + + events="$(mktemp "$shell_context_tmp/events.XXXXXX")" + if ! "$shell_context_tmp/shell-context" "$relative" > "$events"; then + fail "$relative:$base_line shell execution-context parsing failed" + rm -f "$events" + return + fi + while IFS= read -r -d '' record; do + case "$record" in + C) + if ! IFS= read -r -d '' line || + ! IFS= read -r -d '' pipe_input || + ! IFS= read -r -d '' redirect_input || + ! IFS= read -r -d '' assign_count || + ! IFS= read -r -d '' word_count || + [[ ! "$line" =~ ^[1-9][0-9]*$ || + ! "$word_count" =~ ^[1-9][0-9]*$ || + ! "$assign_count" =~ ^[0-9]+$ || + ( "$pipe_input" != true && "$pipe_input" != false ) || + ( "$redirect_input" != true && "$redirect_input" != false ) ]] || + (( assign_count > word_count )); then + fail "$relative:$base_line malformed shell parser event" + break + fi + raw_words=() + for ((index = 0; index < word_count; index++)); do + if ! IFS= read -r -d '' word; then + fail "$relative:$base_line incomplete shell parser word record" + break + fi + raw_words[${#raw_words[@]}]="$word" + done + if (( ${#raw_words[@]} != word_count )) || + ! IFS= read -r -d '' command; then + fail "$relative:$base_line incomplete shell parser command record" + break + fi + parsed_words=() + for word in "${raw_words[@]}"; do + if (( ${#parsed_words[@]} < assign_count )); then + parsed_words[${#parsed_words[@]}]="$word" + continue + fi + if ! tokenize_shell_command "$word"; then + fail "$relative:$base_line cannot decode shell parser word" + break + fi + if (( ${#shell_tokens[@]} == 1 )); then + parsed_words[${#parsed_words[@]}]="${shell_tokens[0]}" + elif (( ${#shell_tokens[@]} == 0 )) && + [[ "$word" == "''" || "$word" == '""' ]]; then + parsed_words[${#parsed_words[@]}]="" + elif [[ "$word" == *'$('* || "$word" == *'`'* || + "$word" == *'<('* || "$word" == *'>('* ]]; then + # Nested executable calls are emitted separately by the AST walk. + parsed_words[${#parsed_words[@]}]="$word" + else + fail "$relative:$base_line cannot decode shell parser word boundary" + break + fi + done + if (( ${#parsed_words[@]} != word_count )); then + break + fi + shell_tokens=("${parsed_words[@]}") + if [[ "$redirect_input" == true ]]; then + shell_tokens[${#shell_tokens[@]}]='= 0 && key_indent == active_step_indent )); then record_ci_step_property "$active_step" "$key" "$value" \ "$line_number" "$comment" "$comment_is_yaml" + if [[ "$key" == run ]]; then + ci_step_run_indent[active_step]=$key_indent + fi fi if [[ "$value" =~ $yaml_block_scalar_re ]]; then @@ -1899,28 +2088,138 @@ scan_ci_step_run() { local step_index="$1" local relative="${ci_files[${ci_step_file[step_index]}]}" local directory="$2" - local value index + local value index line raw indent body_indent=-1 base_line=0 script="" shell="" + local previous="" blanks=0 have_previous=false + local indent_indicator_re='^[|>][+-]?([1-9])' + local -a body_lines=() + local file_index=${ci_step_file[step_index]} + local job_index=${ci_step_job[step_index]} if (( ci_step_run_count[step_index] != 1 )); then return fi value=${ci_step_run_value[step_index]} + if (( ci_file_default_shell_count[file_index] == 1 )); then + shell=${ci_file_default_shell_value[file_index]} + fi + if (( job_index >= 0 )) && + (( ci_job_default_shell_count[job_index] == 1 )); then + shell=${ci_job_default_shell_value[job_index]} + fi + if (( ci_step_shell_count[step_index] == 1 )); then + shell=${ci_step_shell_value[step_index]} + fi + shell="$(unquote_yaml_scalar "$shell")" + if [[ "$shell" == pwsh || "$shell" == powershell || + "$shell" == pwsh\ * || "$shell" == powershell\ * ]]; then + # PowerShell policy remains on its existing, separate path. + if [[ "$value" =~ $yaml_block_scalar_re ]]; then + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" == "$step_index" ]]; then + consume_shell_physical_line "$relative" "${ci_run_line[index]}" \ + "${ci_run_text[index]}" "$directory" true + fi + done + else + consume_shell_physical_line "$relative" "${ci_step_run_line[step_index]}" \ + "$(unquote_yaml_scalar "$value")" "$directory" true + fi + flush_shell_logical_command "$relative" "$directory" true + return + fi reset_shell_logical_command if [[ "$value" =~ $yaml_block_scalar_re ]]; then + if [[ "$value" =~ $indent_indicator_re ]]; then + body_indent=$((ci_step_run_indent[step_index] + BASH_REMATCH[1])) + fi for ((index = 0; index < ${#ci_run_step[@]}; index++)); do if [[ "${ci_run_step[index]}" != "$step_index" ]]; then continue fi - consume_shell_physical_line "$relative" "${ci_run_line[index]}" \ - "${ci_run_text[index]}" "$directory" true + raw=${ci_run_raw[index]} + if (( base_line == 0 )); then + base_line=${ci_run_line[index]} + fi + if [[ -n "$(trim_whitespace "$raw")" && body_indent -lt 0 ]]; then + indent=${raw%%[![:space:]]*} + body_indent=${#indent} + fi + if (( body_indent >= 0 )); then + if [[ -n "$(trim_whitespace "$raw")" ]]; then + indent=${raw%%[![:space:]]*} + if (( ${#indent} < body_indent )); then + fail "$relative:${ci_run_line[index]} malformed run block indentation" + return + fi + fi + if [[ -n "$(trim_whitespace "$raw")" ]]; then + line=${raw:body_indent} + else + line="" + fi + else + line="" + fi + body_lines[${#body_lines[@]}]="$line" done - flush_shell_logical_command "$relative" "$directory" true + if [[ "$value" == '>'* ]]; then + for line in "${body_lines[@]}"; do + if [[ -z "$line" ]]; then + blanks=$((blanks + 1)) + continue + fi + if [[ "$have_previous" == true ]]; then + if (( blanks > 0 )); then + while (( blanks > 0 )); do + script+=$'\n' + blanks=$((blanks - 1)) + done + elif [[ "$previous" == [[:space:]]* || + "$line" == [[:space:]]* ]]; then + script+=$'\n' + else + script+=" " + fi + else + while (( blanks > 0 )); do + script+=$'\n' + blanks=$((blanks - 1)) + done + fi + script+="$line" + previous="$line" + have_previous=true + done + script+=$'\n' + else + for line in "${body_lines[@]}"; do + script+="$line"$'\n' + done + fi else - value="$(unquote_yaml_scalar "$value")" - consume_shell_physical_line "$relative" "${ci_step_run_line[step_index]}" \ - "$value" "$directory" true - flush_shell_logical_command "$relative" "$directory" true + base_line=${ci_step_run_line[step_index]} + script="$(unquote_yaml_scalar "$value")"$'\n' + fi + if (( base_line == 0 )); then + base_line=${ci_step_run_line[step_index]} + fi + inspect_syntax_aware_shell_source "$relative" "$base_line" \ + "$directory" true <<< "$script" +} + +inspect_ci_shell_template() { + local relative="$1" + local line_number="$2" + local shell_value="$3" + local directory="$4" + local selector + + selector="$(unquote_yaml_scalar "$shell_value")" + if [[ "$selector" == bash || "$selector" == sh ]]; then + shell_value="$selector {0}" fi + inspect_shell_logical_command "$relative" "$line_number" \ + "$shell_value" "$directory" true } apply_authored_execution_policy() { @@ -1940,7 +2239,7 @@ apply_authored_execution_policy() { elif [[ "$shell" == *'${{'* ]]; then fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} dynamic workflow defaults.run.shell is outside the bounded execution contract" else - inspect_shell_logical_command "${ci_files[file_index]}" \ + inspect_ci_shell_template "${ci_files[file_index]}" \ "${ci_file_default_shell_line[file_index]}" "$shell" "$directory" true fi fi @@ -1963,7 +2262,7 @@ apply_authored_execution_policy() { elif [[ "$shell" == *'${{'* ]]; then fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} dynamic job defaults.run.shell is outside the bounded execution contract" else - inspect_shell_logical_command "${ci_files[file_index]}" \ + inspect_ci_shell_template "${ci_files[file_index]}" \ "${ci_job_default_shell_line[job_index]}" "$shell" "$directory" true fi fi @@ -1982,7 +2281,7 @@ apply_authored_execution_policy() { elif [[ "$shell" == *'${{'* ]]; then fail "$relative:${ci_step_shell_line[index]} dynamic step shell is outside the bounded execution contract" else - inspect_shell_logical_command "$relative" \ + inspect_ci_shell_template "$relative" \ "${ci_step_shell_line[index]}" "$shell" "$directory" true fi fi From 76364ef26d3b5801a81e88eabfc5caf278259722 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 3 Oct 2026 18:06:04 +0300 Subject: [PATCH 19/21] ci(release): close oracle servers despite idle Chrome connections The custom-index smoke hung in its base-resolution oracle on Chrome 154. Chrome 154 opens connections to the in-process oracle servers that never carry a request. server.close() only reaps connections that are idle after a request, so the await in the oracle teardown waited for Chrome to drop the spare socket, which did not happen while the page stayed open. Chrome 153.0.8010.52, stable when this branch was last green, opens no such connection. Close the oracle servers and drop their remaining connections once every case has finished. The unmodified harness from a39b8b2 hangs the same way on Chrome 154, so the branch diff is not involved. No assertion changes. --- scripts/custom-index-rewrite-browser-smoke.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/custom-index-rewrite-browser-smoke.mjs b/scripts/custom-index-rewrite-browser-smoke.mjs index eefb6c41..c351703c 100644 --- a/scripts/custom-index-rewrite-browser-smoke.mjs +++ b/scripts/custom-index-rewrite-browser-smoke.mjs @@ -1401,7 +1401,7 @@ async function runBaseResolutionOracle() { }); } } finally { - await new Promise((resolveClose) => oracleServer.close(resolveClose)); + await closeOracleServer(oracleServer); } return results; @@ -2034,7 +2034,7 @@ async function runManagedFirstSemanticOracle() { }, }); } finally { - await new Promise((resolveClose) => oracleServer.close(resolveClose)); + await closeOracleServer(oracleServer); } return results; @@ -2069,6 +2069,15 @@ async function waitForOracleDocument(expectedURL) { throw new Error(`HARNESS FAILURE: semantic oracle did not load ${expectedURL}`); } +// Chrome can hold speculative connections that never carry a request. +// server.close() alone waits for Chrome to drop them, which may never happen. +function closeOracleServer(oracleServer) { + return new Promise((resolveClose) => { + oracleServer.close(resolveClose); + oracleServer.closeAllConnections(); + }); +} + function encodeDoubleQuotedAttribute(value) { return value.replaceAll("&", "&").replaceAll('"', """); } From 5e81614b1ae17ae0b6bdd3b6059c69ecc364da66 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 3 Oct 2026 18:05:49 +0300 Subject: [PATCH 20/21] ci(release): bound custom-index smoke harness Browser smoke on 1a2d8e1 stayed silent after the TinyGo custom-index header until the 6h job limit, because the harness had no upper bound on its goxc subprocesses, CDP calls, or fetches and printed nothing before its final evidence line. Give each goxc subprocess a 5 minute limit that stops its process group (SIGTERM, then SIGKILL), each CDP call and the CDP socket open a 30 second limit, and each fetch a 10 second limit. Every bound fails as a HARNESS FAILURE naming the stage. Print one stage line per phase on stderr, and give the Browser smoke job a 30 minute limit; passing runs on this branch took 7.5 to 11 minutes. No assertion changes. --- .github/workflows/ci-browser-smoke.yml | 1 + .../custom-index-rewrite-browser-smoke.mjs | 113 ++++++++++++++++-- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-browser-smoke.yml b/.github/workflows/ci-browser-smoke.yml index 749a8e36..ef26175e 100644 --- a/.github/workflows/ci-browser-smoke.yml +++ b/.github/workflows/ci-browser-smoke.yml @@ -14,6 +14,7 @@ jobs: browser-smoke: name: Browser smoke runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout diff --git a/scripts/custom-index-rewrite-browser-smoke.mjs b/scripts/custom-index-rewrite-browser-smoke.mjs index c351703c..6e2e6350 100644 --- a/scripts/custom-index-rewrite-browser-smoke.mjs +++ b/scripts/custom-index-rewrite-browser-smoke.mjs @@ -25,6 +25,15 @@ if (compiler !== "go" && compiler !== "tinygo") { throw new Error(`HARNESS FAILURE: unsupported compiler ${JSON.stringify(compiler)}`); } +// Upper bounds, not expectations: a stuck operation must fail with its stage +// name instead of staying silent until the CI job limit. +const commandTimeoutMS = 5 * 60 * 1000; +const commandKillGraceMS = 5 * 1000; +const cdpCallTimeoutMS = 30 * 1000; +const fetchTimeoutMS = 10 * 1000; +const startedAt = Date.now(); +let currentStage = "startup"; + const authoredSentinels = [ "", '', @@ -109,16 +118,23 @@ try { profile = await mkdtemp(join(tmpdir(), "goframe-custom-index-chrome-")); const scenarios = []; for (const mode of ["marker", "legacy"]) { + stage(`prepare scenario ${mode}`); scenarios.push(await prepareScenario(mode)); } + stage("prepare scenario generated-url"); const generatedURLScenario = await prepareGeneratedURLScenario(); + stage("prepare scenario base-target"); const targetOnlyFixture = await prepareVariantFixture("base-target", "base-target.html"); const targetOnlyScenario = await prepareScenario("base-target", targetOnlyFixture, "marker"); + stage("negative active-base managed package"); const negativeManaged = await verifyActiveBaseManagedFailure(targetOnlyScenario); + stage("negative managed-structure packages"); const negativeManagedStructure = await verifyManagedStructureFailures(targetOnlyScenario); scenarios.push(targetOnlyScenario); + stage("prepare scenario base-authored"); const authoredBase = await prepareAuthoredBaseScenario(); + stage("start Chrome"); browser = await startBrowser(chrome, [ "--headless", "--no-sandbox", @@ -149,17 +165,26 @@ try { await client.call("Runtime.enable"); await client.call("Page.enable"); await client.call("Network.enable"); + stage("oracle attribute"); const attributeOracle = await runAttributeOracle(); + stage("oracle managed span tree"); const managedSpanOracle = await runManagedSpanTreeOracle(); + stage("oracle managed-first semantic"); const semanticOracle = await runManagedFirstSemanticOracle(); + stage("oracle base resolution"); const baseOracle = await runBaseResolutionOracle(); + stage("oracle generated JavaScript source"); const javascriptSourceOracle = runGeneratedJavaScriptSourceOracle(generatedURLScenario); for (const scenario of scenarios) { + stage(`browser scenario ${scenario.mode}`); scenario.browser = await runBrowserScenario(scenario); } + stage("browser scenario generated-url"); generatedURLScenario.browser = await runGeneratedURLBrowserScenario(generatedURLScenario); + stage("browser scenario base-authored"); authoredBase.browser = await runAuthoredBaseBrowserScenario(authoredBase); + stage("report"); const stableScenarios = scenarios.map((scenario) => ({ mode: scenario.mode, @@ -1978,6 +2003,7 @@ async function runManagedFirstSemanticOracle() { const schedulesBeforeUnknown = orderedRuntimeSchedules.length; const unknownResponse = await fetch( `${origin}/ordered-runtime/unknown.js?delay=999999`, + { signal: AbortSignal.timeout(fetchTimeoutMS) }, ); assert(unknownResponse.status === 404, "unknown ordered runtime resource was not rejected"); await unknownResponse.text(); @@ -2177,7 +2203,10 @@ async function waitForServer(port, child) { throw new Error(`HARNESS FAILURE: goxc serve exited before readiness\n${serverError}`); } try { - const response = await fetch(`http://127.0.0.1:${port}/`, { cache: "no-store" }); + const response = await fetch(`http://127.0.0.1:${port}/`, { + cache: "no-store", + signal: AbortSignal.timeout(fetchTimeoutMS), + }); if (response.ok) return; } catch (error) { lastError = error; @@ -2205,7 +2234,9 @@ async function waitForPage(port) { throw new Error(`HARNESS FAILURE: Chrome exited before CDP was ready\n${browserError}`); } try { - const response = await fetch(`http://127.0.0.1:${port}/json`); + const response = await fetch(`http://127.0.0.1:${port}/json`, { + signal: AbortSignal.timeout(fetchTimeoutMS), + }); const targets = await response.json(); const page = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl); if (page) return page; @@ -2220,8 +2251,18 @@ async function waitForPage(port) { async function connect(url) { const socket = new WebSocket(url); await new Promise((resolveOpen, reject) => { - socket.addEventListener("open", resolveOpen, { once: true }); - socket.addEventListener("error", reject, { once: true }); + const timer = setTimeout(() => { + reject(new Error(`HARNESS FAILURE: CDP socket did not open within ${cdpCallTimeoutMS}ms`)); + socket.close(); + }, cdpCallTimeoutMS); + socket.addEventListener("open", () => { + clearTimeout(timer); + resolveOpen(); + }, { once: true }); + socket.addEventListener("error", (event) => { + clearTimeout(timer); + reject(event); + }, { once: true }); }); let nextID = 1; let closedError = null; @@ -2252,12 +2293,28 @@ async function connect(url) { if (closedError) return Promise.reject(closedError); return new Promise((resolveCall, reject) => { const id = nextID++; - pending.set(id, { resolve: resolveCall, reject }); + const stageName = currentStage; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error( + `HARNESS FAILURE: CDP ${method} timed out after ${cdpCallTimeoutMS}ms during stage ${JSON.stringify(stageName)}`, + )); + }, cdpCallTimeoutMS); + pending.set(id, { + resolve(result) { + clearTimeout(timer); + resolveCall(result); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); try { socket.send(JSON.stringify({ id, method, params })); } catch (error) { + pending.get(id)?.reject(error); pending.delete(id); - reject(error); terminate({ type: "send" }); } }); @@ -2294,25 +2351,67 @@ async function runCommand(command, args, environment) { function runCommandResult(command, args, environment) { return new Promise((resolveCommand, reject) => { + // The child leads its own process group so a timeout also stops the + // compiler processes it started. const child = spawn(command, args, { cwd: rootDir, stdio: ["ignore", "pipe", "pipe"], env: environment, + detached: true, }); + const stageName = currentStage; let output = ""; + let timedOut = false; + let killTimer = null; + const timer = setTimeout(() => { + timedOut = true; + signalProcessGroup(child, "SIGTERM"); + killTimer = setTimeout(() => signalProcessGroup(child, "SIGKILL"), commandKillGraceMS); + }, commandTimeoutMS); child.stdout.on("data", (chunk) => { output += chunk; }); child.stderr.on("data", (chunk) => { output += chunk; }); - child.once("error", reject); + child.once("error", (error) => { + clearTimeout(timer); + clearTimeout(killTimer); + reject(error); + }); child.once("exit", (code, signal) => { + clearTimeout(timer); + clearTimeout(killTimer); + if (timedOut) { + // The leader is gone; make sure no group member outlives it. + signalProcessGroup(child, "SIGKILL"); + reject(new Error( + `HARNESS FAILURE: ${command} ${args.join(" ")} timed out after ${commandTimeoutMS}ms during stage ${JSON.stringify(stageName)} (${signal ?? code})\n${output}`, + )); + return; + } resolveCommand({ code, signal, output }); }); }); } +function signalProcessGroup(child, signal) { + if (child.pid === undefined) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + // ESRCH: every member of the group has already exited. + if (error.code === "ESRCH") return; + child.kill(signal); + } +} + +function stage(name) { + currentStage = name; + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + console.error(`custom index rewrite (${compiler}) [+${elapsed}s] stage: ${name}`); +} + async function stopProcess(child) { if (!child || child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolveExit) => child.once("exit", resolveExit)); From 9eaab40d31a42fa4d21ee2ad0f393639b59e7b72 Mon Sep 17 00:00:00 2001 From: Buton Gray Date: Sat, 3 Oct 2026 18:06:04 +0300 Subject: [PATCH 21/21] docs(ci): document Browser Smoke limits and bounded harness Record the 30 minute Browser Smoke job limit, the staged and bounded custom-index rewrite smoke, and its oracle-server teardown. The other smoke scripts remain individually unbounded. --- CHANGELOG.md | 4 ++++ docs/ci.md | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6139871..a8aca087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ repository-authored TinyGo `0.42.0` package downloads verify the accepted SHA-256 before installation. A fail-closed repository gate preserves those boundaries without changing workflow topology or toolchain versions. +- The Browser Smoke job now has a 30-minute limit. The custom-index rewrite + smoke reports its stages, bounds its subprocesses, CDP calls, and fetches, + and no longer waits on request-less connections that Chrome 154 and later + keep open to its in-process oracle servers. - Adopted TinyGo `0.42.0` for the release compiler baseline, with a measured, deterministic WASM size rebaseline limited to failing absolute ceilings; compression ratios and production compiler flags are unchanged. diff --git a/docs/ci.md b/docs/ci.md index e415bc16..9d97d440 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -210,6 +210,8 @@ older `main.wasm` packages. `.github/workflows/ci-browser-smoke.yml` runs on pull requests, pushes to `main`, and manually through `workflow_dispatch` on `ubuntu-latest` only. +The job has a 30-minute limit; passing runs on this baseline take roughly 8 +to 11 minutes. It installs Go `1.26.6`, TinyGo `0.42.0`, Node.js `24.18.1`, Chrome, and compression tools, then runs: @@ -667,6 +669,13 @@ or reset regressions, fallback component self-capture/report-loop regressions, nested-boundary bubbling regressions, protected-subtree cleanup regressions, or shell identity loss. +The custom-index rewrite smoke prints one stage line per phase on stderr and +bounds its `goxc` subprocesses, CDP calls, and HTTP fetches. Exceeding a bound +is a harness failure that names the stage. Its in-process oracle servers drop +connections that Chrome still holds when they close, because Chrome can keep +speculative connections that never carry a request. The other smoke scripts are +not individually bounded yet and rely on the job limit. + The smoke script must not continue against an unknown server or `about:blank`. ## Artifact and Module Gates