From a3e6b3f50232b37719d1276b1633c696a31551b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:27:22 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(workflow=5Faudit):=20WF028=20=E2=80=94?= =?UTF-8?q?=20reusable=20call=20into=20a=20moved=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyperpolymath/standards moved to metadatastician/standards on 2026-10-25. GitHub keeps a redirect for git and the REST API, but does not follow it when it resolves a reusable workflow's `uses:`, so every caller still naming the old owner ends in startup_failure before any job runs (measured: hypatia governance runs 40424594888 / 40424740139). WF028 flags that call where WF027 finds a reusable call (a job's `uses:` key in block or flow style, continued below its key, or via a job alias), and names the re-pointed reference at the same revision. The moved-repo table is derived from StandardsRepo.former/0, so each further transfer into the org (standards D97) is a row, not a rule. Advisory, severity low, auto_fixable: false, like WF026/WF027 (AGENTIC must-not-promote-to-blocking); no fleet fixer rewrites the owner yet. cli.ex now carries the finding's `replacement` through to SARIF/fleet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfvetbJ5z7uREtbJWw --- .machine_readable/descriptiles/AGENTIC.a2ml | 2 + docs/wiki-pages/Operations.md | 2 +- lib/hypatia/cli.ex | 8 +- lib/rules/standards_repo.ex | 3 + lib/rules/workflow_audit.ex | 128 +++++++++++++++++++- test/rules/workflow_audit_pins_test.exs | 76 ++++++++++++ 6 files changed, 214 insertions(+), 5 deletions(-) diff --git a/.machine_readable/descriptiles/AGENTIC.a2ml b/.machine_readable/descriptiles/AGENTIC.a2ml index ecc2bc39..d69fd8df 100644 --- a/.machine_readable/descriptiles/AGENTIC.a2ml +++ b/.machine_readable/descriptiles/AGENTIC.a2ml @@ -49,6 +49,8 @@ completion-claim-requires-behavioural-tests = true # (detected in block YAML and KYAML/flow style alike, Y-3; repaired in block style only — # a KYAML, flow-step, anchored or aliased finding carries auto_fixable: false and routes to review) # WF027 workflow_audit.ex caller pinned at a reusable revision MEASURED dead (table) -> fleet fix-stale-reusable-pin.sh (Window A) +# WF028 workflow_audit.ex job calls a reusable by a MOVED repository's former name -> NO fleet fixer yet (auto_fixable: false; +# (table: StandardsRepo.former/0 -> current; one row per transfer, appended when it lands) owner re-point is gitbot-fleet's to build) # PIN-003 pr-automerge-policy.json standards revisions whose own actions.lock GitHub # rejects at job setup (545 commits, 2026-08-19..2026-10-19; PI001/PA001 fire) -> fleet fix-stale-reusable-pin.sh (Window B) # known_reusables (sha_bump_propagation.ex) now include hyperpolymath/cicd-suite's gates diff --git a/docs/wiki-pages/Operations.md b/docs/wiki-pages/Operations.md index a0af81c6..7adf9430 100644 --- a/docs/wiki-pages/Operations.md +++ b/docs/wiki-pages/Operations.md @@ -62,7 +62,7 @@ The supervision tree starts Bandit on port 9090 (9099 under `MIX_ENV=test`): | `HYPATIA_ALERT_WEBHOOK_URL` | Webhook for watcher alerts | | `HYPATIA_ALERT_LOG_FILE` | File sink for watcher alerts | | `HYPATIA_EXIT_ZERO` | Make the CLI exit 0 even when findings are present | -| `HYPATIA_ADVISORY` | Also report advisory findings (WF026, WF027) below the severity threshold; they never set the exit code | +| `HYPATIA_ADVISORY` | Also report advisory findings (WF026, WF027, WF028) below the severity threshold; they never set the exit code | ## CLI diff --git a/lib/hypatia/cli.ex b/lib/hypatia/cli.ex index 64bceb15..cf8ea6a0 100644 --- a/lib/hypatia/cli.ex +++ b/lib/hypatia/cli.ex @@ -523,9 +523,10 @@ defmodule Hypatia.CLI do # structural_drift normalizer's `Map.take` above. Before this, # 7 `fix_recipe:` and 7 `recipe_id:` declarations in # workflow_audit.ex were read by nothing at all. - # WF026/WF027 anchor to a line and name their repair script and - # the SHAs involved; keep those so SARIF points at the line and - # the fleet receives the advertised script and replacement. + # WF026/WF027/WF028 anchor to a line and name their repair script + # and the SHAs or re-pointed reference involved; keep those so + # SARIF points at the line and the fleet receives the advertised + # script and replacement. |> Map.merge( Map.take(f, [ :fix_recipe, @@ -536,6 +537,7 @@ defmodule Hypatia.CLI do :fix_script, :current_sha, :replacement_sha, + :replacement, :reusable, :advisory ]) diff --git a/lib/rules/standards_repo.ex b/lib/rules/standards_repo.ex index feaa2424..63c543e2 100644 --- a/lib/rules/standards_repo.ex +++ b/lib/rules/standards_repo.ex @@ -19,6 +19,9 @@ defmodule Hypatia.Rules.StandardsRepo do @doc "The repository's current name." def current, do: @current + @doc "The names the repository was known by before, most recent first." + def former, do: @former + @doc "Every name the repository is known by, current first." def names, do: [@current | @former] diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 413d45b1..67e80050 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -95,6 +95,7 @@ defmodule Hypatia.Rules.WorkflowAudit do secret_history = check_secret_history_coverage(workflow_contents) toolchain_input_missing = check_sha_pinned_toolchain_without_input(workflow_contents) broken_reusable_revisions = check_known_broken_reusable_revision(workflow_contents) + moved_reusable_calls = check_reusable_call_to_moved_repository(workflow_contents) %{ # `flawed_regexes` was computed at the top of this function and @@ -133,7 +134,8 @@ defmodule Hypatia.Rules.WorkflowAudit do flawed_regexes ++ secret_history ++ toolchain_input_missing ++ - broken_reusable_revisions, + broken_reusable_revisions ++ + moved_reusable_calls, missing_count: length(missing), secret_history_count: length(secret_history), unpinned_count: length(unpinned), @@ -159,6 +161,7 @@ defmodule Hypatia.Rules.WorkflowAudit do d_burn_issues_count: length(d_burn_issues), toolchain_input_missing_count: length(toolchain_input_missing), broken_reusable_revision_count: length(broken_reusable_revisions), + moved_reusable_call_count: length(moved_reusable_calls), workflow_count: length(workflow_files), standard_coverage: coverage_percentage(workflow_files) } @@ -3021,6 +3024,129 @@ defmodule Hypatia.Rules.WorkflowAudit do "`jobs:`; the repair script rewrites the keys of `jobs:` only, so this one " <> "routes to review." + # ─── WF028: Reusable-workflow call into a moved repository ──────────── + + # Repositories that moved to another owner (transfer or rename). GitHub + # redirects git and the REST API for the old name, but does not follow the + # redirect for a reusable workflow's `uses:`: the caller ends in + # `startup_failure` ("workflow was not found") before any job is created. + # Measured for standards on 2026-10-26 (hypatia governance runs + # 40424594888, 40424740139, before and after #958 re-pointed it). + # + # One row per move. A transfer into the org (standards D97) is a row, not + # a rule: append it when the move lands, never before. + @moved_repositories Enum.map(Hypatia.Rules.StandardsRepo.former(), fn former -> + %{ + former: former, + current: Hypatia.Rules.StandardsRepo.current(), + moved: "2026-10-25" + } + end) + + @doc "The (former, current) table WF028 fires on." + def moved_repositories, do: @moved_repositories + + @doc """ + WF028: a job calls a reusable workflow by a repository name that has + moved to another owner. + + Every such call is a certain `startup_failure`: the redirect GitHub keeps + for the old name is not followed when a reusable is resolved. Re-pointing + the owner keeps the pinned revision, since a move keeps the history. + + The call is found where WF027 finds it — a `uses:` key of a job under + `jobs:`, in block or flow style, continued below its key, or made through + a job alias — so a path inside a `run:` script, an `env:` value or a + comment never fires. A step's `uses:` (an action, not a reusable) is out + of scope: the redirect is unmeasured there. + + No fleet script rewrites the owner yet, so the finding carries + `auto_fixable: false`. It is `advisory: true` with severity low, as WF026 + and WF027: reported and routed, never a blocking gate (AGENTIC + `must-not-promote-to-blocking`). + """ + def check_reusable_call_to_moved_repository(workflow_contents) do + Enum.flat_map(workflow_contents, fn {filename, content} -> + lines = String.split(content, "\n") + continued = wf_continued_uses(lines) + flow = wf_flow_lines(content) + + aliased = + for %{key_line: n} <- Hypatia.Rules.PinSites.node_alias_sites(content), + into: MapSet.new(), + do: n + + Enum.flat_map(@moved_repositories, fn move -> + repo = + "(?i:#{Regex.escape(move.former)})/(\\.github/workflows/[^@\\s\"'#,}\\]]+)@([^\\s\"'#,}\\]]+)" + + ref_re = + Regex.compile!( + "^\\s*(?:-\\s+)?[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" <> repo + ) + + inline_re = + Regex.compile!("[{,]\\s*[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" <> repo) + + lines + |> Enum.with_index(1) + |> Enum.flat_map(fn {line, number} -> + line = + case Map.fetch(continued, number) do + {:ok, {joined, _how}} -> joined + :error -> line + end + + live? = number in aliased + + calls = + case Regex.run(ref_re, line) do + [_full, path, ref] -> + if wf027_job_key(lines, flow, number) != nil or live?, + do: [{path, ref}], + else: [] + + nil -> + inline_re + |> Regex.scan(line, return: :index) + |> Enum.flat_map(fn [{at, _}, {p_at, p_len}, {r_at, r_len}] -> + if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, + do: [{binary_part(line, p_at, p_len), binary_part(line, r_at, r_len)}], + else: [] + end) + end + + Enum.map(calls, fn {path, ref} -> wf028_finding(filename, number, path, ref, move) end) + end) + end) + end) + end + + defp wf028_finding(filename, number, path, ref, move) do + %{ + rule: "WF028", + type: :reusable_call_to_moved_repository, + file: filename, + line: number, + reusable: move.former <> "/" <> path, + current_sha: ref, + replacement: move.current <> "/" <> path <> "@" <> ref, + # Advisory, as WF026/WF027: reported and routed, never a blocking finding. + severity: :low, + advisory: true, + auto_fixable: false, + fix_script: nil, + reason: + "`#{move.former}` moved to `#{move.current}` on #{move.moved}. GitHub does not " <> + "follow the redirect for a reusable workflow's `uses:`, so this call ends in " <> + "`startup_failure` (\"workflow was not found\") before any job runs. Re-point " <> + "it to `#{move.current}/#{path}@#{ref}` (same revision), regenerate " <> + "`actions.lock` with its tool, and make sure the repository's Actions " <> + "allow-list admits the new owner.", + action: :repoint_moved_reusable + } + end + # ─── WF017: Secret-consuming action without secret-presence gate ────── # Actions whose primary input is a secret and that fail outright when diff --git a/test/rules/workflow_audit_pins_test.exs b/test/rules/workflow_audit_pins_test.exs index fbaa2590..bc632cb8 100644 --- a/test/rules/workflow_audit_pins_test.exs +++ b/test/rules/workflow_audit_pins_test.exs @@ -1726,4 +1726,80 @@ defmodule Hypatia.Rules.WorkflowAuditPinsTest do assert result.broken_reusable_revision_count == 1 end end + + describe "check_reusable_call_to_moved_repository/1 (WF028)" do + @gov ".github/workflows/governance-reusable.yml" + @pin "333b06a01db1874ffedb2de1bfabb195640927c7" + + defp wf028(wf), do: WorkflowAudit.check_reusable_call_to_moved_repository(%{"g.yml" => wf}) + + test "fires on a job calling a standards reusable by its former owner" do + wf = "on: push\njobs:\n governance:\n uses: hyperpolymath/standards/#{@gov}@#{@pin}\n" + [f] = wf028(wf) + assert f.rule == "WF028" + assert f.line == 4 + assert f.severity == :low + assert f.advisory == true + assert f.auto_fixable == false + assert f.fix_script == nil + assert f.current_sha == @pin + assert f.replacement == "metadatastician/standards/#{@gov}@#{@pin}" + assert f.reason =~ "startup_failure" + end + + test "matches the owner and repository in any case, quoted or not" do + wf = "jobs:\n g:\n uses: \"Hyperpolymath/Standards/#{@gov}@#{@pin}\"\n" + assert [%{rule: "WF028"}] = wf028(wf) + end + + test "fires on a flow-style job and on a value continued below its key" do + assert [_] = wf028("jobs:\n g: { uses: hyperpolymath/standards/#{@gov}@#{@pin} }\n") + + assert [_] = + wf028("jobs:\n g:\n uses:\n hyperpolymath/standards/#{@gov}@#{@pin}\n") + end + + test "is silent on the current name" do + assert [] == wf028("jobs:\n g:\n uses: metadatastician/standards/#{@gov}@#{@pin}\n") + end + + test "is silent where the path is not a job's reusable call" do + ref = "hyperpolymath/standards/#{@gov}@#{@pin}" + + wf = + "env:\n REF: #{ref}\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n" <> + " # uses: #{ref}\n - run: echo uses: #{ref}\n" + + assert [] == wf028(wf) + end + + test "is silent on a step's uses: and on a uses: line inside a run: script" do + ref = "hyperpolymath/standards/#{@gov}@#{@pin}" + step = "jobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: #{ref}\n" + script = "jobs:\n b:\n steps:\n - run: |\n uses: #{ref}\n" + assert [] == wf028(step) + assert [] == wf028(script) + end + + test "is silent on another owner's repository of the same name" do + assert [] == wf028("jobs:\n g:\n uses: someone/standards/#{@gov}@#{@pin}\n") + end + + test "is reported by audit/3" do + wf = "jobs:\n g:\n uses: hyperpolymath/standards/#{@gov}@#{@pin}\n" + result = WorkflowAudit.audit(["g.yml"], %{"g.yml" => wf}) + assert result.moved_reusable_call_count == 1 + assert Enum.any?(result.findings, &(&1[:rule] == "WF028")) + end + + test "hypatia's own workflows call no moved repository" do + contents = + for path <- Path.wildcard(".github/workflows/*.yml"), + into: %{}, + do: {Path.basename(path), File.read!(path)} + + assert map_size(contents) > 0 + assert [] == WorkflowAudit.check_reusable_call_to_moved_repository(contents) + end + end end From 3d2cbf7c2c3d435d9e454d9c11c0a84b047f30bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:40:04 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(workflow=5Faudit):=20WF028=20=E2=80=94?= =?UTF-8?q?=20whole=20block=20refs,=20flow=20siblings,=20job=20aliases=20o?= =?UTF-8?q?nly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex findings on #965, each with a test that fails on the old code: - A block-style `uses:` value ends at whitespace or its closing quote, not at `,` `}` `]` `#`: a branch or tag may hold them, and the replacement claims the same revision. Flow delimiters still end a flow entry. - A leading `uses:` no longer hides the flow siblings on its line (`uses: a@x }, b: { uses: a@y`): both are reported. - An anchored mapping counts as a reusable call only where a job is given it whole (`g: *shared`); `- *shared` under `steps:` is a step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfvetbJ5z7uREtbJWw --- lib/rules/workflow_audit.ex | 59 ++++++++++++++++--------- test/rules/workflow_audit_pins_test.exs | 29 ++++++++++++ 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 67e80050..3a56c71b 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -3071,22 +3071,26 @@ defmodule Hypatia.Rules.WorkflowAudit do continued = wf_continued_uses(lines) flow = wf_flow_lines(content) + # Only an alias given as a job's value (`rust: *job`, `jobs: { a: *j }`) + # makes a reusable call; `- *step` and `steps: [ *step ]` are steps. aliased = - for %{key_line: n} <- Hypatia.Rules.PinSites.node_alias_sites(content), + for %{key_line: n, line: at} <- Hypatia.Rules.PinSites.node_alias_sites(content), + wf028_job_alias_line?(Enum.at(lines, at - 1, "")), into: MapSet.new(), do: n Enum.flat_map(@moved_repositories, fn move -> - repo = - "(?i:#{Regex.escape(move.former)})/(\\.github/workflows/[^@\\s\"'#,}\\]]+)@([^\\s\"'#,}\\]]+)" - - ref_re = - Regex.compile!( - "^\\s*(?:-\\s+)?[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" <> repo - ) - - inline_re = - Regex.compile!("[{,]\\s*[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" <> repo) + former = "(?i:#{Regex.escape(move.former)})" + key = "[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" + # A block scalar ends at whitespace or its closing quote; a branch or + # tag may hold `,`, `}`, `]` or `#`. Inside a flow collection those + # end the entry. + block_repo = former <> "/(\\.github/workflows/[^@\\s\"']+)@([^\\s\"']+)" + flow_repo = former <> "/(\\.github/workflows/[^@\\s\"'#,}\\]]+)@([^\\s\"'#,}\\]]+)" + + block_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> block_repo) + leading_flow_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> flow_repo) + inline_re = Regex.compile!("[{,]\\s*" <> key <> flow_repo) lines |> Enum.with_index(1) @@ -3098,30 +3102,43 @@ defmodule Hypatia.Rules.WorkflowAudit do end live? = number in aliased + leading_re = if wf_in_flow?(flow, number), do: leading_flow_re, else: block_re - calls = - case Regex.run(ref_re, line) do + # The `uses:` the line opens with, and every one a later flow entry + # on the same line opens (`uses: a@x }, b: { uses: a@y`). + leading = + case Regex.run(leading_re, line) do [_full, path, ref] -> if wf027_job_key(lines, flow, number) != nil or live?, do: [{path, ref}], else: [] nil -> - inline_re - |> Regex.scan(line, return: :index) - |> Enum.flat_map(fn [{at, _}, {p_at, p_len}, {r_at, r_len}] -> - if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, - do: [{binary_part(line, p_at, p_len), binary_part(line, r_at, r_len)}], - else: [] - end) + [] end - Enum.map(calls, fn {path, ref} -> wf028_finding(filename, number, path, ref, move) end) + inline = + inline_re + |> Regex.scan(line, return: :index) + |> Enum.flat_map(fn [{at, _}, {p_at, p_len}, {r_at, r_len}] -> + if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, + do: [{binary_part(line, p_at, p_len), binary_part(line, r_at, r_len)}], + else: [] + end) + + Enum.map(leading ++ inline, fn {path, ref} -> + wf028_finding(filename, number, path, ref, move) + end) end) end) end) end + defp wf028_job_alias_line?(line) do + Regex.match?(~r/["']?[\w.-]+["']?\s*:\s+\*[^\s,\[\]{}]+/, line) and + not Regex.match?(~r/^\s*-\s+\*/, line) + end + defp wf028_finding(filename, number, path, ref, move) do %{ rule: "WF028", diff --git a/test/rules/workflow_audit_pins_test.exs b/test/rules/workflow_audit_pins_test.exs index bc632cb8..08c24346 100644 --- a/test/rules/workflow_audit_pins_test.exs +++ b/test/rules/workflow_audit_pins_test.exs @@ -1759,6 +1759,35 @@ defmodule Hypatia.Rules.WorkflowAuditPinsTest do wf028("jobs:\n g:\n uses:\n hyperpolymath/standards/#{@gov}@#{@pin}\n") end + test "keeps a block-style branch ref whole, punctuation included" do + wf = "jobs:\n g:\n uses: hyperpolymath/standards/#{@gov}@release,one]\n" + [f] = wf028(wf) + assert f.current_sha == "release,one]" + assert f.replacement == "metadatastician/standards/#{@gov}@release,one]" + end + + test "reports a flow sibling on the line a leading uses: opens" do + ref = "hyperpolymath/standards/#{@gov}" + wf = "jobs: {\n a: {\n uses: #{ref}@x }, b: { uses: #{ref}@y\n }\n}\n" + assert ["x", "y"] == wf |> wf028() |> Enum.map(& &1.current_sha) |> Enum.sort() + end + + test "is silent on an anchored mapping reused only as a step" do + ref = "hyperpolymath/standards/#{@gov}@#{@pin}" + + wf = + "x-shared: &shared\n uses: #{ref}\njobs:\n b:\n runs-on: ubuntu-latest\n" <> + " steps:\n - *shared\n" + + assert [] == wf028(wf) + end + + test "fires on an anchored mapping a job is given whole" do + ref = "hyperpolymath/standards/#{@gov}@#{@pin}" + wf = "x-shared: &shared\n uses: #{ref}\njobs:\n g: *shared\n" + assert [%{line: 2}] = wf028(wf) + end + test "is silent on the current name" do assert [] == wf028("jobs:\n g:\n uses: metadatastician/standards/#{@gov}@#{@pin}\n") end From 10ab3da81fc2a56e496cfc28168abab03e0852dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:48:18 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(workflow=5Faudit):=20WF028=20=E2=80=94?= =?UTF-8?q?=20read=20uses:=20scalars=20as=20YAML=20does;=20classify=20each?= =?UTF-8?q?=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Codex findings on #965, fixed at the root rather than per case: - The `uses:` value is now read as a YAML scalar: a quoted one runs to its closing quote whatever it holds (`"…@release,one"` in a flow job), and only a plain one ends at whitespace (block) or the flow delimiters. - PinSites.node_alias_sites/1 now reports each alias' `kind` (:job or :step), decided where the alias stands, by the classification node_aliases/4 already made. WF028 keeps only :job, so `jobs: { a: *job, b: { steps: [*step] } }` no longer marks the step's anchored `uses:` live. The key is additive; existing callers match on subsets of the map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfvetbJ5z7uREtbJWw --- lib/rules/pin_sites.ex | 35 ++++++++++------ lib/rules/workflow_audit.ex | 53 ++++++++++++++----------- test/rules/workflow_audit_pins_test.exs | 18 +++++++++ 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/lib/rules/pin_sites.ex b/lib/rules/pin_sites.ex index 7324b83b..6030c133 100644 --- a/lib/rules/pin_sites.ex +++ b/lib/rules/pin_sites.ex @@ -583,9 +583,11 @@ defmodule Hypatia.Rules.PinSites do Every step or job given whole as an alias — `- *step` in a job's `steps:`, `*step` as an entry of a `steps: [ ... ]`, `rust: *job` under `jobs:` — resolved to the `uses:` of the mapping its anchor holds: - `%{line, key_line, anchor_line, site}`, the alias' line, the line of that - `uses:` key, the line its reference stands on, and its site as `sites/1` - returns one (offsets into that line). The reference is the key's value as + `%{line, key_line, anchor_line, site, kind}`, the alias' line, the line of + that `uses:` key, the line its reference stands on, its site as `sites/1` + returns one (offsets into that line), and `kind` — `:job` where the alias + is a job's value under `jobs:`, `:step` where it is an item of a step + sequence (or of one whose owner is not shown). The reference is the key's value as YAML resolves it: written on the key's line, continued below it (`continued_site/2`, a block scalar included), or an alias (`uses: *bad`, on the line of `&bad`'s value). @@ -608,15 +610,15 @@ defmodule Hypatia.Rules.PinSites do defs = resolved_defs(numbered) for {{line, prefix}, n} <- numbered, - {name, at} <- node_aliases(line, prefix, rows, n - 1), + {name, at, kind} <- node_aliases(line, prefix, rows, n - 1), %{} = d <- [latest_anchor(defs, name, n, at)], {key_line, site_line, site} <- anchored_uses(d, texts, rows, defs) do - %{line: n, key_line: key_line, anchor_line: site_line, site: site} + %{line: n, key_line: key_line, anchor_line: site_line, site: site, kind: kind} end end # The aliases on `line` that stand for a whole step or job, as `{name, - # offset of the "*"}`: a block list item that is nothing but the alias, in + # offset of the "*", :step | :job}`: a block list item that is nothing but the alias, in # a sequence `steps:` holds (or one whose owner is not shown); a block key # of `jobs:` whose value is the alias; or a flow entry whose brackets make # it an item of a job's step sequence, or a job's value in `jobs: { ... }`. @@ -631,12 +633,12 @@ defmodule Hypatia.Rules.PinSites do case {row, item, value} do {%{dash: d}, [_, lead, name], _} when d != nil -> if sequence_owner(rows, seq_owner(rows, i, d)) != :data, - do: [{name, byte_size(lead)}], + do: [{name, byte_size(lead), :step}], else: [] {%{dash: nil}, _, [_, lead, name]} -> if match?({:value, _, "jobs"}, mapping_of(rows, i)), - do: [{name, byte_size(lead)}], + do: [{name, byte_size(lead), :job}], else: [] _ -> @@ -650,12 +652,19 @@ defmodule Hypatia.Rules.PinSites do star = n_at - 1 stack = flow_stack(prefix <> binary_part(masked, 0, star)) - node? = - if binary_part(masked, c_at, 1) == ":", - do: match?([{"{", "jobs"} | _], stack), - else: alias_step_stack?(stack) + kind = + cond do + binary_part(masked, c_at, 1) == ":" -> + if match?([{"{", "jobs"} | _], stack), do: :job - if node?, do: [{binary_part(masked, n_at, n_len), star}], else: [] + alias_step_stack?(stack) -> + :step + + true -> + nil + end + + if kind, do: [{binary_part(masked, n_at, n_len), star, kind}], else: [] end) block ++ flow diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 3a56c71b..8790df34 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -3073,24 +3073,28 @@ defmodule Hypatia.Rules.WorkflowAudit do # Only an alias given as a job's value (`rust: *job`, `jobs: { a: *j }`) # makes a reusable call; `- *step` and `steps: [ *step ]` are steps. + # PinSites classifies each alias where it stands, not by its line. aliased = - for %{key_line: n, line: at} <- Hypatia.Rules.PinSites.node_alias_sites(content), - wf028_job_alias_line?(Enum.at(lines, at - 1, "")), + for %{key_line: n, kind: :job} <- Hypatia.Rules.PinSites.node_alias_sites(content), into: MapSet.new(), do: n Enum.flat_map(@moved_repositories, fn move -> - former = "(?i:#{Regex.escape(move.former)})" - key = "[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*[\"']?" - # A block scalar ends at whitespace or its closing quote; a branch or - # tag may hold `,`, `}`, `]` or `#`. Inside a flow collection those - # end the entry. - block_repo = former <> "/(\\.github/workflows/[^@\\s\"']+)@([^\\s\"']+)" - flow_repo = former <> "/(\\.github/workflows/[^@\\s\"'#,}\\]]+)@([^\\s\"'#,}\\]]+)" - - block_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> block_repo) - leading_flow_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> flow_repo) - inline_re = Regex.compile!("[{,]\\s*" <> key <> flow_repo) + # The value of a `uses:` key, as YAML reads the scalar: a quoted one + # runs to its closing quote, whatever it holds; an unquoted one ends + # at whitespace in block style (a branch or tag may hold `,` `}` `]` + # `#`) and, inside a flow collection, at the delimiters too. + key = "[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*" + quoted = "\"([^\"]*)\"|'([^']*)'" + block_value = "(?:" <> quoted <> "|([^\\s\"']+))" + flow_value = "(?:" <> quoted <> "|([^\\s\"'#,}\\]]+))" + + block_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> block_value) + leading_flow_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> flow_value) + inline_re = Regex.compile!("[{,]\\s*" <> key <> flow_value) + + call_re = + Regex.compile!("^(?i:#{Regex.escape(move.former)})/(\\.github/workflows/[^@]+)@(.+)$") lines |> Enum.with_index(1) @@ -3107,10 +3111,10 @@ defmodule Hypatia.Rules.WorkflowAudit do # The `uses:` the line opens with, and every one a later flow entry # on the same line opens (`uses: a@x }, b: { uses: a@y`). leading = - case Regex.run(leading_re, line) do - [_full, path, ref] -> + case Regex.run(leading_re, line, return: :index) do + [_full | groups] -> if wf027_job_key(lines, flow, number) != nil or live?, - do: [{path, ref}], + do: [wf028_scalar(line, groups)], else: [] nil -> @@ -3120,23 +3124,26 @@ defmodule Hypatia.Rules.WorkflowAudit do inline = inline_re |> Regex.scan(line, return: :index) - |> Enum.flat_map(fn [{at, _}, {p_at, p_len}, {r_at, r_len}] -> + |> Enum.flat_map(fn [{at, _} | groups] -> if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, - do: [{binary_part(line, p_at, p_len), binary_part(line, r_at, r_len)}], + do: [wf028_scalar(line, groups)], else: [] end) - Enum.map(leading ++ inline, fn {path, ref} -> + for value <- leading ++ inline, + [_, path, ref] <- [Regex.run(call_re, value)] do wf028_finding(filename, number, path, ref, move) - end) + end end) end) end) end - defp wf028_job_alias_line?(line) do - Regex.match?(~r/["']?[\w.-]+["']?\s*:\s+\*[^\s,\[\]{}]+/, line) and - not Regex.match?(~r/^\s*-\s+\*/, line) + # The scalar a value match captured: whichever of the double-quoted, + # single-quoted and plain groups took part. + defp wf028_scalar(line, groups) do + {at, len} = Enum.find(groups, {0, 0}, fn {at, _} -> at >= 0 end) + binary_part(line, at, len) end defp wf028_finding(filename, number, path, ref, move) do diff --git a/test/rules/workflow_audit_pins_test.exs b/test/rules/workflow_audit_pins_test.exs index 08c24346..02e96fe0 100644 --- a/test/rules/workflow_audit_pins_test.exs +++ b/test/rules/workflow_audit_pins_test.exs @@ -1766,6 +1766,24 @@ defmodule Hypatia.Rules.WorkflowAuditPinsTest do assert f.replacement == "metadatastician/standards/#{@gov}@release,one]" end + test "reads a quoted flow value through its closing quote" do + wf = "jobs: { g: { uses: \"hyperpolymath/standards/#{@gov}@release,one\" } }\n" + [f] = wf028(wf) + assert f.current_sha == "release,one" + assert f.replacement == "metadatastician/standards/#{@gov}@release,one" + end + + test "judges each alias where it stands, not by its line" do + ref = "hyperpolymath/standards/#{@gov}@#{@pin}" + + wf = + "x-job: &job\n uses: metadatastician/standards/#{@gov}@#{@pin}\n" <> + "x-step: &step\n uses: #{ref}\n" <> + "jobs: { a: *job, b: { runs-on: ubuntu-latest, steps: [*step] } }\n" + + assert [] == wf028(wf) + end + test "reports a flow sibling on the line a leading uses: opens" do ref = "hyperpolymath/standards/#{@gov}" wf = "jobs: {\n a: {\n uses: #{ref}@x }, b: { uses: #{ref}@y\n }\n}\n" From e7db2dc4997d7cef7013b685d63a2a8501b68cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:56:52 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(workflow=5Faudit):=20WF028=20=E2=80=94?= =?UTF-8?q?=20split=20path=20from=20ref=20at=20the=20workflow's=20extensio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow file name may hold `@` (`build@nightly.yml@release`), so the reusable's path now ends at `.yml`/`.yaml` rather than at the first `@`; `reusable` and `current_sha` are reported as GitHub reads them (Codex, #965). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDfvetbJ5z7uREtbJWw --- lib/rules/workflow_audit.ex | 6 +++++- test/rules/workflow_audit_pins_test.exs | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 8790df34..423e6808 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -3093,8 +3093,12 @@ defmodule Hypatia.Rules.WorkflowAudit do leading_flow_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> flow_value) inline_re = Regex.compile!("[{,]\\s*" <> key <> flow_value) + # The path ends at the workflow's extension: a file name may hold + # `@` (`build@nightly.yml@release`), and so may a branch. call_re = - Regex.compile!("^(?i:#{Regex.escape(move.former)})/(\\.github/workflows/[^@]+)@(.+)$") + Regex.compile!( + "^(?i:#{Regex.escape(move.former)})/(\\.github/workflows/.+?\\.ya?ml)@(.+)$" + ) lines |> Enum.with_index(1) diff --git a/test/rules/workflow_audit_pins_test.exs b/test/rules/workflow_audit_pins_test.exs index 02e96fe0..fe1e6678 100644 --- a/test/rules/workflow_audit_pins_test.exs +++ b/test/rules/workflow_audit_pins_test.exs @@ -1766,6 +1766,15 @@ defmodule Hypatia.Rules.WorkflowAuditPinsTest do assert f.replacement == "metadatastician/standards/#{@gov}@release,one]" end + test "splits path from ref at the workflow's extension" do + wf = + "jobs:\n g:\n uses: hyperpolymath/standards/.github/workflows/build@nightly.yml@release\n" + + [f] = wf028(wf) + assert f.reusable == "hyperpolymath/standards/.github/workflows/build@nightly.yml" + assert f.current_sha == "release" + end + test "reads a quoted flow value through its closing quote" do wf = "jobs: { g: { uses: \"hyperpolymath/standards/#{@gov}@release,one\" } }\n" [f] = wf028(wf)