diff --git a/docs/rules/catalogue.adoc b/docs/rules/catalogue.adoc index de57dbaa..bdef9962 100644 --- a/docs/rules/catalogue.adoc +++ b/docs/rules/catalogue.adoc @@ -16,7 +16,7 @@ a blank ID column means the module emits atom-keyed findings rather than prefixe [cols="34,33,33",options="header"] |=== |Module |Rule IDs |Purpose -|`+actions_lock+` |— |Strict local validation for machine-generated `gh actions-lock` v0.0.2 files. +|`+actions_lock+` |— |Strict local validation for machine-generated `gh actions-lock` v0.0.2 and v0.0.3 files. |`+admin_merge_eligibility+` |DBA002 |Recognises PR shapes that are safe to admin-merge in bulk when the |`+baseline_health+` |BH001 BH002 BH003 BH004 BH005 BH006 BH007 BH008 |Detects degraded `main`-branch baseline conditions that allow silent rot. |`+branch_protection+` |BP001 BP002 BP003 BP004 BP005 BP006 BP007 BP008 BP009 |Branch-protection hygiene rules drawn from the CIS GitHub Benchmark, diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 85b3b8b1..3a292443 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -378,6 +378,8 @@ defmodule Hypatia.Rules.ActionsLock do defp finalize_dependency(state) do {key, dependency, header_line} = state.current_dependency normalized = normalise_ref(key) + # nil is the home host; v0.0.3 records owner and repo IDs per host. + hostname = Map.get(dependency, :hostname) with {:ok, ref} <- fetch_dependency_field(dependency, :ref, key, header_line), {:ok, commit} <- fetch_dependency_field(dependency, :commit, key, header_line), diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index b76ce699..b1c64e99 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -285,8 +285,7 @@ defmodule Hypatia.Rules.ActionsLockTest do test "rejects symbolic-ref and SHA-key contradictions" do symbolic = String.replace(@valid_lock, "ref: 'v7.0.1'", "ref: 'v6.0.0'") - assert {:error, {:line, _, {:symbolic_key_ref_mismatch, _, _}}} = - ActionsLock.parse(symbolic) + assert {:error, {:line, _, {:symbolic_key_ref_mismatch, _, _}}} = ActionsLock.parse(symbolic) sha_key = """ version: 'v0.0.2' @@ -449,12 +448,92 @@ defmodule Hypatia.Rules.ActionsLockTest do " 'actions/checkout@refs:heads/main':\n" ) - assert {:error, {:line, _, {:malformed_dependency_key, _}}} = - ActionsLock.parse(subpath_key) + assert {:error, {:line, _, {:malformed_dependency_key, _}}} = ActionsLock.parse(subpath_key) assert {:error, {:line, _, {:malformed_dependency_key, _}}} = ActionsLock.parse(colon_key) end + test "accepts v0.0.3 locks and validates the optional hostname" do + v3 = String.replace(@valid_lock, "v0.0.2", "v0.0.3") + assert {:ok, %ActionsLock{version: "v0.0.3"}} = ActionsLock.parse(v3) + + with_host = fn host -> + String.replace( + v3, + " repo_id: 197814629\n", + " repo_id: 197814629\n hostname: '#{host}'\n" + ) + end + + assert {:ok, _} = ActionsLock.parse(with_host.("github.com")) + assert {:ok, _} = ActionsLock.parse(with_host.("acme.ghe.com")) + + long_label = String.duplicate("a", 64) <> ".ghe.com" + assert {:ok, _} = ActionsLock.parse(with_host.(String.duplicate("a", 63) <> ".ghe.com")) + + for bad <- [ + "GitHub.com", + "example.com", + "acme.ghe.com.evil", + "-x.ghe.com", + "foo.bar.ghe.com", + long_label + ] do + assert {:error, {:line, _, {:malformed_dependency_hostname, ^bad}}} = + ActionsLock.parse(with_host.(bad)) + end + + dup = + String.replace( + with_host.("github.com"), + "hostname: 'github.com'\n", + "hostname: 'github.com'\n hostname: 'github.com'\n" + ) + + assert {:error, {:line, _, {:duplicate_dependency_field, _, :hostname}}} = + ActionsLock.parse(dup) + + v2_with_host = + String.replace( + @valid_lock, + " repo_id: 197814629\n", + " repo_id: 197814629\n hostname: 'github.com'\n" + ) + + assert {:error, {:line, _, :malformed_dependency_hostname}} = ActionsLock.parse(v2_with_host) + end + + test "scopes owner and repository identities to their v0.0.3 host" do + mixed = """ + version: 'v0.0.3' + workflows: + '.github/workflows/ci.yml': + - 'acme/foo@v1' + - 'acme/bar@v1' + dependencies: + 'acme/bar@v1': + hostname: 'github.com' + ref: 'v1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 2 + repo_id: 20 + 'acme/foo@v1': + ref: 'v1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 1 + repo_id: 20 + """ + + assert {:ok, lock} = ActionsLock.parse(mixed) + assert lock.dependencies["acme/bar@v1"].hostname == "github.com" + assert lock.dependencies["acme/foo@v1"].hostname == nil + + same_host = String.replace(mixed, " hostname: 'github.com'\n", "") + + assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = + ActionsLock.parse(same_host) + end + test "parses the repository's authoritative generated lockfile" do content = File.read!(".github/workflows/actions.lock") assert {:ok, parsed} = ActionsLock.parse(content)