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
252 changes: 250 additions & 2 deletions lib/rules/code_scanning_alerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
Requires GITHUB_TOKEN with `code_scanning_alerts: read` permission
(fine-grained PAT) or `security_events` scope (classic PAT).

Rule IDs: CSA001-CSA004
Rule IDs: CSA001-CSA006

### Scorecard False Positive Handling (CSA005-CSA006)

CSA005: Auto-detects Scorecard MaintainedID and CodeReviewID alerts that are
known false positives (repos <90 days old, single-contributor repos). These
are reported as low-severity findings to track the noise without escalating.

CSA006: Provides configuration advice for repos with Scorecard structural
issues (e.g., single-contributor repos with CodeReviewID alerts). Helps
maintainers understand why these alerts appear and what (if anything) can
be done about them.
"""

require Logger
Expand All @@ -38,6 +49,9 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
# Dismissal reasons accepted by policy without further review.
@accepted_dismissals ~w(false\ positive used\ in\ tests won't\ fix)

# Scorecard-specific checks that can be auto-dismissed
@scorecard_false_positive_checks ~w(MaintainedID CodeReviewID) # Checks that often produce false positives

# ─── CSA001: Open code-scanning alerts ─────────────────────────────────

@doc """
Expand Down Expand Up @@ -302,7 +316,9 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
csa001_open_alerts(owner, repo) ++
csa002_severity_summary(owner, repo) ++
csa003_stale_alerts(owner, repo) ++
csa004_dismissed_without_fix(owner, repo)
csa004_dismissed_without_fix(owner, repo) ++
csa005_scorecard_false_positives(owner, repo) ++
csa006_scorecard_config_advice(owner, repo)

deduped =
findings
Expand Down Expand Up @@ -434,8 +450,240 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
end
end

# ─── CSA005: Scorecard false positives (MaintainedID, CodeReviewID) ───────────

@doc """
CSA005: Auto-dismiss Scorecard alerts that are known false positives.

MaintainedID: Scorecard gives score 0 to repos <90 days old. This is expected
behavior and will auto-resolve after 90 days.

CodeReviewID: Single-contributor repos cannot have code review. This is a
structural limitation, not a security issue.

This rule proactively dismisses these alerts to reduce noise in the security tab.
"""
def csa005_scorecard_false_positives(owner, repo) do
case fetch_alerts(owner, repo) do
{:ok, alerts} ->
alerts
|> Enum.filter(&(&1["state"] == "open"))
|> Enum.filter(&(get_in(&1, ["rule", "id"]) in @scorecard_false_positive_checks))
|> Enum.filter(&(get_in(&1, ["tool", "name"]) == "Scorecard"))
|> Enum.map(fn alert ->
rule_id = get_in(alert, ["rule", "id"])
number = alert["number"]

# Build dismissal reason and comment based on rule_id
{reason, comment} =
case rule_id do
"MaintainedID" ->
{"false positive",
"Repository is less than 90 days old. Scorecard Maintained check gives score 0 for new projects. This is expected behavior and will auto-resolve after 90 days."}
"CodeReviewID" ->
{"won't fix",
"Single-contributor repository. Code review requires multiple human contributors. This is a structural limitation of the project, not a security issue."}
_ ->
{"false positive", "Scorecard false positive - auto-dismissed by Hypatia"}
end

%{
rule: "CSA005",
file: "#{owner}/#{repo}",
severity: :low,
reason: "Scorecard #{rule_id} alert #{number} is a known false positive",
action: :automate,
detail: %{
alert_number: number,
rule_id: rule_id,
tool: "Scorecard",
dismissal_reason: reason,
dismissal_comment: comment,
url: alert["html_url"]
}
}
end)

{:error, _} ->
[]
end
end

# ─── CSA006: Scorecard configuration advice ──────────────────────────────

@doc """
CSA006: Meta-finding when a repo has Scorecard alerts that could be prevented
by configuration changes (e.g., single-contributor repos with CodeReviewID).

This helps repository maintainers understand structural issues that generate
recurring alerts.
"""
def csa006_scorecard_config_advice(owner, repo) do
case fetch_alerts(owner, repo) do
{:ok, alerts} ->
# Count Scorecard alerts by rule_id
by_rule =
alerts
|> Enum.filter(&(get_in(&1, ["tool", "name"]) == "Scorecard"))
|> Enum.group_by(&get_in(&1, ["rule", "id"]))

findings = []

# Check for MaintainedID alerts (repo <90 days old)
if Map.has_key?(by_rule, "MaintainedID") do
maintained_alerts = Map.get(by_rule, "MaintainedID")
open_maintained = Enum.filter(maintained_alerts, &(&1["state"] == "open"))

if length(open_maintained) > 0 do
# Get repo creation date
repo_info = fetch_repo_info(owner, repo)
created_at = repo_info["created_at"]

# Check if repo is <90 days old
if is_repo_less_than_90_days?(created_at) do
findings = [
%{
rule: "CSA006",
file: "#{owner}/#{repo}",
severity: :info,
reason: "Repository has Scorecard MaintainedID alerts but is <90 days old - these are expected and will auto-resolve",
action: :inform,
detail: %{
alert_count: length(open_maintained),
repo_created_at: created_at,
suggestion: "No action needed. These alerts will disappear after 90 days."
}
}
| findings
]
end
end
end

# Check for CodeReviewID alerts in single-contributor repos
if Map.has_key?(by_rule, "CodeReviewID") do
code_review_alerts = Map.get(by_rule, "CodeReviewID")
open_code_review = Enum.filter(code_review_alerts, &(&1["state"] == "open"))

if length(open_code_review) > 0 do
# Check if repo has only one human contributor
if has_single_contributor?(owner, repo) do
findings = [
%{
rule: "CSA006",
file: "#{owner}/#{repo}",
severity: :medium,
reason: "Repository has Scorecard CodeReviewID alerts but has only one human contributor - code review is impractical",
action: :configure,
detail: %{
alert_count: length(open_code_review),
suggestion: "Add more contributors or accept that code review is not feasible for this project."
}
}
| findings
]
end
end
end

findings

{:error, _} ->
[]
end
end

# ─── Helpers ───────────────────────────────────────────────────────────

# Fetch repository info from GitHub API
defp fetch_repo_info(owner, repo) do
token = System.get_env("GITHUB_TOKEN")

if token == nil or token == "" do
%{"created_at" => "", "contributors" => []}
else
url = "#{@github_api_base}/repos/#{owner}/#{repo}"

case System.cmd(
"curl",
[
"-s",
"-f",
"-H",
"Accept: application/vnd.github+json",
"-H",
"Authorization: Bearer #{token}",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
url
],
stderr_to_stdout: true
) do
{body, 0} ->
case Jason.decode(body) do
{:ok, info} -> info
_ -> %{"created_at" => "", "contributors" => []}
end
_ -> %{"created_at" => "", "contributors" => []}
end
end
end

# Check if repo is less than 90 days old
defp is_repo_less_than_90_days?(created_at) do
case DateTime.from_iso8601(created_at) do
{:ok, dt, _} ->
days = DateTime.diff(DateTime.utc_now(), dt, :day)
days < 90
_ -> false
end
end

# Check if repo has only one human contributor
defp has_single_contributor?(owner, repo) do
token = System.get_env("GITHUB_TOKEN")

if token == nil or token == "" do
false
else
url = "#{@github_api_base}/repos/#{owner}/#{repo}/contributors?anon=1"

case System.cmd(
"curl",
[
"-s",
"-f",
"-H",
"Accept: application/vnd.github+json",
"-H",
"Authorization: Bearer #{token}",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
url
],
stderr_to_stdout: true
) do
{body, 0} ->
case Jason.decode(body) do
{:ok, contributors} when is_list(contributors) ->
# Filter out bots and check human contributors
human_contributors =
Enum.filter(contributors, fn c ->
type = c["type"] || ""
login = c["login"] || ""
# Exclude bot accounts
!String.contains?(login, "[bot]") &&
!String.ends_with?(login, "-bot") &&
type != "Bot"
end)
length(human_contributors) <= 1
_ -> false
end
_ -> false
end
end
end

# Normalise the heterogeneous severity surface (CodeQL uses note/
# warning/error, third-party SARIF often uses critical/high/medium/low,
# GitHub's `security_severity_level` uses critical/high/medium/low) onto
Expand Down
Loading
Loading