Skip to content
Open
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
43 changes: 40 additions & 3 deletions lib/hypatia/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ defmodule Hypatia.CLI do

--rules <list> Comma-separated rule modules to run (default: all)
Available: root_hygiene,honest_completion,workflow_audit,
cicd_rules,code_safety,migration_rules,scorecard,
cicd_rules,research_extensions,
code_safety,migration_rules,scorecard,
green_web,git_state,dependabot_alerts,
secret_scanning_alerts,code_scanning_alerts,
structural_drift,implementation_inside_canon
Expand All @@ -45,6 +46,7 @@ defmodule Hypatia.CLI do
:honest_completion,
:workflow_audit,
:cicd_rules,
:research_extensions,
:code_safety,
:migration_rules,
:scorecard,
Expand All @@ -61,6 +63,11 @@ defmodule Hypatia.CLI do
"critical" => 1,
"high" => 2,
"medium" => 3,
# `:warn` is emitted by research_extensions (6 rules) and workflow_audit.
# Absent from this map it fell to the `Map.get/3` default of 5, so the
# `rank <= threshold` filter below dropped every warn finding at the
# default `--severity medium`. Ranked with medium: warn IS medium-tier.
"warn" => 3,
"low" => 4,
"info" => 5
}
Expand Down Expand Up @@ -530,6 +537,36 @@ defmodule Hypatia.CLI do
results
end

# Research Extensions (RE001-RE010) - Snyk/StepSecurity/Endor/academic
results =
if :research_extensions in rules do
case Hypatia.Rules.ResearchExtensions.scan(repo_path) do
%{findings: findings} ->
normalized =
Enum.map(findings, fn f ->
%{
rule_module: "research_extensions",
severity: to_string(f.severity),
type: f.rule,
file: Map.get(f, :file, "."),
# RE004 carries its line under `:detail`; the rest carry
# none. Both shapes degrade to nil, which SARIF renders
# as startLine 1 exactly as before.
line: get_in(f, [:detail, :line]) || Map.get(f, :line),
reason: f.reason,
action: to_string(f.action)
}
end)

results ++ normalized

_ ->
results
end
else
results
end

# Code Safety
results =
if :code_safety in rules do
Expand Down Expand Up @@ -1297,8 +1334,8 @@ defmodule Hypatia.CLI do
OPTIONS:
--rules, -r <list> Comma-separated rule modules (default: all)
Available: root_hygiene,honest_completion,
workflow_audit,cicd_rules,code_safety,
migration_rules,scorecard,green_web,
workflow_audit,cicd_rules,research_extensions,
code_safety,migration_rules,scorecard,green_web,
git_state,dependabot_alerts,
secret_scanning_alerts,code_scanning_alerts,
structural_drift,implementation_inside_canon
Expand Down
1 change: 1 addition & 0 deletions lib/hypatia/sarif.ex
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ defmodule Hypatia.SARIF do
defp level_for("critical"), do: "error"
defp level_for("high"), do: "error"
defp level_for("medium"), do: "warning"
defp level_for("warn"), do: "warning"
defp level_for(_), do: "note"

defp rel_uri("", _root), do: "."
Expand Down
18 changes: 13 additions & 5 deletions lib/rules/rules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defmodule Hypatia.Rules do
alias Hypatia.Rules.WorkflowHardening
alias Hypatia.Rules.SupplyChain
alias Hypatia.Rules.BranchProtection
# alias Hypatia.Rules.ResearchExtensions # wired in follow-up after PR #325 merges
alias Hypatia.Rules.ResearchExtensions

@doc """
Run a comprehensive scan on a file's content given its path and language.
Expand Down Expand Up @@ -694,9 +694,16 @@ defmodule Hypatia.Rules do
"""
defdelegate scan_branch_protection(owner, repo), to: BranchProtection, as: :scan

# ResearchExtensions (RE001-RE010) delegate added in follow-up once
# PR #325 lands on main. The facade for the other four families is
# below.
@doc """
Run research-extension checks (RE001-RE010) drawn from Snyk,
StepSecurity, Endor Labs and the academic supply-chain literature:
absent/audit-only harden-runner, cache-key poisoning via head_ref,
tag-pinned containers, exit-swallowing test steps, unpinned nested
composite uses, workflow-level secret env, spoofable bot gates,
fromJSON(secrets), and provenance-free workflow_run artifacts.
Pure local file scan - no GitHub API.
"""
defdelegate scan_research_extensions(repo_path, opts \\ []), to: ResearchExtensions, as: :scan

@doc """
Run every estate-policy rule available against a repository in one
Expand All @@ -718,7 +725,8 @@ defmodule Hypatia.Rules do
parts = [
BaselineHealth.scan(repo_path, opts),
WorkflowHardening.scan(repo_path, opts),
SupplyChain.scan(repo_path, opts)
SupplyChain.scan(repo_path, opts),
ResearchExtensions.scan(repo_path, opts)
]

parts =
Expand Down
160 changes: 160 additions & 0 deletions test/research_extensions_wiring_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>

defmodule Hypatia.Rules.ResearchExtensionsWiringTest do
@moduledoc """
RE001-RE010 landed in PR #325 (2026-05-26) with a full test suite and
no caller. `test/research_extensions_test.exs` proves the *rules* work;
these tests prove the *wiring*, which is what was missing:

