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
26 changes: 25 additions & 1 deletion lib/hypatia/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion lib/rules/root_hygiene.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
2 changes: 1 addition & 1 deletion lib/rules/rsr_conformance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
12 changes: 12 additions & 0 deletions test/root_hygiene_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions test/workflow_audit_path_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading