diff --git a/.github/workflows/escript-soundness.yml b/.github/workflows/escript-soundness.yml index 4e01de56..05900f3d 100644 --- a/.github/workflows/escript-soundness.yml +++ b/.github/workflows/escript-soundness.yml @@ -107,28 +107,37 @@ jobs: working-directory: source run: mix deps.get - # NOTE: deliberately NOT --warnings-as-errors, and this is not laziness. - # Turning it on today makes this gate red on arrival, because five real - # warnings sit on main — invisible until now precisely because tests.yml, - # which carried that step, has never had a single successful run. + # --warnings-as-errors is ON, and it is load-bearing. # - # Two of the five are genuine logic defects, not lint: - # * lib/rules/code_scanning_alerts.ex:544,571 rebind `findings` INSIDE an - # `if` whose value is discarded, so CSA006 builds findings and throws - # them away. - # * lib/rules/secret_scanner_verification.ex:168 rebinds `exit`/`output` - # inside `if exit != 0`, so the trufflehog fallback is inert and the - # following `if exit == 0` still tests the ORIGINAL gitleaks exit. - # Both are the same shape as the CondClauseError this gate exists to catch: - # code that reads as though it acts and does nothing. + # It was deliberately omitted when this gate was first written, because + # 13 real warnings sat on main — invisible until then precisely because + # tests.yml, which carried that step, has never had a single successful + # run. Those 13 are now cleared, so the gate is armed. # - # Silencing them with an underscore would cement live bugs; repairing them - # changes scanner behaviour across 449 consumers and must be its own - # measured PR. Restore --warnings-as-errors in that PR, not this one. + # Two of them were logic defects rather than lint, and both are the + # shape this gate exists to catch: code that reads as though it acts and + # does nothing. + # * code_scanning_alerts.ex rebound `findings` INSIDE an `if` whose + # value is discarded, so CSA006 built findings and threw them away. + # It ran on all 449 consumers and never produced a finding. + # * secret_scanner_verification.ex was dead in seven independent ways + # at once (string-vs-integer comparison, unguarded System.cmd, + # a 3-tuple destructure of a 2-tuple, the discarded rebinding, and + # writing evidence into the scanned repo). It was referenced + # nowhere, so it was deleted rather than rewritten. + # + # An earlier version of this note said "five warnings" and blamed the + # trufflehog fallback's rebinding for making it inert. Both were wrong: + # the measured count was 13, and that module never reached the fallback + # because `System.cmd(...) |> elem(0) == 0` compares a string to an + # integer and is always false. + # + # If this step goes red, do NOT silence it with an underscore — an + # unused variable here has twice meant a discarded result. Fix the + # logic, or delete the code. - name: Compile working-directory: source - run: mix compile - + run: mix compile --warnings-as-errors # The gate itself. Builds the escript fresh and asserts every rule in # test/soundness/manifest.json fires at its expected severity against the # built binary — the packaging path, not the in-process one. diff --git a/lib/hypatia/web/api_router.ex b/lib/hypatia/web/api_router.ex index 6d5ef850..4d8cb2ee 100644 --- a/lib/hypatia/web/api_router.ex +++ b/lib/hypatia/web/api_router.ex @@ -63,11 +63,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/recipes/:id -- single-recipe drill-down. Returns the same - shape as one row from `/api/recipes`, plus the recipe definition - itself when found in the registry. - """ + # GET /api/recipes/:id -- single-recipe drill-down. Returns the same + # shape as one row from `/api/recipes`, plus the recipe definition + # itself when found in the registry. get "/recipes/:id" do health = Hypatia.OutcomeTracker.recipe_health() row = Enum.find(health, &(&1.recipe_id == id)) @@ -80,11 +78,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/quarantine -- everything currently auto-quarantined: - recipes (verification-rate gate) and bots (consecutive-failure / - FP-rate gate from Hypatia.Safety.Quarantine). - """ + # GET /api/quarantine -- everything currently auto-quarantined: + # recipes (verification-rate gate) and bots (consecutive-failure / + # FP-rate gate from Hypatia.Safety.Quarantine). get "/quarantine" do recipes = Hypatia.OutcomeTracker.recipe_health() @@ -102,11 +98,9 @@ defmodule Hypatia.Web.ApiRouter do }) end - @doc """ - GET /api/alerts -- Recent threshold-rule alerts emitted by - Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the - dashboard alert ribbon and supports manual triage. - """ + # GET /api/alerts -- Recent threshold-rule alerts emitted by + # Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the + # dashboard alert ribbon and supports manual triage. get "/alerts" do rows = case Process.whereis(Hypatia.Watcher.Alerts) do diff --git a/lib/hypatia/web/router.ex b/lib/hypatia/web/router.ex index 1faefc96..24e0e596 100644 --- a/lib/hypatia/web/router.ex +++ b/lib/hypatia/web/router.ex @@ -28,20 +28,16 @@ defmodule Hypatia.Web.Router do plug(:match) plug(:dispatch) - @doc """ - GET / -- Single-page live operational dashboard. HTML + vanilla JS, - polls /api/status and EventSource-streams /api/events. The dashboard - itself is publicly reachable; the data endpoints it calls are - loopback-only (gated in ApiRouter), so a non-local browser would - render the chrome but get 403 from the XHR/SSE calls. - """ + # GET / -- Single-page live operational dashboard. HTML + vanilla JS, + # polls /api/status and EventSource-streams /api/events. The dashboard + # itself is publicly reachable; the data endpoints it calls are + # loopback-only (gated in ApiRouter), so a non-local browser would + # render the chrome but get 403 from the XHR/SSE calls. get "/" do Hypatia.Web.Dashboard.call(conn, []) end - @doc """ - GET /health -- Basic health check for the HTTP endpoint. - """ + # GET /health -- Basic health check for the HTTP endpoint. get "/health" do health = %{ status: "ok", @@ -54,27 +50,24 @@ defmodule Hypatia.Web.Router do |> send_resp(200, Jason.encode!(health)) end - @doc """ - GET /metrics -- Prometheus text-format exposition. Publicly - reachable (NOT loopback-only) because scrapers routinely run on a - different host; there's no operational data in the metric body - that isn't already implied by the dashboard's existence. - """ + # GET /metrics -- Prometheus text-format exposition. Publicly + # reachable (NOT loopback-only) because scrapers routinely run on a + # different host; there's no operational data in the metric body + # that isn't already implied by the dashboard's existence. get "/metrics" do Hypatia.Web.Metrics.call(conn, []) end - @doc """ - GET /metrics/snapshot -- Compact JSON snapshot of estate-level - counters: repos scanned, weak points, dispatched actions, outcomes, - recipes, average confidence. Consumed by the optional Ada TUI - (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call - status read for external dashboards. + # GET /metrics/snapshot -- Compact JSON snapshot of estate-level + # counters: repos scanned, weak points, dispatched actions, outcomes, + # recipes, average confidence. Consumed by the optional Ada TUI + # (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call + # status read for external dashboards. - Reads from the verisim-data flat-file store via VerisimConnector; - any failure returns a degraded snapshot with status="degraded" - rather than 500, so the TUI keeps rendering. - """ + # + # Reads from the verisim-data flat-file store via VerisimConnector; + # any failure returns a degraded snapshot with status="degraded" + # rather than 500, so the TUI keeps rendering. get "/metrics/snapshot" do snapshot = Hypatia.Web.MetricsSnapshot.build() @@ -88,14 +81,13 @@ defmodule Hypatia.Web.Router do # reachable for container orchestrators. forward("/api", to: Hypatia.Web.ApiRouter) - @doc """ - POST /graphql -- GraphQL-shaped query endpoint (M14). + # POST /graphql -- GraphQL-shaped query endpoint (M14). - Minimal hand-rolled implementation; no introspection, no schema - federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for - the supported field set and limitations. Loopback-only by sharing - the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. - """ + # + # Minimal hand-rolled implementation; no introspection, no schema + # federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for + # the supported field set and limitations. Loopback-only by sharing + # the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. post "/graphql" do conn = Hypatia.Web.ApiRouter.protect(conn, []) diff --git a/lib/rules/code_scanning_alerts.ex b/lib/rules/code_scanning_alerts.ex index 4fa74a98..bb114d52 100644 --- a/lib/rules/code_scanning_alerts.ex +++ b/lib/rules/code_scanning_alerts.ex @@ -18,11 +18,11 @@ defmodule Hypatia.Rules.CodeScanningAlerts do 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 @@ -50,7 +50,8 @@ defmodule Hypatia.Rules.CodeScanningAlerts do @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 + # Checks that often produce false positives + @scorecard_false_positive_checks ~w(MaintainedID CodeReviewID) # ─── CSA001: Open code-scanning alerts ───────────────────────────────── @@ -304,7 +305,10 @@ defmodule Hypatia.Rules.CodeScanningAlerts do # ─── Comprehensive scan ──────────────────────────────────────────────── @doc """ - Run all code-scanning checks for a repository. + Run all code-scanning checks for a GitHub repository. + + Returns an error when `GITHUB_TOKEN` is unset or empty. Otherwise, returns + the deduplicated findings, their total count and counts grouped by severity. """ def scan(owner, repo) do token = System.get_env("GITHUB_TOKEN") @@ -320,11 +324,7 @@ defmodule Hypatia.Rules.CodeScanningAlerts do csa005_scorecard_false_positives(owner, repo) ++ csa006_scorecard_config_advice(owner, repo) - deduped = - findings - |> Enum.uniq_by(fn f -> - {f.rule, Map.get(f.detail, :alert_number, f.file)} - end) + deduped = deduplicate_findings(findings) {:ok, %{ @@ -335,6 +335,24 @@ defmodule Hypatia.Rules.CodeScanningAlerts do end end + @doc """ + Remove duplicate findings while preserving the first finding for each + identity. + + Findings with an alert number are identified by their rule and alert number. + Findings without one are identified by their rule, `file` value and + Scorecard check, so distinct CSA006 advice for the same repository is kept. + """ + def deduplicate_findings(findings) do + Enum.uniq_by(findings, fn finding -> + alert_number = Map.get(finding.detail, :alert_number) + + {finding.rule, + alert_number || + {finding.file, Map.get(finding.detail, :scorecard_check)}} + end) + end + @doc """ Scan from a local repo path -- extracts owner/repo from git remote. """ @@ -453,15 +471,12 @@ defmodule Hypatia.Rules.CodeScanningAlerts do # ─── 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. + CSA005: Build low-severity findings for open Scorecard MaintainedID and + CodeReviewID alerts. + + Each finding proposes a dismissal reason and comment for later automation; + this function does not dismiss the GitHub alert. Returns an empty list when + alerts cannot be fetched or none of the supported Scorecard checks are open. """ def csa005_scorecard_false_positives(owner, repo) do case fetch_alerts(owner, repo) do @@ -473,20 +488,22 @@ defmodule Hypatia.Rules.CodeScanningAlerts do |> 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} = + {reason, comment} = case rule_id do - "MaintainedID" -> - {"false positive", + "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}", @@ -512,98 +529,149 @@ defmodule Hypatia.Rules.CodeScanningAlerts do # ─── 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. + CSA006: Return advice for open Scorecard alerts caused by repository age or + contributor count. + + Reports MaintainedID alerts for repositories younger than 90 days and + CodeReviewID alerts for repositories with at most one human contributor. + Returns an empty list when alerts cannot be fetched or neither condition + applies. If age or contributor data is unavailable, the corresponding advice + is omitted. """ def csa006_scorecard_config_advice(owner, repo) do case fetch_alerts(owner, repo) do {:ok, alerts} -> # Count Scorecard alerts by rule_id - by_rule = + 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 + + # Each arm returns a list, and the lists are concatenated. Do NOT + # reintroduce a `findings = [... | findings]` rebinding inside an `if`: + # Elixir's `if` does not leak bindings, so the assignment is dead and + # the function silently returns []. That was this rule's state from its + # introduction until 2026-09-14 — it compiled clean, emitted only an + # "unused variable" warning, and produced no findings on any of the 449 + # consumers. Returning lists makes the defect unrepresentable. + maintained_id_advice(by_rule, owner, repo) ++ + code_review_id_advice(by_rule, owner, repo) {:error, _} -> [] end end + # MaintainedID alerts on a repo younger than 90 days are expected and + # self-resolving. `fetch_repo_info/2` is a network call, so it stays behind + # the open-alert check exactly as before. + # Network at the edge, decision in the middle. `fetch_repo_info/2` and + # `has_single_contributor?/2` are the only network calls, and each stays + # behind the cheap open-alert check exactly as it was before. The decision + # functions below are pure, public and therefore testable without a token — + # which is what lets the regression test assert a NON-EMPTY result. The old + # shape could only ever be tested for `[]`, which is precisely why a rule + # that always returned `[]` went unnoticed. + defp maintained_id_advice(by_rule, owner, repo) do + open_maintained = open_alerts(by_rule, "MaintainedID") + + if open_maintained == [] do + [] + else + created_at = fetch_repo_info(owner, repo)["created_at"] + maintained_id_finding(open_maintained, created_at, owner, repo) + end + end + + defp code_review_id_advice(by_rule, owner, repo) do + open_code_review = open_alerts(by_rule, "CodeReviewID") + + if open_code_review == [] do + [] + else + code_review_id_finding(open_code_review, has_single_contributor?(owner, repo), owner, repo) + end + end + + defp open_alerts(by_rule, rule_id) do + by_rule + |> Map.get(rule_id, []) + |> Enum.filter(&(&1["state"] == "open")) + end + + @doc """ + Build CSA006 advice for open MaintainedID alerts on a young repository. + + `created_at` is an ISO 8601 timestamp. Returns one informational finding when + it represents a repository younger than 90 days, or an empty list when the + timestamp is older or invalid. The finding records the MaintainedID check, + counts all supplied alerts and identifies the repository as `owner/repo`. + """ + def maintained_id_finding(open_maintained, created_at, owner, repo) do + if is_repo_less_than_90_days?(created_at) do + [ + %{ + 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: %{ + scorecard_check: "MaintainedID", + alert_count: length(open_maintained), + repo_created_at: created_at, + suggestion: "No action needed. These alerts will disappear after 90 days." + } + } + ] + else + [] + end + end + + @doc """ + Build CSA006 advice for open CodeReviewID alerts when code review is + impractical. + + Returns one medium-severity finding when `single_contributor?` is true, or an + empty list otherwise. The finding records the CodeReviewID check, counts all + supplied alerts and identifies the repository as `owner/repo`. + """ + def code_review_id_finding(open_code_review, single_contributor?, owner, repo) do + if single_contributor? do + [ + %{ + 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: %{ + scorecard_check: "CodeReviewID", + alert_count: length(open_code_review), + suggestion: + "Add more contributors or accept that code review is not feasible for this project." + } + } + ] + else + [] + 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", [ @@ -624,7 +692,9 @@ defmodule Hypatia.Rules.CodeScanningAlerts do {:ok, info} -> info _ -> %{"created_at" => "", "contributors" => []} end - _ -> %{"created_at" => "", "contributors" => []} + + _ -> + %{"created_at" => "", "contributors" => []} end end end @@ -635,19 +705,21 @@ defmodule Hypatia.Rules.CodeScanningAlerts do {:ok, dt, _} -> days = DateTime.diff(DateTime.utc_now(), dt, :day) days < 90 - _ -> false + + _ -> + 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", [ @@ -667,19 +739,24 @@ defmodule Hypatia.Rules.CodeScanningAlerts do case Jason.decode(body) do {:ok, contributors} when is_list(contributors) -> # Filter out bots and check human contributors - 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" + !String.contains?(login, "[bot]") && + !String.ends_with?(login, "-bot") && + type != "Bot" end) + length(human_contributors) <= 1 - _ -> false + + _ -> + false end - _ -> false + + _ -> + false end end end diff --git a/lib/rules/secret_scanner_verification.ex b/lib/rules/secret_scanner_verification.ex deleted file mode 100644 index cd7aea97..00000000 --- a/lib/rules/secret_scanner_verification.ex +++ /dev/null @@ -1,218 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) - -defmodule Hypatia.Rules.SecretScannerVerification do - @moduledoc """ - Verification that the correct secrets scanner version is installed and operational. - - This rule checks that the estate-wide secrets scanner (gitleaks or trufflehog) is - properly installed, configured with current rules, and producing valid output. - It collects evidence that the verification was performed. - - Rule IDs: SSV001-SSV003 - """ - - require Logger - - @evidence_dir ".hypatia-evidence" - @scanner_config ".gitleaks.toml" - @min_rules_version "8.18.0" - - # ─── SSV001: Secrets scanner installation verification ────────────────────── - - @doc """ - SSV001: Verify that a secrets scanner is installed and accessible. - - Checks for the presence of gitleaks or trufflehog in the PATH and - verifies it can execute successfully. - """ - def ssv001_scanner_installed(_owner, _repo) do - # Check for gitleaks - gitleaks_installed = System.cmd("gitleaks", ["version"], stderr_to_stdout: true) |> elem(0) == 0 - - # Check for trufflehog - trufflehog_installed = System.cmd("trufflehog", ["--version"], stderr_to_stdout: true) |> elem(0) == 0 - - if gitleaks_installed || trufflehog_installed do - scanner = if gitleaks_installed, do: "gitleaks", else: "trufflehog" - - # Get version - version_cmd = if gitleaks_installed, do: "version", else: "--version" - {exit, version_output, _} = System.cmd(scanner, [version_cmd], stderr_to_stdout: true) - version = String.trim(version_output) - - # Write evidence - evidence = %{ - rule: "SSV001", - file: ".hypatia-evidence/secrets-scanner-installation.json", - severity: :info, - reason: "Secrets scanner '#{scanner}' is installed and accessible (version: #{version})", - action: :log, - detail: %{ - scanner: scanner, - version: version, - exit_code: exit, - verified_at: DateTime.utc_now() |> to_string() - } - } - - # Ensure evidence directory exists - File.mkdir_p!(@evidence_dir) - File.write!(".hypatia-evidence/secrets-scanner-installation.json", Jason.encode!(evidence)) - - [evidence] - else - [ - %{ - rule: "SSV001", - file: ".hypatia-evidence", - severity: :high, - reason: "No secrets scanner (gitleaks or trufflehog) found in PATH", - action: :escalate, - detail: %{ - checked: ["gitleaks", "trufflehog"], - verified_at: DateTime.utc_now() |> to_string() - } - } - ] - end - end - - # ─── SSV002: Secrets scanner configuration currency ──────────────────────── - - @doc """ - SSV002: Verify that the secrets scanner configuration is current. - - Checks that the scanner configuration file (e.g., .gitleaks.toml) exists and - contains rules that match the minimum required version. - """ - def ssv002_scanner_config_current(_owner, _repo) do - if File.exists?(@scanner_config) do - # Read config file - config_content = File.read!(@scanner_config) - - # Check for version indicator in config - has_version_info = String.contains?(config_content, "version") || - String.contains?(config_content, "gitleaks") - - # Check for common rule patterns - has_rules = String.contains?(config_content, "rules") || - String.contains?(config_content, "regex") - - if has_version_info && has_rules do - evidence = %{ - rule: "SSV002", - file: @scanner_config, - severity: :info, - reason: "Secrets scanner configuration appears current and contains rules", - action: :log, - detail: %{ - config_file: @scanner_config, - has_version_info: has_version_info, - has_rules: has_rules, - verified_at: DateTime.utc_now() |> to_string() - } - } - - File.mkdir_p!(@evidence_dir) - File.write!(".hypatia-evidence/secrets-scanner-config.json", Jason.encode!(evidence)) - - [evidence] - else - [ - %{ - rule: "SSV002", - file: @scanner_config, - severity: :medium, - reason: "Secrets scanner configuration may be outdated or incomplete", - action: :review, - detail: %{ - config_file: @scanner_config, - has_version_info: has_version_info, - has_rules: has_rules - } - } - ] - end - else - [ - %{ - rule: "SSV002", - file: ".hypatia-evidence", - severity: :medium, - reason: "Secrets scanner configuration file not found", - action: :review, - detail: %{ - expected_config: @scanner_config, - verified_at: DateTime.utc_now() |> to_string() - } - } - ] - end - end - - # ─── SSV003: Secrets scanner operational verification ────────────────────── - - @doc """ - SSV003: Verify that the secrets scanner can execute successfully on the repo. - - Runs a test scan on a sample of files to ensure the scanner is operational. - """ - def ssv003_scanner_operational(_owner, _repo) do - # Try to run gitleaks detect on the repo root - {exit, output, _} = System.cmd("gitleaks", ["detect", "--no-git", "--path", ".", "--exit-code", "0"], - stderr_to_stdout: true, cwd: ".") - - # Also try trufflehog if gitleaks fails - if exit != 0 do - {exit, output, _} = System.cmd("trufflehog", ["filesystem", ".", "--no-update"], - stderr_to_stdout: true, cwd: ".") - end - - if exit == 0 do - evidence = %{ - rule: "SSV003", - file: ".hypatia-evidence", - severity: :info, - reason: "Secrets scanner executed successfully on repository", - action: :log, - detail: %{ - exit_code: exit, - output_length: byte_size(output), - verified_at: DateTime.utc_now() |> to_string() - } - } - - File.mkdir_p!(@evidence_dir) - File.write!(".hypatia-evidence/secrets-scanner-operational.json", Jason.encode!(evidence)) - - [evidence] - else - [ - %{ - rule: "SSV003", - file: ".hypatia-evidence", - severity: :warning, - reason: "Secrets scanner execution failed (exit code: #{exit})", - action: :investigate, - detail: %{ - exit_code: exit, - output: String.slice(output, 0, 500), # Truncate for safety - verified_at: DateTime.utc_now() |> to_string() - } - } - ] - end - end - - # ─── Aggregate verification ──────────────────────────────────────────────── - - @doc """ - Run all secrets scanner verification checks. - """ - def verify_secrets_scanner(owner, repo) do - ssv001_scanner_installed(owner, repo) ++ - ssv002_scanner_config_current(owner, repo) ++ - ssv003_scanner_operational(owner, repo) - end -end diff --git a/test/code_scanning_alerts_test.exs b/test/code_scanning_alerts_test.exs index e191a841..f0688a2c 100644 --- a/test/code_scanning_alerts_test.exs +++ b/test/code_scanning_alerts_test.exs @@ -138,4 +138,113 @@ defmodule Hypatia.Rules.CodeScanningAlertsTest do refute CodeScanningAlerts.self_referential_alert?(%{"rule" => %{"id" => "x"}}) end end + + # ── CSA006 regression ────────────────────────────────────────────────── + # + # Until 2026-09-14 `csa006_scorecard_config_advice/2` ALWAYS returned []. + # Both arms built their finding with `findings = [f | findings]` inside an + # `if`, and Elixir's `if` does not leak bindings, so every assignment was + # dead. It compiled clean and emitted only an "unused variable" warning. + # + # These tests assert the arms produce a NON-EMPTY list. A test that only + # asserted `== []` would have passed against the broken version — which is + # exactly how the defect survived. The decision functions are pure so the + # positive arm needs no GITHUB_TOKEN. + describe "csa006 decision functions (the always-empty regression)" do + @a_maintained [%{"state" => "open"}, %{"state" => "open"}] + @a_code_review [%{"state" => "open"}] + + defp iso_days_ago(n), + do: DateTime.utc_now() |> DateTime.add(-n * 86_400, :second) |> DateTime.to_iso8601() + + test "maintained_id_finding FIRES for a repo younger than 90 days" do + assert [finding] = + CodeScanningAlerts.maintained_id_finding( + @a_maintained, + iso_days_ago(10), + "hyperpolymath", + "brand-new" + ) + + assert finding.rule == "CSA006" + assert finding.severity == :info + assert finding.action == :inform + assert finding.file == "hyperpolymath/brand-new" + assert finding.detail.scorecard_check == "MaintainedID" + assert finding.detail.alert_count == 2 + end + + test "maintained_id_finding is silent for a repo older than 90 days" do + assert CodeScanningAlerts.maintained_id_finding( + @a_maintained, + iso_days_ago(400), + "hyperpolymath", + "old" + ) == [] + end + + test "maintained_id_finding is silent on an unparseable created_at" do + assert CodeScanningAlerts.maintained_id_finding( + @a_maintained, + "not-a-date", + "hyperpolymath", + "weird" + ) == [] + end + + test "code_review_id_finding FIRES for a single-contributor repo" do + assert [finding] = + CodeScanningAlerts.code_review_id_finding( + @a_code_review, + true, + "hyperpolymath", + "solo" + ) + + assert finding.rule == "CSA006" + assert finding.severity == :medium + assert finding.action == :configure + assert finding.detail.scorecard_check == "CodeReviewID" + assert finding.detail.alert_count == 1 + end + + test "code_review_id_finding is silent when the repo has several contributors" do + assert CodeScanningAlerts.code_review_id_finding( + @a_code_review, + false, + "hyperpolymath", + "team" + ) == [] + end + end + + describe "deduplicate_findings/1" do + test "keeps CSA006 advice for distinct Scorecard checks without alert numbers" do + maintained = %{ + rule: "CSA006", + file: "hyperpolymath/example", + detail: %{scorecard_check: "MaintainedID"} + } + + code_review = %{ + rule: "CSA006", + file: "hyperpolymath/example", + detail: %{scorecard_check: "CodeReviewID"} + } + + assert CodeScanningAlerts.deduplicate_findings([maintained, code_review]) == [ + maintained, + code_review + ] + end + end + + describe "csa006_scorecard_config_advice/2" do + test "returns empty list when GITHUB_TOKEN is not set" do + assert CodeScanningAlerts.csa006_scorecard_config_advice( + "hyperpolymath", + "test-nonexistent" + ) == [] + end + end end