diff --git a/src/executors/semgrep.yml b/src/executors/semgrep.yml index 734de7f..494a13b 100644 --- a/src/executors/semgrep.yml +++ b/src/executors/semgrep.yml @@ -3,8 +3,8 @@ description: > parameters: version: type: string - default: "1.61.1" + default: "1.181.0" description: Semgrep image version tag. docker: - - image: returntocorp/semgrep:<< parameters.version >> + - image: semgrep/semgrep:<< parameters.version >> resource_class: medium diff --git a/src/scripts/run_semgrep.sh b/src/scripts/run_semgrep.sh index 80cc365..8ae1aee 100755 --- a/src/scripts/run_semgrep.sh +++ b/src/scripts/run_semgrep.sh @@ -15,22 +15,45 @@ munitor_header "run_semgrep" CONFIG="${SEMGREP_CONFIG:-auto}" FAIL_ON_FINDINGS="${SEMGREP_FAIL_ON_FINDINGS:-true}" +REPORT="${SEMGREP_REPORT:-/tmp/semgrep-results.json}" echo "Running Semgrep SAST scan (config: ${CONFIG}, fail_on_findings: ${FAIL_ON_FINDINGS})..." +EXIT_CODE=0 semgrep ci \ --config "${CONFIG}" \ --json \ - --output /tmp/semgrep-results.json || { - EXIT_CODE=$? - if [[ "${FAIL_ON_FINDINGS}" == "true" ]]; then - echo "ERROR: Semgrep found issues." - echo "Review the report at: /tmp/semgrep-results.json" - exit "${EXIT_CODE}" - else - echo "WARNING: Semgrep found issues (warn-only mode, not failing build)." - echo "Review the report at: /tmp/semgrep-results.json" - fi - } + --output "${REPORT}" || EXIT_CODE=$? + +# Summarise the report, and fail a scan that ran no rules or scanned no files: +# semgrep exits 0 when the downloaded rules fail to parse (an outdated semgrep), +# which used to pass this job without scanning anything. +python3 - "${REPORT}" <<'PY' +import json, sys +report = json.load(open(sys.argv[1])) +scanned = len(report.get("paths", {}).get("scanned", [])) +results = report.get("results", []) +errors = report.get("errors", []) +rule_errors = [e for e in errors if "rule" in str(e.get("type", "")).lower() + or "config" in str(e.get("message", "")).lower()] +print(f"Semgrep: {scanned} files scanned, {len(results)} findings, {len(errors)} errors.") +for r in results[:50]: + print(f" {r.get('check_id')} {r.get('path')}:{r.get('start', {}).get('line')}") +if scanned == 0 or rule_errors: + for e in rule_errors[:5]: + print(f" error: {str(e.get('message', e))[:300]}") + print("ERROR: Semgrep did not run its rules (no files scanned or invalid rule configuration).") + sys.exit(2) +PY + +if [[ "${EXIT_CODE}" -ne 0 ]]; then + if [[ "${FAIL_ON_FINDINGS}" == "true" ]]; then + echo "ERROR: Semgrep found issues." + echo "Review the report at: ${REPORT}" + exit "${EXIT_CODE}" + fi + echo "WARNING: Semgrep found issues (warn-only mode, not failing build)." + echo "Review the report at: ${REPORT}" +fi echo "Semgrep scan completed." diff --git a/src/scripts/run_trivy.sh b/src/scripts/run_trivy.sh index c9bcffd..3063c1b 100755 --- a/src/scripts/run_trivy.sh +++ b/src/scripts/run_trivy.sh @@ -36,13 +36,17 @@ fi echo "Running Trivy filesystem scan..." +# --exit-code 1: without it trivy exits 0 even when it reports HIGH/CRITICAL +# findings, so this job could never fail. trivy fs \ --format json \ --output /tmp/trivy-results.json \ --severity HIGH,CRITICAL \ + --exit-code 1 \ . || { EXIT_CODE=$? echo "Trivy found vulnerabilities. See /tmp/trivy-results.json" + trivy fs --severity HIGH,CRITICAL . || true exit "${EXIT_CODE}" } diff --git a/tests/test_scan_gates.sh b/tests/test_scan_gates.sh new file mode 100644 index 0000000..95d99d9 --- /dev/null +++ b/tests/test_scan_gates.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# The filesystem Trivy scan and the Semgrep scan must fail when they find +# something, and Semgrep must fail when it ran no rules. Local tool stubs; no +# network needed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_SCRIPTS="$(cd "${SCRIPT_DIR}/../src/scripts" && pwd)" +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT +PASS=0 +FAIL=0 +pass() { echo "PASS"; PASS=$((PASS + 1)); } +fail() { echo "FAIL"; FAIL=$((FAIL + 1)); } + +mkdir -p "${WORK}/home/bin" "${WORK}/bin" "${WORK}/repo" +cat > "${WORK}/home/bin/trivy" <<'EOF' +#!/usr/bin/env bash +printf 'trivy %s\n' "$*" >> "${CALL_LOG}" +# Real trivy exits 0 on findings unless --exit-code is given. +for a in "$@"; do [[ "$a" == --exit-code ]] && exit "${SCAN_EXIT:-0}"; done +exit 0 +EOF +cat > "${WORK}/bin/semgrep" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${SEMGREP_FAKE_REPORT}" > "${SEMGREP_REPORT}" +exit "${SEMGREP_FAKE_EXIT:-0}" +EOF +chmod +x "${WORK}/home/bin/trivy" "${WORK}/bin/semgrep" +export PATH="${WORK}/bin:${PATH}" HOME="${WORK}/home" CALL_LOG="${WORK}/calls" +export SEMGREP_REPORT="${WORK}/semgrep.json" +cd "${WORK}/repo" + +echo "=== Scan gate tests ===" +echo -n " TEST: trivy fs fails the job on HIGH/CRITICAL findings... " +if ! SCAN_EXIT=1 bash "${SRC_SCRIPTS}/run_trivy.sh" > "${WORK}/trivy.log" 2>&1 && + grep -q '^trivy fs .*--severity HIGH,CRITICAL --exit-code 1 ' "${CALL_LOG}"; then + pass +else + fail +fi + +echo -n " TEST: trivy fs passes a clean scan... " +if SCAN_EXIT=0 bash "${SRC_SCRIPTS}/run_trivy.sh" > "${WORK}/trivy.log" 2>&1; then + pass +else + fail +fi + +if command -v python3 &>/dev/null; then + CLEAN='{"results":[],"errors":[],"paths":{"scanned":["a.java","b.java"]}}' + FOUND='{"results":[{"check_id":"java.lang.security.x","path":"a.java","start":{"line":3}}],"errors":[],"paths":{"scanned":["a.java"]}}' + NORULES='{"results":[],"errors":[{"type":"InvalidRuleSchemaError","message":"invalid configuration file found (1 configs were invalid)"}],"paths":{"scanned":[]}}' + + echo -n " TEST: semgrep passes a clean scan... " + if SEMGREP_FAKE_REPORT="${CLEAN}" bash "${SRC_SCRIPTS}/run_semgrep.sh" > "${WORK}/sg.log" 2>&1 && + grep -q '2 files scanned, 0 findings' "${WORK}/sg.log"; then + pass + else + fail + fi + + echo -n " TEST: semgrep fails on findings and lists them... " + if ! SEMGREP_FAKE_REPORT="${FOUND}" SEMGREP_FAKE_EXIT=1 bash "${SRC_SCRIPTS}/run_semgrep.sh" > "${WORK}/sg.log" 2>&1 && + grep -q 'java.lang.security.x a.java:3' "${WORK}/sg.log"; then + pass + else + fail + fi + + echo -n " TEST: semgrep warn-only mode passes with findings... " + if SEMGREP_FAIL_ON_FINDINGS=false SEMGREP_FAKE_REPORT="${FOUND}" SEMGREP_FAKE_EXIT=1 \ + bash "${SRC_SCRIPTS}/run_semgrep.sh" > "${WORK}/sg.log" 2>&1; then + pass + else + fail + fi + + echo -n " TEST: semgrep fails when no rules ran, even with exit 0 and warn-only... " + if ! SEMGREP_FAIL_ON_FINDINGS=false SEMGREP_FAKE_REPORT="${NORULES}" SEMGREP_FAKE_EXIT=0 \ + bash "${SRC_SCRIPTS}/run_semgrep.sh" > "${WORK}/sg.log" 2>&1 && + grep -q 'did not run its rules' "${WORK}/sg.log"; then + pass + else + fail + fi +else + echo " SKIP: semgrep gate tests need python3" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ "${FAIL}" -eq 0 ]]