Security & correctness hardening + zero-regression roadmap sweep (22/23 review items) #53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # Builds and runs the full test suite on every push and pull request, so a | |
| # broken build is caught here — before a v* tag fires the publish pipeline. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: | |
| jobs: | |
| test: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: maven | |
| - name: Set up Node.js (JS/TS analyzer tests need a Node runtime) | |
| uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.4.0 | |
| with: | |
| node-version: '20' | |
| - name: Build and test | |
| # `verify`, not `test`: the cli integration tests spawn the daemon and | |
| # need its shaded fat jar, which is only built at the package phase. | |
| run: mvn -B -ntp verify | |
| # Dependency CVE gate + SBOM. Runs independently of `test` so neither blocks | |
| # the other. The SBOM is produced via the `-Psbom` Maven profile (off by | |
| # default — a plain `mvn verify` never touches it), then scanned offline with | |
| # osv-scanner against a cached vulnerability DB. Gate policy (security.md): | |
| # HIGH/CRITICAL (CVSS base score >= 7.0) hard-fail the build; Medium/Low are | |
| # surfaced in the logs but do NOT block (documented, not gated). | |
| security: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| env: | |
| # osv-scanner reads its local DB from here; we cache this directory so | |
| # the NVD/OSV feed is fetched at most once per week, never per-run, and | |
| # the scan itself runs with --offline-vulnerabilities (no live feed). | |
| OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: ${{ github.workspace }}/.osv-db | |
| OSV_SCANNER_VERSION: v2.3.8 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: maven | |
| - name: Generate CycloneDX SBOM | |
| # `package`, because the SBOM aggregates the resolved dependency graph; | |
| # -Psbom is the only thing that activates the cyclonedx plugin. Skip | |
| # tests here — the `test` job already runs them; we only need the graph. | |
| run: mvn -B -ntp -Psbom -DskipTests package | |
| # Plugins/ jars are fetched as part of package; resolution stays inside | |
| # Maven (settings.xml mirrors / Nexus / proxy apply), as elsewhere. | |
| - name: Cache OSV vulnerability database | |
| uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 | |
| with: | |
| path: ${{ env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY }} | |
| # Rotate weekly so the offline DB stays reasonably fresh without | |
| # re-downloading on every run. | |
| key: osv-db-${{ env.OSV_SCANNER_VERSION }}-${{ github.run_id }} | |
| restore-keys: | | |
| osv-db-${{ env.OSV_SCANNER_VERSION }}- | |
| - name: Install osv-scanner (pinned) | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/osv" | |
| curl -fsSL -o "$RUNNER_TEMP/osv/osv-scanner" \ | |
| "https://github.com/google/osv-scanner/releases/download/${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64" | |
| chmod +x "$RUNNER_TEMP/osv/osv-scanner" | |
| echo "$RUNNER_TEMP/osv" >> "$GITHUB_PATH" | |
| - name: Scan dependencies (offline DB) and gate on HIGH/CRITICAL | |
| run: | | |
| set -uo pipefail | |
| mkdir -p "$OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY" | |
| # outputName uses ${project.version}; resolve the actual emitted file. | |
| SBOM=$(ls target/*-bom.json | head -n1) | |
| echo "Scanning SBOM: $SBOM" | |
| # --offline-vulnerabilities: never contacts the live feed during scan. | |
| # --download-offline-databases: refresh the cached DB if cache missed. | |
| # osv-scanner exits 1 on ANY finding; we ignore its exit code and gate | |
| # ourselves on CVSS severity from the JSON instead. | |
| osv-scanner scan \ | |
| --offline-vulnerabilities \ | |
| --download-offline-databases \ | |
| --format json \ | |
| --sbom "$SBOM" > osv-results.json || true | |
| # Gate: max_severity is a CVSS base-score string per vulnerability | |
| # group. >= 7.0 == HIGH or CRITICAL (CVSS v3). Anything below is | |
| # non-blocking per security.md. An unscored group (null or "") is | |
| # coerced to 0 so a missing CVSS never crashes or trips the gate. | |
| SEV='((.max_severity // "0") | if . == "" then "0" else . end | tonumber)' | |
| HIGH=$(jq "[.results[]?.packages[]?.groups[]? | ${SEV} | select(. >= 7.0)] | length" osv-results.json) | |
| echo "HIGH/CRITICAL findings (CVSS >= 7.0): ${HIGH}" | |
| jq -r ".results[]?.packages[]? | .package as \$p | .groups[]? | ${SEV} as \$s | select(\$s >= 7.0) | \" BLOCK \(\$p.name)@\(\$p.version) CVSS=\(.max_severity) \(.ids | join(\",\"))\"" osv-results.json || true | |
| if [ "${HIGH:-0}" -gt 0 ]; then | |
| echo "::error::${HIGH} HIGH/CRITICAL dependency vulnerabilit(ies) found — failing per security policy." | |
| exit 1 | |
| fi | |
| echo "No HIGH/CRITICAL dependency vulnerabilities. (Medium/Low, if any, are non-blocking.)" | |
| - name: Upload SBOM + scan results | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: sbom-and-cve-scan | |
| path: | | |
| target/*-bom.json | |
| target/*-bom.xml | |
| osv-results.json | |
| if-no-files-found: warn | |
| # Byte-reproducible build gate. Builds the dist artifact twice (clean | |
| # between), with the fixed project.build.outputTimestamp from the pom | |
| # normalizing every archive entry's timestamp and order. If the two | |
| # sha256 digests of target/*dist*.zip differ, the build is not | |
| # reproducible and the job fails. The dist zip is emitted by the | |
| # maven-assembly-plugin `build-dist-zip` execution bound to the `package` | |
| # phase (target/sonar-predictor-dist-<version>.zip), so `package` is the | |
| # exact goal that produces it; -DskipTests still runs the | |
| # process-test-classes shade execs the assembly's lib/ jars depend on. | |
| reproducible: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: maven | |
| - name: Set up Node.js (JS/TS analyzer tests need a Node runtime) | |
| uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.4.0 | |
| with: | |
| node-version: '20' | |
| - name: Build dist (1st) and record digest | |
| run: | | |
| set -euo pipefail | |
| mvn -B -ntp -DskipTests clean package | |
| ZIP1=$(ls target/*dist*.zip | head -n1) | |
| echo "First dist zip: $ZIP1" | |
| sha256sum "$ZIP1" | awk '{print $1}' > /tmp/dist-hash-1.txt | |
| cat /tmp/dist-hash-1.txt | |
| - name: Build dist (2nd) and record digest | |
| run: | | |
| set -euo pipefail | |
| mvn -B -ntp -DskipTests clean package | |
| ZIP2=$(ls target/*dist*.zip | head -n1) | |
| echo "Second dist zip: $ZIP2" | |
| sha256sum "$ZIP2" | awk '{print $1}' > /tmp/dist-hash-2.txt | |
| cat /tmp/dist-hash-2.txt | |
| - name: Compare digests (fail if not byte-reproducible) | |
| run: | | |
| set -euo pipefail | |
| H1=$(cat /tmp/dist-hash-1.txt) | |
| H2=$(cat /tmp/dist-hash-2.txt) | |
| echo "Build 1: $H1" | |
| echo "Build 2: $H2" | |
| if [ "$H1" != "$H2" ]; then | |
| echo "::error::Dist zip is not byte-reproducible ($H1 != $H2)." | |
| exit 1 | |
| fi | |
| echo "Dist zip is byte-reproducible: $H1" | |
| # Hermetic offline build. First warms the local repo (~/.m2) and the | |
| # plugins/ analyzer copies over the network, then proves `mvn -o clean | |
| # verify` succeeds with NO network access. dependency:go-offline alone is | |
| # insufficient: the maven-dependency-plugin `copy` executions fetch the 10 | |
| # analyzer jars (and the host jar is built locally), so a normal | |
| # `-DskipTests package` is run first to populate ~/.m2 and plugins/ before | |
| # the offline verify. | |
| offline-build: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: maven | |
| - name: Set up Node.js (JS/TS analyzer tests need a Node runtime) | |
| uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.4.0 | |
| with: | |
| node-version: '20' | |
| - name: Warm local repo (resolve plugins, deps, analyzer copies) | |
| run: | | |
| set -euo pipefail | |
| # Resolve the plugin/dependency graph... | |
| mvn -B -ntp dependency:go-offline | |
| # ...then a full package to populate ~/.m2 with the analyzer jars | |
| # pulled by the dependency:copy executions and to build the local | |
| # host/shade jars the assembly needs. | |
| mvn -B -ntp -DskipTests package | |
| - name: Offline verify (no network) | |
| run: | | |
| set -euo pipefail | |
| # -o forces fully offline: any unresolved artifact fails here, | |
| # proving the warmed repo is self-sufficient. | |
| mvn -B -o clean verify |