Repository navigation
feat(workflow_audit): report actions.lock records pointing at a moved repository #1109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f4a72af
66fd5cb
c7a71fc
4a2b3d3
feba296
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If repository 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 " <> | ||
|
Comment on lines
+611
to
+612
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the detected reason is 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a repository moves twice and the shared registry contains
A -> BandB -> Cunder its one-row-per-move extension model, this map preservesA's immediate destination asB. Consequently, anactions.lockrecord still namingAis reported asA -> B, and the audit instructs users to relock against an address that is itself obsolete and still fails. Collapse same-repo_idmove chains to the final current repository before producing the finding.Useful? React with 👍 / 👎.