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
42 changes: 42 additions & 0 deletions lib/hypatia/spdx.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>

defmodule Hypatia.SPDX do
@moduledoc """
SPDX licence identifiers, from a vendored copy of the SPDX License List.

The list lives in `priv/spdx/license-ids.txt` and is read at compile time,
so the escript carries it without needing `priv/` at run time. Refresh it
with `scripts/refresh-spdx-license-ids.sh <tag>` when SPDX publishes a new
release; the first line of the file names the version in use.
"""

@list_path Path.expand("../../priv/spdx/license-ids.txt", __DIR__)
@external_resource @list_path

{header, ids} =
@list_path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.split_with(&String.starts_with?(&1, "#"))

if length(ids) < 500 do
raise "#{@list_path} holds only #{length(ids)} identifiers; regenerate it"
end

@ids MapSet.new(ids)
@version_line hd(header)

@doc """
Whether `id` is an SPDX licence identifier on the vendored list, or a
REUSE custom licence (`LicenseRef-` followed by letters, digits, `.` or
`-`). Matching is exact, as REUSE requires for `LICENSES/` file names.
Deprecated identifiers count: they still name a licence.
"""
def licence_id?(id) when is_binary(id) do
MapSet.member?(@ids, id) or Regex.match?(~r/\ALicenseRef-[A-Za-z0-9.\-]+\z/, id)
end

@doc "The header line naming the SPDX License List version in use."
def list_version, do: @version_line
end
19 changes: 12 additions & 7 deletions lib/rules/root_hygiene.ex
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,16 @@ defmodule Hypatia.Rules.RootHygiene do
end)
end

# A REUSE `LICENSES/` directory counts only if it holds a licence text: a
# non-empty `<SPDX-ID>.<ext>` file, per the REUSE spec. Dotfiles (such as
# `.gitkeep`) and READMEs do not count.
defp reuse_licenses?(repo_path) do
@doc """
Whether `repo_path` has a REUSE `LICENSES/` directory holding a licence
text: a non-empty `<SPDX-ID>.<ext>` file, per the REUSE spec. The stem must
be an identifier on the vendored SPDX License List or a `LicenseRef-`
(`Hypatia.SPDX.licence_id?/1`), so `notes.txt`, READMEs and dotfiles such
as `.gitkeep` do not count.

Shared with `Hypatia.ScorecardIngestor`, so both licence checks agree.
"""
def reuse_licenses?(repo_path) do
dir = Path.join(repo_path, "LICENSES")

case File.ls(dir) do
Expand All @@ -512,9 +518,8 @@ defmodule Hypatia.Rules.RootHygiene do
defp reuse_licence_text?(dir, name) do
path = Path.join(dir, name)

not String.starts_with?(name, ".") and
not String.starts_with?(String.downcase(name), "readme") and
Path.extname(name) != "" and
Path.extname(name) != "" and
Hypatia.SPDX.licence_id?(Path.rootname(name)) and
File.regular?(path) and
match?({:ok, %File.Stat{size: size}} when size > 0, File.stat(path))
end
Expand Down
128 changes: 106 additions & 22 deletions lib/rules/secret_scanning_alerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ defmodule Hypatia.Rules.SecretScanningAlerts do

@github_api_base "https://api.github.com"
@max_alerts_per_repo 100
# Pages followed before giving up loudly (100 alerts per page).
@max_pages 50
# Per-request transfer deadline, so one stalled repo cannot hang a sweep.
@request_timeout_seconds 30

# Stale thresholds (days). A revoked secret left in history is still a
# finding -- but a fresh open alert is much more urgent.
Expand Down Expand Up @@ -319,31 +323,111 @@ defmodule Hypatia.Rules.SecretScanningAlerts do
"#{@github_api_base}/repos/#{owner}/#{repo}/secret-scanning/alerts" <>
"?per_page=#{@max_alerts_per_repo}"

# No `-f`: on an HTTP error GitHub's JSON body carries the reason
# (for example "Secret scanning is disabled on this repository"),
# and `-f` would discard it. The status code is appended instead.
case System.cmd(
"curl",
[
"-s",
"-w",
"\n%{http_code}",
"-H",
"Accept: application/vnd.github+json",
"-H",
"Authorization: Bearer #{token}",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
url
],
stderr_to_stdout: true
) do
{output, 0} -> parse_response(output)
{error, _} -> {:error, "curl failed: #{String.slice(error, 0, 200)}"}
end
collect_pages(url, &request(&1, token))
end
end

@doc false
# Follow GitHub's `Link: rel="next"` pages until the last one. A list cut
# short at one page would report older open alerts as absent, so hitting
# the page cap is an error, never a truncated success. Every `next` URL
# must stay on the API host: the token goes with each request.
def collect_pages(url, request, acc \\ [], page \\ 1)

def collect_pages(_url, _request, _acc, page) when page > @max_pages do
{:error,
"more than #{@max_pages * @max_alerts_per_repo} alerts -- " <>
"refusing to report a truncated list"}
end

def collect_pages(url, request, acc, page) do
with {:ok, output} <- request.(url),
{:ok, alerts, next} <- parse_page(output) do
cond do
next == nil ->
{:ok, acc ++ alerts}

String.starts_with?(next, @github_api_base <> "/") ->
collect_pages(next, request, acc ++ alerts, page + 1)

true ->
{:error, "refusing to follow a pagination link off #{@github_api_base}"}
end
end
end

# No `-f`: on an HTTP error GitHub's JSON body carries the reason (for
# example "Secret scanning is disabled on this repository"), and `-f` would
# discard it. `-i` keeps the headers for the `Link` pagination header, and
# the status code is appended after the body. `--max-time` stops one stalled
# repository from hanging an estate-wide sweep.
defp request(url, token) do
case System.cmd(
"curl",
[
"-s",
"-i",
"--max-time",
Integer.to_string(@request_timeout_seconds),
"-w",
"\n%{http_code}",
"-H",
"Accept: application/vnd.github+json",
"-H",
"Authorization: Bearer #{token}",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
url
],
stderr_to_stdout: true
) do
{output, 0} ->
{:ok, output}

{_, 28} ->
{:error, "GitHub API request timed out after #{@request_timeout_seconds}s"}

{error, code} ->
{:error, "curl failed (exit #{code}): #{String.slice(error, 0, 200)}"}
end
end

@doc false
# `output` is curl's `-i` output: one or more header blocks (an HTTPS proxy
# adds its own "Connection established" block first), the body, then a
# newline and the HTTP status. Returns the alerts and the next page's URL.
def parse_page(output) do
{headers, rest} = split_headers(output)

with {:ok, alerts} <- parse_response(rest) do
{:ok, alerts, next_link(headers)}
end
end

defp split_headers(output, headers \\ "") do
if String.starts_with?(output, "HTTP/") do
case String.split(output, ~r/\r?\n\r?\n/, parts: 2) do
[block, rest] -> split_headers(rest, block)
[block] -> {block, ""}
end
else
{headers, output}
end
end

defp next_link(headers) do
headers
|> String.split(~r/\r?\n/)
|> Enum.find_value(fn line ->
with [_, value] <- Regex.run(~r/\Alink:\s*(.*)\z/i, line),
[_, next] <- Regex.run(~r/<([^>]+)>\s*;\s*rel="next"/, value) do
next
else
_ -> nil
end
end)
end

@doc false
# `output` is the response body followed by a newline and the HTTP status.
def parse_response(output) do
Expand Down
5 changes: 4 additions & 1 deletion lib/scorecard_ingestor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,14 @@ defmodule Hypatia.ScorecardIngestor do
Path.join(repo_path, "LICENSE"),
Path.join(repo_path, "LICENSE.txt"),
Path.join(repo_path, "LICENSE.md"),
Path.join(repo_path, "LICENSE.adoc"),
Path.join(repo_path, "LICENCE"),
Path.join(repo_path, "COPYING")
]

unless Enum.any?(license_paths, &File.exists?/1) do
# Same REUSE rule as root hygiene, so the two never disagree.
unless Enum.any?(license_paths, &File.exists?/1) or
Hypatia.Rules.RootHygiene.reuse_licenses?(repo_path) do
Comment thread
coderabbitai[bot] marked this conversation as resolved.
make_pattern("SC-010", "License", repo_name, "No LICENSE file found in #{repo_name}")
end
end
Expand Down
Loading
Loading