diff --git a/lib/hypatia/cli.ex b/lib/hypatia/cli.ex index 8857ca00..02836132 100644 --- a/lib/hypatia/cli.ex +++ b/lib/hypatia/cli.ex @@ -24,7 +24,8 @@ defmodule Hypatia.CLI do --rules 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 @@ -45,6 +46,7 @@ defmodule Hypatia.CLI do :honest_completion, :workflow_audit, :cicd_rules, + :research_extensions, :code_safety, :migration_rules, :scorecard, @@ -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 } @@ -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 @@ -1297,8 +1334,8 @@ defmodule Hypatia.CLI do OPTIONS: --rules, -r 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 diff --git a/lib/hypatia/sarif.ex b/lib/hypatia/sarif.ex index 96a6c11e..e2c38593 100644 --- a/lib/hypatia/sarif.ex +++ b/lib/hypatia/sarif.ex @@ -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: "." diff --git a/lib/rules/rules.ex b/lib/rules/rules.ex index 412b8ef4..af9a0b79 100644 --- a/lib/rules/rules.ex +++ b/lib/rules/rules.ex @@ -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. @@ -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 @@ -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 = diff --git a/test/research_extensions_wiring_test.exs b/test/research_extensions_wiring_test.exs new file mode 100644 index 00000000..88b738be --- /dev/null +++ b/test/research_extensions_wiring_test.exs @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +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