diff --git a/hypatia_rules_praxis.deed b/hypatia_rules_praxis.deed index 3434d52c..e1dacdfd 100644 --- a/hypatia_rules_praxis.deed +++ b/hypatia_rules_praxis.deed @@ -3343,6 +3343,15 @@ :excusable-at repo :scope-form exact-paths :status active) + ;; defaulted: text excusable-at scope-form + (rule-def + :id "hypatia/workflow_audit/actions_lock_moved_repository" + :text "hypatia/workflow_audit/actions_lock_moved_repository in lib/rules/workflow_audit.ex." + :class advisory + :severity minor + :excusable-at repo + :scope-form exact-paths + :status active) ;; defaulted: text class excusable-at scope-form (rule-def :id "hypatia/workflow_audit/actions_lock_stale" diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 9aa250bb..5a3a432b 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -711,6 +711,73 @@ defmodule Hypatia.Rules.ActionsLock do do: key end + # Repositories known to have moved owner. A record that still names the old + # location, or names the new location with the old owner's id, cannot be + # verified by the runner: the workflow that calls it fails at startup with + # no job and no log (CASE-054, CoprocessorRuntime.jl). The moves come from + # the shared registry (Hypatia.Rules.MovedRepositories), which WF028 also + # reads, so a new transfer is recorded once (Codex on #1109). + defp moved_repositories do + Map.new(Hypatia.Rules.MovedRepositories.entries(), fn m -> + {String.downcase(m.former), + %{to: String.downcase(m.current), owner_id: m.current_owner_id, repo_id: m.repo_id}} + end) + end + + @doc """ + Records that point at a repository's old home after a transfer, read from + the lock alone. Returns `[%{key, from, to, reason}]`, sorted by key: + + * `:old_name` - the key names a known moved repository's old location, + with that repository's `repo_id`; + * `:stale_owner_id` - the key names the new location, but its `owner_id` + is not the new owner's. + + (One `repo_id` under two names is already refused by parse/2 as + `:repository_id_reused`, and reported as an invalid lock.) A referral for + the relock (cicd-squabbler); nothing is rewritten here. + """ + @spec moved_repository_keys(t() | map()) :: [map()] + def moved_repository_keys(%__MODULE__{dependencies: dependencies}), + do: moved_repository_keys(dependencies) + + def moved_repository_keys(dependencies) when is_map(dependencies) do + moves = moved_repositories() + renamed = Map.new(moves, fn {from, m} -> {m.to, Map.put(m, :from, from)} end) + + dependencies + |> Enum.flat_map(fn {key, dep} -> + repo = key_repository(key) + + cond do + is_nil(repo) -> + [] + + # Only the moved repository itself: a new repository created at the + # old name has another repo_id (Codex on #1109). + (moved = moves[repo]) && dep.repo_id == moved.repo_id -> + [%{key: key, from: repo, to: moved.to, reason: :old_name}] + + (moved = renamed[repo]) && dep.owner_id != moved.owner_id -> + [%{key: key, from: moved.from, to: repo, reason: :stale_owner_id}] + + true -> + [] + end + end) + |> Enum.sort_by(& &1.key) + end + + # `owner/repo` of a dependency key (`owner/repo@ref`), lower-cased. + defp key_repository(key) do + with [path, _ref] <- String.split(key, "@", parts: 2), + [owner, repo | _] <- String.split(path, "/") do + String.downcase(owner <> "/" <> repo) + else + _ -> nil + end + end + defp validate_workflow_dependencies(state) do missing = for {path, refs} <- state.workflows, diff --git a/lib/rules/moved_repositories.ex b/lib/rules/moved_repositories.ex index ead1899b..431d92d7 100644 --- a/lib/rules/moved_repositories.ex +++ b/lib/rules/moved_repositories.ex @@ -16,6 +16,8 @@ defmodule Hypatia.Rules.MovedRepositories do former: "hyperpolymath/standards", current: "metadatastician/standards", repo_id: 1_116_521_501, + # The new owner's account id, as a relocked actions.lock records it. + current_owner_id: 297_579_217, moved: "2026-10-25", evidence: "https://github.com/hyperpolymath/hypatia/actions/runs/40424594888", additional_evidence: "https://github.com/hyperpolymath/hypatia/actions/runs/40424740139" diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 9718db02..ee9a8329 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -111,6 +111,11 @@ defmodule Hypatia.Rules.WorkflowAudit do do: [], else: check_actions_lock_tag_keys(opts[:actions_lock], workflow_contents) + lock_moved_repos = + if opts[:partial], + do: [], + else: check_actions_lock_moved_repositories(opts[:actions_lock], workflow_contents) + %{ # `flawed_regexes` was computed at the top of this function and # counted into `flawed_regex_count:` below, but was never added to @@ -155,7 +160,8 @@ defmodule Hypatia.Rules.WorkflowAudit do mirror_visibility ++ gitleaks_full_history ++ full_history_remains ++ - lock_tag_keys, + lock_tag_keys ++ + lock_moved_repos, missing_count: length(missing), secret_history_count: length(secret_history), unpinned_count: length(unpinned), @@ -573,6 +579,50 @@ defmodule Hypatia.Rules.WorkflowAudit do end end + @doc """ + Repository-wide: `actions.lock` records that still point at a moved + repository's old home (`ActionsLock.moved_repository_keys/1`). The runner + cannot verify such a record, so the calling workflow dies at startup with + no job and no log. One advisory info finding per audit, referred to the + relock (cicd-squabbler); never rewritten here. + """ + def check_actions_lock_moved_repositories(lock_content, workflow_contents \\ nil) + + def check_actions_lock_moved_repositories(lock_content, workflow_contents) + when is_binary(lock_content) do + opts = + if is_map(workflow_contents), + do: [reusable_callers: Hypatia.Rules.ActionsLock.reusable_callers(workflow_contents)], + else: [] + + with {:ok, lock} <- Hypatia.Rules.ActionsLock.parse(lock_content, opts), + [_ | _] = moved <- Hypatia.Rules.ActionsLock.moved_repository_keys(lock) do + [ + %{ + type: :actions_lock_moved_repository, + rule_id: "hypatia/workflow_audit/actions_lock_moved_repository", + file: "actions.lock", + severity: :info, + advisory: true, + action: :report, + keys: Enum.map(moved, & &1.key), + moved: moved, + detail: + "#{length(moved)} actions.lock record(s) point at a moved repository's old " <> + "home (e.g. #{describe_moved(hd(moved))}). The runner cannot verify them, so " <> + "the calling workflow fails at startup with no job and no log. Referral: " <> + "relock against the new owner (cicd-squabbler)." + } + ] + else + _ -> [] + end + end + + def check_actions_lock_moved_repositories(_, _workflow_contents), do: [] + + defp describe_moved(%{key: key, to: to}), do: "#{key} -> #{to}" + defp parse_actions_lock(nil, _workflow_contents, _complete?), do: {:usable, nil} defp parse_actions_lock(content, workflow_contents, complete?) do diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 8e8e4c28..ba22231f 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -146,6 +146,105 @@ defmodule Hypatia.Rules.ActionsLockTest do refute Enum.any?(fs, &(&1[:type] == :actions_lock_tag_keyed)) end + describe "moved repositories (CASE-054)" do + @old_sha "da2c748ab1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6" + + defp lock_with(key, owner_id, repo_id) do + """ + version: 'v0.0.2' + workflows: + '.github/workflows/secret-scanner.yml': + - '#{key}' + dependencies: + '#{key}': + ref: '#{@old_sha}' + commit: 'sha1-#{@old_sha}' + owner_id: #{owner_id} + repo_id: #{repo_id} + """ + end + + test "a record still naming hyperpolymath/standards is an old name" do + key = "hyperpolymath/standards@#{@old_sha}" + lock = lock_with(key, 6_759_885, 1_116_521_501) + assert {:ok, parsed} = ActionsLock.parse(lock) + + assert [%{key: ^key, to: "metadatastician/standards", reason: :old_name}] = + ActionsLock.moved_repository_keys(parsed) + + assert [ + %{ + type: :actions_lock_moved_repository, + severity: :info, + advisory: true, + action: :report, + keys: [^key] + } + ] = Hypatia.Rules.WorkflowAudit.check_actions_lock_moved_repositories(lock) + end + + test "the new name with the old owner's id is stale" do + key = "metadatastician/standards@#{@old_sha}" + {:ok, parsed} = ActionsLock.parse(lock_with(key, 6_759_885, 1_116_521_501)) + assert [%{key: ^key, reason: :stale_owner_id}] = ActionsLock.moved_repository_keys(parsed) + end + + test "the new name with the new owner's id is clean" do + key = "metadatastician/standards@#{@old_sha}" + lock = lock_with(key, 297_579_217, 1_116_521_501) + {:ok, parsed} = ActionsLock.parse(lock) + assert ActionsLock.moved_repository_keys(parsed) == [] + assert Hypatia.Rules.WorkflowAudit.check_actions_lock_moved_repositories(lock) == [] + end + + # Codex on #1109. + test "a new repository at the old name, with another repo_id, is not the moved one" do + key = "hyperpolymath/standards@#{@old_sha}" + {:ok, parsed} = ActionsLock.parse(lock_with(key, 6_759_885, 1_200_000_001)) + assert ActionsLock.moved_repository_keys(parsed) == [] + end + + test "the lookup comes from the shared moved-repositories registry" do + for m <- Hypatia.Rules.MovedRepositories.entries() do + key = "#{m.former}@#{@old_sha}" + {:ok, parsed} = ActionsLock.parse(lock_with(key, 1, m.repo_id)) + assert [%{reason: :old_name, to: to}] = ActionsLock.moved_repository_keys(parsed) + assert to == m.current + assert is_integer(m.current_owner_id) + end + end + + test "hypatia's own lock has no moved-repository record" do + lock = File.read!(Path.expand("../../.github/workflows/actions.lock", __DIR__)) + {:ok, parsed} = ActionsLock.parse(lock) + assert ActionsLock.moved_repository_keys(parsed) == [] + end + + test "the audit reports it once, and not in a partial audit" do + key = "hyperpolymath/standards@#{@old_sha}" + lock = lock_with(key, 6_759_885, 1_116_521_501) + + wf = + "on: push\njobs:\n scan:\n uses: hyperpolymath/standards/.github/workflows/" <> + "secret-scanner-reusable.yml@#{@old_sha}\n" + + %{findings: fs} = + Hypatia.Rules.WorkflowAudit.audit(["secret-scanner.yml"], %{"secret-scanner.yml" => wf}, + actions_lock: lock + ) + + assert Enum.count(fs, &(&1[:type] == :actions_lock_moved_repository)) == 1 + + %{findings: partial} = + Hypatia.Rules.WorkflowAudit.audit(["secret-scanner.yml"], %{"secret-scanner.yml" => wf}, + actions_lock: lock, + partial: true + ) + + refute Enum.any?(partial, &(&1[:type] == :actions_lock_moved_repository)) + end + end + test "accepts uppercase hexadecimal commit suffixes" do lock = String.replace(