From f4a72afbe1a1d0f17aed3abce292418540bc29c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:02:51 +0000 Subject: [PATCH 1/2] feat(workflow_audit): report actions.lock records pointing at a moved repository CASE-054 (CoprocessorRuntime.jl): the required "scan / gitleaks" never reported because secret-scanner and five other workflows died at startup, with no job and no log. The lock still recorded hyperpolymath/standards@da2c748 with owner_id 6759885; the repository moved to metadatastician (owner_id 297579217, same repo_id 1116521501), so the runner could not verify the record. ActionsLock.moved_repository_keys/1 reads the lock alone against a table of known transfers: a key naming the old location (:old_name), or the new location with the old owner's id (:stale_owner_id). The audit reports one advisory info finding, referred to the relock (cicd-squabbler); nothing is rewritten. One repo_id under two names is already refused by parse/2. Verified on CoprocessorRuntime.jl main e57738b: reports exactly that record. Hypatia's own lock is clean. Owner ruling 14 Nov. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- hypatia_rules_praxis.deed | 9 ++++ lib/rules/actions_lock.ex | 63 ++++++++++++++++++++++++ lib/rules/workflow_audit.ex | 52 +++++++++++++++++++- test/rules/actions_lock_test.exs | 82 ++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) 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..c4e67e4b 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -711,6 +711,69 @@ 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). Extend this table when + # a repository is transferred; the ids are the new owner's. + @moved_repositories %{ + "hyperpolymath/standards" => %{ + to: "metadatastician/standards", + owner_id: 297_579_217, + repo_id: 1_116_521_501 + } + } + + @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; + * `: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 + renamed = Map.new(@moved_repositories, 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) -> + [] + + moved = @moved_repositories[repo] -> + [%{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/workflow_audit.ex b/lib/rules/workflow_audit.ex index 170795ab..8a6ba6ba 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..6b4846b0 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -146,6 +146,88 @@ 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 + + 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( From c7a71fcad93c80881853f838db1b779b65d8f7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:41:34 +0000 Subject: [PATCH 2/2] fix(rules): lock moved-repo check reads the shared registry, matches repo_id Codex on #1109: - The moves now come from Hypatia.Rules.MovedRepositories, which WF028 also reads, so a new transfer is recorded once. The entry gains current_owner_id (additive; the schema version is unchanged). - :old_name needs the moved repository's repo_id, so a new repository created at the old name is not reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/actions_lock.ex | 28 ++++++++++++++++------------ lib/rules/moved_repositories.ex | 2 ++ test/rules/actions_lock_test.exs | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index c4e67e4b..5a3a432b 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -714,21 +714,22 @@ defmodule Hypatia.Rules.ActionsLock do # 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). Extend this table when - # a repository is transferred; the ids are the new owner's. - @moved_repositories %{ - "hyperpolymath/standards" => %{ - to: "metadatastician/standards", - owner_id: 297_579_217, - repo_id: 1_116_521_501 - } - } + # 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; + * `: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. @@ -741,7 +742,8 @@ defmodule Hypatia.Rules.ActionsLock do do: moved_repository_keys(dependencies) def moved_repository_keys(dependencies) when is_map(dependencies) do - renamed = Map.new(@moved_repositories, fn {from, m} -> {m.to, Map.put(m, :from, from)} end) + 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} -> @@ -751,7 +753,9 @@ defmodule Hypatia.Rules.ActionsLock do is_nil(repo) -> [] - moved = @moved_repositories[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 -> 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/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 6b4846b0..ba22231f 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -197,6 +197,23 @@ defmodule Hypatia.Rules.ActionsLockTest do 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)