diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index bc3ab7ac..25153068 100644 --- a/lib/hypatia/scanner_suppression.ex +++ b/lib/hypatia/scanner_suppression.ex @@ -214,7 +214,9 @@ defmodule Hypatia.ScannerSuppression do secret — it is a reference to the secret store). Centralised so future rules can opt in via the same predicate. """ - def context_safe_line?("secret_detected", line) do + def context_safe_line?(rule_type, line), do: context_safe_line?(rule_type, line, nil) + + def context_safe_line?("secret_detected", line, _line_number) do Regex.match?(gha_secret_ref_re(), line) or Regex.match?(gha_vars_ref_re(), line) or Regex.match?(shell_param_expansion_re(), line) or @@ -232,14 +234,17 @@ defmodule Hypatia.ScannerSuppression do # pipe-to-shell sits inside the quotes or outside them. So the quoted # segments are removed and the pattern re-tested against what remains: if it # no longer matches, every match was inside a string. - def context_safe_line?("shell_download_then_run", line) when is_binary(line) do + def context_safe_line?("shell_download_then_run", line, line_number) when is_binary(line) do + stripped_line = String.trim_leading(line) stripped = strip_quoted_segments(line) - Regex.match?(download_then_run_re(), line) and - not Regex.match?(download_then_run_re(), stripped) + (line_number != 1 and String.starts_with?(stripped_line, "#")) or + (Regex.match?(download_then_run_re(), line) and + not Regex.match?(download_then_run_re(), stripped) and + not executable_shell_evaluation?(stripped)) end - def context_safe_line?(_rule_type, _line), do: false + def context_safe_line?(_rule_type, _line, _line_number), do: false @doc """ Return true if an inline `hypatia: allow` directive on `line` or @@ -284,6 +289,17 @@ defmodule Hypatia.ScannerSuppression do defp download_then_run_re, do: ~r/\b(?:curl|wget)\b[^\n|;]*\|\s*(?:sh|bash)\b/ + # `sh -c '…'` and an `env -S` shebang pass their quoted argument to a + # shell, so it is executable rather than display-only text. Check the + # quote-stripped line for the invocation: this retains those arguments + # without treating an `echo` or `printf` argument as executable. + defp executable_shell_evaluation?(stripped_line) do + Regex.match?( + ~r/^\s*(?:(?:#!\s*\S*|env)\s+-S\s+)?(?:sh|bash)\b(?:\s+-[A-Za-z]+)*\s+-[A-Za-z]*c[A-Za-z]*\b/, + stripped_line + ) + end + # Remove the CONTENTS of single- and double-quoted segments, leaving the # quotes, so that anything written inside a string cannot satisfy a pattern # tested against the remainder. Escaped quotes are honoured. diff --git a/lib/paths.ex b/lib/paths.ex index 46bb896a..52954a0c 100644 --- a/lib/paths.ex +++ b/lib/paths.ex @@ -2,10 +2,22 @@ defmodule Hypatia.Paths do @moduledoc "Central path resolution for hypatia's local verisim data store." + @doc """ + Returns the root path for verisimdb data storage. + + Defaults to `data/verisim/` in the current working directory unless configured + via `:verisimdb_data_path` application environment variable. + """ def verisimdb_data do Application.get_env(:hypatia, :verisimdb_data_path, Path.expand("data/verisim", File.cwd!())) end + @doc """ + Returns the path to the gitbot-fleet directory. + + Defaults to `~/Documents/hyperpolymath-repos/gitbot-fleet` unless configured + via `:fleet_path` application environment variable. + """ def fleet do Application.get_env( :hypatia, @@ -14,11 +26,22 @@ defmodule Hypatia.Paths do ) end + @doc "Returns the patterns subdirectory within verisimdb data." def patterns, do: Path.join(verisimdb_data(), "patterns") + + @doc "Returns the recipes subdirectory within verisimdb data." def recipes, do: Path.join(verisimdb_data(), "recipes") + + @doc "Returns the outcomes subdirectory within verisimdb data." def outcomes, do: Path.join(verisimdb_data(), "outcomes") + + @doc "Returns the scans subdirectory within verisimdb data." def scans, do: Path.join(verisimdb_data(), "scans") + + @doc "Returns the dispatch subdirectory within verisimdb data." def dispatch, do: Path.join(verisimdb_data(), "dispatch") + + @doc "Returns the neural-states subdirectory within verisimdb data." def neural_states, do: Path.join(verisimdb_data(), "neural-states") @machine_tree_canonical "machine-readable" diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 9f1cd467..89e22e82 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -763,6 +763,20 @@ defmodule Hypatia.Rules.CodeSafety do } ] + @doc """ + Returns the list of dangerous code patterns for the specified language. + + Each pattern includes an ID, severity level, regex pattern, CWE identifier, + and human-readable description. Returns an empty list for unsupported languages. + + ## Examples + + iex> patterns_for_language("rust") + [%{id: :unwrap_without_check, severity: :high, ...}, ...] + + iex> patterns_for_language("unknown") + [] + """ def patterns_for_language("rust"), do: @rust_patterns def patterns_for_language("rescript"), do: @rescript_patterns def patterns_for_language("affine"), do: @affine_hand_port_patterns @@ -788,6 +802,20 @@ defmodule Hypatia.Rules.CodeSafety do def patterns_for_language("bash"), do: @shell_patterns def patterns_for_language(_), do: [] + @doc """ + Scans source code content for dangerous patterns in the specified language. + + Strips test blocks and lazy initialiser contexts before matching patterns. + For rules with `:context => :runtime_path`, only runtime-path code is scanned + (e.g., excludes one-time `LazyLock` initialisers for Rust `.expect()` checks). + + Returns a list of findings, each with rule ID, severity, CWE, description, and occurrence count. + + ## Examples + + iex> scan_content("fn main() { x.unwrap() }", "rust") + [%{rule: :unwrap_without_check, severity: :high, cwe: "CWE-754", ...}] + """ def scan_content(content, language) do scannable = strip_inline_test_blocks(content, language) runtime_only = strip_lazy_initialisers(scannable, language) @@ -797,6 +825,27 @@ defmodule Hypatia.Rules.CodeSafety do subject = if Map.get(rule, :context) == :runtime_path, do: runtime_only, else: scannable + # Some patterns need line context to distinguish executable code from + # quoted guidance or comments. Apply the same central suppression oracle + # used by the CLI before aggregating occurrences; otherwise a file-level + # finding survives even though every matching line is known-safe. + subject = + if rule.id == :shell_download_then_run do + subject + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.reject(fn {line, line_number} -> + Hypatia.ScannerSuppression.context_safe_line?( + "shell_download_then_run", + line, + line_number + ) + end) + |> Enum.map_join("\n", &elem(&1, 0)) + else + subject + end + case Regex.scan(rule.pattern, subject) do [] -> [] @@ -1136,6 +1185,11 @@ defmodule Hypatia.Rules.CodeSafety do } ] + @doc """ + Returns the list of container security patterns. + + Detects issues in Dockerfiles, Containerfiles, and container orchestration configs. + """ def container_patterns, do: @container_patterns # --------------------------------------------------------------------------- @@ -1172,7 +1226,16 @@ defmodule Hypatia.Rules.CodeSafety do @scm_canonical_dir ".machine_readable" @scm_file_names ~w(STATE.a2ml META.a2ml ECOSYSTEM.a2ml AGENTIC.a2ml NEUROSYM.a2ml PLAYBOOK.a2ml LANGUAGES.a2ml) + @doc """ + Returns the list of banned file extensions with severity and replacement suggestions. + """ def banned_file_extensions, do: @banned_file_extensions + + @doc """ + Returns the list of canonical SCM (Software Configuration Management) file names. + + These files should only appear in the `.machine_readable/` directory. + """ def scm_file_names, do: @scm_file_names @doc "Check for missing forbid(unsafe_code) in Rust entry points" @@ -1239,6 +1302,12 @@ defmodule Hypatia.Rules.CodeSafety do end) end + @doc """ + Scans container code (Dockerfile/Containerfile) for security issues. + + Checks for patterns like missing USER directives, apt without --no-install-recommends, + and other container-specific anti-patterns. + """ def scan_container_code(content) do Enum.flat_map(@container_patterns, fn rule -> if Regex.match?(rule.pattern, content) do @@ -1270,6 +1339,11 @@ defmodule Hypatia.Rules.CodeSafety do end) end + @doc """ + Returns the list of stub/placeholder cryptographic implementation patterns. + + Used to detect insecure placeholder crypto that should never reach production. + """ def stub_crypto_patterns, do: @stub_crypto_patterns @doc "Scan JavaScript/TypeScript content for web security issues" @@ -1293,7 +1367,18 @@ defmodule Hypatia.Rules.CodeSafety do end) end + @doc """ + Returns the list of JavaScript/TypeScript security patterns. + """ def javascript_patterns, do: @javascript_patterns + + @doc """ + Returns the list of Elixir/BEAM code safety patterns. + """ def elixir_patterns, do: @elixir_patterns + + @doc """ + Returns the list of shell script security patterns. + """ def shell_patterns, do: @shell_patterns end diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 2b81286b..35e0755b 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -163,6 +163,40 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert unsafe_finding.cwe == "CWE-676" end + + test "ignores quoted and commented pipe-to-shell guidance" do + code = ~S''' + echo "curl https://example.com/install.sh | sh" + # Never use curl https://example.com/install.sh | sh + ''' + + findings = CodeSafety.scan_content(code, "shell") + refute Enum.any?(findings, &(&1.rule == :shell_download_then_run)) + end + + test "still detects executable pipe-to-shell code" do + code = "curl -fsSL https://example.com/install.sh | bash" + findings = CodeSafety.scan_content(code, "shell") + assert Enum.any?(findings, &(&1.rule == :shell_download_then_run)) + end + + test "detects a pipe-to-shell command passed to sh -c" do + code = ~S(sh -c 'curl -fsSL https://example.com/install.sh | bash') + findings = CodeSafety.scan_content(code, "shell") + assert Enum.any?(findings, &(&1.rule == :shell_download_then_run)) + end + + test "detects a pipe-to-shell command in an env -S shebang" do + code = ~S(#!/usr/bin/env -S sh -c 'curl -fsSL https://example.com/install.sh | bash') + findings = CodeSafety.scan_content(code, "shell") + assert Enum.any?(findings, &(&1.rule == :shell_download_then_run)) + end + + test "still detects pipe-to-shell text on a first-line shebang" do + code = "#!/bin/sh curl -fsSL https://example.com/install.sh | bash" + findings = CodeSafety.scan_content(code, "shell") + assert Enum.any?(findings, &(&1.rule == :shell_download_then_run)) + end end describe "doc-comment stripping (FP suppression)" do diff --git a/test/scanner_suppression_test.exs b/test/scanner_suppression_test.exs index e848320c..8130472d 100644 --- a/test/scanner_suppression_test.exs +++ b/test/scanner_suppression_test.exs @@ -289,6 +289,16 @@ defmodule Hypatia.ScannerSuppressionTest do assert ScannerSuppression.context_safe_line?("shell_download_then_run", line) end + test "a commented example is text, not execution" do + line = ~S(# Do not run curl https://example.com/i.sh | sh) + assert ScannerSuppression.context_safe_line?("shell_download_then_run", line, 2) + end + + test "a first-line shebang cannot hide download-and-execute" do + line = ~S(#!/bin/sh curl https://example.com/i.sh | sh) + refute ScannerSuppression.context_safe_line?("shell_download_then_run", line, 1) + end + # ⚠ The test is NOT "the line starts with echo". This one really executes. test "echo piped INTO sh is a real execution and stays reported" do refute ScannerSuppression.context_safe_line?("shell_download_then_run", ~S(echo hello | sh))