diff --git a/.github/scripts/check-cves.sh b/.github/scripts/check-cves.sh
new file mode 100644
index 0000000000..e2129326b6
--- /dev/null
+++ b/.github/scripts/check-cves.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# Fast CVE check for the full reactor, including the Tycho target platform.
+#
+# Generates an aggregate CycloneDX SBOM (tycho-sbom maps p2 artifacts to their
+# real Maven coordinates), extracts the pkg:maven purls and queries the OSV.dev
+# batch API for known vulnerabilities. Advisories listed in
+# .github/security/cve-ignores.json (with a reason) are filtered out.
+#
+# Self-tests, so a broken scan can never silently pass:
+# - a canary purl with well-known, never-withdrawn advisories is appended to
+# every batch; if OSV reports nothing for it the scan aborts,
+# - if the SBOM yields fewer Maven purls than expected the scan aborts
+# (guards against dependency-resolution regressions producing empty BOMs).
+#
+# osv-scanner is deliberately NOT used here: its CycloneDX ingestion (2.5.0)
+# drops Maven groupIds and its offline mode silently reports zero findings.
+#
+# Usage: check-cves.sh [--skip-sbom]
+# --skip-sbom reuse an existing ddk-parent/target/bom.json (fast local iteration)
+#
+# Environment:
+# CVE_SCAN_THREADS Maven -T value for SBOM generation (default: 2C)
+# CVE_SCAN_MIN_PURLS minimum expected pkg:maven purls (default: 100)
+# CVE_SCAN_CANARY canary purl override (used by negative self-tests)
+#
+# Exit codes: 0 = clean, 1 = findings, 2 = infrastructure or self-test failure.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+BOM="${REPO_ROOT}/ddk-parent/target/bom.json"
+IGNORES="${REPO_ROOT}/.github/security/cve-ignores.json"
+OUT_DIR="${REPO_ROOT}/ddk-parent/target/cve-scan"
+OSV_BATCH_API="https://api.osv.dev/v1/querybatch"
+OSV_VULN_API="https://api.osv.dev/v1/vulns"
+CANARY_PURL="${CVE_SCAN_CANARY:-pkg:maven/commons-collections/commons-collections@3.2.1}"
+MIN_MAVEN_PURLS="${CVE_SCAN_MIN_PURLS:-100}"
+THREADS="${CVE_SCAN_THREADS:-2C}"
+BATCH_SIZE=500
+
+for tool in jq curl mvn; do
+ if ! command -v "${tool}" > /dev/null; then
+ echo "ERROR: '${tool}' is required but not on PATH." >&2
+ [ "${tool}" = "jq" ] && echo " install: brew install jq (macOS) / apt-get install jq (Linux)" >&2
+ exit 2
+ fi
+done
+
+if [ "${1:-}" != "--skip-sbom" ]; then
+ echo "Generating aggregate SBOM (Tycho target platform included)..."
+ sbom_log="$(mktemp)"
+ # dependency:resolve is required: Tycho only injects p2 dependencies into the
+ # Maven model under full dependency resolution; without it the BOM contains
+ # only the reactor's own modules.
+ if ! mvn -f "${REPO_ROOT}/ddk-parent/pom.xml" -T "${THREADS}" --batch-mode --quiet \
+ validate \
+ org.apache.maven.plugins:maven-dependency-plugin:3.8.1:resolve \
+ org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeBom \
+ org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom > "${sbom_log}" 2>&1; then
+ echo "ERROR: SBOM generation failed; last 30 lines:" >&2
+ tail -30 "${sbom_log}" >&2
+ rm -f "${sbom_log}"
+ exit 2
+ fi
+ rm -f "${sbom_log}"
+elif [ ! -f "${BOM}" ]; then
+ echo "ERROR: --skip-sbom given but ${BOM} does not exist." >&2
+ exit 2
+fi
+
+mkdir -p "${OUT_DIR}"
+
+# pkg:maven purls, purl qualifiers (?type=jar) stripped; canary appended last.
+jq -r '[.components[].purl // "" | select(startswith("pkg:maven")) | split("?")[0]] | unique | .[]' \
+ "${BOM}" > "${OUT_DIR}/maven-purls.txt"
+# pkg:p2 purls have no Maven identity and cannot be matched by OSV; they are
+# recorded as the documented coverage gap (deep-scan tier territory).
+jq -r '[.components[].purl // "" | select(startswith("pkg:p2"))] | unique | .[]' \
+ "${BOM}" > "${OUT_DIR}/p2-purls.txt"
+
+maven_count="$(wc -l < "${OUT_DIR}/maven-purls.txt" | tr -d ' ')"
+p2_count="$(wc -l < "${OUT_DIR}/p2-purls.txt" | tr -d ' ')"
+echo "SBOM: ${maven_count} Maven purls to scan, ${p2_count} p2-only components (see cve-scan/p2-purls.txt)."
+
+if [ "${maven_count}" -lt "${MIN_MAVEN_PURLS}" ]; then
+ echo "SCAN SELF-TEST FAILED: only ${maven_count} Maven purls in the SBOM (expected >= ${MIN_MAVEN_PURLS})." >&2
+ echo "The SBOM is likely missing the target platform - do not trust this scan." >&2
+ exit 2
+fi
+
+printf '%s\n' "${CANARY_PURL}" >> "${OUT_DIR}/maven-purls.txt"
+
+# Query OSV in batches; results arrive in query order, so ordering is preserved.
+: > "${OUT_DIR}/osv-results.jsonl"
+total="$((maven_count + 1))"
+offset=0
+while [ "${offset}" -lt "${total}" ]; do
+ jq -R -n --argjson from "${offset}" --argjson size "${BATCH_SIZE}" \
+ '{queries: [inputs] | .[$from:($from + $size)] | map({package: {purl: .}})}' \
+ < "${OUT_DIR}/maven-purls.txt" > "${OUT_DIR}/batch-request.json"
+ if ! curl -sS --fail --retry 3 --retry-delay 2 --max-time 60 \
+ -X POST -d @"${OUT_DIR}/batch-request.json" "${OSV_BATCH_API}" \
+ | jq -c '.results[]' >> "${OUT_DIR}/osv-results.jsonl"; then
+ echo "ERROR: OSV batch query failed (network or API error) - do not trust this scan." >&2
+ exit 2
+ fi
+ offset="$((offset + BATCH_SIZE))"
+done
+
+result_count="$(wc -l < "${OUT_DIR}/osv-results.jsonl" | tr -d ' ')"
+if [ "${result_count}" -ne "${total}" ]; then
+ echo "ERROR: OSV returned ${result_count} results for ${total} queries - do not trust this scan." >&2
+ exit 2
+fi
+
+# Canary self-test: the last result belongs to the canary purl and must carry
+# at least one advisory, otherwise the scan pipeline is broken.
+canary_vulns="$(tail -1 "${OUT_DIR}/osv-results.jsonl" | jq '[.vulns // []] | flatten | length')"
+if [ "${canary_vulns}" -eq 0 ]; then
+ echo "SCAN SELF-TEST FAILED: canary ${CANARY_PURL} returned no advisories." >&2
+ echo "The OSV query pipeline is broken - do not trust this scan." >&2
+ exit 2
+fi
+echo "Canary self-test passed (${canary_vulns} advisories on ${CANARY_PURL})."
+
+# Join purls with their results (canary line dropped by sed).
+paste -d '\t' "${OUT_DIR}/maven-purls.txt" "${OUT_DIR}/osv-results.jsonl" \
+ | sed '$d' \
+ | jq -R -s '
+ [split("\n")[] | select(length > 0) | split("\t")
+ | {purl: .[0], ids: [(.[1] | fromjson).vulns // [] | .[].id]}]
+ | map(select(.ids | length > 0))
+ ' > "${OUT_DIR}/findings-raw.json"
+
+# Drop advisories listed in the ignore ledger (matching id, and purl prefix if given).
+jq --slurpfile ignores "${IGNORES}" '
+ map(. as $f | .ids = [$f.ids[] | . as $id
+ | select(([$ignores[0].ignores[] | . as $e
+ | select($e.id == $id and (($e.purl == null) or ($f.purl | startswith($e.purl))))] | length) == 0)])
+ | map(select(.ids | length > 0))
+ ' "${OUT_DIR}/findings-raw.json" > "${OUT_DIR}/findings.json"
+
+finding_count="$(jq 'length' "${OUT_DIR}/findings.json")"
+if [ "${finding_count}" -eq 0 ]; then
+ echo "OK: no known vulnerabilities in ${maven_count} scanned components (after documented ignores)."
+ exit 0
+fi
+
+echo ""
+echo "VULNERABILITIES FOUND in ${finding_count} component(s):"
+echo ""
+jq -r '.[] | " \(.purl)\n \(.ids | join(", "))"' "${OUT_DIR}/findings.json"
+echo ""
+echo "Advisory details:"
+for id in $(jq -r '[.[].ids[]] | unique | .[]' "${OUT_DIR}/findings.json"); do
+ summary="$(curl -sS --max-time 20 "${OSV_VULN_API}/${id}" | jq -r '.summary // .details // "(no summary)"' | head -1)"
+ echo " ${id}: ${summary}"
+done
+echo ""
+echo "Fix the dependency (usually via ddk-target/ddk.target), or add a justified"
+echo "entry to .github/security/cve-ignores.json (see that file for the format)."
+exit 1
diff --git a/.github/security/cve-ignores.json b/.github/security/cve-ignores.json
new file mode 100644
index 0000000000..a40e2c2f7d
--- /dev/null
+++ b/.github/security/cve-ignores.json
@@ -0,0 +1,41 @@
+{
+ "_comment": [
+ "Ignore ledger for .github/scripts/check-cves.sh.",
+ "Every entry must state the advisory id, why it does not affect shipped DDK",
+ "artifacts (or why the risk is accepted), and a review_by date after which",
+ "it should be re-evaluated. 'purl' (optional) restricts the ignore to purls",
+ "starting with that prefix; without it the advisory is ignored everywhere."
+ ],
+ "ignores": [
+ {
+ "id": "GHSA-f4v5-65jj-pcr2",
+ "purl": "pkg:maven/org.eclipse.jetty/jetty-server",
+ "reason": "Jetty is pulled only by com.avaloq.tools.ddk.xtext.test (SWTBot/Equinox test infrastructure); it is not part of the shipped ddk-repository update site.",
+ "review_by": "2027-02-01"
+ },
+ {
+ "id": "GHSA-2fvj-hgj9-j2gr",
+ "purl": "pkg:maven/org.eclipse.jetty/jetty-security",
+ "reason": "Jetty is pulled only by com.avaloq.tools.ddk.xtext.test (SWTBot/Equinox test infrastructure); it is not part of the shipped ddk-repository update site.",
+ "review_by": "2027-02-01"
+ },
+ {
+ "id": "GHSA-2fvj-hgj9-j2gr",
+ "purl": "pkg:maven/org.eclipse.jetty.ee8/jetty-ee8-security",
+ "reason": "Jetty is pulled only by com.avaloq.tools.ddk.xtext.test (SWTBot/Equinox test infrastructure); it is not part of the shipped ddk-repository update site.",
+ "review_by": "2027-02-01"
+ },
+ {
+ "id": "GHSA-qv9r-c865-cp47",
+ "purl": "pkg:maven/org.apache.logging.log4j/log4j-api",
+ "reason": "Low-severity improper JSON encoding of non-finite floats in MapMessage serialization (CVE-2026-49844); DDK logging does not use MapMessage JSON layouts. Fixed in log4j 2.26.1, which no Eclipse Orbit release ships yet (4.40.0 is newest, carries 2.26.0) - bump ddk.target in a dedicated PR when Orbit picks it up.",
+ "review_by": "2026-11-01"
+ },
+ {
+ "id": "GHSA-rqfh-9r24-8c9r",
+ "purl": "pkg:maven/org.assertj/assertj-core",
+ "reason": "assertj-core 3.24.2 IS part of the shipped ddk-repository (pulled via check.ui/checkcfg.ui). Advisory is an XXE in the isXmlEqualTo assertion (CVSS AV:L) - only exploitable by test code asserting on attacker-controlled XML; accepted until the target platform picks up a fixed assertj.",
+ "review_by": "2026-11-01"
+ }
+ ]
+}
diff --git a/.github/workflows/cve-scan.yml b/.github/workflows/cve-scan.yml
new file mode 100644
index 0000000000..792d4d6564
--- /dev/null
+++ b/.github/workflows/cve-scan.yml
@@ -0,0 +1,39 @@
+name: cve-scan
+on:
+ pull_request:
+ workflow_dispatch:
+jobs:
+ cve-scan:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ - name: Set up Workspace Environment Variable
+ run: echo "WORKSPACE=${{ github.workspace }}" >> $GITHUB_ENV
+ - name: Restore Maven dependency cache
+ # Restore-only: PR scopes cannot share caches with each other, so per-PR
+ # saves are dead weight that evicts the useful master-scoped caches
+ # (10 GB repo budget). The producer is snapshot.yml on master pushes
+ # (Linux-maven-publish-*). Path and key must mirror snapshot.yml exactly:
+ # the literal path spec is hashed into the cache *version*, so any
+ # variation (~/.m2 vs /home/runner/.m2) makes its caches unmatchable.
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-publish-${{ hashFiles('**/pom.xml', '**/*.target') }}
+ restore-keys: ${{ runner.os }}-maven-publish-
+ - name: Check dependencies for known CVEs
+ run: bash .github/scripts/check-cves.sh
+ - name: Archive SBOM and scan results
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: cve-scan-results
+ path: |
+ ddk-parent/target/bom.json
+ ddk-parent/target/cve-scan/
+ retention-days: 30
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b281a9530a..e901c855f9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,6 +12,23 @@ By providing a pull request
You will promptly notify project maintainers if you become aware of any facts or circumstances that would make the above commitments inaccurate in any way.
+## Security scanning
+
+Every pull request is checked for dependencies with known vulnerabilities (the `cve-scan` workflow). To run the same check locally:
+
+```
+bash .github/scripts/check-cves.sh
+```
+
+It needs `jq`, `curl` and network access to the [OSV.dev](https://osv.dev) API (one small batched query), and takes well under a minute with a warm Maven repository. Pass `--skip-sbom` to re-scan without regenerating the SBOM.
+
+When the check fails:
+
+* prefer fixing the dependency, usually by updating `ddk-target/ddk.target`,
+* if the advisory demonstrably does not affect shipped DDK artifacts (e.g. it is confined to test bundles), add an entry with a justification and a review date to `.github/security/cve-ignores.json`.
+
+A failure reading `SCAN SELF-TEST FAILED` means the scan itself is broken or the OSV API is unreachable — the check fails loudly rather than passing silently. Re-run it; do not bypass it.
+
## Guidelines for Pull Requests
* Provide a good pull request description
diff --git a/ddk-parent/pom.xml b/ddk-parent/pom.xml
index 64de805f04..38c921c898 100644
--- a/ddk-parent/pom.xml
+++ b/ddk-parent/pom.xml
@@ -64,6 +64,8 @@
https://dsldevkit.github.io/dsl-devkit/p2/releases/latest/
https://dsldevkit.github.io/dsl-devkit/p2/snapshots/latest/
+
+ ${snapshot.repo.url}
@@ -432,6 +434,24 @@
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ 2.9.1
+
+
+ org.eclipse.tycho
+ tycho-sbom
+ ${tycho.version}
+
+
+
+ json
+ 1.5
+
+ false
+
+