From 6dc51b931a74d4ca22a5f6b440573b009b04f857 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:51:37 +0100 Subject: [PATCH 1/4] fix(scan): apply contextual shell suppression --- lib/hypatia/scanner_suppression.ex | 6 ++++-- lib/rules/code_safety.ex | 10 ++++++++++ mix.lock | 2 +- test/code_safety_test.exs | 16 ++++++++++++++++ test/scanner_suppression_test.exs | 5 +++++ 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index bc3ab7ac..830df11d 100644 --- a/lib/hypatia/scanner_suppression.ex +++ b/lib/hypatia/scanner_suppression.ex @@ -233,10 +233,12 @@ defmodule Hypatia.ScannerSuppression do # 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 + 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) + String.starts_with?(stripped_line, "#") or + (Regex.match?(download_then_run_re(), line) and + not Regex.match?(download_then_run_re(), stripped)) end def context_safe_line?(_rule_type, _line), do: false diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 9f1cd467..5664f6c0 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -797,6 +797,16 @@ 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 = + subject + |> String.split("\n") + |> Enum.reject(&Hypatia.ScannerSuppression.context_safe_line?(to_string(rule.id), &1)) + |> Enum.join("\n") + case Regex.scan(rule.pattern, subject) do [] -> [] diff --git a/mix.lock b/mix.lock index 23d1ec7a..fbc2e200 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "bandit": {:hex, :bandit, "1.12.4", "10bbab488edf8162318d736c19c5837077b8fca2bf5d95b07b33830387124f62", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "84513318c5752a2a8017664450f889b47fae5d53d64698ddf1e4fb09a7449e8d"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 2b81286b..660c9481 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -163,6 +163,22 @@ 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 end describe "doc-comment stripping (FP suppression)" do diff --git a/test/scanner_suppression_test.exs b/test/scanner_suppression_test.exs index e848320c..83e6f98e 100644 --- a/test/scanner_suppression_test.exs +++ b/test/scanner_suppression_test.exs @@ -289,6 +289,11 @@ 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) + 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)) From 68fe8fe429fdd4811b588d2b0533ce447cdaf15a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:22:35 +0100 Subject: [PATCH 2/4] fix(scan): preserve semantics in contextual suppression Apply shell line filtering only to the context-sensitive rule, keep all other regex subjects byte-for-byte intact, and retain first-line shebang detection. Add both safe-comment and executable-shebang controls. --- lib/hypatia/scanner_suppression.ex | 10 ++++++---- lib/rules/code_safety.ex | 19 +++++++++++++++---- test/code_safety_test.exs | 6 ++++++ test/scanner_suppression_test.exs | 7 ++++++- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index 830df11d..1d79ecf3 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,16 +234,16 @@ 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) - String.starts_with?(stripped_line, "#") or + (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)) 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 diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 5664f6c0..5019ddaa 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -802,10 +802,21 @@ defmodule Hypatia.Rules.CodeSafety do # used by the CLI before aggregating occurrences; otherwise a file-level # finding survives even though every matching line is known-safe. subject = - subject - |> String.split("\n") - |> Enum.reject(&Hypatia.ScannerSuppression.context_safe_line?(to_string(rule.id), &1)) - |> Enum.join("\n") + 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 [] -> diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 660c9481..8e69fb98 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -179,6 +179,12 @@ defmodule Hypatia.Rules.CodeSafetyTest do 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 83e6f98e..8130472d 100644 --- a/test/scanner_suppression_test.exs +++ b/test/scanner_suppression_test.exs @@ -291,7 +291,12 @@ defmodule Hypatia.ScannerSuppressionTest do 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) + 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. From 8936d57eb75a535fa7bada3ce5feaad864602680 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:28:25 +0000 Subject: [PATCH 3/4] Fix CodeRabbit issues in PR #737 --- lib/hypatia/scanner_suppression.ex | 14 +++++++++++++- test/code_safety_test.exs | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index 1d79ecf3..25153068 100644 --- a/lib/hypatia/scanner_suppression.ex +++ b/lib/hypatia/scanner_suppression.ex @@ -240,7 +240,8 @@ defmodule Hypatia.ScannerSuppression do (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)) + not Regex.match?(download_then_run_re(), stripped) and + not executable_shell_evaluation?(stripped)) end def context_safe_line?(_rule_type, _line, _line_number), do: false @@ -288,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/test/code_safety_test.exs b/test/code_safety_test.exs index 8e69fb98..35e0755b 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -180,6 +180,18 @@ defmodule Hypatia.Rules.CodeSafetyTest do 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") From ae7fd12b3ee8f276a6484371f3b56e1ed86fa79f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:50:55 +0000 Subject: [PATCH 4/4] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 failed pre-merge check. Co-authored-by: CodeRabbit --- lib/paths.ex | 23 +++++++++++++++ lib/rules/code_safety.ex | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) 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 5019ddaa..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) @@ -1157,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 # --------------------------------------------------------------------------- @@ -1193,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" @@ -1260,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 @@ -1291,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" @@ -1314,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