diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index c3277c30..3ef7458f 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -1,499 +1,247 @@ [ { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", - "type": "believe_me", - "file": "src/abi/RuleEngine.idr", + "type": "expect_in_hot_path", + "file": "fixer/src/scanner.rs", "action": "flag" }, { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", - "type": "elixir_system_cmd_interpolation", - "file": "lib/direct_github_pr.ex", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "elixir_system_cmd_interpolation", - "file": "lib/hypatia/cli.ex", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "elixir_system_cmd_interpolation", - "file": "lib/mix/tasks/hypatia.audit_repos.ex", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "elixir_system_cmd_interpolation", - "file": "lib/mix/tasks/hypatia.deploy_prevention_workflows.ex", + "type": "expect_in_hot_path", + "file": "integration/src/ci_simulation/assertions.rs", "action": "flag" }, { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", - "type": "elixir_system_cmd_interpolation", - "file": "lib/rules/structural_drift.ex", + "type": "expect_in_hot_path", + "file": "integration/src/ci_simulation/scenarios.rs", "action": "flag" }, { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "adapters/src/sourcehut.rs", + "type": "ncl_docker_not_podman", + "file": ".machine_readable/svc/k9/hypatia-metadata.k9.ncl", "action": "flag" }, { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_dangerous_default", "file": "cli/src/commands/batch.rs", "action": "flag" }, { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_dangerous_default", "file": "cli/src/commands/scan.rs", "action": "flag" }, { - "severity": "critical", - "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "clients/rust/hypatia-client/src/ffi.rs", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "data/src/cache.rs", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "data/src/dragonfly.rs", - "action": "flag" - }, - { - "severity": "critical", - "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "integration/src/ci_simulation/scenarios.rs", - "action": "flag" - }, - { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_dangerous_default", - "file": "integration/tests/fleet_test.rs", + "file": "scripts/ci-tools/src/bin/check-k9iser-paths.rs", "action": "flag" }, { - "severity": "critical", - "rule_module": "code_safety", - "type": "unwrap_dangerous_default", - "file": "integration/tests/forge_test.rs", - "action": "flag" - }, - { - "severity": "critical", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_dangerous_default", "file": "tools/cii-registrar/src/main.rs", "action": "flag" }, { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": ".audittraining/security-errors/echidnabot.md", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": ".audittraining/security-errors/echidnabot.md", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": ".github/workflows/integration.yml", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": ".hypatia-exemptions.md", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "adapters/src/codeberg.rs", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "adapters/tests/adapter_tests.rs", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "hooks/lib/cache.sh", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "integration/run-tests.sh", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "lib/rules/security_errors.ex", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "scripts/fix-scripts/fix-hardcoded-secrets.sh", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "scripts/fix-scripts/fix-hardcoded-secrets.sh", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "security_errors", - "type": "secret_detected", - "file": "test/code_safety_test.exs", - "action": "revoke_rotate_and_purge" - }, - { - "severity": "critical", - "rule_module": "structural_drift", - "type": "SD008", - "file": "src/abi/RuleEngine.idr", - "action": "fix_proof" - }, - { - "severity": "critical", - "rule_module": "workflow_audit", - "type": "actions_expression_injection", - "file": "mirror.yml", - "action": "sanitize_context" - }, - { - "severity": "critical", - "rule_module": "workflow_audit", - "type": "actions_expression_injection", - "file": "quality.yml", - "action": "sanitize_context" - }, - { - "severity": "high", - "rule_module": "cicd_rules", - "type": "missing_requirement", - "file": ".github/dependabot.yml", - "action": "create" - }, - { - "severity": "high", - "rule_module": "cicd_rules", - "type": "missing_requirement", - "file": ".github/workflows/scorecard.yml", - "action": "create" - }, - { - "severity": "high", - "rule_module": "cicd_rules", - "type": "missing_requirement", - "file": "permissions: read-all", - "action": "create" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "elixir_send_unsanitised", - "file": "lib/rules/security_errors.ex", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "from_raw", - "file": "clients/rust/hypatia-client/src/ffi.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "lock_unwrap", - "file": "cli/src/app_state.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "ncl_http_url", - "file": "fleet-config.k9.ncl", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "panic_macro", - "file": "integration/src/ci_simulation/assertions.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "shell_download_then_run", - "file": "scripts/fix-scripts/fix-heredoc-install.sh", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "adapters/src/bitbucket.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "adapters/src/codeberg.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "adapters/src/github.rs", - "action": "flag" - }, - { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "adapters/src/gitlab.rs", - "action": "flag" - }, - { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "adapters/src/radicle.rs", + "file": "cli/build.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "adapters/src/sourcehut.rs", + "file": "cli/src/commands/batch.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "adapters/tests/adapter_tests.rs", + "file": "cli/src/commands/fleet.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "cli/build.rs", + "file": "cli/src/commands/scan.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "cli/src/app_state.rs", + "file": "cli/src/output.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "cli/src/commands/batch.rs", + "file": "fixer/src/main.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "cli/src/commands/fleet.rs", + "file": "integration/src/ci_simulation/scenarios.rs", "action": "flag" }, { - "severity": "high", + "severity": "low", "rule_module": "code_safety", "type": "unwrap_without_check", - "file": "cli/src/commands/scan.rs", + "file": "integration/src/lib.rs", "action": "flag" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "cli/src/config.rs", - "action": "flag" + "severity": "low", + "rule_module": "git_state", + "type": "GS006", + "file": ".", + "action": "review" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "cli/src/output.rs", + "severity": "low", + "rule_module": "honest_completion", + "type": "no_state_file", + "file": "/home/user/hypatia", "action": "flag" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "clients/rust/hypatia-client/src/ffi.rs", - "action": "flag" + "severity": "low", + "rule_module": "root_hygiene", + "type": "stale", + "file": "DESIGN-NARRATIVE.md", + "action": "move" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "data/src/cache.rs", - "action": "flag" + "severity": "low", + "rule_module": "structural_drift", + "type": "SD013", + "file": ".gitignore", + "action": "globalise_gitignore_pattern" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "data/src/dragonfly.rs", - "action": "flag" + "severity": "low", + "rule_module": "structural_drift", + "type": "SD013", + "file": ".gitignore", + "action": "globalise_gitignore_pattern" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "data/src/verisim.rs", - "action": "flag" + "severity": "low", + "rule_module": "structural_drift", + "type": "SD013", + "file": ".gitignore", + "action": "globalise_gitignore_pattern" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "fixer/src/main.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "guix-nix-policy.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/src/ci_simulation/mod.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "instant-sync.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/src/ci_simulation/scenarios.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "jekyll-gh-pages.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/src/lib.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "jekyll.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/arangodb_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "npm-bun-blocker.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/ci_simulation_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "rsr-antipattern.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/fleet_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "scorecard-enforcer.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/forge_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "ts-blocker.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/hooks_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "wellknown-enforcement.yml", + "action": "create" }, { - "severity": "high", - "rule_module": "code_safety", - "type": "unwrap_without_check", - "file": "integration/tests/registry_test.rs", - "action": "flag" + "severity": "low", + "rule_module": "workflow_audit", + "type": "missing_workflow", + "file": "workflow-linter.yml", + "action": "create" }, { - "severity": "high", + "severity": "medium", "rule_module": "git_state", - "type": "GS005", + "type": "GS001", "file": ".", - "action": "flag" + "action": "commit" }, { - "severity": "high", - "rule_module": "workflow_audit", - "type": "download_then_run", - "file": "docs.yml", - "action": "verify_download_integrity" + "severity": "medium", + "rule_module": "git_state", + "type": "GS007", + "file": ".", + "action": "delete_remote_branches" }, { - "severity": "high", - "rule_module": "workflow_audit", - "type": "unsafe_curl_payload", - "file": "hypatia-scan.yml", - "action": "use_jq_payload" + "severity": "medium", + "rule_module": "structural_drift", + "type": "SD009", + "file": "ffi/zig/src/main.zig", + "action": "add_spdx_header" } ] diff --git a/lib/recipe_matcher.ex b/lib/recipe_matcher.ex index 21c1d618..748fe50e 100644 --- a/lib/recipe_matcher.ex +++ b/lib/recipe_matcher.ex @@ -32,8 +32,7 @@ defmodule Hypatia.RecipeMatcher do def best_recipe(pattern_id, language) do find_recipes(pattern_id) |> Enum.find(fn recipe -> - langs = Map.get(recipe, "languages", []) - "*" in langs or language in langs + langs_match?(Map.get(recipe, "languages", []), language) end) end @@ -140,12 +139,12 @@ defmodule Hypatia.RecipeMatcher do # Match recipe by target_categories field -- most reliable match defp category_match_recipe(pattern, language) do category = Map.get(pattern, "category", "") + effective_language = effective_language_for(pattern, language) all_recipes() |> Enum.filter(fn recipe -> cats = Map.get(recipe, "target_categories", []) - langs = Map.get(recipe, "languages", []) - lang_ok = "*" in langs or language in langs + lang_ok = langs_match?(Map.get(recipe, "languages", []), effective_language) lang_ok and category in cats end) |> Enum.sort_by(fn r -> Map.get(r, "confidence", 0) end, :desc) @@ -155,6 +154,7 @@ defmodule Hypatia.RecipeMatcher do defp fuzzy_match_recipe(pattern, language) do pa_rule = Map.get(pattern, "pa_rule", "") description = Map.get(pattern, "description", "") |> String.downcase() + effective_language = effective_language_for(pattern, language) # Skip if no PA rule to match against if pa_rule == "" do @@ -162,8 +162,7 @@ defmodule Hypatia.RecipeMatcher do else all_recipes() |> Enum.filter(fn recipe -> - langs = Map.get(recipe, "languages", []) - lang_ok = "*" in langs or language in langs + lang_ok = langs_match?(Map.get(recipe, "languages", []), effective_language) recipe_pattern_ids = Map.get(recipe, "pattern_ids", []) @@ -196,6 +195,37 @@ defmodule Hypatia.RecipeMatcher do end end + # Both "*" and "any" are language-agnostic sentinels. Historical recipes + # use one or the other; treating them as synonyms keeps both groups + # routable (without this, ~12 recipes declared "any" matched no patterns). + defp langs_match?(langs, language) do + "*" in langs or "any" in langs or language in langs + end + + # Scorecard / workflow-file findings are about .github/workflows/*.yml, + # not the repo's primary language. Without this remap, recipes declared + # `languages: ["yaml"]` (pin-deps, token-permissions, etc.) never match + # because no repo has yaml as its primary language, and every scorecard + # finding falls through to :control "no safe fix available". + defp effective_language_for(pattern, language) do + cond do + Map.get(pattern, "source") == "scorecard" -> "yaml" + workflow_file_category?(Map.get(pattern, "category", "")) -> "yaml" + true -> language + end + end + + defp workflow_file_category?(category) do + category in [ + "DependencyPinning", + "PinnedDependencies", + "TokenPermissions", + "DangerousWorkflow", + "DependencyUpdateTool", + "BranchProtection" + ] + end + defp load_recipe(path) do with {:ok, content} <- File.read(path), {:ok, data} <- Jason.decode(content) do diff --git a/test/recipe_matcher_test.exs b/test/recipe_matcher_test.exs index c2ead171..5eae2cb5 100644 --- a/test/recipe_matcher_test.exs +++ b/test/recipe_matcher_test.exs @@ -93,4 +93,52 @@ defmodule Hypatia.RecipeMatcherTest do assert RecipeMatcher.substitution_for_category("FakeCategory") == nil end end + + describe "best_recipe_for_pattern/2 — language matching" do + test "'any' sentinel matches any repo language" do + # recipe-scorecard-license declares languages: ["any"] + pattern = %{ + "id" => "SC-010-some-repo", + "category" => "License", + "pa_rule" => "SC010", + "source" => "scorecard" + } + + recipe = RecipeMatcher.best_recipe_for_pattern(pattern, "rust") + assert recipe != nil + assert recipe["id"] in ["recipe-scorecard-license", "recipe-add-license-file"] + assert recipe["fix_script"] not in [nil, ""] + end + + test "scorecard DependencyPinning pattern resolves yaml recipe regardless of repo language" do + # Reproduces the production gap: SC013 findings across 230+ repos + # routed to :control "no safe fix available" because recipe-pin-deps + # declares languages: ["yaml"] and no repo has yaml as primary lang. + pattern = %{ + "id" => "SC-013-007-lang", + "category" => "DependencyPinning", + "pa_rule" => "SC013", + "source" => "scorecard", + "description" => "1 workflow(s) with tag-pinned actions" + } + + recipe = RecipeMatcher.best_recipe_for_pattern(pattern, "elixir") + assert recipe != nil, "scorecard pattern must route to a recipe, not :control" + assert recipe["fix_script"] not in [nil, ""] + assert recipe["triangle_tier"] in ["eliminate", "substitute"] + end + + test "scorecard TokenPermissions pattern resolves yaml recipe" do + pattern = %{ + "id" => "SC-018-some-repo", + "category" => "TokenPermissions", + "pa_rule" => "SC018", + "source" => "scorecard" + } + + recipe = RecipeMatcher.best_recipe_for_pattern(pattern, "go") + assert recipe != nil + assert recipe["fix_script"] == "fix-workflow-permissions.sh" + end + end end