diff --git a/.github/config/tool-manifest.json b/.github/config/tool-manifest.json index e0ad5a4..d81d09e 100644 --- a/.github/config/tool-manifest.json +++ b/.github/config/tool-manifest.json @@ -5,6 +5,11 @@ "env": "SEMGREP_ENABLED", "output": "semgrep.sarif" }, + { + "id": "opengrep", + "env": "OPENGREP_ENABLED", + "output": "opengrep.sarif" + }, { "id": "eslint", "env": "ESLINT_ENABLED", @@ -20,6 +25,11 @@ "env": "RUFF_ENABLED", "output": "ruff.sarif" }, + { + "id": "brakeman", + "env": "BRAKEMAN_ENABLED", + "output": "brakeman.sarif" + }, { "id": "pylint", "env": "PYLINT_ENABLED", diff --git a/.github/scripts/brakeman_sarif_paths.py b/.github/scripts/brakeman_sarif_paths.py new file mode 100644 index 0000000..4e37dfb --- /dev/null +++ b/.github/scripts/brakeman_sarif_paths.py @@ -0,0 +1,81 @@ +import argparse +import json +import sys +from urllib.parse import urlparse + +from sarif_converter_common import load_json_file, normalize_path, write_json_file + + +def _is_external_uri(uri): + parsed = urlparse(uri) + return bool(parsed.scheme and parsed.scheme.lower() != "file") + + +def _strip_file_uri(uri): + parsed = urlparse(uri) + if parsed.scheme.lower() != "file": + return uri + return parsed.path or "" + + +def _prefixed_path(uri, root, base_dir): + if not isinstance(uri, str) or not uri or _is_external_uri(uri): + return uri + normalized = normalize_path(_strip_file_uri(uri), base_dir=base_dir) + if root == "." or normalized == root or normalized.startswith(f"{root}/"): + return normalized + return f"{root}/{normalized}" + + +def _normalize_artifact_locations(value, root, base_dir): + if isinstance(value, list): + for item in value: + _normalize_artifact_locations(item, root, base_dir) + return + if not isinstance(value, dict): + return + if isinstance(value.get("uri"), str): + value["uri"] = _prefixed_path(value["uri"], root, base_dir) + for child in value.values(): + _normalize_artifact_locations(child, root, base_dir) + + +def normalize_brakeman_sarif_paths(document, root, base_dir="."): + if not isinstance(document, dict): + raise ValueError("invalid Brakeman SARIF input") + if document.get("version") != "2.1.0": + document["version"] = "2.1.0" + runs = document.get("runs") + if not isinstance(runs, list): + document["runs"] = [] + return document + root = normalize_path(root or ".", base_dir=base_dir) + for run in runs: + if not isinstance(run, dict): + continue + driver = run.setdefault("tool", {}).setdefault("driver", {}) + if root != ".": + driver["name"] = f"Brakeman ({root})" + _normalize_artifact_locations(run.get("results"), root, base_dir) + return document + + +def _main(argv): + parser = argparse.ArgumentParser(description="Normalize Brakeman SARIF paths for nested Rails roots.") + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--root", required=True) + parser.add_argument("--base-dir", default=".") + args = parser.parse_args(argv) + + document = load_json_file(args.input) + if not isinstance(document, dict): + print("invalid Brakeman SARIF input", file=sys.stderr) + return 1 + normalized = normalize_brakeman_sarif_paths(document, root=args.root, base_dir=args.base_dir) + write_json_file(args.output, normalized) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/.github/scripts/run_brakeman.sh b/.github/scripts/run_brakeman.sh new file mode 100644 index 0000000..480b446 --- /dev/null +++ b/.github/scripts/run_brakeman.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BRAKEMAN_GEM_SHA256:?}" +: "${BRAKEMAN_VERSION:?}" +: "${RACC_GEM_SHA256:?}" +: "${RACC_VERSION:?}" +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +raw="$SARIF_DIR/brakeman.raw.sarif" +out="$SARIF_DIR/brakeman.sarif" +roots_list="" +brakeman_config="$RUNNER_TEMP/brakeman-sigilix.yml" +brakeman_ignore="$RUNNER_TEMP/brakeman-ignore.json" +brakeman_gem_cache="$RUNNER_TEMP/brakeman-gem-cache" +brakeman_raw_dir="$RUNNER_TEMP/brakeman-raw" +raw_files=() + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" "$brakeman_gem_cache" "$brakeman_raw_dir" +roots_list="$(mktemp "$RUNNER_TEMP/brakeman-roots.XXXXXX")" + +cleanup_brakeman() { + rm -f "$roots_list" "$brakeman_config" "$brakeman_ignore" + rm -rf "$brakeman_gem_cache" "$brakeman_raw_dir" +} +trap cleanup_brakeman EXIT + +emit_empty_sarif() { + printf '{"version":"2.1.0","runs":[]}' > "$raw" +} + +discover_rails_roots() { + find -P . \ + \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ + -o -name 'coverage' -o -name '.next' -o -name 'out' -o -name 'vendor' -o -name '.terraform' \) -prune \) -o \ + \( -type f -path '*/config/application.rb' -print0 \) \ + | while IFS= read -r -d '' application_file; do + root="${application_file%/config/application.rb}" + printf '%s\0' "${root:-.}" + done \ + | sort -zu +} + +write_runner_brakeman_config() { + printf '%s\n' '--- {}' > "$brakeman_config" + printf '%s\n' '{"ignored_warnings":[]}' > "$brakeman_ignore" +} + +fetch_verified_gem() { + local name="$1" + local version="$2" + local checksum="$3" + local path="$brakeman_gem_cache/${name}-${version}.gem" + rm -f "$path" + if ! (cd "$brakeman_gem_cache" && gem fetch --norc --clear-sources --source https://rubygems.org "$name" -v "$version" >/dev/null); then + echo "::warning::${name} gem fetch failed - manifest will record missing output." + rm -f "$path" + return 1 + fi + if [ ! -s "$path" ]; then + echo "::warning::${name} gem package missing after fetch - manifest will record missing output." + return 1 + fi + if ! printf '%s %s\n' "$checksum" "$path" | sha256sum -c --strict -; then + echo "::warning::${name} gem checksum mismatch - manifest will record missing output." + return 1 + fi +} + +cd "$SOURCE_DIR" +if ! discover_rails_roots > "$roots_list"; then + echo "::warning::Brakeman Rails root discovery failed - emitting empty Brakeman SARIF run." + emit_empty_sarif +elif ! grep -qz . "$roots_list"; then + echo "::notice::No Rails roots found - emitting empty Brakeman SARIF run." + emit_empty_sarif +elif [[ ! "$BRAKEMAN_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "::warning::Brakeman version must be a pinned x.y.z version - manifest will record missing output." +elif [[ ! "$RACC_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "::warning::racc version must be a pinned x.y.z version - manifest will record missing output." +elif [[ ! "$BRAKEMAN_GEM_SHA256" =~ ^[0-9a-f]{64}$ ]] || [[ ! "$RACC_GEM_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "::warning::Brakeman gem checksums must be pinned SHA256 values - manifest will record missing output." +elif ! command -v ruby >/dev/null 2>&1 || ! command -v gem >/dev/null 2>&1; then + echo "::warning::Ruby and gem are required for Brakeman - manifest will record missing output." +else + export GEM_HOME="$RUNNER_TEMP/brakeman-gems" + export GEM_PATH="$GEM_HOME" + export PATH="$GEM_HOME/bin:$PATH" + mkdir -p "$GEM_HOME" + write_runner_brakeman_config + if ! fetch_verified_gem racc "$RACC_VERSION" "$RACC_GEM_SHA256"; then + : + elif ! fetch_verified_gem brakeman "$BRAKEMAN_VERSION" "$BRAKEMAN_GEM_SHA256"; then + : + elif ! gem install --norc --local --no-document --install-dir "$GEM_HOME" \ + "$brakeman_gem_cache/racc-${RACC_VERSION}.gem" \ + "$brakeman_gem_cache/brakeman-${BRAKEMAN_VERSION}.gem"; then + echo "::warning::Brakeman install failed - manifest will record missing output." + elif ! brakeman_version="$(brakeman --version 2>/dev/null)"; then + echo "::warning::Brakeman version check failed - manifest will record missing output." + elif ! printf '%s\n' "$brakeman_version" | grep -Eq "(^|[^0-9.])${BRAKEMAN_VERSION}([^0-9.]|$)"; then + echo "::warning::Brakeman installed version mismatch - manifest will record missing output." + else + index=0 + while IFS= read -r -d '' root; do + root="${root#./}" + if [ -z "$root" ]; then root="."; fi + if [[ "$root" == ".." || "$root" == "../"* || "$root" == *"/.." || "$root" == *"/../"* ]]; then + echo "::warning::Skipping Brakeman root with traversal segments: ${root}" + continue + fi + if [ "$root" = "." ]; then + root_abs="$SOURCE_DIR" + elif ! root_abs="$(cd "$SOURCE_DIR/$root" && pwd -P)"; then + echo "::warning::Unable to resolve Brakeman root ${root} - skipping." + continue + fi + if [[ "$root_abs" != "$SOURCE_DIR" && "$root_abs" != "$SOURCE_DIR"/* ]]; then + echo "::warning::Brakeman root ${root} resolves outside source directory - skipping." + continue + fi + root_raw="$brakeman_raw_dir/brakeman-${index}.raw.sarif" + root_normalized="$brakeman_raw_dir/brakeman-${index}.sarif" + if brakeman \ + --path "$root_abs" \ + --config-file "$brakeman_config" \ + --ignore-config "$brakeman_ignore" \ + --show-ignored \ + --no-exit-on-warn \ + --no-exit-on-error \ + --format sarif \ + --output "$root_raw" \ + --quiet; then + : + else + echo "::warning::Brakeman scan for ${root} exited non-zero - using SARIF output if present." + fi + if [ -s "$root_raw" ]; then + if python3 "$RUNNER_DIR/.github/scripts/brakeman_sarif_paths.py" \ + "$root_raw" "$root_normalized" --root "$root" --base-dir "$SOURCE_DIR" \ + && [ -s "$root_normalized" ]; then + raw_files+=("$root_normalized") + else + echo "::warning::Brakeman SARIF path normalization failed for ${root} - discarding output." + fi + else + echo "::warning::Brakeman scan for ${root} produced no SARIF output." + fi + index=$((index + 1)) + done < "$roots_list" + if [ "${#raw_files[@]}" -eq 0 ]; then + echo "::warning::Brakeman produced no SARIF output for detected Rails roots - manifest will record missing output." + elif [ "${#raw_files[@]}" -eq 1 ]; then + cp "${raw_files[0]}" "$raw" \ + || { echo "::warning::Brakeman failed to copy SARIF output - manifest will record missing output."; rm -f "$raw"; } + else + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_merge.py" \ + "${raw_files[@]}" -o "$raw" \ + || { echo "::warning::Brakeman SARIF merge failed - manifest will record missing output."; rm -f "$raw"; } + fi + fi +fi + +if [ -s "$raw" ]; then + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ + brakeman "$raw" "$out" --cap "$RESULT_CAP" --ensure-run \ + || echo "::warning::Brakeman SARIF normalization failed - manifest will record missing output." +fi diff --git a/.github/scripts/run_opengrep.sh b/.github/scripts/run_opengrep.sh new file mode 100644 index 0000000..fcc49fe --- /dev/null +++ b/.github/scripts/run_opengrep.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${OPENGREP_MANYLINUX_AARCH64_SHA256:?}" +: "${OPENGREP_MANYLINUX_X86_SHA256:?}" +: "${OPENGREP_VERSION:?}" +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" +: "${OPENGREP_CONFIG:=p/security-audit,p/owasp-top-ten}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +raw="$SARIF_DIR/opengrep.raw.sarif" +out="$SARIF_DIR/opengrep.sarif" +opengrep_bin="$RUNNER_TEMP/opengrep" +asset="" +checksum="" +config_args=() + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" + +cleanup_opengrep() { + rm -f "$opengrep_bin" +} +trap cleanup_opengrep EXIT + +parse_opengrep_configs() { + local item + local trimmed + IFS=',' read -r -a config_items <<< "$OPENGREP_CONFIG" + for item in "${config_items[@]}"; do + trimmed="${item#"${item%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + if [ -z "$trimmed" ]; then + echo "::warning::OpenGrep config contains an empty ruleset - manifest will record missing output." + return 1 + fi + if [[ "$trimmed" == -* ]]; then + echo "::warning::OpenGrep config '$trimmed' must not start with '-' - manifest will record missing output." + return 1 + fi + if [[ ! "$trimmed" =~ ^[A-Za-z0-9._/@-]+$ ]]; then + echo "::warning::OpenGrep config '$trimmed' contains unsupported characters - manifest will record missing output." + return 1 + fi + config_args+=(--config "$trimmed") + done + if [ "${#config_args[@]}" -eq 0 ]; then + echo "::warning::OpenGrep config is empty - manifest will record missing output." + return 1 + fi +} + +case "$(uname -m)" in + x86_64|amd64) + asset="opengrep_manylinux_x86" + checksum="$OPENGREP_MANYLINUX_X86_SHA256" + ;; + aarch64|arm64) + asset="opengrep_manylinux_aarch64" + checksum="$OPENGREP_MANYLINUX_AARCH64_SHA256" + ;; + *) + echo "::warning::OpenGrep unsupported runner architecture $(uname -m) - manifest will record missing output." + ;; +esac + +cd "$SOURCE_DIR" +if [ -z "$asset" ]; then + : +elif [[ ! "$OPENGREP_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "::warning::OpenGrep version must be a pinned x.y.z version - manifest will record missing output." +elif [[ ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then + echo "::warning::OpenGrep checksum must be a pinned SHA256 value - manifest will record missing output." +elif ! parse_opengrep_configs; then + : +elif ! curl -fsSL -o "$opengrep_bin" \ + "https://github.com/opengrep/opengrep/releases/download/v${OPENGREP_VERSION}/${asset}"; then + echo "::warning::OpenGrep download failed - manifest will record missing output." +elif ! printf '%s %s\n' "$checksum" "$opengrep_bin" | sha256sum -c --strict -; then + echo "::warning::OpenGrep checksum mismatch - manifest will record missing output." +elif ! chmod +x "$opengrep_bin"; then + echo "::warning::OpenGrep chmod failed - manifest will record missing output." +elif ! opengrep_version="$("$opengrep_bin" --version 2>/dev/null)"; then + echo "::warning::OpenGrep version check failed - manifest will record missing output." +elif ! printf '%s\n' "$opengrep_version" | grep -Eq "(^|[^0-9.])${OPENGREP_VERSION}([^0-9.]|$)"; then + echo "::warning::OpenGrep installed version mismatch - manifest will record missing output." +else + "$opengrep_bin" scan \ + "${config_args[@]}" \ + --exclude=node_modules \ + --exclude=dist \ + --exclude=build \ + --exclude=coverage \ + --exclude=.next \ + --exclude=out \ + --exclude=.venv \ + --exclude=vendor \ + --exclude=.tox \ + --exclude=.terraform \ + --sarif-output="$raw" \ + . || true + if [ ! -s "$raw" ]; then + echo "::warning::OpenGrep scan produced no SARIF output - manifest will record missing output." + fi +fi + +if [ -s "$raw" ]; then + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ + opengrep "$raw" "$out" --cap "$RESULT_CAP" --ensure-run \ + || echo "::warning::OpenGrep SARIF normalization failed - manifest will record missing output." +fi diff --git a/.github/scripts/security_sast_tools_workflow_test.py b/.github/scripts/security_sast_tools_workflow_test.py new file mode 100644 index 0000000..a775463 --- /dev/null +++ b/.github/scripts/security_sast_tools_workflow_test.py @@ -0,0 +1,186 @@ +import json +import os +import re +import tempfile +import unittest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +WORKFLOW_PATH = os.path.join(ROOT, ".github", "workflows", "scan.yml") +MANIFEST_PATH = os.path.join(ROOT, ".github", "config", "tool-manifest.json") +SCRIPT_DIR = os.path.join(ROOT, ".github", "scripts") + +SAST_TOOL_OUTPUTS = { + "opengrep": "opengrep.sarif", + "brakeman": "brakeman.sarif", +} + + +class SecuritySastToolsWorkflowTest(unittest.TestCase): + def read_file(self, path): + with open(path, encoding="utf-8") as handle: + return handle.read() + + def workflow_text(self): + return self.read_file(WORKFLOW_PATH) + + def workflow_input_block(self, input_name): + pattern = rf"\n {re.escape(input_name)}:\n(?P(?: .+\n)+)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group("block") + + def workflow_step_block(self, step_name): + pattern = rf"(?ms)^ - name: {re.escape(step_name)}\n.+?(?=^ - name: |\Z)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group(0) + + def manifest_rows(self): + with open(MANIFEST_PATH, encoding="utf-8") as handle: + return {row["id"]: row for row in json.load(handle)["tools"]} + + def script_text(self, filename): + return self.read_file(os.path.join(SCRIPT_DIR, filename)) + + def test_sast_tools_are_default_on_and_manifested(self): + text = self.workflow_text() + rows = self.manifest_rows() + + for tool_id, output in SAST_TOOL_OUTPUTS.items(): + env_var = tool_id.upper().replace("-", "_") + "_ENABLED" + self.assertIn(" default: true\n", self.workflow_input_block(tool_id)) + self.assertIn(f"{env_var}: ${{{{ inputs.{tool_id} }}}}", text) + self.assertEqual(rows[tool_id], {"id": tool_id, "env": env_var, "output": output}) + + self.assertIn('OPENGREP_VERSION: "1.22.0"', text) + self.assertIn("OPENGREP_MANYLINUX_X86_SHA256:", text) + self.assertIn("OPENGREP_MANYLINUX_AARCH64_SHA256:", text) + self.assertIn('BRAKEMAN_VERSION: "8.0.4"', text) + self.assertIn("BRAKEMAN_GEM_SHA256:", text) + self.assertIn('RACC_VERSION: "1.8.1"', text) + self.assertIn("RACC_GEM_SHA256:", text) + + def test_workflow_delegates_sast_tools_to_runner_scripts(self): + expectations = { + "Run OpenGrep to SARIF": "run_opengrep.sh", + "Run Brakeman to SARIF": "run_brakeman.sh", + } + + for step_name, script_name in expectations.items(): + block = self.workflow_step_block(step_name) + self.assertIn(f'bash "$RUNNER_DIR/.github/scripts/{script_name}"', block) + + def test_opengrep_wrapper_uses_pinned_release_and_configured_rulesets(self): + text = self.script_text("run_opengrep.sh") + + self.assertIn("OPENGREP_VERSION", text) + self.assertIn("OPENGREP_MANYLINUX_X86_SHA256", text) + self.assertIn("OPENGREP_MANYLINUX_AARCH64_SHA256", text) + self.assertIn('case "$(uname -m)" in', text) + self.assertIn("opengrep_manylinux_x86", text) + self.assertIn("opengrep_manylinux_aarch64", text) + self.assertIn("sha256sum -c --strict", text) + self.assertIn("OpenGrep installed version mismatch", text) + self.assertIn("[^0-9.])${OPENGREP_VERSION}([^0-9.]", text) + self.assertIn("parse_opengrep_configs", text) + self.assertIn('"$trimmed" == -*', text) + self.assertIn(': "${OPENGREP_CONFIG:=p/security-audit,p/owasp-top-ten}"', text) + self.assertIn("--sarif-output=\"$raw\"", text) + self.assertIn("--config", text) + self.assertIn("--exclude=node_modules", text) + self.assertIn("sigilix_sarif_contract.py", text) + + def test_brakeman_wrapper_detects_rails_before_install_and_ignores_caller_config(self): + text = self.script_text("run_brakeman.sh") + + self.assertIn("BRAKEMAN_VERSION", text) + self.assertIn("discover_rails_roots", text) + self.assertIn("config/application.rb", text) + self.assertIn("No Rails roots found", text) + self.assertIn('export GEM_HOME="$RUNNER_TEMP/brakeman-gems"', text) + self.assertIn("fetch_verified_gem", text) + self.assertIn('rm -f "$path"', text) + self.assertIn("gem fetch --norc --clear-sources --source https://rubygems.org", text) + self.assertIn("sha256sum -c --strict", text) + self.assertIn("gem install --norc --local --no-document --install-dir \"$GEM_HOME\"", text) + self.assertIn("Skipping Brakeman root with traversal segments", text) + self.assertIn("resolves outside source directory", text) + self.assertIn('--path "$root_abs"', text) + self.assertIn("--config-file \"$brakeman_config\"", text) + self.assertIn("--ignore-config \"$brakeman_ignore\"", text) + self.assertIn("--show-ignored", text) + self.assertIn("--no-exit-on-warn", text) + self.assertIn("--no-exit-on-error", text) + self.assertIn("--format sarif", text) + self.assertIn("[^0-9.])${BRAKEMAN_VERSION}([^0-9.]", text) + self.assertIn("Brakeman SARIF path normalization failed", text) + self.assertIn("Brakeman failed to copy SARIF output", text) + self.assertIn("Brakeman SARIF merge failed", text) + self.assertIn("sigilix_sarif_merge.py", text) + self.assertIn("sigilix_sarif_contract.py", text) + + +class BrakemanSarifPathTest(unittest.TestCase): + def test_brakeman_sarif_paths_are_prefixed_for_nested_rails_roots(self): + from brakeman_sarif_paths import normalize_brakeman_sarif_paths + + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = os.path.join(tmpdir, "services", "billing") + os.makedirs(app_dir) + document = { + "version": "2.1.0", + "runs": [ + { + "tool": {"driver": {"name": "Brakeman"}}, + "results": [ + { + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "app/models/user.rb"}, + } + } + ], + }, + { + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": f"file://localhost{app_dir}/app/controllers/users_controller.rb", + }, + } + } + ], + }, + ], + } + ], + } + + normalized = normalize_brakeman_sarif_paths(document, root="services/billing", base_dir=tmpdir) + + run = normalized["runs"][0] + self.assertEqual(run["tool"]["driver"]["name"], "Brakeman (services/billing)") + uris = [ + result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] + for result in run["results"] + ] + self.assertEqual( + uris, + [ + "services/billing/app/models/user.rb", + "services/billing/app/controllers/users_controller.rb", + ], + ) + + def test_brakeman_sarif_path_normalizer_rejects_invalid_documents(self): + from brakeman_sarif_paths import normalize_brakeman_sarif_paths + + with self.assertRaises(ValueError): + normalize_brakeman_sarif_paths([], root=".", base_dir=".") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/sigilix_sarif_contract.py b/.github/scripts/sigilix_sarif_contract.py index 88ac5b2..30ab340 100644 --- a/.github/scripts/sigilix_sarif_contract.py +++ b/.github/scripts/sigilix_sarif_contract.py @@ -8,9 +8,11 @@ KNOWN_TOOL_IDS = frozenset( { "semgrep", + "opengrep", "eslint", "tsc", "ruff", + "brakeman", "pylint", "flake8", "knip", @@ -39,9 +41,11 @@ ) DEFAULT_TOOL_NAMES = { "semgrep": "Semgrep", + "opengrep": "OpenGrep", "eslint": "ESLint", "tsc": "TypeScript Compiler", "ruff": "Ruff", + "brakeman": "Brakeman", "pylint": "Pylint", "flake8": "Flake8", "knip": "Knip", diff --git a/.github/scripts/sigilix_sarif_test.py b/.github/scripts/sigilix_sarif_test.py index 95f01cc..d796d59 100644 --- a/.github/scripts/sigilix_sarif_test.py +++ b/.github/scripts/sigilix_sarif_test.py @@ -83,6 +83,8 @@ def test_contract_accepts_legacy_and_next_batch_tool_ids(self): ("oxlint", "Oxlint"), ("ast-grep", "ast-grep"), ("regal", "Regal"), + ("opengrep", "OpenGrep"), + ("brakeman", "Brakeman"), ): run = attach_sigilix_metadata({}, tool_id) driver = run["tool"]["driver"] @@ -492,6 +494,11 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): "regal": "regal.sarif", } +SAST_TOOL_OUTPUTS = { + "opengrep": "opengrep.sarif", + "brakeman": "brakeman.sarif", +} + LANGUAGE_CONVERTER_TOOL_OUTPUTS = { "flake8": "flake8.sarif", "stylelint": "stylelint.sarif", @@ -522,6 +529,7 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): **TERRAFORM_TOOL_OUTPUTS, **LANGUAGE_SARIF_TOOL_OUTPUTS, **POLICY_TOOL_OUTPUTS, + **SAST_TOOL_OUTPUTS, **LANGUAGE_CONVERTER_TOOL_OUTPUTS, **CONFIG_TOOL_OUTPUTS, **CI_SECURITY_TOOL_OUTPUTS, @@ -611,6 +619,8 @@ def test_tool_output_groups_are_disjoint(self): self.assertFalse(legacy_tools & set(OPT_IN_SECURITY_TOOL_OUTPUTS)) self.assertFalse(legacy_tools & set(LANGUAGE_SARIF_TOOL_OUTPUTS)) self.assertFalse(set(OPT_IN_SECURITY_TOOL_OUTPUTS) & set(LANGUAGE_SARIF_TOOL_OUTPUTS)) + self.assertFalse(legacy_tools & set(SAST_TOOL_OUTPUTS)) + self.assertFalse(set(SAST_TOOL_OUTPUTS) & set(LANGUAGE_SARIF_TOOL_OUTPUTS)) self.assertNotIn("zizmor", OPT_IN_SECURITY_TOOL_OUTPUTS) self.assertIn("zizmor", CI_SECURITY_TOOL_OUTPUTS) self.assertNotIn("hadolint", OPT_IN_SECURITY_TOOL_OUTPUTS) @@ -671,6 +681,7 @@ def test_catalog_tool_outputs_are_manifested_and_merged(self): **TERRAFORM_TOOL_OUTPUTS, **LANGUAGE_SARIF_TOOL_OUTPUTS, **POLICY_TOOL_OUTPUTS, + **SAST_TOOL_OUTPUTS, **LANGUAGE_CONVERTER_TOOL_OUTPUTS, **CONFIG_TOOL_OUTPUTS, **CI_SECURITY_TOOL_OUTPUTS, @@ -750,6 +761,8 @@ def test_next_batch_tool_versions_are_pinned(self): "TFLINT_VERSION", "BIOME_VERSION", "OXLINT_VERSION", + "OPENGREP_VERSION", + "BRAKEMAN_VERSION", ): match = re.search(rf"\n {env_var}: \"([^\"]+)\"\n", text) self.assertIsNotNone(match) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68dbdf7..db451e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: python3 .github/scripts/oxlint_workflow_test.py python3 .github/scripts/ast_grep_workflow_test.py python3 .github/scripts/policy_iac_tools_workflow_test.py + python3 .github/scripts/security_sast_tools_workflow_test.py python3 -m py_compile .github/scripts/*.py lint: diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 0145fbb..ce57408 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -37,6 +37,16 @@ on: required: false default: "auto" type: string + opengrep: + description: "Run OpenGrep and attach Sigilix deterministic-tool metadata." + required: false + default: true + type: boolean + opengrep-config: + description: "Comma-separated OpenGrep rulesets passed as --config values." + required: false + default: "p/security-audit,p/owasp-top-ten" + type: string eslint: description: "Run ESLint. Safe mode avoids repository config/plugins." required: false @@ -57,6 +67,11 @@ on: required: false default: true type: boolean + brakeman: + description: "Run Brakeman on detected Rails applications and merge its SARIF." + required: false + default: true + type: boolean pylint: description: "Run Pylint with Sigilix-owned high-confidence Python error rules." required: false @@ -205,7 +220,9 @@ jobs: AST_GREP_LINUX_X64_GNU_INTEGRITY: "sha512-r/o9Mag6OZmGevY9OJjatuUKDOX1rSvgo29qSfxpMbIciiH3hkzEW/2w1xTPZI8xnM7iC+k+CkGoknmoXVTYGg==" AST_GREP_NPM_INTEGRITY: "sha512-DGi6xXAOBJubGg9QWqTeW8PzKSGHWEOa3uxXspEfYf532yb3lHmNJAcKMl1d+O9Xs9bTcNeDLC8se+O+tirEFQ==" AST_GREP_VERSION: "0.43.0" + BRAKEMAN_GEM_SHA256: "7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f" BIOME_VERSION: "2.4.16" + BRAKEMAN_VERSION: "8.0.4" CHECKMAKE_LINUX_AMD64_SHA256: "e2effb876913f3ee2caef0ba35f6202c5e8a3cd55a077d8d2b9ce2034257b6af" CHECKMAKE_VERSION: "0.3.2" CHECKOV_VERSION: "3.3.1" @@ -226,11 +243,16 @@ jobs: KNIP_VERSION: "6.16.1" MARKDOWNLINT_VERSION: "0.48.0" NODE_VERSION: "22.13.0" + OPENGREP_MANYLINUX_AARCH64_SHA256: "8df71670e20336646687c6f4ddf9b4532f1a7fcd8a8ea7bfa4ea46747f61e088" + OPENGREP_MANYLINUX_X86_SHA256: "45bcd58440e397ed52c50e953ccf5948909ea77087c9186fc7d277216f62e319" + OPENGREP_VERSION: "1.22.0" OSV_SCANNER_VERSION: "2.3.8" OXLINT_LINUX_X64_GNU_INTEGRITY: "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==" OXLINT_NPM_INTEGRITY: "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==" OXLINT_VERSION: "1.69.0" PYLINT_VERSION: "4.0.5" + RACC_GEM_SHA256: "4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f" + RACC_VERSION: "1.8.1" REGAL_LINUX_X86_64_SHA256: "6769dcd8e88bc5ba5ff4fac500e4a99d55b3eec3d1d0842833d84f6820a2a80f" REGAL_VERSION: "0.41.1" RESULT_CAP: ${{ inputs.result-cap }} @@ -300,6 +322,15 @@ jobs: python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ semgrep "$raw" "$out" --cap "$RESULT_CAP" --ensure-run + - name: Run OpenGrep to SARIF + if: ${{ inputs.opengrep }} + env: + OPENGREP_CONFIG: ${{ inputs.opengrep-config }} + run: | + set -euo pipefail + cd "$SOURCE_DIR" + bash "$RUNNER_DIR/.github/scripts/run_opengrep.sh" + - name: Run ESLint to SARIF if: ${{ inputs.eslint }} env: @@ -332,6 +363,13 @@ jobs: python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ ruff "$raw" "$out" --cap "$RESULT_CAP" --ensure-run + - name: Run Brakeman to SARIF + if: ${{ inputs.brakeman }} + run: | + set -euo pipefail + cd "$SOURCE_DIR" + bash "$RUNNER_DIR/.github/scripts/run_brakeman.sh" + - name: Run Pylint to SARIF if: ${{ inputs.pylint }} run: | @@ -835,9 +873,11 @@ jobs: - name: Build scan manifest env: SEMGREP_ENABLED: ${{ inputs.semgrep }} + OPENGREP_ENABLED: ${{ inputs.opengrep }} ESLINT_ENABLED: ${{ inputs.eslint }} TSC_ENABLED: ${{ inputs.tsc }} RUFF_ENABLED: ${{ inputs.ruff }} + BRAKEMAN_ENABLED: ${{ inputs.brakeman }} PYLINT_ENABLED: ${{ inputs.pylint }} FLAKE8_ENABLED: ${{ inputs.flake8 }} KNIP_ENABLED: ${{ inputs.knip }} diff --git a/README.md b/README.md index fe72915..01e7bf6 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ Current staged catalog: | Tool | Default | Notes | | --- | --- | --- | | Semgrep | on | Native SARIF with Sigilix metadata. `semgrep-config` defaults to `auto`. | +| OpenGrep | on | Native SARIF with Sigilix metadata. `opengrep-config` defaults to `p/security-audit,p/owasp-top-ten`. | | ESLint | on | Safe mode by default: no repository config or plugins. Uses Sigilix-owned JavaScript/TypeScript logic, promise, and security rules; typed TypeScript promise rules turn on when a TS config is detected. Use `eslint-mode: repo-config` only when you accept executing the caller repository's ESLint config/plugins in the scan job. | | TypeScript Compiler | on | Converts `tsc --noEmit` diagnostics to SARIF when TypeScript configs are present. Bare external dependency-resolution noise is filtered, path-like unresolved imports stay visible as review signal, and declaration-file library checks are skipped to keep dependency noise bounded. | | Ruff | on | Native SARIF with Sigilix metadata. | +| Brakeman | on | Native SARIF with Sigilix metadata for detected Rails applications. Caller Brakeman config and ignore files are bypassed so ignored warnings still reach review evidence. | | Pylint | on | Converts Pylint JSON output to SARIF. Uses a Sigilix-owned high-confidence profile: Pylint fatal/error checks only, with caller config ignored and dependency-sensitive `import-error`/`no-member` disabled. | | Flake8 | on | Converts Flake8 text output to SARIF when a `.flake8` marker is present. The config content is ignored; Sigilix runs high-confidence PyFlakes/parse checks only to avoid duplicating broad Ruff/Pylint style feedback. | | Knip | on | Converts Knip JSON output to SARIF. Uses a Sigilix-owned JavaScript/TypeScript profile for unresolved imports, unlisted dependencies, and missing package-script binaries; broad unused-export/file reports are not enabled. | @@ -58,6 +60,8 @@ Current staged catalog: > `markdownlint`, `dotenv-linter`, and `checkmake` now default to `true`. Set the matching > boolean input to `false` in the caller workflow to suppress one of them. > `regal` now defaults to `true`. Set `regal: false` (boolean) in the caller workflow to suppress it. +> `opengrep` and `brakeman` now default to `true`. Set the matching boolean input to `false` +> to suppress one of them; `opengrep-config` accepts comma-separated OpenGrep rulesets. These SIG-107 slices move the runner toward broader third-party tool parity. The Sigilix metadata contract is currently attached to every listed tool. @@ -98,7 +102,7 @@ a moving ref cannot prove which version of the runner ran. Default-on tool booleans: `semgrep`, `eslint`, `ruff`, `actionlint`, `shellcheck`, `yamllint`, `markdownlint`, `dotenv-linter`, `checkmake`, `gitleaks`, `osv-scanner`, `zizmor`, `hadolint`, `biome`, `oxlint`, `ast-grep`, `pylint`, `flake8`, `knip`, `golangci-lint`, `htmlhint`, -`stylelint`, `tflint`, `regal`, and `tsc`. +`stylelint`, `tflint`, `regal`, `opengrep`, `brakeman`, and `tsc`. Default-off opt-in tool booleans: `checkov`, `trivy`, and `trufflehog`. @@ -107,6 +111,7 @@ Other useful inputs: | Input | Default | Meaning | | --- | --- | --- | | `semgrep-config` | `auto` | Ruleset passed to `semgrep --config`. | +| `opengrep-config` | `p/security-audit,p/owasp-top-ten` | Comma-separated rulesets passed to OpenGrep as repeated `--config` values. | | `eslint-mode` | `safe` | `safe` avoids repository config/plugins; `repo-config` opts in to the caller's ESLint config and plugins, which execute in the no-OIDC scan job. | | `result-cap` | `500` | Maximum kept findings per Sigilix-managed tool run. Dropped counts are stored in SARIF metadata. | | `sarif-byte-cap` | `7800000` | Maximum merged SARIF payload bytes before later runs are dropped. |