diff --git a/.github/workflows/action-test.yml b/.github/workflows/action-test.yml new file mode 100644 index 0000000..06e4a11 --- /dev/null +++ b/.github/workflows/action-test.yml @@ -0,0 +1,93 @@ +name: Action self-test + +on: + pull_request: + paths: + - action.yml + - scripts/** + - gitlab/** + - .github/workflows/action-test.yml + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + CONFIGURATION_URL: https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v2026-01-31/xrechnung-3.0.2-validator-configuration-2026-01-31.zip + CONFIGURATION_SHA256: 6a5a5911a421b25fbc423f62f93f894df7b236f5d73ca4f84bb222a945082704 + VALIDATOR_SHA256: 244978514ad48f67c7573acfffc8f4fd73d81feda6f276710033f9913579857e + # A conformant XRechnung 3.0.2 reference instance from the KoSIT test suite. + SAMPLE_URL: https://raw.githubusercontent.com/itplr-kosit/xrechnung-testsuite/b3791d47a2e1446b1751a27cb732aac294231293/src/test/business-cases/standard/01.01a-INVOICE_uncefact.xml + +jobs: + accepts-a-valid-document: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + # "always" proves the sealed Docker path — once through the + # empty-input fallback, once with an explicit image override — + # and "never" the host JVM path. + include: + - sealed: always + image: "" + - sealed: always + image: eclipse-temurin:21-jre + - sealed: never + image: "" + steps: + - uses: actions/checkout@v7 + + - name: Fetch a conformant reference instance + run: | + mkdir samples + curl -fsSL "$SAMPLE_URL" -o samples/invoice.xml + + - name: Validate (must accept) + id: validate + uses: ./ + with: + version: "1.6.2" + checksum: ${{ env.VALIDATOR_SHA256 }} + configuration: ${{ env.CONFIGURATION_URL }} + configuration-checksum: ${{ env.CONFIGURATION_SHA256 }} + files: samples/ + sealed: ${{ matrix.sealed }} + image: ${{ matrix.image }} + + - name: Outputs are reported + run: | + test "${{ steps.validate.outputs.accepted }}" = "1" + test "${{ steps.validate.outputs.rejected }}" = "0" + test -d "${{ steps.validate.outputs.report-dir }}" + + rejects-a-broken-document: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Fetch and break a reference instance + run: | + mkdir samples + curl -fsSL "$SAMPLE_URL" -o samples/invoice.xml + # Corrupt the issue date (BT-2): schema-invalid, must be rejected. + sed -i '0,/format="102">[0-9]*not-a-date-standalone.jar -s -D The [daemon documentation](./docs/daemon.md) shows more usage details and further configuration options. +### Continuous Integration + +The repository ships a GitHub Action and a GitLab CI template so documents can be validated as part of a pipeline. + +Validate a directory of XML documents against the current XRechnung configuration with the GitHub Action: + +```yaml +- uses: itplr-kosit/validator@main + with: + version: "1.6.2" + configuration: "https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v2026-01-31/xrechnung-3.0.2-validator-configuration-2026-01-31.zip" + files: invoices/ +``` + +The action downloads the released standalone jar, runs every document and fails the job if any document is rejected, printing the failing rule ids. The optional `checksum` and `configuration-checksum` inputs verify the downloads against a SHA-256 pin. The reports are written to `report-dir` (default `validator-reports`), and the `accepted`/`rejected` counts are exposed as outputs. + +When Docker is available on the runner the action executes the validator inside an `eclipse-temurin:21-jre` container without network access, a read-only root, no capabilities and no privilege escalation; the document and the configuration are mounted read-only, only the report directory is writable, and downloads always happen outside the container, so the validator itself never touches the network. The `sealed` input controls this (`auto` default, `always`, `never`), and the `image` input replaces the default image — the floating tag receives Java security patches automatically, while a digest-pinned reference can be passed for a stricter supply-chain policy. + +On GitLab, include [`gitlab/validator.gitlab-ci.yml`](./gitlab/validator.gitlab-ci.yml) and extend the `.kosit-validate` job template; its variables mirror the action inputs and its job image is the same pinned runtime the sealed action uses, so both platforms execute the validator in an identical environment. + ## Packages The Validator distribution contains the following artifacts: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..188855b --- /dev/null +++ b/action.yml @@ -0,0 +1,103 @@ +name: KoSIT Validator +description: >- + Validate XML documents against a validator configuration (e.g. XRechnung) + using the KoSIT validator. Downloads a released validator jar, optionally + verifies checksums, runs each file and fails if any document is rejected. +author: KoSIT contributors +branding: + icon: check-circle + color: green + +inputs: + version: + description: >- + Validator release to use (a tag of this repository without the leading + "v", e.g. "1.6.2"). The standalone jar is downloaded from the GitHub + release of this repository. + required: false + default: "1.6.2" + checksum: + description: >- + Optional SHA-256 checksum of the validator jar. When set, the download + is verified against it and the action fails on a mismatch. + required: false + default: "" + configuration: + description: >- + The validator configuration: either a URL to a configuration zip (for + example a release asset of validator-configuration-xrechnung) or a + path to an already unpacked configuration directory containing + scenarios.xml. + required: true + configuration-checksum: + description: >- + Optional SHA-256 checksum of the configuration zip. Only used when + "configuration" is a URL. + required: false + default: "" + files: + description: >- + XML documents to validate: a file, a directory (validated recursively, + *.xml) or a shell glob. + required: true + report-dir: + description: Directory the validation reports are written to. + required: false + default: validator-reports + sealed: + description: >- + Execute the validator inside a Docker container without network access, + a read-only root, no capabilities and no privilege escalation; the + document, the jar and the configuration are mounted read-only, only + the report directory is writable. "auto" (default) seals when + Docker is available and falls back to the host JVM otherwise; "always" + fails when Docker is unavailable; "never" always uses the host JVM. + Downloads are always performed on the host, so the validator itself + never needs the network. + required: false + default: auto + image: + description: >- + Container image for the sealed execution. The default floats on the + Java 21 JRE contract so runtime security patches arrive without a + change to this repository; pass a digest-pinned reference for a + stricter supply-chain policy. + required: false + default: eclipse-temurin:21-jre + +outputs: + report-dir: + description: Directory containing the validation reports. + value: ${{ steps.validate.outputs.report-dir }} + accepted: + description: Number of accepted documents. + value: ${{ steps.validate.outputs.accepted }} + rejected: + description: Number of rejected documents. + value: ${{ steps.validate.outputs.rejected }} + +runs: + using: composite + steps: + # The host JVM is only used by unsealed runs: "never", or "auto" on a + # runner without Docker. "always" executes exclusively in the container. + - name: Set up Java + if: ${{ inputs.sealed != 'always' }} + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Validate + id: validate + shell: bash + env: + KOSIT_VERSION: ${{ inputs.version }} + KOSIT_CHECKSUM: ${{ inputs.checksum }} + KOSIT_CONFIGURATION: ${{ inputs.configuration }} + KOSIT_CONFIGURATION_CHECKSUM: ${{ inputs.configuration-checksum }} + KOSIT_FILES: ${{ inputs.files }} + KOSIT_REPORT_DIR: ${{ inputs.report-dir }} + KOSIT_SEALED: ${{ inputs.sealed }} + KOSIT_IMAGE: ${{ inputs.image }} + run: bash "$GITHUB_ACTION_PATH/scripts/validate.sh" diff --git a/gitlab/validator.gitlab-ci.yml b/gitlab/validator.gitlab-ci.yml new file mode 100644 index 0000000..40db646 --- /dev/null +++ b/gitlab/validator.gitlab-ci.yml @@ -0,0 +1,55 @@ +# Reusable GitLab CI template for the KoSIT validator. +# +# Include it and extend the hidden job: +# +# include: +# - remote: "https://raw.githubusercontent.com/itplr-kosit/validator//gitlab/validator.gitlab-ci.yml" +# +# validate-invoices: +# extends: .kosit-validate +# variables: +# KOSIT_SCRIPT_REF: "" +# KOSIT_CONFIGURATION: "https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v2026-01-31/xrechnung-3.0.2-validator-configuration-2026-01-31.zip" +# KOSIT_FILES: "invoices/" +# +# The job fetches scripts/validate.sh — the same engine the GitHub Action +# runs — from this repository at KOSIT_SCRIPT_REF. Use the same ref for +# the include and the script, and pin a tag or commit for reproducible +# pipelines. The variables are the script's interface: +# +# KOSIT_VERSION validator release, default 1.6.2 +# KOSIT_CHECKSUM optional SHA-256 of the validator jar +# KOSIT_CONFIGURATION configuration zip URL, or a path to an +# unpacked configuration with scenarios.xml +# KOSIT_CONFIGURATION_CHECKSUM optional SHA-256 of the configuration zip +# KOSIT_FILES file, directory or glob of XML documents +# KOSIT_REPORT_DIR report output directory, default validator-reports +# +# The job image is the same runtime the GitHub Action uses for its sealed +# execution, so both platforms run the validator in an identical +# environment. The tag pins the Java 21 JRE contract and floats the patch +# level; override KOSIT_IMAGE with a digest-pinned reference for a +# stricter policy. Docker is not available inside the job container, so +# the script runs its host path — which here already is the pinned image. + +.kosit-validate: + image: $KOSIT_IMAGE + variables: + KOSIT_IMAGE: "eclipse-temurin:21-jre" + KOSIT_SCRIPT_REF: "main" + KOSIT_VERSION: "1.6.2" + KOSIT_CHECKSUM: "" + KOSIT_CONFIGURATION: "" + KOSIT_CONFIGURATION_CHECKSUM: "" + KOSIT_FILES: "" + KOSIT_REPORT_DIR: "validator-reports" + KOSIT_SEALED: "never" + KOSIT_CACHE: "$CI_PROJECT_DIR/.kosit-cache" + script: + - apt-get update -qq >/dev/null && apt-get install -qqy --no-install-recommends curl unzip >/dev/null + - curl -fsSL "https://raw.githubusercontent.com/itplr-kosit/validator/$KOSIT_SCRIPT_REF/scripts/validate.sh" -o /tmp/kosit-validate.sh + - bash /tmp/kosit-validate.sh + artifacts: + when: always + paths: + - $KOSIT_REPORT_DIR diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..6e727f9 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Validate XML documents with a released validator jar. The shared engine +# behind the GitHub Action (action.yml) and the GitLab CI template +# (gitlab/validator.gitlab-ci.yml); configured through environment +# variables so both platforms drive the identical logic. +# +# KOSIT_VERSION validator release, e.g. "1.6.2" +# KOSIT_CHECKSUM optional SHA-256 of the validator jar +# KOSIT_CONFIGURATION URL of a configuration zip, or path to an +# unpacked configuration containing scenarios.xml +# KOSIT_CONFIGURATION_CHECKSUM optional SHA-256 of the configuration zip +# KOSIT_FILES file, directory (recursive *.xml) or glob +# KOSIT_REPORT_DIR report output directory (default validator-reports) +# KOSIT_SEALED auto (default) | always | never — run the +# validator in a Docker container without network, +# a read-only root, no capabilities and no +# privilege escalation +# KOSIT_IMAGE container image for sealed runs (default +# eclipse-temurin:21-jre; floats the Java 21 JRE +# contract — pass a digest-pinned reference for a +# stricter policy) +# KOSIT_CACHE download cache directory +# +# Exit 0 iff every document is accepted. When GITHUB_OUTPUT is set, the +# accepted/rejected counts and the report directory are written to it. +set -euo pipefail + +VERSION="${KOSIT_VERSION:-1.6.2}" +CHECKSUM="${KOSIT_CHECKSUM:-}" +CONFIGURATION="${KOSIT_CONFIGURATION:-}" +CONFIGURATION_CHECKSUM="${KOSIT_CONFIGURATION_CHECKSUM:-}" +FILES="${KOSIT_FILES:-}" +REPORT_DIR="${KOSIT_REPORT_DIR:-validator-reports}" +SEALED="${KOSIT_SEALED:-auto}" +IMAGE="${KOSIT_IMAGE:-eclipse-temurin:21-jre}" +CACHE="${KOSIT_CACHE:-${RUNNER_TEMP:-/tmp}/kosit-validator}" + +verify() { # + if command -v sha256sum >/dev/null 2>&1; then + echo "$1 $2" | sha256sum -c - >/dev/null + else + echo "$1 $2" | shasum -a 256 -c - >/dev/null + fi +} + +mkdir -p "$CACHE" + +jar="$CACHE/validator-$VERSION-standalone.jar" +if [ ! -f "$jar" ]; then + echo "Downloading validator $VERSION..." + curl -fsSL \ + "https://github.com/itplr-kosit/validator/releases/download/v$VERSION/validator-$VERSION-standalone.jar" \ + -o "$jar" +fi +if [ -n "$CHECKSUM" ]; then + verify "$CHECKSUM" "$jar" +fi + +case "$CONFIGURATION" in + http://*|https://*) + config_dir="$CACHE/configuration" + if [ ! -f "$config_dir/scenarios.xml" ]; then + echo "Downloading configuration..." + curl -fsSL "$CONFIGURATION" -o "$CACHE/configuration.zip" + if [ -n "$CONFIGURATION_CHECKSUM" ]; then + verify "$CONFIGURATION_CHECKSUM" "$CACHE/configuration.zip" + fi + unzip -o -q "$CACHE/configuration.zip" -d "$config_dir" + fi + ;; + *) + config_dir="$CONFIGURATION" + ;; +esac +if [ ! -f "$config_dir/scenarios.xml" ]; then + echo "No scenarios.xml in configuration: $CONFIGURATION" >&2 + exit 2 +fi + +shopt -s nullglob +files=() +if [ -d "$FILES" ]; then + while IFS= read -r f; do files+=("$f"); done \ + < <(find "$FILES" -name '*.xml' -type f | sort) +else + for f in $FILES; do files+=("$f"); done +fi +if [ "${#files[@]}" -eq 0 ]; then + echo "No XML documents matched: $FILES" >&2 + exit 2 +fi + +# Sealed execution: the validator runs in a JRE container with no network, +# a read-only root, no capabilities, no privilege escalation, an init +# process and a pids limit, as the caller's uid. The document, the jar and +# the configuration are mounted read-only; only the report directory and +# /tmp (a bounded tmpfs) are writable. Downloads happened above, on the host. +use_docker=0 +case "$SEALED" in + never) ;; + always) + if ! docker info >/dev/null 2>&1; then + echo "KOSIT_SEALED=always, but Docker is unavailable" >&2 + exit 2 + fi + use_docker=1 + ;; + auto|"") + if docker info >/dev/null 2>&1; then use_docker=1; fi + ;; + *) + echo "Unknown value for KOSIT_SEALED: $SEALED" >&2 + exit 2 + ;; +esac + +run_validator() { # + if [ "$use_docker" = "1" ]; then + workdir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/kosit-run.XXXXXX")" + cp "$1" "$workdir/$(basename "$1")" + mkdir -p "$workdir/reports" + docker run --rm --init --network none --read-only \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \ + --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 \ + --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$jar:/opt/validator.jar:ro" \ + -v "$config_dir:/opt/configuration:ro" \ + -v "$workdir:/work:ro" \ + -v "$workdir/reports:/work/reports" -w /work/reports \ + "$IMAGE" \ + java -jar /opt/validator.jar -s /opt/configuration/scenarios.xml \ + -r /opt/configuration -o /work/reports "/work/$(basename "$1")" + status=$? + cp "$workdir/reports/"* "$2"/ 2>/dev/null || true + rm -rf "$workdir" + return "$status" + fi + java -jar "$jar" -s "$config_dir/scenarios.xml" \ + -r "$config_dir" -o "$2" "$1" +} + +mkdir -p "$REPORT_DIR" +rule_ids='\[[A-Z][A-Z0-9]*(-[A-Z0-9]+)+\]' +accepted=0 +rejected=0 +for f in "${files[@]}"; do + if output="$(run_validator "$f" "$REPORT_DIR" 2>&1)"; then + accepted=$((accepted + 1)) + echo "PASS $f" + else + rejected=$((rejected + 1)) + report="$REPORT_DIR/$(basename "${f%.xml}")-report.xml" + ids="$( { printf '%s' "$output"; [ -f "$report" ] && cat "$report"; } \ + | grep -oE "$rule_ids" | sort -u | paste -sd' ' - || true)" + echo "FAIL $f ${ids:-}" + printf '%s\n' "$output" | grep -iE 'rejected|error' || true + fi +done + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "report-dir=$REPORT_DIR" + echo "accepted=$accepted" + echo "rejected=$rejected" + } >> "$GITHUB_OUTPUT" +fi +echo "Accepted: $accepted Rejected: $rejected" +[ "$rejected" -eq 0 ]