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
2 changes: 2 additions & 0 deletions .machine_readable/descriptiles/AGENTIC.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki-pages/Operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions lib/hypatia/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -536,6 +537,7 @@ defmodule Hypatia.CLI do
:fix_script,
:current_sha,
:replacement_sha,
:replacement,
:reusable,
:advisory
])
Expand Down
35 changes: 22 additions & 13 deletions lib/rules/pin_sites.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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: { ... }`.
Expand All @@ -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: []

_ ->
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lib/rules/standards_repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
156 changes: 155 additions & 1 deletion lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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)
}
Expand Down Expand Up @@ -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),

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 Recognize aliases continued below job keys

When a job's alias value is placed on the following line, as in jobs:\n g:\n *job, valid YAML resolves g to the anchored mapping, but node_alias_sites/1 returns no job entry because it recognizes only the same-line g: *job form. If that external mapping calls the moved repository, aliased remains empty and WF028 omits the failing reusable call; include aliases used as continued job values when collecting live anchor lines.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Leaving this as is. g: with *job on the line below is valid YAML, but the classifier is PinSites.node_aliases/4, which WF027, PinIntegrity and the merge policy also rely on. Teaching it a new layout changes all of them, which is out of proportion for a rare form in an advisory rule. If it's worth doing, it's a PinSites change with its own tests across those callers, not something to ride along on WF028.


Generated by Claude Code

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 = "\"([^\"]*)\"|'([^']*)'"

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 Decode quoted YAML scalars before matching moved repositories

When a valid double-quoted uses: value contains a YAML escape, the regex compares the encoded source rather than the decoded scalar, so a call such as uses: "\x68yperpolymath/standards/.github/workflows/x.yml@main" resolves to the moved repository but produces no WF028 finding. Decode double-quoted YAML values before applying call_re so escaped owner, path, or ref characters cannot bypass the rule.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Leaving this as is. The path traced is evasion: someone would have to write \x68yperpolymath on purpose. WF028 isn't a security control; it warns a repository that its own call will hit startup_failure, and the failure itself is loud and visible on every run. Nobody has a reason to hide from it. Decoding YAML escapes would also mean writing a scalar decoder the codebase doesn't have, which costs more than this blind spot does.


Generated by Claude Code

block_value = "(?:" <> quoted <> "|([^\\s\"']+))"
flow_value = "(?:" <> quoted <> "|([^\\s\"'#,}\\]]+))"

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 hashes inside unquoted flow references

In an unquoted flow scalar, # starts a YAML comment only when preceded by whitespace, and Git permits it in branch/tag names. For jobs: { g: { uses: hyperpolymath/standards/.github/workflows/x.yml@release#one } }, this character class truncates the parsed ref to release, so WF028 emits incorrect current_sha and replacement metadata instead of preserving release#one; allow # in the plain value while continuing to stop at whitespace.

Useful? React with 👍 / 👎.


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
Comment on lines +3107 to +3109

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 Read unindented continuations inside flow jobs

Inside a flow mapping YAML does not use indentation to determine the value, so jobs: {\ng: {\nuses:\nhyperpolymath/standards/.github/workflows/x.yml@main\n}\n} is a valid reusable call. wf_continued_uses/1 applies block-style indentation rules and returns no entry for this layout, leaving the empty uses: line unchanged here and causing WF028 to miss the call; resolve continued values using the active flow context rather than requiring deeper indentation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Leaving this as is. The layout is valid YAML, but I traced who writes it: nobody does. Estate workflows are block-style, with a few KYAML (flow) jobs that keep the value on its key's line or indent it. The resolver is wf_continued_uses/1, which WF027 shares, so fixing it here would widen a shared helper for a layout with no known caller. WF028 is advisory, so the cost of a miss is one missed hint. If this ever needs fixing, it belongs in the shared resolver for WF027 and WF028 together, not in this PR.


Generated by Claude Code

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: []
Comment thread
hyperpolymath marked this conversation as resolved.

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?,

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 Track live anchored uses sites rather than whole lines

When multiple anchored mappings are defined on one physical line, aliased contains only their shared key_line, so a job alias makes live? true for every inline uses: match on that line. For example, if &job contains a current-repository call and &step contains the moved-repository call on the same line, with *job used as a job and *step used only under steps:, this branch reports the step-level call even though WF028 excludes actions; retain the specific anchored site offset or identity instead of marking the entire line live.

Useful? React with 👍 / 👎.

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
Expand Down
Loading
Loading