* `Hypatia.CLI.collect_findings/2` actually reaches the module,
* the normalized finding shape survives the seam,
* RE004's line - which the module nests under `:detail` - is carried
through rather than dropped,
* the `:warn` tier renders as a real SARIF level instead of falling
to the catch-all.

Without these, a future refactor can unwire the module and every rule
test still passes.
"""
use ExUnit.Case, async: true

alias Hypatia.CLI
alias Hypatia.Rules
alias Hypatia.SARIF

@tmp_dir System.tmp_dir!()

# Trips RE001 (touches `secrets.*` with no harden-runner; :warn, no line)
# and RE004 (`docker://` pinned by tag; :warn, line nested under :detail).
@tripwire """
name: Deploy
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: docker://alpine:3.21
- run: deploy --token=${{ secrets.DEPLOY_KEY }}
"""

defp tripwire_repo do
repo = Path.join(@tmp_dir, "re_wiring_#{System.unique_integer([:positive])}")
wf = Path.join([repo, ".github", "workflows"])
File.mkdir_p!(wf)
File.write!(Path.join(wf, "deploy.yml"), @tripwire)
on_exit(fn -> File.rm_rf!(repo) end)
repo
end

defp re004(repo) do
repo
|> CLI.collect_findings([:research_extensions])
|> Enum.find(&(&1.type == "RE004"))
end

describe "CLI.collect_findings/2 reaches ResearchExtensions" do
test "the :research_extensions branch emits normalized findings" do
findings = CLI.collect_findings(tripwire_repo(), [:research_extensions])

refute findings == [],
"collect_findings/2 returned nothing for :research_extensions - the branch is unwired"

assert Enum.all?(findings, &(&1.rule_module == "research_extensions"))
assert Enum.all?(findings, &is_binary(&1.severity))
assert Enum.all?(findings, &is_binary(&1.action))
assert "RE004" in Enum.map(findings, & &1.type)
end

test "a different rule module does not emit research_extensions findings" do
findings = CLI.collect_findings(tripwire_repo(), [:code_safety])
refute Enum.any?(findings, &(&1.rule_module == "research_extensions"))
end

test "a repo with no workflows produces no research_extensions findings" do
clean = Path.join(@tmp_dir, "re_clean_#{System.unique_integer([:positive])}")
File.mkdir_p!(clean)
on_exit(fn -> File.rm_rf!(clean) end)

assert CLI.collect_findings(clean, [:research_extensions]) == []
end
end

describe ":line carry-through" do
test "RE004's line, nested under :detail, survives normalization" do
f = re004(tripwire_repo())

assert f, "RE004 did not fire on the tripwire workflow"

assert is_integer(f.line) and f.line > 0,
"ResearchExtensions nests RE004's line under :detail; the " <>
"normalizer must reach it, not just Map.get(f, :line)"
end

test "the carried line renders as a non-degenerate SARIF startLine" do
repo = tripwire_repo()
f = re004(repo)

[result] =
[f]
|> SARIF.from_findings(repo)
|> Map.fetch!("runs")
|> hd()
|> Map.fetch!("results")

start_line =
get_in(result, ["locations", Access.at(0), "physicalLocation", "region", "startLine"])

assert start_line == f.line

refute start_line == 1,
"startLine 1 is the fallback SARIF uses when :line is absent - " <>
"the line was lost at the normalization seam"
end
end

describe "the :warn tier is not discarded" do
# Six of the ten RE rules emit `severity: :warn`. "warn" was absent from
# CLI's @severity_order, so `Map.get(@severity_order, "warn", 5)` gave it
# rank 5; the filter `rank <= threshold` at the default threshold of
# "medium" (3) then dropped every one of them *after* the rules had run.
# SARIF's level mapping mirrors that ranking, so it guards the same fix.
test "warn maps to the SARIF warning level, not the note catch-all" do
[result] =
[
%{
severity: "warn",
rule_module: "research_extensions",
type: "RE001",
file: ".github/workflows/deploy.yml",
reason: "no harden-runner"
}
]
|> SARIF.from_findings("/tmp")
|> Map.fetch!("runs")
|> hd()
|> Map.fetch!("results")

assert result["level"] == "warning",
"warn fell through to the catch-all - it is being treated as " <>
"lower than info by every consumer that ranks severities"
end
end

describe "Rules facade" do
test "scan_research_extensions/2 delegates to the module" do
result = Rules.scan_research_extensions(tripwire_repo())

assert %{findings: _, total: total, by_severity: _, dispatch: _} = result
assert total > 0
end

test "scan_all_estate_policies/2 includes the research-extension family" do
%{findings: findings} = Rules.scan_all_estate_policies(tripwire_repo())

assert Enum.any?(findings, &(Map.get(&1, :rule) in ["RE001", "RE004"])),
"the estate-policy facade documents 'every estate-policy rule' " <>
"but omitted RE001-RE010"
end
end
end
Loading