From 4aeccb801ee561153acfa50d73b4a5ec644857d4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 15:42:51 -0500 Subject: [PATCH 1/2] fix(scan): make the security-scan and sast-scan gates fail on findings - run_trivy.sh: `trivy fs` ran without --exit-code, so it always exited 0 and security-scan passed with HIGH/CRITICAL findings in the report. Add --exit-code 1 and print the findings table on failure. - Semgrep 1.61.1 cannot parse the current `auto` ruleset, so sast-scan ran zero rules on zero files and passed. Move the executor to semgrep/semgrep:1.181.0, and fail the job when the report shows no files scanned or a rule configuration error (even in warn-only mode). Print a findings summary in the job output. - tests/test_scan_gates.sh covers both gates with tool stubs. Consumers pick this up when they bump their pinned orb version; repos with existing HIGH/CRITICAL or Semgrep findings will need to triage them then. Co-Authored-By: Claude Opus 5.5 --- src/executors/semgrep.yml | 4 +- src/scripts/run_semgrep.sh | 45 +++++++++++++----- src/scripts/run_trivy.sh | 4 ++ tests/test_scan_gates.sh | 93 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 tests/test_scan_gates.sh 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 ]] -- 2.54.0 From 21517b25e783277740a58a4b939500510436e02d Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 19 Oct 2026 07:50:52 -0500 Subject: [PATCH 2/2] fix: refresh generated JVM runtime packages on every build Co-Authored-By: Codex Signed-off-by: James Maes --- README.md | 2 +- docs/SESSION-STATE.md | 14 ++++++++++ docs/TODO.md | 2 ++ src/scripts/docker_build.sh | 11 +++++++- tests/docker_build.bats | 6 +++++ tests/test_docker_build.sh | 53 +++++++++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/docker_build.bats create mode 100644 tests/test_docker_build.sh diff --git a/README.md b/README.md index 436be33..d701592 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ contexts: **What runs:** Maven build/test, Checkstyle, SpotBugs, PMD, JaCoCo coverage, OWASP + Trivy security, Docker build/push (Dockerfile auto-generated), gitleaks. OWASP dependency check requires the `nvd` context; if omitted, the security scan runs without NVD database enrichment. -**Dockerfile:** Auto-generated production-ready image using Eclipse Temurin JRE Alpine. Includes non-root user (uid 1001), JVM container tuning flags, Docker HEALTHCHECK, and configurable port via `health.port`. The pre-built JAR from CI is copied directly (no redundant Maven build stage). +**Dockerfile:** Auto-generated production-ready image using Eclipse Temurin JRE Alpine. Includes non-root user (uid 1001), JVM container tuning flags, Docker HEALTHCHECK, and configurable port via `health.port`. The pre-built JAR from CI is copied directly (no redundant Maven build stage). PR and release image builds pull the current base and bypass Docker layer caching for generated Java/Gradle runtime images, ensuring the Alpine package upgrade runs on every build. Node builds retain their layer cache. Trivy gates remain required. ### `terraform` diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a796468..29abe49 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,17 @@ # Munitor session state +## JVM runtime package freshness (2026-10-19) + +Website-Backend release6258 and PR scans6266/6277 reused a cached apk upgrade +layer containing vulnerable libexpat. The shared docker_build command now pulls +the base and bypasses layer caching for generated Java/Gradle runtimes, while +Node caching and all Trivy gates remain intact. Narrow behavioral tests cover +both pipeline types, alternate/missing manifests, tags, provenance, and both +PR/release job routes. All eight focused shell cases passed via the coordinating +agent; ShellCheck and git diff --check passed. Full make/BATS/orb validation and +real-image scanning remain pending. Stable release and consumer adoption remain +pending; no unchanged vulnerable image should be retried. + ## PR container scanning (2026-10-13) Uncommitted work on `feature/pr-image-scan` adds `docker_scan` after @@ -84,3 +96,5 @@ after Java installation. APT now receives noninteractive debconf and automatic needrestart settings; a stubbed integration test verifies both APT invocations. These fixes require normal feature-to-develop review and stable tag publication from main before website consumers can select the new version. Nothing deployed. + +Runtime freshness validation: coordinating agent completed make all (lint, pack, orb validation, scripts,277template assertions) and all7BATS tests in Ubuntu. Real consumer image scan remains required after release. diff --git a/docs/TODO.md b/docs/TODO.md index 4d1d0e3..641d742 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,8 @@ ## Pending +- [ ] Release JVM runtime cache fix and adopt the stable orb in Website-Backend; verify fresh image Trivy before release. + - [ ] Run PR image-scan script/template/BATS tests on Linux and verify a consumer PR with Docker/Trivy before publishing. - [x] Add PR-only image scans for the four application templates, supplemental scan-only mode, JSON artifacts, and regression tests (uncommitted). diff --git a/src/scripts/docker_build.sh b/src/scripts/docker_build.sh index bee2465..91dee60 100755 --- a/src/scripts/docker_build.sh +++ b/src/scripts/docker_build.sh @@ -23,7 +23,16 @@ munitor_header "docker_build (${FULL_IMAGE}:${VERSION})" munitor_check_tool docker --version # Build with version tag; add env tag when set (e.g., develop, staging) -BUILD_CMD=(docker build -f "${DOCKERFILE}" -t "${FULL_IMAGE}:${VERSION}") +BUILD_CMD=(docker build --pull -f "${DOCKERFILE}" -t "${FULL_IMAGE}:${VERSION}") +# Generated JVM images only copy a prebuilt JAR. Re-run their package upgrades: +# a cached apk upgrade layer can retain vulnerable packages indefinitely. +CONFIG_FILE="${MUNITOR_CONFIG:-.munitor.yml}" +if [[ -f "${CONFIG_FILE}" ]]; then + munitor_check_tool yq --version + case "$(yq -r '.pipeline' "${CONFIG_FILE}")" in + java-webapp|gradle-webapp) BUILD_CMD+=(--no-cache) ;; + esac +fi if [[ -n "${ENV_TAG}" ]]; then BUILD_CMD+=(-t "${FULL_IMAGE}:${ENV_TAG}") fi diff --git a/tests/docker_build.bats b/tests/docker_build.bats new file mode 100644 index 0000000..84b3a79 --- /dev/null +++ b/tests/docker_build.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "PR and release JVM builds refresh packages without disabling Node caching or Trivy" { + run bash "${BATS_TEST_DIRNAME}/test_docker_build.sh" + [ "$status" -eq 0 ] +} diff --git a/tests/test_docker_build.sh b/tests/test_docker_build.sh new file mode 100644 index 0000000..63c96b5 --- /dev/null +++ b/tests/test_docker_build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Assert the actual shared PR/release build command without a Docker daemon. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT +mkdir -p "${WORK}/bin" "${WORK}/repo" +cat > "${WORK}/bin/docker" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == --version ]] && exit 0 +printf '%s\n' "$@" > "${CALL_LOG}" +MOCK +cat > "${WORK}/bin/yq" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == --version ]] && exit 0 +[[ "$1" == -r && "$2" == .pipeline ]] || exit 2 +sed -n 's/^pipeline: //p' "$3" +MOCK +chmod +x "${WORK}/bin/docker" "${WORK}/bin/yq" +export PATH="${WORK}/bin:${PATH}" +export CALL_LOG="${WORK}/calls" BASH_ENV="${WORK}/env" +export IMAGE_NAME=app REGISTRY=ghcr.io/example PROJECT_VERSION=1.2.3 +export CIRCLE_SHA1=abc123 DOCKER_ENV_TAG=develop +cd "${WORK}/repo" +for pipeline in java-webapp gradle-webapp node-api node-webapp; do + printf 'pipeline: %s\n' "${pipeline}" > .munitor.yml + bash "${PROJECT_DIR}/src/scripts/docker_build.sh" > "${WORK}/output" + grep -qx -- '--pull' "${CALL_LOG}" + if [[ "${pipeline}" == java-webapp || "${pipeline}" == gradle-webapp ]]; then + grep -qx -- '--no-cache' "${CALL_LOG}" + else + ! grep -qx -- '--no-cache' "${CALL_LOG}" + fi + grep -qx 'org.opencontainers.image.revision=abc123' "${CALL_LOG}" + grep -qx 'ghcr.io/example/app:1.2.3' "${CALL_LOG}" + grep -qx 'ghcr.io/example/app:develop' "${CALL_LOG}" + echo "PASS: ${pipeline} cache policy, provenance and tags" +done +mv .munitor.yml 'custom config.yml' +printf 'pipeline: java-webapp\n' > 'custom config.yml' +MUNITOR_CONFIG='custom config.yml' bash "${PROJECT_DIR}/src/scripts/docker_build.sh" > "${WORK}/output" +grep -qx -- '--no-cache' "${CALL_LOG}" +echo 'PASS: alternate manifest path' +bash "${PROJECT_DIR}/src/scripts/docker_build.sh" > "${WORK}/output" +grep -qx -- '--pull' "${CALL_LOG}" +! grep -qx -- '--no-cache' "${CALL_LOG}" +echo 'PASS: standalone build without manifest' +for job in docker_scan docker_build_push; do + grep -q '^ - docker_build:' "${PROJECT_DIR}/src/jobs/${job}.yml" + grep -q '^ - run_trivy_image:' "${PROJECT_DIR}/src/jobs/${job}.yml" + echo "PASS: ${job} uses shared builder and retains Trivy" +done -- 2.54.0