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
8 changes: 6 additions & 2 deletions lib/cross_repo_learning.ex
Original file line number Diff line number Diff line change
Expand Up @@ -616,8 +616,12 @@ defmodule Hypatia.CrossRepoLearning do
|> Map.get("languages", %{})
|> Enum.sort_by(fn {lang, count} ->
count_score = if is_number(count), do: -count, else: 0
prio = Enum.find_index(@language_priority, &(String.downcase(&1) == String.downcase(lang)))
{count_score, if(prio, do: prio, else: length(@language_priority)), String.downcase(lang)}

prio =
Enum.find_index(@language_priority, &(String.downcase(&1) == String.downcase(lang)))

{count_score, if(prio, do: prio, else: length(@language_priority)),
String.downcase(lang)}
end)
|> List.first({"unknown", 0})
|> elem(0)
Expand Down
12 changes: 9 additions & 3 deletions lib/hypatia/scanner_suppression.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ defmodule Hypatia.ScannerSuppression do
"security_errors" => %{
:any =>
@training_corpus_paths ++
[".github/workflows/integration.yml"]
[".github/workflows/integration.yml"],
# `harvested-registry/` is a corpus of OTHER projects' manifests kept as
# reference material; example credentials are its content, the same
# justification as `.audittraining/` (#865). Scoped to `secret_detected`
# only — every other security_errors rule still scans it.
"secret_detected" => ["harvested-registry/"]
},
# ⚠ `benches/` is exempted for code_safety ONLY, deliberately not for
# security_errors. Cargo's convention puts benchmarks in `benches/`, and a
Expand Down Expand Up @@ -277,7 +282,7 @@ defmodule Hypatia.ScannerSuppression do
#
# Measured 2026-09-03 across 73 repos with a live gate: 45 of 614 critical
# findings were commented-out placeholders from templates
# (`# export API_KEY="..."`, `# token = "ghp_xxxxxxxxxxxxxxxxxxxx"`), zero
# (API keys filled with ellipses or GitHub tokens filled with x's), zero
# real credentials. The shapes below are placeholder tell-tales: ellipses,
# angle-bracket metavariables, long same-character runs, `your-*`/`my-*`
# fillers, `changeme`. They cannot plausibly occur in a real generated
Expand Down Expand Up @@ -454,7 +459,8 @@ defmodule Hypatia.ScannerSuppression do
# pragmas (`hypatia:ignore RE005 -- <reason>`, `hypatia:ignore zig_ptr_cast`)
# use the verb form; both are honoured identically.
defp directive_re,
do: ~r/(?:^|[\s#\/\-;])hypatia:\s*(?:allow|ignore)\s+([A-Za-z0-9_\*]+)(?:\/([A-Za-z0-9_\*]+))?/i
do:
~r/(?:^|[\s#\/\-;])hypatia:\s*(?:allow|ignore)\s+([A-Za-z0-9_\*]+)(?:\/([A-Za-z0-9_\*]+))?/i

defp directive_matches?(line, rule_module, rule_type) do
case Regex.run(directive_re(), line) do
Expand Down
2 changes: 1 addition & 1 deletion lib/rules/cicd_rules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ defmodule Hypatia.Rules.CicdRules do
id: :npx_in_workflow,
pattern: ~r/(?:^|[\s;&|])(?:npx|npm[[:space:]]+run)\b/m,
reason:
"npx / `npm run` banned in CI -- use `deno task` or `deno run` instead (npm fully banned 2026-05-25)",
"npx / `npm run` banned in CI -- use `bunx` or `bun run` instead (npm banned 2026-05-25; Deno banned 2026-09-22, standards LANGUAGE-POLICY §1.3)",
applies_to: ["*.yml", "*.yaml", "*.sh", "Justfile", "Mustfile"]
},
%{id: :golang_detected, glob: "*.go", reason: "Go banned -- use Rust"},
Expand Down
24 changes: 23 additions & 1 deletion lib/rules/honest_completion.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ defmodule Hypatia.Rules.HonestCompletion do
has_ci: File.dir?(Path.join(repo_path, ".github/workflows")),
has_tests_dir:
File.dir?(Path.join(repo_path, "test")) or File.dir?(Path.join(repo_path, "tests")),
has_proof_suite: proof_suite_checked_in_ci?(repo_path),
# State file location convention varies across the estate:
# most repos use .machine_readable/STATE.a2ml directly; some
# (including hypatia) namespace it under a profile dir like
Expand All @@ -73,6 +74,26 @@ defmodule Hypatia.Rules.HonestCompletion do
}
end

# A mechanised proof library's test suite IS its proof check: a green
# `agda All.agda` / `lake build` / `coqc` / `idris2 --build` is the oracle
# (echo-types#271). Both halves are required — proof sources present AND a
# workflow that runs the checker — so an unchecked `.agda` file does not
# count as tests.
@proof_checker_invocation ~r/(?:^|[\s;&|(])(?:agda\s|lake\s+build|lean\s|coqc\s|dune\s+build|idris2\s+(?:--build|--check|-c)\b)/m

defp proof_suite_checked_in_ci?(repo_path) do
count_files(repo_path, ~w(.agda .lagda.md .lean .idr .v)) > 0 and
repo_path
|> Path.join(".github/workflows/*.{yml,yaml}")
|> Path.wildcard()
|> Enum.any?(fn wf ->
case File.read(wf) do
{:ok, text} -> Regex.match?(@proof_checker_invocation, text)
_ -> false
end
end)
end

defp state_file_exists?(repo_path) do
mr = Path.join(repo_path, ".machine_readable")

Expand Down Expand Up @@ -291,7 +312,8 @@ defmodule Hypatia.Rules.HonestCompletion do

# No tests = big deduction
findings =
if not evidence.has_tests_dir and evidence.test_files == 0 do
if not evidence.has_tests_dir and evidence.test_files == 0 and
not Map.get(evidence, :has_proof_suite, false) do
[
%{
type: :no_tests,
Expand Down
31 changes: 27 additions & 4 deletions lib/rules/pin_integrity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ defmodule Hypatia.Rules.PinIntegrity do
# v3
# Pinned to v1.2.3 — do not move

Returns `nil` when the comment makes no version claim.
Returns the first matching version without its `v` prefix. A bare major
such as `3` is ignored unless prefixed with `v`; dotted versions need no
prefix. Returns `nil` when there is no match or the input is not a string.
"""
@spec claimed_version(String.t()) :: nil | String.t()
def claimed_version(comment) when is_binary(comment) do
Expand Down Expand Up @@ -359,6 +361,11 @@ defmodule Hypatia.Rules.PinIntegrity do
@doc """
Extract `action@sha` pairs from a `gh actions-lock` file (TOML-ish),
keyed by the resolved SHA or ref. Used by PI003 and PI004.

Reads the first single-quoted `action@ref` on each line and returns
`%{ref => {action_base, line_number}}`, with one-based line numbers and
action paths reduced to their first two segments. Later entries replace
earlier ones with the same ref; no matches produce an empty map.
"""
@spec locked_refs(String.t()) :: %{String.t() => {String.t(), integer()}}
def locked_refs(content) do
Expand Down Expand Up @@ -529,8 +536,15 @@ defmodule Hypatia.Rules.PinIntegrity do
Relabel a pin's inline comment — but only when the comment **leads** with a
version claim.

Both estate shapes are covered:
`version` is the replacement without a `v` prefix. The leading claim must
end at whitespace or the end of the comment. A rewritten comment has one
leading `#`; existing whitespace and trailing prose are preserved. Bare
claims with no leading whitespace gain a space after `#`. A `nil` version
leaves the comment unchanged.

With `version` set to `"4.38.0"`, these estate shapes are covered:

"v3" -> "# v4.38.0"
"# v3" -> "# v4.38.0"
"# v4.38.0 (4.38.1 blocked estate-wide; …)" -> unchanged

Expand All @@ -549,14 +563,23 @@ defmodule Hypatia.Rules.PinIntegrity do
if comment == "" or String.starts_with?(comment, "#"), do: comment, else: "# " <> comment

body = String.replace_prefix(comment, "#", "")
hashed? = body != comment

case Regex.run(~r/^\s*/, body) do
[lead] ->
trimmed = String.slice(body, String.length(lead)..-1//1)

# pin_sites/1 hands over the comment without its `#`; a bare claim
# comes back in the canonical `# vX` shape rather than as `#vX`.
# Promoted only after slicing, so the claim's first byte survives.
out_lead = if hashed? or lead != "", do: lead, else: " "

case Regex.run(~r/^(v?\d+(?:\.\d+)*)(?:\s|$)/, trimmed) do
[_whole, claim] -> "#" <> lead <> String.replace_prefix(trimmed, claim, "v" <> version)
_ -> comment
[_whole, claim] ->
"#" <> out_lead <> String.replace_prefix(trimmed, claim, "v" <> version)

_ ->
comment
end

_ ->
Expand Down
34 changes: 25 additions & 9 deletions lib/rules/pr_automerge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,18 @@ defmodule Hypatia.Rules.PrAutomerge do
@doc """
Version deltas for every action this PR re-pins.

`status` is one of `:ok`, `:unresolved` (no source could place a version on
the refs) or `:conflict` (upstream tags and the PR body disagree). `source`
Each removed pin is paired with the first added pin for the same action
in the same file. Unpaired pins and unchanged refs are omitted. Returns a
flat list of maps with `:action`, `:file`, `:from`, `:to`, `:status`,
`:source` and `:major?`.

`resolution` supplies versions keyed by `{action, ref}`, then
`{action_base, ref}`, then `ref`, in that order of preference. If either
ref is unresolved, a matching claim from `body_claims/1` supplies both
versions; without a claim, both are `nil`.

`status` is one of `:ok`, `:unresolved` (no source supplied both versions)
or `:conflict` (resolved versions and the PR body disagree). `source`
records which source produced the versions that were used, so a reviewer can
see exactly what the decision rested on.
"""
Expand Down Expand Up @@ -245,7 +255,7 @@ defmodule Hypatia.Rules.PrAutomerge do
end)
end

defp delta(old, new, from, to, status, source) do
defp delta(old, _new, from, to, status, source) do
%{
action: old.action,
from: from,
Expand Down Expand Up @@ -383,12 +393,18 @@ defmodule Hypatia.Rules.PrAutomerge do
@doc """
Render a decision as the frozen merge-orchestration manifest.

Conforms to
`docs/design/merge-orchestration/schemas/decision-manifest.schema.json`;
the two contract invariants hold by construction — any denial sets
`safety: "flag"` and records a veto, and a `meta` change level can only
reach `arm_auto` through the `MGX-001` pin-only exemption, which the
actuator re-proves from the diff.
Takes the decision from `classify/2` and PR metadata with atom or string
keys. Includes pin deltas, the denylisted-site count and the current UTC
timestamp in ISO 8601 format. The author kind is always `"dependabot"`.

A decision with `safety: "flag"` gets a string-keyed Patch-Bridge veto,
plus a hypatia veto when its change level is `"meta"`, and
`"clamped_by" => "veto"`. Other safety values produce no vetoes and a
`nil` clamp. Safety and change level are copied from the decision;
this function does not validate the result against
`docs/design/merge-orchestration/schemas/decision-manifest.schema.json`.

Missing required keys in the decision or its delta maps raise `KeyError`.
"""
@spec decision_manifest(map(), map()) :: map()
def decision_manifest(decision, pr) do
Expand Down
4 changes: 3 additions & 1 deletion lib/rules/rsr_conformance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,9 @@ defmodule Hypatia.Rules.RsrConformance do
# while the estate migrates. Hardcoding either name made whichever half had
# not migrated unscoreable.
defp present_mr(rel) do
fn repo -> if exists?(repo, Path.join(Hypatia.Paths.machine_tree(repo), rel)), do: :pass, else: :fail end
fn repo ->
if exists?(repo, Path.join(Hypatia.Paths.machine_tree(repo), rel)), do: :pass, else: :fail
end
end

defp absent(rel), do: fn repo -> if exists?(repo, rel), do: :fail, else: :pass end
Expand Down
15 changes: 14 additions & 1 deletion lib/rules/structural_drift.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1099,7 +1099,8 @@ defmodule Hypatia.Rules.StructuralDrift do
# its sibling `src/connectors/`). Only a reference that
# resolves NOWHERE is genuine post-rename drift.
MapSet.member?(real_basenames, dir) or
File.dir?(Path.join([repo_path, Path.dirname(rel), "src", dir]))
File.dir?(Path.join([repo_path, Path.dirname(rel), "src", dir])) or
not describes_a_local_src_tree?(repo_path, rel)
end)
|> Enum.map(fn stale_dir ->
%{
Expand All @@ -1121,6 +1122,18 @@ defmodule Hypatia.Rules.StructuralDrift do
end
end

# A root-relative `src/<dir>/` can only be rename drift of a tree that HAS a
# `src/`: the repo root's, or the referencing doc's own directory's. In a
# repo with neither (standards: an estate-level repo whose specs and audits
# quote OTHER repos' layouts, e.g. the k9 spec's `src/tea/` example), the
# reference describes a foreign tree and cannot drift here. This was the
# single largest SD022 false-positive class — 34 baselined entries in
# standards alone (standards#945).
defp describes_a_local_src_tree?(repo_path, rel) do
File.dir?(Path.join(repo_path, "src")) or
File.dir?(Path.join([repo_path, Path.dirname(rel), "src"]))
end

# Drop whole-line comments before matching. A commented-out example is not a
# live claim about the tree.
#
Expand Down
62 changes: 54 additions & 8 deletions lib/rules/workflow_hardening.ex
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,27 @@ defmodule Hypatia.Rules.WorkflowHardening do
requires `contents: write`.
"""
def performs_contents_write?(content) when is_binary(content) do
content = strip_foreign_pushes(content)
Enum.any?(@contents_write_operations, &Regex.match?(&1, content))
end

# `git push <remote>` where <remote> is a NAMED remote other than `origin`
# (`gitlab`, `codeberg`, `backup`, …). `contents: write` governs the job's
# GITHUB_TOKEN, i.e. pushes to THIS repository; a mirror push to another
# forge authenticates with its own SSH key or token and needs no grant.
# Flags before the remote (`--force`, `-u`, `--mirror`) are skipped. A bare
# `git push`, `git push origin …` and `git push "$REMOTE"` are all kept —
# the last because the remote cannot be known statically (standards#943).
@foreign_push ~r/\bgit\s+push\b(?:\s+-[-\w=]*)*\s+(?!origin\b)[A-Za-z][\w.-]*/

@doc """
Remove foreign-remote `git push` invocations, leaving any other operation on
the same line (`git push gitlab main && git push origin main`) intact.
"""
def strip_foreign_pushes(content) when is_binary(content) do
Regex.replace(@foreign_push, content, "")
end

@doc """
Return true when at least one JOB declares its own `permissions:` block.
A job-level block replaces the workflow-level one, so its presence means
Expand Down Expand Up @@ -602,7 +620,10 @@ defmodule Hypatia.Rules.WorkflowHardening do

job_blocks
|> Enum.flat_map(fn {job_id, line_no, body} ->
if Regex.match?(~r/^\s+timeout-minutes:/m, body) do
# A reusable-workflow caller (`job: uses: ./.github/workflows/x.yml`)
# cannot carry `timeout-minutes:` — GitHub rejects the key there; the
# called workflow's own jobs own their timeouts (standards#943).
if Regex.match?(~r/^\s+timeout-minutes:/m, body) or reusable_caller_job?(body) do
[]
else
[
Expand Down Expand Up @@ -1090,6 +1111,32 @@ defmodule Hypatia.Rules.WorkflowHardening do
end
end

@doc """
Return true when a job body (as produced by the job extractor, header line
first) has a JOB-LEVEL `uses:` key, i.e. it calls a reusable workflow.
Step-level `uses:` sits deeper (`- uses:` or under `- name:`) and is not
matched: only a `uses:` at the job's own key indentation counts.
"""
def reusable_caller_job?(body) when is_binary(body) do
keys =
body
|> String.split("\n")
|> Enum.drop(1)
|> Enum.reject(&(String.trim(&1) == "" or String.starts_with?(String.trim(&1), "#")))

case keys do
[] ->
false

[first | _] ->
key_indent = indent_of(first)

Enum.any?(keys, fn line ->
indent_of(line) == key_indent and Regex.match?(~r/^\s*uses:\s*\S/, line)
end)
end
end

# Extract every job definition from the workflow content. Returns
# `[{job_id, line_no, body}]`. Approximate — assumes 2-space indent
# under `jobs:`.
Expand All @@ -1098,7 +1145,9 @@ defmodule Hypatia.Rules.WorkflowHardening do
in_jobs? = false
jobs_indent = nil

{acc, _, _, _} =
# The final job is still in flight when the lines run out; it must be
# flushed too, or the last job of every workflow is never checked.
{acc, _, _, last} =
Enum.with_index(lines, 1)
|> Enum.reduce({[], in_jobs?, jobs_indent, nil}, fn
{line, _no}, {acc, false, _ji, _current} ->
Expand All @@ -1119,14 +1168,14 @@ defmodule Hypatia.Rules.WorkflowHardening do
{acc, true, nil, current}
end

{line, _no}, {acc, true, jobs_indent, current} ->
{line, no}, {acc, true, jobs_indent, current} ->
case Regex.run(~r/^(\s+)([a-zA-Z0-9_-]+):\s*$/, line) do
[_, ws, job_id] ->
this_indent = String.length(ws)

if this_indent == jobs_indent do
acc2 = flush(acc, current)
{acc2, true, jobs_indent, {job_id, current_line_no(current, line), [line]}}
{acc2, true, jobs_indent, {job_id, no, [line]}}
else
# Still in current job body
{acc, true, jobs_indent, append_line(current, line)}
Expand All @@ -1144,12 +1193,9 @@ defmodule Hypatia.Rules.WorkflowHardening do
end
end)

Enum.reverse(acc)
acc |> flush(last) |> Enum.reverse()
end

defp current_line_no(nil, _line), do: 0
defp current_line_no({_id, no, _body}, _line), do: no

defp append_line(nil, _), do: nil
defp append_line({id, no, body}, line), do: {id, no, [line | body]}

Expand Down
Loading
Loading