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/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/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..423e6808 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,157 @@ 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) + + # 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, kind: :job} <- Hypatia.Rules.PinSites.node_alias_sites(content), + into: MapSet.new(), + do: n + + Enum.flat_map(@moved_repositories, fn move -> + # 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) + + # 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/.+?\\.ya?ml)@(.+)$" + ) + + 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 + leading_re = if wf_in_flow?(flow, number), do: leading_flow_re, else: block_re + + # 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, return: :index) do + [_full | groups] -> + if wf027_job_key(lines, flow, number) != nil or live?, + do: [wf028_scalar(line, groups)], + else: [] + + nil -> + [] + end + + inline = + inline_re + |> Regex.scan(line, return: :index) + |> Enum.flat_map(fn [{at, _} | groups] -> + if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, + do: [wf028_scalar(line, groups)], + else: [] + end) + + for value <- leading ++ inline, + [_, path, ref] <- [Regex.run(call_re, value)] do + wf028_finding(filename, number, path, ref, move) + end + end) + end) + end) + end + + # 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 + %{ + 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..fe1e6678 100644 --- a/test/rules/workflow_audit_pins_test.exs +++ b/test/rules/workflow_audit_pins_test.exs @@ -1726,4 +1726,136 @@ 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 "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 "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) + 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" + 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 + + 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