diff --git a/.github/ACTION_PINS.md b/.github/ACTION_PINS.md new file mode 100644 index 0000000..4433bc6 --- /dev/null +++ b/.github/ACTION_PINS.md @@ -0,0 +1,60 @@ +# Reviewed action pins + +Resolved release tags through each authoritative repository Git ref (including +annotated-tag dereferencing) on 2026-09-26 UTC. These are source pins, not claims +that runtime tool downloads are made immutable by pinning an action. + +| Repository/action | Release | Commit | +| --- | --- | --- | +| `actions/checkout` | [v7.0.1](https://github.com/actions/checkout/releases/tag/v7.0.1) | `3d3c42e5aac5ba805825da76410c181273ba90b1` | +| `actions/setup-java` | [v6.0.1](https://github.com/actions/setup-java/releases/tag/v6.0.1) | `de7274f081f381c8f8158605e0321c36c376e2e6` | +| `actions/upload-artifact` | [v4.6.2](https://github.com/actions/upload-artifact/releases/tag/v4.6.2) | `ea165f8d65b6e75b540449e92b4886f43607fa02` | +| `actions/download-artifact` | [v4.3.0](https://github.com/actions/download-artifact/releases/tag/v4.3.0) | `d3f86a106a0bac45b974a628896c90dbdf5c8093` | +| `github/codeql-action` | [v4.38.2](https://github.com/github/codeql-action/releases/tag/v4.38.2) | `2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2` | +| `advanced-security/maven-dependency-submission-action` | [v6.0.1](https://github.com/advanced-security/maven-dependency-submission-action/releases/tag/v6.0.1) | `a64327a7329c9939cf675e458452febe1894a70c` | + +CodeQL uses the `init` and `analyze` subactions at the same commit. No other +external actions or reusable workflows are referenced. Upload/download stay on +the existing v4 major versions; upgrading their packaging protocol is separate +work. All actions are JavaScript actions, not composite actions with hidden +`uses` references. Their metadata, entrypoints and relevant credential/download +paths were reviewed, along with the release changes. + +CodeQL's release tag is annotated: the Git ref points to tag object +`88585263c0627ee42c0e1c5143a112c8d6f4aa18`, which must be dereferenced to the +commit in the table. Do not copy an annotated tag object's SHA into a commit +pin. Verify the object's type and follow tag targets until it is a commit +(equivalently, inspect the peeled `refs/tags/^{}` Git ref). + +- Checkout uses Git and the supplied token for retrieval; persistence is disabled. +- Setup Java runs its bundled JavaScript, downloads Temurin from the upstream + service when absent from the hosted toolcache and verifies the distribution. + Dependency caching is disabled. JDK version lines are intentionally updated + within their supported major versions; a commit pin does not pin the JDK bytes. +- Artifact actions use bundled clients and GitHub's artifact service. Download + is restricted to the same workflow run; no privileged cross-run reuse occurs. +- CodeQL runs bundled JavaScript and obtains its corresponding CodeQL tools/query + bundle. No repository token with content-write permission or custom scanner + secret is available to the analysis jobs. +- Maven submission v6.0.1 runs the checkout's Maven command with the pinned + `com.github.ferstl:depgraph-maven-plugin:4.0.3`, then submits with GitHub's bundled + dependency toolkit. It has no composite action references. Its source logs + the snapshot. Maven plugins and their resolved transitives remain a separate + supply-chain boundary, covered by build graph submission. + +Working allowlist (GitHub-owned/verified blanket allowances are disabled): + +```text +actions/checkout@* +actions/setup-java@* +actions/upload-artifact@* +actions/download-artifact@* +github/codeql-action/init@* +github/codeql-action/analyze@* +advanced-security/maven-dependency-submission-action@* +``` + +GitHub's full-SHA policy separately enforces the immutable action reference. +Dependabot proposes SHA updates weekly; reviewers must repeat source/release and +transitive behavior review, run actionlint, and wait for the full packaging gates. +Future release actions in #95 must satisfy the same inventory and review policy. diff --git a/.github/CI_SECURITY.md b/.github/CI_SECURITY.md new file mode 100644 index 0000000..ee11dd5 --- /dev/null +++ b/.github/CI_SECURITY.md @@ -0,0 +1,121 @@ +# CI and security operations + +## Required coverage and trust boundaries + +`Java CI gate` requires the clean JDK 17 reactor build, JSP/Jakarta source parity, +CI policy tests, the Docker/Selenium browser test, exact reactor JAR inclusion in +the Jakarta WAR, and every ESAPI version from 2.5.1.0 through 2.7.0.0. +`Packaged consumer gate` requires JDK 17 artifact preparation/API and Java 8 +signature checks, package guard tests, the original packaged bytes on Java +8/11/17/21/25, and core/JSP/ESAPI unit tests with the Java 8 JVM and coverage. +JDK 21/25 build probes remain advisory. Require **both** gates: neither observes +the other workflow. The gates run with `always()` and accept only `success` for +every expected dependency; missing, failed, skipped and cancelled jobs fail. + +Both workflows run on pushes to main, pull requests, weekly schedules and manual +dispatch, with bounded timeouts and per-workflow/ref concurrency cancellation. +There are no path filters that could silently omit a required check. Changes to +workflow topology must update the gate dependencies and required check names +only after the replacement checks have succeeded. + +Every checkout disables credential persistence. PRs use `pull_request`, never +`pull_request_target` or privileged `workflow_run` execution. Fork tokens are +read-only and receive no repository secrets; all external contributors require +run approval. A fork's artifacts are consumed only within that same unprivileged +run. Artifact download has no cross-run/repository/token input, preserving the +original packaged bytes. No privileged job restores PR artifacts or caches. + +All Maven invocations use a fresh runner-temporary repository. Shared Maven +caching is deliberately disabled, including Java 8's necessary `install`, all +scanner builds, and intentional release commits. Setup Java's pinned v6.0.1 +does expose cache controls, but this configuration does not rely on any of them. +See [local quarantine guidance](../RELEASING.md#maven-storage-and-repository-controls). + +Baseline main runs [Java CI 36220505798](https://github.com/OWASP/owasp-java-encoder/actions/runs/36220505798) +and [consumers 36220505783](https://github.com/OWASP/owasp-java-encoder/actions/runs/36220505783) +had 14 Maven cache misses and one hit (Java 8 install job). Build took 3m14s; +ESAPI jobs 65–97s; preparation 85s; Java 8 tests 87s; runtimes 12–16s. +The separate clean test-compilation and install lifecycles are now one clean +verify lifecycle. Further core sharing between ESAPI versions is deferred: it +would complicate reactor resolution and package checks for little measured gain. + +## Scanning and dependency updates + +Advanced CodeQL in `codeql.yaml` is the single analysis owner; leave default +setup unconfigured. Java uses a manual JDK 17 build of all four libraries and +the optional `testJakarta` application; Actions and Python tooling use extraction +without a build. It runs on PRs, main, a weekly schedule and manual dispatch. +Only analysis jobs request `security-events: write`; fork PRs use GitHub's +restricted PR SARIF upload path, not a general write token. Reports are retained +as artifacts and in Code scanning. No scanner secrets are required. + +Dependency submission runs only for the trusted default branch, including manual +dispatch. Its only elevated permission is job-level `contents: write` for the +snapshot API. It builds its own checkout without caches or imported artifacts. +Separate correlators submit the normal reactor and the optional Jakarta profile. +The pinned Maven submission action includes all resolved project scopes, +including runtime, test and provided dependencies. Maven dependency plugin +3.9.0 `resolve-plugins` separately resolves build/report plugins and their +transitives; `scripts/build-dependency-snapshot.py` submits those edges as +development dependencies. Graph reports and submission JSON are retained for +inspection. Inspect representative ESAPI/AntiSamy HTTP transitives and Jakarta +Spring/Tomcat dependencies in the resulting graph; alert counts are not gates. + +Dependabot checks all library POMs, the parent and optional app weekly, with +separate Maven and SHA-pinned Actions groups and grouped Maven security updates. +Normal review and complete CI apply to automated PRs; no automatic merging is +configured. Review new action source and transitive downloads as well as pins. +The nonstandard XML files under `compatibility/dependencies` remain explicit +manual compatibility fixtures. In particular Felix 5.6.12 is an intentional +OSGi R6/Java 8 baseline, not a production dependency; the Maven ignore prevents +an automatic baseline replacement. Inspect any advisory against its actual +local bundle-loading test scope, and record a specific disposition. Do not +suppress advisories across all Felix versions or application deployments. + +ESAPI advisory triage lives in [the adapter guide](../esapi/README.md#dependency-security-triage). +Upstream fixes are preferred; tested mitigations remain possible. No transitive +finding is dismissed merely because another library introduces it. Optional +Scorecard publication, best-practices registration and another scheduled scanner +are follow-ups, not prerequisites for these operating controls. + +## Repository controls and recovery + +Before changes, snapshot repository/ruleset, Actions, Pages, collaborator/team +and webhook settings. Store webhook configuration privately; do not publish +endpoint tokens. Record changes and negative tests in issue #169. + +Use read-only default Actions tokens, no bot PR approvals, supported secret +scanning/push protection, all-external-contributor run approval, and an allowlist +of the exact action repositories/paths in [the action inventory](ACTION_PINS.md). +Enable SHA enforcement after pins and the allowlist are ready. Re-read both +Actions endpoints afterward: updating the general permissions endpoint may reset +`github_owned_allowed` to true; reapply the exact allowlist and test rejection of +an unlisted GitHub-owned action as well as a mutable action tag. When adding a +reviewed action, update the allowlist before merging the workflow. Old open PRs +must refresh their workflows before rerunning under the SHA policy. + +Main requires both complete CI gates and successful CodeQL language jobs in a +separate ruleset with no bypass. The review rule keeps one independent approval, +dismisses stale approvals, requires approval of the latest push, and requires +resolution of review threads. The +verified maintainers Jim Manico and Jeremy Long have only +PR-based, audit-visible emergency **review** bypass; it cannot bypass required +checks or directly push main. An emergency bypass is not independent approval. +CODEOWNERS enforcement remains with #127's ownership validation; do not name +unverified owners or treat review rules as CODEOWNERS validation. + +All tags prohibit update/deletion with no bypass; new signed release tags still +follow `RELEASING.md`. GitHub `required_signatures` checks commit signatures, +not annotated tag signatures. Legacy Codacy and Travis webhooks have no current +workflow/required-check owner and return 404/502; disable them while retaining +their private configuration for recovery. Site retirement remains with #96. + +For recovery, an administrator uses Settings or the REST API, records the actor, +reason and affected PR in #169, makes the narrowest temporary change, then +restores and verifies the controls. Do not introduce privileged PR workflows to +repair policy failures. Check names can be repaired without removing review +requirements; action patterns can be added without disabling SHA enforcement. +There is no standing direct-push bypass. Never move a published tag or blanket +disable tag protection. A disposable unpublished policy-test tag can be removed +using an exclusion for that exact tag only, then immediately removing the +exclusion. Review branch cleanup against current main and open PRs individually. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..09ac4cf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: maven + directories: + - / + - /core + - /jsp + - /jakarta + - /esapi + - /jakarta-test + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Etc/UTC + open-pull-requests-limit: 5 + groups: + maven-dependencies: + applies-to: version-updates + patterns: ['*'] + maven-security: + applies-to: security-updates + patterns: ['*'] + # The Felix 5.6.12 fixture is the intentional OSGi R6/Java 8 baseline. + # Review alerts by execution scope; never dismiss or upgrade it blindly. + ignore: + - dependency-name: org.apache.felix:org.apache.felix.framework + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Etc/UTC + open-pull-requests-limit: 3 + groups: + actions: + patterns: ['*'] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c7f98c9..ee01f25 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,43 +5,71 @@ on: branches: - main pull_request: + workflow_dispatch: + schedule: + - cron: '23 6 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read +defaults: + run: + shell: bash + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 25 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" - name: Check JSP and Jakarta source parity run: python3 scripts/check-taglib-parity.py - name: Set up JDK 17 - uses: actions/setup-java@v6 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: java-version: '17' distribution: 'temurin' - cache: maven - - name: Check jakarta-test uses this build's encoder version + - name: Test CI policy boundaries + run: python3 -m unittest discover -s scripts/tests + - name: Verify clean reactor including the required Docker/browser test + run: mvn -B -ntp clean verify -PtestJakarta 2>&1 | tee build.log + - name: Confirm the Jakarta application contains this reactor's exact JAR run: | - evaluate() { - mvn -B -ntp -q "$@" org.apache.maven.plugins:maven-help-plugin:3.5.2:evaluate -DforceStdout - } - reactor=$(evaluate -N -Dexpression=project.version) || { echo "$reactor"; exit 1; } - tested=$(evaluate -f jakarta-test/pom.xml -Dexpression=encoder.version) || { echo "$tested"; exit 1; } - if [ "$reactor" != "$tested" ]; then - echo "::error file=jakarta-test/pom.xml::encoder.version is '$tested' but the build is '$reactor'. Update jakarta-test/pom.xml when bumping the version." - exit 1 - fi - echo "jakarta-test tests encoder-jakarta-jsp $tested from this build" - - name: Test clean reactor compilation - run: mvn -B -ntp clean test-compile - - name: Run build - run: mvn -B -ntp install -PtestJakarta + python3 - <<'PY' + from pathlib import Path + from zipfile import ZipFile + jars = [p for p in Path('jakarta/target').glob('encoder-jakarta-jsp-*.jar') + if not p.name.endswith(('-sources.jar', '-javadoc.jar'))] + assert len(jars) == 1, jars + jar = jars[0] + with ZipFile('jakarta-test/target/jakarta-test.war') as war: + assert war.read('WEB-INF/lib/' + jar.name) == jar.read_bytes() + print('Jakarta WAR contains the exact reactor JAR:', jar.name) + PY + - name: Preserve build and browser diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: reactor-browser-diagnostics + path: | + build.log + **/target/surefire-reports/ + **/target/failsafe-reports/ esapi-compatibility: name: ESAPI ${{ matrix.esapi-version }} runs-on: ubuntu-latest + timeout-minutes: 25 strategy: fail-fast: false matrix: @@ -57,12 +85,32 @@ jobs: - '2.6.2.0' - '2.7.0.0' steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" - name: Set up JDK 17 - uses: actions/setup-java@v6 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: java-version: '17' distribution: 'temurin' - cache: maven - name: Test ESAPI compatibility run: mvn -B -ntp -pl esapi -am verify -Desapi.version=${{ matrix.esapi-version }} + + gate: + name: Java CI gate + if: ${{ always() }} + needs: [build, esapi-compatibility] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Require every build, browser and ESAPI matrix result + env: + NEEDS: ${{ toJSON(needs) }} + run: python3 scripts/check-ci-gate.py build esapi-compatibility diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..b92d881 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,65 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '41 6 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: manual + - language: actions + build-mode: none + - language: python + build-mode: none + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - if: matrix.language == 'java-kotlin' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Compile all libraries and the optional Jakarta application + if: matrix.language == 'java-kotlin' + run: mvn -B -ntp clean package -PtestJakarta -DskipTests + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: /language:${{ matrix.language }} + output: target/codeql-results + - name: Retain analysis reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: codeql-${{ matrix.language }} + path: target/codeql-results diff --git a/.github/workflows/consumer-compatibility.yaml b/.github/workflows/consumer-compatibility.yaml index 9bc2533..7e7f8c8 100644 --- a/.github/workflows/consumer-compatibility.yaml +++ b/.github/workflows/consumer-compatibility.yaml @@ -4,8 +4,14 @@ on: push: branches: [main] pull_request: + schedule: + - cron: '23 6 * * 1' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -19,26 +25,31 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '17' - cache: maven - name: Verify libraries, Java 8 API signatures, and public API compatibility run: mvn -B -ntp clean verify 2>&1 | tee build.log - name: Prepare isolated packaged consumers - run: python3 compatibility/consumers.py prepare 2>&1 | tee prepare.log + run: python3 compatibility/consumers.py prepare --repository "$RUNNER_TEMP/m2" 2>&1 | tee prepare.log - name: Verify artifact guards reject broken packages run: python3 -m unittest discover -s compatibility/tests 2>&1 | tee guards.log - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: packaged-consumers path: target/compatibility if-no-files-found: error - name: Preserve build diagnostics if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: consumer-build-diagnostics path: | @@ -57,12 +68,18 @@ jobs: matrix: java: ['8', '11', '17', '21', '25'] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java }} - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: packaged-consumers path: target/compatibility @@ -71,7 +88,7 @@ jobs: run: python3 compatibility/consumers.py run --runtime ${{ matrix.java }} 2>&1 | tee runtime.log - name: Preserve runtime diagnostics if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: consumer-java-${{ matrix.java }}-diagnostics path: runtime.log @@ -82,14 +99,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: | 8 17 - cache: maven - name: Build with JDK 17 run: mvn -B -ntp -DskipTests install -pl core,jsp,esapi -am 2>&1 | tee build.log - name: Run unit tests and collect coverage with Java 8 @@ -108,7 +130,7 @@ jobs: done - name: Preserve Java 8 unit-test diagnostics if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: consumer-java-8-unit-test-diagnostics path: | @@ -131,17 +153,22 @@ jobs: matrix: java: ['21', '25'] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java }} - cache: maven - name: Verify libraries on a newer build JDK run: mvn -B -ntp clean verify 2>&1 | tee build.log - name: Preserve build diagnostics including compiler warnings if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: advisory-build-${{ matrix.java }}-diagnostics path: | @@ -149,3 +176,18 @@ jobs: **/target/surefire-reports/ **/target/failsafe-reports/ **/target/japicmp/ + + gate: + name: Packaged consumer gate + if: ${{ always() }} + needs: [prepare, runtime, java8-unit-tests] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Require artifact preparation, every runtime and Java 8 unit tests + env: + NEEDS: ${{ toJSON(needs) }} + run: python3 scripts/check-ci-gate.py prepare runtime java8-unit-tests diff --git a/.github/workflows/dependency-submission.yaml b/.github/workflows/dependency-submission.yaml new file mode 100644 index 0000000..069a883 --- /dev/null +++ b/.github/workflows/dependency-submission.yaml @@ -0,0 +1,71 @@ +name: Dependency submission + +on: + push: + branches: [main] + schedule: + - cron: '53 6 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + submit: + # No PR event, untrusted ref, PR artifact or shared cache may enter this job. + if: github.ref == 'refs/heads/main' && github.repository == 'OWASP/owasp-java-encoder' + name: Dependencies (${{ matrix.graph }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - graph: libraries + profile: '' + - graph: jakarta-app + profile: -PtestJakarta + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and isolate Maven storage + run: | + python3 scripts/check-ci-version.py --ref "$GITHUB_REF" + echo "MAVEN_OPTS=-Dmaven.repo.local=$RUNNER_TEMP/m2" >> "$GITHUB_ENV" + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + - name: Submit resolved runtime and test graph + uses: advanced-security/maven-dependency-submission-action@a64327a7329c9939cf675e458452febe1894a70c # v6.0.1 + with: + # Package in the same invocation so the optional app resolves reactor JARs. + maven-args: -B -ntp ${{ matrix.profile }} -DskipTests package + correlator: encoder-${{ matrix.graph }} + - name: Resolve build plugins and their dependencies + env: + PROFILE: ${{ matrix.profile }} + run: mvn -B -ntp ${PROFILE:+"$PROFILE"} org.apache.maven.plugins:maven-dependency-plugin:3.9.0:resolve-plugins -DoutputFile=target/build-dependencies.txt + - name: Submit build graph + env: + GH_TOKEN: ${{ github.token }} + GRAPH: ${{ matrix.graph }} + run: | + python3 scripts/build-dependency-snapshot.py --correlator "encoder-build-$GRAPH" --output target/build-snapshot.json + gh api --method POST "repos/$GITHUB_REPOSITORY/dependency-graph/snapshots" --input target/build-snapshot.json + - name: Preserve resolved graphs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dependency-graphs-${{ matrix.graph }} + path: | + **/target/*dependency*.json + **/target/build-dependencies.txt + target/build-snapshot.json diff --git a/.gitignore b/.gitignore index 140b296..adc5f64 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ nb-configuration.xml /target/ /jakarta/target/ /jakarta-test/target/ + +# Python CI/compatibility tooling +__pycache__/ +*.pyc diff --git a/RELEASING.md b/RELEASING.md index 77ddd2a..61488db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -54,6 +54,11 @@ existing Maven version. 2. Apply reviewed security fixes privately until publication is ready. 3. Set the version in the root POM, each library module's parent POM, and `encoder.version` in `jakarta-test/pom.xml`. Set the root SCM tag to `v`. + `python3 scripts/check-ci-version.py` requires `-SNAPSHOT` plus SCM `HEAD` + during development. The explicit non-snapshot version plus matching + `v` SCM tag is the intentional release-commit form; review these + changes together. This permits CI on the reviewed release PR/commit before + creating a tag. The guard is a consistency check, not release approval. 4. Update README dependency examples, the security policy's supported versions, and the release notes. Include security advisories, compatibility changes, all Maven coordinates, signing fingerprint, and verification commands. @@ -129,3 +134,28 @@ and javadoc.io against the actual publication status as well. Published Maven coordinates are immutable. If release tags are protected, an incorrect tag requires a maintainer to resolve the protection and correction explicitly; never silently move an existing release tag. + +## Maven storage and repository controls + +CI uses `verify` for ordinary builds, including `-PtestJakarta`; it does not need +to install the reactor. The Java 8 test-JVM job deliberately installs libraries +for its second Maven invocation, always in an isolated, uncached runner-temporary +repository. Release builds use the same isolation. No workflow restores or saves +Maven repositories, and packaged consumers use only artifacts from their own +workflow run. See [CI/security operations](.github/CI_SECURITY.md). + +If an older local build installed a released encoder version, inspect only the +affected `~/.m2/repository/org/owasp/encoder//` directories. +Look for local-install provenance in `_remote.repositories` (entries without a +remote repository), compare POM/JAR hashes and signatures with the actual Central +or retained signed release, and move uncertain version directories to a dated +quarantine outside the repository. Re-resolve those coordinates using a fresh +local repository. Do not erase the entire encoder repository: other versions, +snapshots and the exact signed 1.4.1 artifacts may be intentional. Never replace +or publish a release to repair local cache contamination. + +All Git tags are protected against update and deletion. GitHub commit-signature +rules do not verify annotated tag signatures: run `git verify-tag v` +and check the project key fingerprint separately. Repository recovery and the +limited, audit-visible emergency PR review bypass are documented in +[CI/security operations](.github/CI_SECURITY.md#repository-controls-and-recovery). diff --git a/esapi/README.md b/esapi/README.md index cc0331f..3f46646 100644 --- a/esapi/README.md +++ b/esapi/README.md @@ -159,9 +159,14 @@ transitive dependencies for CVE-2025-48976 and CVE-2025-48734. Its upstream POM intentionally depends on the milestone `commons-collections4` 4.5.0-M2; this adapter does not override ESAPI's tested graph. -A review of the default graph on 2026-09-11 also found later advisories in -ESAPI's legacy dependencies and the HTTP Components versions provided through -AntiSamy: +### Dependency security triage + +The resolved dependency submissions and [Dependabot alerts][dependency-alerts] +are the ongoing inventory, including transitive runtime/test dependencies and +build plugins. Review the actual version, path and execution scope of each new +alert; the fact that ESAPI introduces a dependency is not a dismissal reason. +On 2026-09-26 UTC, Maven Central still had 2.7.0.0 as the newest stable ESAPI +release (2.7.0.1-RC1 was a prerelease). The default graph includes these findings: - Commons Configuration 1.10: [GHSA-pvp8-3xj6-8c6x][]; no patched 1.x release - Commons Lang 2.6: [GHSA-j288-q9x7-2f5v][]; no patched 2.x release @@ -169,9 +174,28 @@ AntiSamy: - HttpCore and HttpCore H2 5.3.4: [GHSA-hf6x-8p5f-cgmf][] and [GHSA-v3jc-474w-2wm6][]; patched in 5.4.3 -The adapter does not force untested transitive upgrades. Applications that use -the affected ESAPI or AntiSamy features should assess those advisories and -manage patched versions where compatible. +The Commons Configuration finding concerns resource use while loading untrusted +configuration; delegated ESAPI calls initialize reference configuration, so keep +that configuration trusted. Commons Lang's finding concerns attacker-controlled +class names passed to `ClassUtils.getClass`. HttpClient's finding concerns classic +HTTP response decoding and connection release; HttpCore's findings concern HTTP/1 +header parsing and HTTP/2 HPACK decoding. The adapter's Java Encoder-backed +methods perform string encoding without these HTTP or configuration operations. +Delegated methods and applications using other ESAPI/AntiSamy features have a +different scope, so this is not a blanket application reachability conclusion. + +Disposition: retain these findings for upstream/application assessment; no +blanket suppression or untested POM override is applied. Prefer a stable upstream +ESAPI release with a tested fixed graph. If it is unavailable, a mitigation or +override may be accepted after review of the affected feature's reachability, +API/runtime compatibility, dependency convergence, and the complete adapter and +packaged-consumer matrix. Commons Configuration 2.x and Commons Lang 3.x use +different APIs/namespaces and cannot silently replace the legacy coordinates. +The maintainer team owns this triage. Record the advisory, affected versions, +scope, evidence, owner and recheck date +for any exception; time-limit suppressions and reopen them when assumptions +change. Recheck this disposition on the next ESAPI release or within 90 days. +Do not close an alert simply because it is transitive or adapter tests pass. ESAPI 2.7 disables `encodeForSQL` by default. The adapter preserves that safer behavior; use parameterized queries instead of enabling the legacy method. @@ -182,6 +206,7 @@ on the module path. This stable identity is the one used by the adapter's JPMS dependency declaration. [esapi-security]: https://github.com/ESAPI/esapi-java-legacy/security +[dependency-alerts]: https://github.com/OWASP/owasp-java-encoder/security/dependabot [esapi-latest]: https://github.com/ESAPI/esapi-java-legacy/releases/latest [esapi-release]: https://github.com/ESAPI/esapi-java-legacy/releases/tag/esapi-2.7.0.0 [esapi-url-reference]: https://github.com/ESAPI/esapi-java-legacy/blob/esapi-2.7.0.0/src/main/java/org/owasp/esapi/reference/DefaultEncoder.java#L506-L516 diff --git a/releases/batch-02-validation.md b/releases/batch-02-validation.md new file mode 100644 index 0000000..0f40f9e --- /dev/null +++ b/releases/batch-02-validation.md @@ -0,0 +1,105 @@ +# Batch 02 validation — 2026-09-26 UTC + +Implementation: [PR #173](https://github.com/OWASP/owasp-java-encoder/pull/173), +initial implementation `49bfc33`. This covers #102, #109, #97, #119 and #108 under +[#169](https://github.com/OWASP/owasp-java-encoder/issues/169). No release artifacts, +release tags, library algorithms or dependency versions were changed. + +## Implementation validation + +- Actionlint 1.7.12 passes every workflow. Every external `uses` reference is a + full 40-character SHA; authoritative release mappings and runtime trust review + are in [ACTION_PINS.md](../.github/ACTION_PINS.md). +- Ten Python policy/parser tests pass. Negative cases cover accidental release + versions, mismatched module/application versions, snapshot/tag mismatch, + intentional release and next-snapshot flow, missing/failed/cancelled/skipped + required jobs, malformed build reports, dependency edges and classifiers. +- Local Maven 3.9.12 / OpenJDK 17.0.20.1 `clean verify` passed in a newly created + repository: 2,118 unit tests and eight Failsafe tests, with API compatibility + and Java 8 signatures. No release coordinates were installed into shared + storage. Packaged consumers pass on Java 17, including classpath/module-path + and OSGi; all 11 package-guard tests pass. +- The optional Jakarta application packages and generates its graph in the + same reactor without `install`. Its WAR contains the byte-identical + `encoder-jakarta-jsp` JAR from this build. CI also asserts this after the + required Docker/browser test. Local Docker browser execution was not used; + the real test passed in GitHub CI. +- Local resolved graphs include Commons Configuration 1.10, Commons Lang 2.6, + HttpClient 5.4.4, HttpCore 5.3.4, Spring Web 6.2.19 and Tomcat 10.1.55. + Build-plugin graphs include direct plugins and transitives such as + `maven-compiler-plugin` and `plexus-compiler-javac`, submitted as development + dependencies. The normal and optional-profile submissions have separate + correlators. Default-branch delivery and final SBOM verification are recorded + in #169 after merge; an arbitrary number of alerts is not an acceptance gate. + +All **26 checks** passed on the initial PR head, including the two new gates, +all ten ESAPI versions, every packaged runtime, Java 8 unit tests, the browser +build, three CodeQL analyses and both advisory build probes: + +- [Java CI](https://github.com/OWASP/owasp-java-encoder/actions/runs/36221392615) +- [Packaged consumers](https://github.com/OWASP/owasp-java-encoder/actions/runs/36221392627) +- [CodeQL](https://github.com/OWASP/owasp-java-encoder/actions/runs/36221392642) + +CodeQL's Java, Actions and Python analyses all uploaded reports with zero +findings. The final provenance check replaced CodeQL's annotated tag-object ID +with its dereferenced commit SHA, preserving the reviewed release source. Final +head check evidence is recorded in #169. The browser build took 2m39s versus the 3m14s baseline; packaged +preparation took 1m52s versus 1m25s; Java 8 tests took 1m13s versus 1m27s. +These are single-run observations, not a benchmark. See the baseline cache +inventory and preserved coverage in [CI_SECURITY.md](../.github/CI_SECURITY.md). + +Fork behavior was checked against the real external-contributor PR #168 +[Java CI run](https://github.com/OWASP/owasp-java-encoder/actions/runs/36220576710): +`pull_request`, fork head repository, read-only contents/metadata token, +`Secret source: None`, and successful browser/matrix checks. The new workflows +retain that event boundary and add no PR content-write job, shared Maven cache, +credential persistence, or cross-run artifact source. CodeQL uses GitHub's +restricted fork-PR SARIF path. A new CodeQL run from an external fork was not +manufactured; the full three-language PR analyses passed on #173. + +## Live settings and negative tests + +Settings were read and privately snapshotted before editing. REST readback +confirms read-only default Actions tokens, disabled Actions PR approvals, +`all_external_contributors` approval policy, enabled secret scanning and push +protection, and Pages HTTPS enforcement. HTTPS required the settings UI because +REST returned a certificate-not-found error; both the checked UI and subsequent +REST readback confirm the saved setting. The existing site and Pages source +were preserved. Codacy/Travis webhooks returning 404/502 are disabled, with +configuration retained privately for recovery. + +The exact seven-pattern action allowlist is active, with GitHub-owned and +verified-publisher blanket allowances disabled, and full-SHA enforcement enabled. +Updating the general Actions endpoint initially reset the GitHub-owned allowance; +a live negative probe caught this, and the exact allowlist was reapplied. + +- [Mutable action tag rejected before execution](https://github.com/OWASP/owasp-java-encoder/actions/runs/36221410237). +- [Unlisted pinned GitHub-owned action rejected before execution on rerun](https://github.com/OWASP/owasp-java-encoder/actions/runs/36221409741). + Its initial harmless probe ran and explicitly failed, exposing the reset; + the final startup rejection confirms the corrected policy. +- The all-tags ruleset rejects update and deletion, with no bypass. Both REST + attempts against a disposable test tag returned HTTP 422. The tag was removed + through a temporary exclusion for that exact test ref, then the exclusion was + removed. Every original tag/ref/object was compared unchanged afterward. +- Main's review rules require one approval, stale-review dismissal, latest-push + approval and resolved review threads. The prior all-admin `always` bypass is + replaced by PR-only bypass for verified maintainers `jmanico` and `jeremylong`. + GitHub rejected the otherwise verified team ID, so the supported individual + user actors are used. Emergency bypass is explicitly not independent review. +- A separate no-bypass ruleset requires `Java CI gate`, `Packaged consumer gate`, + and the three CodeQL language jobs, bound to the GitHub Actions application + (15368), with up-to-date branches required. It was enabled only after all five + check names had successful runs. +- A disposable branch with exact copies of both main rulesets rejected a direct + update with an unchanged-tree commit: HTTP 422, PR required and all five status + checks expected. The branch remained unchanged; temporary rulesets and branch + were removed. Main was unchanged. #173 separately remained `REVIEW_REQUIRED` + after successful checks, verifying the normal review gate. + +Recovery steps were recorded before restrictions and are now in +[CI_SECURITY.md](../.github/CI_SECURITY.md#repository-controls-and-recovery). +CODEOWNERS validation stays with #127; site retirement with #96; future release +workflow pins with #95. Optional public Scorecard/best-practices registration and +additional scheduled scanners are explicitly deferred. Existing ESAPI advisories +remain visible with version/reachability/scope triage in the adapter guide; none +is blanket-suppressed. The 1.5 full-backlog release gate remains unchanged. diff --git a/scripts/build-dependency-snapshot.py b/scripts/build-dependency-snapshot.py new file mode 100644 index 0000000..c0b6027 --- /dev/null +++ b/scripts/build-dependency-snapshot.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Convert Maven dependency:resolve-plugins 3.9.0 reports to a GitHub snapshot. + +Runtime/test graphs are submitted by the Maven submission action. This separate +development-scope graph retains each build plugin's resolved dependency edges. +No network or credentials are needed to generate the reviewable JSON file. +""" +import argparse +from datetime import datetime, timezone +import json +import os +from pathlib import Path +from urllib.parse import quote, urlencode + + +def purl(coordinate): + parts = coordinate.split(':') + if len(parts) not in (4, 5) or not all(parts): + raise ValueError('Unexpected Maven artifact coordinate: ' + coordinate) + group, artifact, packaging = parts[:3] + qualifiers = {'type': packaging} + if len(parts) == 5: + qualifiers['classifier'] = parts[3] + return ('pkg:maven/' + quote(group, safe='') + '/' + quote(artifact, safe='') + + '@' + quote(parts[-1], safe='') + '?' + urlencode(sorted(qualifiers.items()))) + + +def parse_report(text): + if 'The following plugins have been resolved:' not in text: + raise ValueError('Missing Maven resolve-plugins report header') + resolved = {} + plugin = None + for line in text.splitlines(): + if not line.strip() or line == 'The following plugins have been resolved:': + continue + if not line.startswith(' ') or line.strip() == 'none': + raise ValueError('Unexpected or empty plugin report: ' + line) + indirect = line.startswith(' ') + key = purl(line.strip()) + node = resolved.setdefault(key, {'package_url': key, 'relationship': 'indirect', + 'scope': 'development', 'dependencies': []}) + if not indirect: + node['relationship'] = 'direct' + plugin = key + elif plugin is None: + raise ValueError('Dependency precedes its plugin') + elif key != plugin and key not in resolved[plugin]['dependencies']: + resolved[plugin]['dependencies'].append(key) + if not resolved: + raise ValueError('Empty build dependency graph') + return resolved + + +def snapshot(root, correlator): + manifests = {} + for report in sorted(root.glob('**/target/build-dependencies.txt')): + pom = (report.parent.parent / 'pom.xml').relative_to(root).as_posix() + if not (root / pom).is_file(): + raise ValueError('No POM for report: ' + str(report)) + manifests[pom + ' (build)'] = { + 'name': pom + ' (build)', 'file': {'source_location': pom}, + 'resolved': parse_report(report.read_text())} + if not manifests: + raise ValueError('No resolved build dependency reports') + return {'version': 0, 'sha': os.environ['GITHUB_SHA'], 'ref': os.environ['GITHUB_REF'], + 'job': {'correlator': correlator, 'id': os.environ['GITHUB_RUN_ID']}, + 'detector': {'name': 'encoder-maven-build-graph', 'version': '1.0.0', + 'url': 'https://github.com/OWASP/owasp-java-encoder'}, + 'scanned': datetime.now(timezone.utc).isoformat(), 'manifests': manifests} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument('--correlator', required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + result = snapshot(args.root.resolve(), args.correlator) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + '\n') + print('Prepared build manifests:', ', '.join(result['manifests'])) diff --git a/scripts/check-ci-gate.py b/scripts/check-ci-gate.py new file mode 100644 index 0000000..58b7915 --- /dev/null +++ b/scripts/check-ci-gate.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Fail closed unless every explicitly required job succeeded.""" +import json +import os +import sys + + +def check(needs, required): + if set(needs) != set(required): + raise ValueError('Unexpected or missing required jobs: ' + repr(needs)) + failed = {name: job.get('result') for name, job in needs.items() + if job.get('result') != 'success'} + if failed: + raise ValueError('Required jobs did not succeed: ' + repr(failed)) + + +if __name__ == '__main__': + check(json.loads(os.environ['NEEDS']), sys.argv[1:]) + print('All required jobs succeeded') diff --git a/scripts/check-ci-version.py b/scripts/check-ci-version.py new file mode 100644 index 0000000..7033908 --- /dev/null +++ b/scripts/check-ci-version.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Check development versions and the explicit, reviewed release-commit form. + +This checks build inputs; it never authorizes publishing or verifies signatures. +""" +import argparse +from pathlib import Path +import re +import xml.etree.ElementTree as ET + +NS = {'p': 'http://maven.apache.org/POM/4.0.0'} + + +def check(root, snapshot_only=False, ref=''): + pom = ET.parse(root / 'pom.xml') + version = pom.findtext('p:version', namespaces=NS) + tag = pom.findtext('p:scm/p:tag', namespaces=NS) + if not version or not re.fullmatch(r'\d+\.\d+\.\d+(?:-SNAPSHOT)?', version): + raise ValueError('Root version must be an explicit x.y.z[-SNAPSHOT]') + snapshot = version.endswith('-SNAPSHOT') + if snapshot: + if tag != 'HEAD': + raise ValueError('Development versions require SCM tag HEAD') + elif snapshot_only or tag != 'v' + version: + raise ValueError('Development requires -SNAPSHOT; an intentional release commit must set SCM tag v') + if ref.startswith('refs/tags/') and (snapshot or ref != 'refs/tags/v' + version): + raise ValueError('Release tag and POM version must match') + for module in pom.findall('p:modules/p:module', NS): + child = ET.parse(root / module.text / 'pom.xml') + if child.findtext('p:parent/p:version', namespaces=NS) != version: + raise ValueError(module.text + ': parent version differs from root') + own_version = child.findtext('p:version', namespaces=NS) + if own_version is not None and own_version != version: + raise ValueError(module.text + ': library version differs from root') + app = ET.parse(root / 'jakarta-test/pom.xml') + if app.findtext('p:properties/p:encoder.version', namespaces=NS) != version: + raise ValueError('jakarta-test must test the current reactor encoder.version') + return 'snapshot' if snapshot else 'release' + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument('--snapshot-only', action='store_true') + parser.add_argument('--ref', default='') + args = parser.parse_args() + try: + print('Version policy passed:', check(args.root, args.snapshot_only, args.ref)) + except (ValueError, ET.ParseError) as error: + parser.exit(1, str(error) + '\n') diff --git a/scripts/tests/test_build_dependency_snapshot.py b/scripts/tests/test_build_dependency_snapshot.py new file mode 100644 index 0000000..4d80a1e --- /dev/null +++ b/scripts/tests/test_build_dependency_snapshot.py @@ -0,0 +1,36 @@ +"""Verify plugin graph parsing keeps edges, scope, classifiers and fails closed.""" +from test_ci_policy import load +import unittest + +graph = load('build-dependency-snapshot') + + +class BuildGraph(unittest.TestCase): + def test_edges_and_direct_precedence(self): + nodes = graph.parse_report(''' +The following plugins have been resolved: + org.example:compiler:jar:1.0 + org.example:shared:jar:2.0 + org.example:shared:jar:tests:2.0 + org.example:shared:jar:2.0 + org.example:compiler:jar:1.0 +''') + compiler = graph.purl('org.example:compiler:jar:1.0') + shared = graph.purl('org.example:shared:jar:2.0') + tests = graph.purl('org.example:shared:jar:tests:2.0') + self.assertEqual([shared, tests], nodes[compiler]['dependencies']) + self.assertEqual('direct', nodes[shared]['relationship']) + self.assertEqual('indirect', nodes[tests]['relationship']) + self.assertTrue(all(n['scope'] == 'development' for n in nodes.values())) + + def test_invalid_or_empty_reports_rejected(self): + for report in ('', 'The following plugins have been resolved:', + 'The following plugins have been resolved:\n none', + 'The following plugins have been resolved:\n a:b:jar:1', + 'The following plugins have been resolved:\n a:b'): + with self.subTest(report=report), self.assertRaises(ValueError): + graph.parse_report(report) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_ci_policy.py b/scripts/tests/test_ci_policy.py new file mode 100644 index 0000000..ea25168 --- /dev/null +++ b/scripts/tests/test_ci_policy.py @@ -0,0 +1,95 @@ +"""Negative tests for the CI release/version and aggregate-result boundaries.""" +import importlib.util +from pathlib import Path +import shutil +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +def load(name): + spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +version = load('check-ci-version') +gate = load('check-ci-gate') + + +class VersionPolicy(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.poms = ['pom.xml', 'core/pom.xml', 'jsp/pom.xml', 'jakarta/pom.xml', + 'esapi/pom.xml', 'jakarta-test/pom.xml'] + for name in self.poms: + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / name, target) + self.current = version.ET.parse(self.root / 'pom.xml').findtext('p:version', namespaces=version.NS) + # Normalize even when these tests run on the intentional release commit. + self.replace(self.current, '9.8.7-SNAPSHOT') + self.replace('v9.8.7-SNAPSHOT', 'HEAD') + + def replace(self, old, new, names=None): + for name in names or self.poms: + path = self.root / name + path.write_text(path.read_text().replace(old, new)) + + def test_snapshot(self): + self.assertEqual('snapshot', version.check(self.root, snapshot_only=True)) + + def test_accidental_release_rejected(self): + self.replace('9.8.7-SNAPSHOT', '9.8.7') + with self.assertRaises(ValueError): + version.check(self.root) + + def test_reviewed_release_and_next_snapshot(self): + self.replace('9.8.7-SNAPSHOT', '9.8.7') + self.replace('HEAD', 'v9.8.7') + self.assertEqual('release', version.check(self.root)) + self.assertEqual('release', version.check(self.root, ref='refs/tags/v9.8.7')) + for kwargs in ({'snapshot_only': True}, {'ref': 'refs/tags/v9.8.6'}): + with self.assertRaises(ValueError): + version.check(self.root, **kwargs) + self.replace('9.8.7', '9.8.8-SNAPSHOT') + self.replace('v9.8.8-SNAPSHOT', 'HEAD') + self.assertEqual('snapshot', version.check(self.root)) + + def test_mismatched_module_or_app(self): + for name in self.poms[1:]: + with self.subTest(name=name): + self.replace('9.8.7-SNAPSHOT', '9.8.6-SNAPSHOT', [name]) + with self.assertRaises(ValueError): + version.check(self.root) + self.replace('9.8.6-SNAPSHOT', '9.8.7-SNAPSHOT', [name]) + + def test_snapshot_cannot_claim_release_tag(self): + with self.assertRaises(ValueError): + version.check(self.root, ref='refs/tags/v9.8.7') + self.replace('HEAD', 'v9.8.7') + with self.assertRaises(ValueError): + version.check(self.root) + + +class GatePolicy(unittest.TestCase): + def test_success(self): + gate.check({'build': {'result': 'success'}, 'matrix': {'result': 'success'}}, ['build', 'matrix']) + + def test_failure_cancel_skip_and_unknown_fail_closed(self): + for result in ('failure', 'cancelled', 'skipped', 'neutral', '', None): + with self.subTest(result=result), self.assertRaises(ValueError): + gate.check({'build': {'result': 'success'}, 'matrix': {'result': result}}, ['build', 'matrix']) + + def test_missing_or_extra_job(self): + for needs in ({}, {'build': {'result': 'success'}, 'other': {'result': 'success'}}): + with self.assertRaises(ValueError): + gate.check(needs, ['build']) + + +if __name__ == '__main__': + unittest.main()