diff --git a/.reuse/dep5 b/.reuse/dep5 index fb63b500..97db7052 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -19,3 +19,12 @@ Files: integration/fixtures/* Copyright: 2026 Jonathan D.A. Jewell License: MPL-2.0 Comment: Test fixtures. + +Files: test/fixtures/actions_lock/*.lock +Copyright: 2026 Jonathan D.A. Jewell +License: MPL-2.0 +Comment: Verbatim `gh actions-lock` output, copied byte-for-byte from the + named repository and commit (standards-b3ce78e-*.lock is + hyperpolymath/standards .github/workflows/actions.lock at b3ce78e). These + are tool-owned files: never hand-edit or reformat them; replace them with a + fresh copy instead. diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 2d6987fe..e1fe0ad6 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -15,16 +15,31 @@ defmodule Hypatia.Rules.ActionsLock do single tool with a versioned stable format, and Hypatia must not add a general YAML dependency merely to consume that format. Unknown or malformed lockfile lines fail closed rather than being guessed at. + + v0.0.3 differs from v0.0.2 only by the optional per-dependency `hostname` + field (github/actions-lockfile `schema/lockfile-v0.0.3.json`). An omitted + hostname binds the pin to the home host. Hypatia audits github.com + repositories, where the format allows only an omitted hostname or + `github.com`. A well-formed `*.ghe.com` tenant hostname is therefore rejected + rather than accepted: `owner_id`/`repo_id` are host-scoped, and the + `owner/repo@ref` keys used for association carry no host. Every parsed + dependency records its hostname, defaulting to `github.com` as v0.0.2 does. """ @supported_versions ["v0.0.2", "v0.0.3"] + @hostname_versions ["v0.0.3"] + @home_hostname "github.com" @action_ref ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[^\s':]+$/ @dependency_key ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[^\s':]+$/ @sha_ref ~r/^[0-9A-Fa-f]{40}$/ + # The schema's canonical hostname grammar: github.com or one lowercase DNS + # label under ghe.com. No scheme, port, path, or surrounding whitespace. + @canonical_hostname ~r/^(?:github\.com|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.ghe\.com)$/ defstruct version: nil, workflows: %{}, dependencies: %{} @type dependency :: %{ + hostname: String.t(), ref: String.t(), commit: String.t(), owner_id: pos_integer(), @@ -189,6 +204,13 @@ defmodule Hypatia.Rules.ActionsLock do end end + match = Regex.run(~r/^ hostname: '([^']*)'$/, line) -> + [_, hostname] = match + put_dependency_hostname(state, hostname, line_number) + + Regex.match?(~r/^ hostname:/, line) -> + line_error(line_number, :malformed_dependency_hostname) + match = Regex.run(~r/^ ref: '([^']+)'$/, line) -> [_, ref] = match put_dependency_field(state, :ref, ref, line_number) @@ -279,6 +301,22 @@ defmodule Hypatia.Rules.ActionsLock do end end + defp put_dependency_hostname(state, hostname, line_number) do + cond do + state.version not in @hostname_versions -> + line_error(line_number, {:hostname_not_in_version, state.version}) + + not Regex.match?(@canonical_hostname, hostname) -> + line_error(line_number, {:malformed_dependency_hostname, hostname}) + + hostname != @home_hostname -> + line_error(line_number, {:unsupported_dependency_hostname, hostname}) + + true -> + put_dependency_field(state, :hostname, hostname, line_number) + end + end + defp identity_field("owner_id"), do: :owner_id defp identity_field("repo_id"), do: :repo_id @@ -326,6 +364,7 @@ defmodule Hypatia.Rules.ActionsLock do ), false <- Map.has_key?(state.dependencies, normalized) do stored = %{ + hostname: Map.get(dependency, :hostname, @home_hostname), ref: ref, commit: commit, owner_id: owner_id, diff --git a/test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock b/test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock new file mode 100644 index 00000000..a997efd9 --- /dev/null +++ b/test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock @@ -0,0 +1,326 @@ +# This file is machine-generated by `gh actions-lock`. +# Do not edit by hand; run `gh actions-lock` to update. +# Docs: https://gh.io/actions-lockfile +version: 'v0.0.3' +workflows: + '.github/workflows/actions-lock-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/affinescript-verify.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7' + '.github/workflows/allowlist-preflight-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/apply-workflow-pins.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/boj-build.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/canon-spine-lockstep.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/casket-pages.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + '.github/workflows/changelog-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/changelog.yml': [] + '.github/workflows/check-suite-monitor.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' + '.github/workflows/ci-pipeline.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/codeql-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + '.github/workflows/codeql.yml': [] + '.github/workflows/debt-measure.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/deed-conformance.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/dependabot-lock-regen.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/doc-format.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/dyadt-verify.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/echidna-verify.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + '.github/workflows/elixir-ci-reusable.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + '.github/workflows/elixir-ci.yml': [] + '.github/workflows/github-backup-mirror-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/governance-reusable.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/governance.yml': [] + '.github/workflows/hypatia-scan-reusable.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + '.github/workflows/hypatia-scan.yml': [] + '.github/workflows/ijson-jcs.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/instant-sync.yml': + - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' + '.github/workflows/js-runtimes-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/k9-contractile.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/label-triage.yml': [] + '.github/workflows/labels.yml': [] + '.github/workflows/launcher-standard-lockstep.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/lock-selfcheck.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/lockfile-drift-detect.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/makefile-blocker.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/mirror-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + '.github/workflows/mirror.yml': + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' + '.github/workflows/no-js-scan.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/pages-archive.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + '.github/workflows/pages.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + '.github/workflows/propagate-hooks.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/provisioning-check-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/readme-derive-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/readme-derive.yml': [] + '.github/workflows/rust-ci-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + '.github/workflows/scorecard-enforcer.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' + '.github/workflows/scorecard-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' + '.github/workflows/secret-scanner-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/self-test.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + '.github/workflows/settings-drift-detect.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/signed-push-smoke.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/spark-theatre-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/tag-ruleset-canon.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + '.github/workflows/tailscale-connect-reusable.yml': + - 'tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd' + '.github/workflows/uuid-v8.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/k9-conformance.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' +dependencies: + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: 'v6.1.0' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': + ref: 'v6.0.0' + commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' + owner_id: 44036562 + repo_id: 513659658 + 'actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510': + ref: 'v3.3.0' + commit: 'sha1-64ab729a6294000f5aa4a4bc0b73d1f32bc45510' + owner_id: 44036562 + repo_id: 642580244 + 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1': + ref: 'v3.2.0' + commit: 'sha1-bcd2ba49218906704ab6c1aa796996da409d3eb1' + owner_id: 44036562 + repo_id: 642580244 + 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': + ref: 'v5.0.1' + commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' + owner_id: 44036562 + repo_id: 438112499 + 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333': + ref: 'v8.0.2' + commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + owner_id: 44036562 + repo_id: 192626254 + 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3': + ref: 'v9.0.0' + commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' + owner_id: 44036562 + repo_id: 205262760 + 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': + ref: 'v7.0.0' + commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9': + ref: 'v7.0.2' + commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': + ref: 'v5.0.0' + commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' + owner_id: 44036562 + repo_id: 496012378 + uses: + - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': + ref: 'v2.0.5' + commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + owner_id: 42048915 + repo_id: 356423100 + 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8': + ref: 'v1' + commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' + owner_id: 1940490 + repo_id: 260749683 + 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f': + ref: 'v1' + commit: 'sha1-e2a55d2ffb04f378e9626c28d38b36d230d1e12f' + owner_id: 1940490 + repo_id: 260749683 + 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': + ref: 'v3.0.0' + commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' + owner_id: 26415196 + repo_id: 297874902 + 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: 'v1.24.1' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' + owner_id: 9919 + repo_id: 259445878 + 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': + ref: 'v2.2.1' + commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' + owner_id: 1006268 + repo_id: 212984112 + 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d': + ref: 'v2.12.1' + commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + owner_id: 75048950 + repo_id: 623796603 + 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597': + ref: '427eb35c40552d912d8c757518e8326f0d20f597' + commit: 'sha1-427eb35c40552d912d8c757518e8326f0d20f597' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7': + ref: 'v3.9.0' + commit: 'sha1-93303b622b2522e4411e295f9e77411a24912ac7' + owner_id: 1841483 + repo_id: 220799100 + 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': + ref: 'v2.5.0' + commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' + owner_id: 67707773 + repo_id: 421101922 + 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6': + ref: 'v2.2.0' + commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' + owner_id: 108928776 + repo_id: 512644635 + 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697': + ref: 'v4.0.1' + commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' + owner_id: 18365890 + repo_id: 220359305 + 'step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25': + ref: 'v2.22.1' + commit: 'sha1-ccd8616d44fd3846e67624a50d5aad6d37bf2d25' + owner_id: 88700172 + repo_id: 422287306 + 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': + ref: 'v2.9.2' + commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + owner_id: 580492 + repo_id: 298565987 + 'tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd': + ref: 'v4.2.0' + commit: 'sha1-d1b6cd204f8dceda5b3eaad7f1f767be390056cd' + owner_id: 48932923 + repo_id: 360548653 + 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': + ref: 'v0.10.0' + commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' + owner_id: 135788 + repo_id: 208510314 diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 2c80e74c..f3fb3d97 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -296,6 +296,133 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:error, {:line, _, :duplicate_version}} = ActionsLock.parse(duplicate) end + describe "v0.0.3 hostname field" do + @standards_v003 "test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock" + + @v003_lock """ + # This file is machine-generated by `gh actions-lock`. + version: 'v0.0.3' + workflows: + '.github/workflows/ci.yml': + - 'actions/checkout@v7.0.1' + dependencies: + 'actions/checkout@v7.0.1': + hostname: 'github.com' + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + """ + + defp with_hostname(value) do + String.replace(@v003_lock, "hostname: 'github.com'", "hostname: #{value}") + end + + test "parses hyperpolymath/standards' generated v0.0.3 lockfile" do + assert {:ok, parsed} = ActionsLock.parse(File.read!(@standards_v003)) + + assert parsed.version == "v0.0.3" + assert map_size(parsed.workflows) == 57 + assert map_size(parsed.dependencies) == 28 + assert Enum.all?(Map.values(parsed.dependencies), &(&1.hostname == "github.com")) + + checkout = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" + assert ActionsLock.pinned?(parsed, "actions-lock-gate.yml", checkout) + assert parsed.workflows[".github/workflows/changelog.yml"] == MapSet.new() + + assert MapSet.member?( + parsed.dependencies[ + "hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597" + ].uses, + checkout + ) + end + + test "an omitted hostname binds the pin to github.com" do + omitted = String.replace(@v003_lock, " hostname: 'github.com'\n", "") + + assert {:ok, parsed} = ActionsLock.parse(omitted) + assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" + assert ActionsLock.pinned?(parsed, "ci.yml", "actions/checkout@v7.0.1") + end + + test "an explicit github.com hostname is accepted and keeps association" do + assert {:ok, parsed} = ActionsLock.parse(@v003_lock) + assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" + assert ActionsLock.pinned?(parsed, "ci.yml", "actions/checkout@v7.0.1") + refute ActionsLock.pinned?(parsed, "other.yml", "actions/checkout@v7.0.1") + end + + test "v0.0.2 lockfiles default to github.com and may not carry a hostname" do + assert {:ok, parsed} = ActionsLock.parse(@valid_lock) + assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" + + v002 = String.replace(@v003_lock, "v0.0.3", "v0.0.2") + + assert {:error, {:line, 8, {:hostname_not_in_version, "v0.0.2"}}} = + ActionsLock.parse(v002) + end + + test "a well-formed GHE.com tenant hostname fails closed" do + assert {:error, {:line, 8, {:unsupported_dependency_hostname, "octocorp.ghe.com"}}} = + ActionsLock.parse(with_hostname("'octocorp.ghe.com'")) + end + + test "non-canonical hostnames are malformed" do + for value <- [ + "", + "GitHub.com", + "https://github.com", + "github.com:443", + "github.com/", + " github.com", + "github.com.evil.example", + "-tenant.ghe.com", + "a.b.ghe.com" + ] do + assert {:error, {:line, 8, {:malformed_dependency_hostname, ^value}}} = + ActionsLock.parse(with_hostname("'#{value}'")), + "expected #{inspect(value)} to be rejected" + end + end + + test "unquoted, double-quoted, and duplicate hostnames fail closed" do + for value <- ["github.com", ~s("github.com")] do + assert {:error, {:line, 8, :malformed_dependency_hostname}} = + ActionsLock.parse(with_hostname(value)) + end + + duplicate = + String.replace( + @v003_lock, + "hostname: 'github.com'", + "hostname: 'github.com'\n hostname: 'github.com'" + ) + + assert {:error, {:line, 9, {:duplicate_dependency_field, _, :hostname}}} = + ActionsLock.parse(duplicate) + end + + test "a hostname outside a dependency entry fails closed" do + orphan = + String.replace( + @v003_lock, + " 'actions/checkout@v7.0.1':\n hostname", + " hostname" + ) + + assert {:error, {:line, 7, :dependency_field_without_dependency}} = + ActionsLock.parse(orphan) + end + + test "versions after v0.0.3 still fail closed" do + for version <- ["v0.0.4", "v0.1.0", "v1.0.0"] do + assert {:error, {:line, 2, {:unsupported_version, ^version}}} = + @v003_lock |> String.replace("v0.0.3", version) |> ActionsLock.parse() + end + end + end + test "legacy Rules workflow scanner delegates to path-aware lock semantics" do assert Rules.scan_workflow(@workflow, filename: "ci.yml", actions_lock: @valid_lock) == [] diff --git a/test/workflow_audit_test.exs b/test/workflow_audit_test.exs index 203f4666..386c64af 100644 --- a/test/workflow_audit_test.exs +++ b/test/workflow_audit_test.exs @@ -895,6 +895,27 @@ defmodule Hypatia.Rules.WorkflowAuditTest do assert [%{type: :invalid_actions_lock, action: :regenerate}] = findings end + + test "a generated v0.0.3 lock with hostname fields is valid and keeps association" do + standards = File.read!("test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock") + + refute Enum.any?( + WorkflowAudit.check_unpinned_actions(@wf, actions_lock: standards), + &(&1.type == :invalid_actions_lock) + ) + + v003 = + @lock + |> String.replace("version: 'v0.0.2'", "version: 'v0.0.3'") + |> String.replace(" ref: '", " hostname: 'github.com'\n ref: '") + + assert WorkflowAudit.check_unpinned_actions(@wf, actions_lock: v003) == [] + + tenant = String.replace(v003, "'github.com'", "'octocorp.ghe.com'", global: false) + + assert [%{type: :invalid_actions_lock, action: :regenerate}] = + WorkflowAudit.check_unpinned_actions(@wf, actions_lock: tenant) + end end # ── The job-level blind spot ────────────────────────────────────────────── diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index fb51b0dd..1f2880c4 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -321,6 +321,42 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do File.rm_rf!(repo) end + test "a generated v0.0.3 lock is valid, and a GHE.com tenant hostname fails closed" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - uses: actions/checkout@v7.0.1 + """) + + lock = Path.join([repo, ".github", "workflows", "actions.lock"]) + File.cp!("test/fixtures/actions_lock/standards-b3ce78e-v0.0.3.lock", lock) + + refute Enum.any?( + WorkflowHardening.wh004_unpinned_uses(repo), + &(&1.rule == "invalid_actions_lock") + ) + + write_actions_lock(repo, ".github/workflows/test.yml") + + v003 = + lock + |> File.read!() + |> String.replace("v0.0.2", "v0.0.3") + |> String.replace("ref: 'v7.0.1'", "hostname: 'github.com'\n ref: 'v7.0.1'") + + File.write!(lock, v003) + assert WorkflowHardening.wh004_unpinned_uses(repo) == [] + + File.write!(lock, String.replace(v003, "'github.com'", "'octocorp.ghe.com'")) + + assert [%{rule: "invalid_actions_lock", severity: :high}] = + WorkflowHardening.wh004_unpinned_uses(repo) + + File.rm_rf!(repo) + end + test "accepts 40-char SHA-pinned actions" do repo = create_repo_with_workflow("""