diff --git a/lib/hypatia/cli.ex b/lib/hypatia/cli.ex index 112cb000..3d799bd6 100644 --- a/lib/hypatia/cli.ex +++ b/lib/hypatia/cli.ex @@ -454,7 +454,15 @@ defmodule Hypatia.CLI do rule_module: "workflow_audit", severity: to_string(Map.get(f, :severity, :medium)), type: to_string(type), - file: Map.get(f, :file, Map.get(f, :files, "") |> listify()), + # WorkflowAudit keys findings by bare basename (`ci.yml`) so + # its missing-workflow checks can compare names; qualify at + # this boundary so SARIF anchors to the real file and + # `.hypatia-ignore` entries written as `.github/workflows/x` + # match. + file: + Map.get(f, :file, Map.get(f, :files, "")) + |> qualify_workflow_file() + |> listify(), reason: workflow_finding_message(f), action: to_string(Map.get(f, :action, Map.get(f, :fix, :flag))) } @@ -1439,6 +1447,22 @@ defmodule Hypatia.CLI do end end + @doc """ + Qualifies bare workflow filenames relative to `.github/workflows`. + + Accepts a filename or a list of filenames, returning the qualified path or + a list of qualified paths. Strings containing `/`, empty strings, and + non-string values are returned unchanged. Lists are processed recursively. + """ + def qualify_workflow_file(names) when is_list(names), + do: Enum.map(names, &qualify_workflow_file/1) + + def qualify_workflow_file(name) when is_binary(name) and name != "" do + if String.contains?(name, "/"), do: name, else: Path.join(".github/workflows", name) + end + + def qualify_workflow_file(other), do: other + defp listify(val) when is_list(val), do: Enum.join(val, ", ") defp listify(val), do: to_string(val) diff --git a/lib/rules/root_hygiene.ex b/lib/rules/root_hygiene.ex index df08439f..fff197ae 100644 --- a/lib/rules/root_hygiene.ex +++ b/lib/rules/root_hygiene.ex @@ -440,7 +440,14 @@ defmodule Hypatia.Rules.RootHygiene do required = [ %{file: "LICENSE", alternatives: ["LICENSE.txt"], severity: :critical}, %{file: ".editorconfig", alternatives: [], severity: :medium}, - %{file: "0-AI-MANIFEST.a2ml", alternatives: ["AI.a2ml"], severity: :high} + # `.deed` is the DEED-manifest spelling of the same gatekeeper file + # (panoply ships it and `Validate DEED manifests` checks it); it is the + # manifest, not a missing one. + %{ + file: "0-AI-MANIFEST.a2ml", + alternatives: ["0-AI-MANIFEST.deed", "AI.a2ml"], + severity: :high + } ] Enum.flat_map(required, fn req -> diff --git a/lib/rules/rsr_conformance.ex b/lib/rules/rsr_conformance.ex index 20935162..82b70f3f 100644 --- a/lib/rules/rsr_conformance.ex +++ b/lib/rules/rsr_conformance.ex @@ -352,7 +352,7 @@ defmodule Hypatia.Rules.RsrConformance do ".well-known/ai.txt", ".well-known/humans.txt" ]), - "2.3.1" => present("0-AI-MANIFEST.a2ml"), + "2.3.1" => any_of(["0-AI-MANIFEST.a2ml", "0-AI-MANIFEST.deed"]), "3.1.1" => present_mr("descriptiles"), "3.1.2" => descriptile("STATE"), "3.1.3" => descriptile("META"), diff --git a/test/root_hygiene_test.exs b/test/root_hygiene_test.exs index b3a96a92..91f55ef9 100644 --- a/test/root_hygiene_test.exs +++ b/test/root_hygiene_test.exs @@ -203,6 +203,18 @@ defmodule Hypatia.Rules.RootHygieneTest do findings = RootHygiene.scan_required_missing(["LICENSE"]) refute Enum.any?(findings, &(&1.file == "SECURITY.md")) end + + test "accepts 0-AI-MANIFEST.deed as the manifest" do + findings = + RootHygiene.scan_required_missing(["LICENSE", ".editorconfig", "0-AI-MANIFEST.deed"]) + + assert findings == [] + end + + test "still flags a repo with neither manifest spelling" do + findings = RootHygiene.scan_required_missing(["LICENSE", ".editorconfig"]) + assert [%{file: "0-AI-MANIFEST.a2ml", type: :missing}] = findings + end end describe "scan/1" do diff --git a/test/workflow_audit_path_test.exs b/test/workflow_audit_path_test.exs new file mode 100644 index 00000000..1e65e8de --- /dev/null +++ b/test/workflow_audit_path_test.exs @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.WorkflowAuditPathTest do + use ExUnit.Case, async: true + + alias Hypatia.CLI + + # workflow_audit findings used to carry the bare basename (`ci.yml`) as + # their file, so the uploaded SARIF did not anchor to a real path and + # `.hypatia-ignore` entries written as `.github/workflows/ci.yml` never + # matched. Exercised through the real pipeline, not a hand-built finding. + + setup do + dir = Path.join(System.tmp_dir!(), "hyp-wfpath-#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(Path.join(dir, ".github/workflows")) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "unpinned_action reports the repo-relative workflow path", %{dir: dir} do + File.write!(Path.join(dir, ".github/workflows/ci.yml"), """ + name: ci + on: push + permissions: {} + jobs: + b: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + """) + + findings = + dir + |> CLI.collect_findings([:workflow_audit]) + |> Enum.filter(&(&1.rule_module == "workflow_audit" and &1.type == "unpinned_action")) + + assert [%{file: ".github/workflows/ci.yml"}] = findings + end + + test "qualify_workflow_file/1 leaves paths and lists sensible" do + assert CLI.qualify_workflow_file("ci.yml") == ".github/workflows/ci.yml" + assert CLI.qualify_workflow_file(".github/workflows/ci.yml") == ".github/workflows/ci.yml" + + assert CLI.qualify_workflow_file(["a.yml", "b.yml"]) == + [".github/workflows/a.yml", ".github/workflows/b.yml"] + + assert CLI.qualify_workflow_file("") == "" + end +end