Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions hypatia_rules_praxis.deed
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions lib/rules/actions_lock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +721 to +724

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve chained transfers to the final repository

When a repository moves twice and the shared registry contains A -> B and B -> C under its one-row-per-move extension model, this map preserves A's immediate destination as B. Consequently, an actions.lock record still naming A is reported as A -> B, and the audit instructs users to relock against an address that is itself obsolete and still fails. Collapse same-repo_id move chains to the final current repository before producing the finding.

Useful? React with 👍 / 👎.

Comment on lines +721 to +724

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve transfers that share a former slug

If repository R1 moves from A to B, a replacement R2 is created at A, and R2 later moves to C, the registry correctly needs two rows with the same former slug but different stable repo_id values. This Map.new/2 silently retains only one of them, so locks for the other moved repository fail the new repo_id guard and are never reported. Group entries by former slug and select the row matching the dependency's repo_id instead of discarding one transfer.

Useful? React with 👍 / 👎.

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,
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/moved_repositories.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 51 additions & 1 deletion lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 " <>
Comment on lines +611 to +612

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish stale owner metadata in the advisory

When the detected reason is :stale_owner_id, the key already names the current repository, so this message incorrectly says it points at the old home and renders a self-mapping such as metadatastician/standards@<sha> -> metadatastician/standards. That obscures the actual owner-ID mismatch the user must diagnose; format this case separately as stale owner metadata, ideally including the observed and expected IDs.

Useful? React with 👍 / 👎.

"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
Expand Down
99 changes: 99 additions & 0 deletions test/rules/actions_lock_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading