From 58142ceee38b8ba75f62b77dea534f3b40450a6d Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 11:33:22 +0200 Subject: [PATCH 01/37] Add JSON vulnerability scan outputs --- .github/workflows/sbom-vulnerability-scan.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 44fcec8..a5d4c5d 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -83,6 +83,15 @@ jobs: fail-build: ${{ inputs.fail-build }} output-format: sarif + - name: Generate Docker vulnerability JSON report + if: inputs.dockerfile != '' + id: docker-vulnerability-json + uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 + with: + sbom: docker.cyclonedx.json + fail-build: false + output-format: json + - name: Upload Docker vulnerability results to GitHub Security if: inputs.dockerfile != '' uses: github/codeql-action/upload-sarif@v4 @@ -143,6 +152,15 @@ jobs: fail-build: ${{ inputs.fail-build }} output-format: sarif + - name: Generate Python vulnerability JSON report + if: inputs.requirements != '' || inputs.pyproject != '' + id: python-vulnerability-json + uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 + with: + path: .venv + fail-build: false + output-format: json + - name: Upload Python vulnerability results to GitHub Security if: inputs.requirements != '' || inputs.pyproject != '' uses: github/codeql-action/upload-sarif@v4 From b22bf6160b1eaf0449095274f43cbc2f02fcf677 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 15:28:10 +0200 Subject: [PATCH 02/37] Add OpenVEX report generation --- .github/workflows/sbom-vulnerability-scan.yml | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index a5d4c5d..caef933 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -36,6 +36,17 @@ on: default: false type: boolean + generate-openvex: + description: "Generate an OpenVEX document from detected vulnerabilities" + required: false + default: false + type: boolean + + product-id: + description: "Product identifier (PURL or IRI) used in the OpenVEX document" + required: false + type: string + jobs: sbom-vulnerability-scan: runs-on: ubuntu-latest @@ -54,11 +65,22 @@ jobs: exit 1 fi + if [ "${{ inputs.generate-openvex }}" = "true" ] && [ -z "${{ inputs.product-id }}" ]; then + echo "product-id is required when generate-openvex is enabled." + exit 1 + fi + - name: Checkout the code uses: actions/checkout@v7 with: fetch-depth: ${{ inputs.fetch_depth }} + - name: Install vexctl + if: inputs.generate-openvex + uses: openvex/setup-vexctl@e85ca48f3c8a376289f6476129d59cda82147e71 # v0.1.1 + with: + vexctl-release: '0.4.4' + # Docker - name: Build the Docker image if: inputs.dockerfile != '' @@ -84,7 +106,7 @@ jobs: output-format: sarif - name: Generate Docker vulnerability JSON report - if: inputs.dockerfile != '' + if: inputs.dockerfile != '' && inputs.generate-openvex id: docker-vulnerability-json uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 with: @@ -92,6 +114,42 @@ jobs: fail-build: false output-format: json + - name: Generate Docker OpenVEX report + if: inputs.dockerfile != '' && inputs.generate-openvex + env: + GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.report }} + PRODUCT_ID: ${{ inputs.product-id }} + run: | + mkdir -p .openvex/docker + + jq -r '.matches[].vulnerability.id' "$GRYPE_REPORT" \ + | sort -u > .openvex/docker/vulnerabilities.txt + + count=0 + + while IFS= read -r vuln; do + [ -z "$vuln" ] && continue + + vexctl create \ + --author "mundialis" \ + --author-role "Software Dev" \ + --file ".openvex/docker/vex-${count}.json" \ + "$PRODUCT_ID" \ + "$vuln" \ + "under_investigation" \ + >/dev/null 2>&1 + + count=$((count + 1)) + done < .openvex/docker/vulnerabilities.txt + + if [ "$count" -gt 0 ]; then + vexctl merge \ + --author "mundialis" \ + --author-role "Software Dev" \ + .openvex/docker/vex-*.json \ + > .openvex/docker/openvex.json 2>/dev/null + fi + - name: Upload Docker vulnerability results to GitHub Security if: inputs.dockerfile != '' uses: github/codeql-action/upload-sarif@v4 @@ -153,7 +211,7 @@ jobs: output-format: sarif - name: Generate Python vulnerability JSON report - if: inputs.requirements != '' || inputs.pyproject != '' + if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex id: python-vulnerability-json uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 with: From 1fccdef3930bf63eec747fb9d596e90aa1c3868a Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 16:01:32 +0200 Subject: [PATCH 03/37] Use vexctl container for OpenVEX generation --- .github/workflows/sbom-vulnerability-scan.yml | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index caef933..3cd8dde 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -75,12 +75,6 @@ jobs: with: fetch-depth: ${{ inputs.fetch_depth }} - - name: Install vexctl - if: inputs.generate-openvex - uses: openvex/setup-vexctl@e85ca48f3c8a376289f6476129d59cda82147e71 # v0.1.1 - with: - vexctl-release: '0.4.4' - # Docker - name: Build the Docker image if: inputs.dockerfile != '' @@ -130,7 +124,12 @@ jobs: while IFS= read -r vuln; do [ -z "$vuln" ] && continue - vexctl create \ + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:/work" \ + -w /work \ + ghcr.io/openvex/vexctl:v0.4.4 \ + create \ --author "mundialis" \ --author-role "Software Dev" \ --file ".openvex/docker/vex-${count}.json" \ @@ -143,7 +142,12 @@ jobs: done < .openvex/docker/vulnerabilities.txt if [ "$count" -gt 0 ]; then - vexctl merge \ + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:/work" \ + -w /work \ + ghcr.io/openvex/vexctl:v0.4.4 \ + merge \ --author "mundialis" \ --author-role "Software Dev" \ .openvex/docker/vex-*.json \ From 4aa62fa41b0caf74ec9fe895b96d20c05d857be8 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 16:29:10 +0200 Subject: [PATCH 04/37] Test compatible vexctl version --- .github/workflows/sbom-vulnerability-scan.yml | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 3cd8dde..c96134d 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -75,6 +75,12 @@ jobs: with: fetch-depth: ${{ inputs.fetch_depth }} + - name: Install vexctl + if: inputs.generate-openvex + uses: openvex/setup-vexctl@e85ca48f3c8a376289f6476129d59cda82147e71 # v0.1.1 + with: + vexctl-release: '0.3.0' + # Docker - name: Build the Docker image if: inputs.dockerfile != '' @@ -124,12 +130,7 @@ jobs: while IFS= read -r vuln; do [ -z "$vuln" ] && continue - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/work" \ - -w /work \ - ghcr.io/openvex/vexctl:v0.4.4 \ - create \ + vexctl create \ --author "mundialis" \ --author-role "Software Dev" \ --file ".openvex/docker/vex-${count}.json" \ @@ -142,12 +143,7 @@ jobs: done < .openvex/docker/vulnerabilities.txt if [ "$count" -gt 0 ]; then - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/work" \ - -w /work \ - ghcr.io/openvex/vexctl:v0.4.4 \ - merge \ + vexctl merge \ --author "mundialis" \ --author-role "Software Dev" \ .openvex/docker/vex-*.json \ From e3ec1c86774e635e1e0352c36776c1986f93301a Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 16:33:26 +0200 Subject: [PATCH 05/37] repport to json --- .github/workflows/sbom-vulnerability-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index c96134d..6c7c2e7 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -117,7 +117,7 @@ jobs: - name: Generate Docker OpenVEX report if: inputs.dockerfile != '' && inputs.generate-openvex env: - GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.report }} + GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.json }} PRODUCT_ID: ${{ inputs.product-id }} run: | mkdir -p .openvex/docker From a05a7ae48990f7749f82a5a550e2f4b79627e72a Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 16:48:25 +0200 Subject: [PATCH 06/37] Validate generated OpenVEX report --- .github/workflows/sbom-vulnerability-scan.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 6c7c2e7..a431693 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -148,6 +148,26 @@ jobs: --author-role "Software Dev" \ .openvex/docker/vex-*.json \ > .openvex/docker/openvex.json 2>/dev/null + + if [ ! -s .openvex/docker/openvex.json ]; then + echo "OpenVEX report generation failed." + exit 1 + fi + + jq -e ' + .["@context"] == "https://openvex.dev/ns/v0.2.0" + and (.statements | type == "array") + and (.statements | length > 0) + and all( + .statements[]; + (.vulnerability.name | type == "string") + and (.products | type == "array") + and (.products | length > 0) + and (.status == "under_investigation") + ) + ' .openvex/docker/openvex.json >/dev/null + + echo "OpenVEX report validated successfully." fi - name: Upload Docker vulnerability results to GitHub Security From 9dae6ef3599da92fe6f2f68fa19b3f067651ed86 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 9 Sep 2026 17:03:06 +0200 Subject: [PATCH 07/37] Add Python OpenVEX report generation --- .github/workflows/sbom-vulnerability-scan.yml | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index a431693..2b573cb 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -239,6 +239,62 @@ jobs: fail-build: false output-format: json + - name: Generate Python OpenVEX report + if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex + env: + GRYPE_REPORT: ${{ steps.python-vulnerability-json.outputs.report }} + PRODUCT_ID: ${{ inputs.product-id }} + run: | + mkdir -p .openvex/python + + jq -r '.matches[].vulnerability.id' "$GRYPE_REPORT" \ + | sort -u > .openvex/python/vulnerabilities.txt + + count=0 + + while IFS= read -r vuln; do + [ -z "$vuln" ] && continue + + vexctl create \ + --author "mundialis" \ + --author-role "Software Dev" \ + --file ".openvex/python/vex-${count}.json" \ + "$PRODUCT_ID" \ + "$vuln" \ + "under_investigation" \ + >/dev/null 2>&1 + + count=$((count + 1)) + done < .openvex/python/vulnerabilities.txt + + if [ "$count" -gt 0 ]; then + vexctl merge \ + --author "mundialis" \ + --author-role "Software Dev" \ + .openvex/python/vex-*.json \ + > .openvex/python/openvex.json 2>/dev/null + + if [ ! -s .openvex/python/openvex.json ]; then + echo "OpenVEX report generation failed." + exit 1 + fi + + jq -e ' + .["@context"] == "https://openvex.dev/ns/v0.2.0" + and (.statements | type == "array") + and (.statements | length > 0) + and all( + .statements[]; + (.vulnerability.name | type == "string") + and (.products | type == "array") + and (.products | length > 0) + and (.status == "under_investigation") + ) + ' .openvex/python/openvex.json >/dev/null + + echo "OpenVEX report validated successfully." + fi + - name: Upload Python vulnerability results to GitHub Security if: inputs.requirements != '' || inputs.pyproject != '' uses: github/codeql-action/upload-sarif@v4 From 476c31ed37d8da8f37463d98e570bca1ee7d378b Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 14 Sep 2026 13:57:01 +0200 Subject: [PATCH 08/37] Fix Python Grype JSON output reference --- .github/workflows/sbom-vulnerability-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 2b573cb..402d1a7 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -242,7 +242,7 @@ jobs: - name: Generate Python OpenVEX report if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex env: - GRYPE_REPORT: ${{ steps.python-vulnerability-json.outputs.report }} + GRYPE_REPORT: ${{ steps.python-vulnerability-json.outputs.json }} PRODUCT_ID: ${{ inputs.product-id }} run: | mkdir -p .openvex/python From 47f8ac5e9093682f1640b27c890c606df3615743 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 14 Sep 2026 15:06:29 +0200 Subject: [PATCH 09/37] Align OpenVEX workflow with latest main --- .github/workflows/sbom-vulnerability-scan.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 402d1a7..23baca6 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -98,7 +98,7 @@ jobs: - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' id: docker-vulnerability-scan - uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 + uses: anchore/scan-action@v7 with: image: sbom: docker.cyclonedx.json @@ -108,7 +108,7 @@ jobs: - name: Generate Docker vulnerability JSON report if: inputs.dockerfile != '' && inputs.generate-openvex id: docker-vulnerability-json - uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 + uses: anchore/scan-action@v7 with: sbom: docker.cyclonedx.json fail-build: false @@ -233,9 +233,9 @@ jobs: - name: Generate Python vulnerability JSON report if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex id: python-vulnerability-json - uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 + uses: anchore/scan-action@v7 with: - path: .venv + path: .sbom_venv fail-build: false output-format: json From 913100b73dcf0ad9ae9bf4fe2572b369964eb596 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 15 Sep 2026 09:34:50 +0200 Subject: [PATCH 10/37] Add CycloneDX VEX generation --- .github/workflows/sbom-vulnerability-scan.yml | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 23baca6..eb8044f 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -47,6 +47,12 @@ on: required: false type: string + generate-cyclonedx-vex: + description: "Generate a CycloneDX VEX document from detected vulnerabilities" + required: false + default: false + type: boolean + jobs: sbom-vulnerability-scan: runs-on: ubuntu-latest @@ -106,13 +112,115 @@ jobs: output-format: sarif - name: Generate Docker vulnerability JSON report - if: inputs.dockerfile != '' && inputs.generate-openvex + if: inputs.dockerfile != '' && (inputs.generate-openvex || inputs.generate-cyclonedx-vex) id: docker-vulnerability-json uses: anchore/scan-action@v7 with: sbom: docker.cyclonedx.json fail-build: false output-format: json + + - name: Generate Docker CycloneDX VEX report + if: inputs.dockerfile != '' && inputs.generate-cyclonedx-vex + env: + GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.json }} + run: | + mkdir -p .cyclonedx-vex/docker + + python <<'PY' + import json + import os + from pathlib import Path + + with open("docker.cyclonedx.json") as f: + sbom = json.load(f) + + with open(os.environ["GRYPE_REPORT"]) as f: + grype = json.load(f) + + components = sbom.get("components", []) + + components_by_purl = { + component.get("purl"): component + for component in components + if component.get("purl") + } + + vex_components = {} + vulnerabilities = [] + + for match in grype.get("matches", []): + artifact = match.get("artifact", {}) + vulnerability = match.get("vulnerability", {}) + + purl = artifact.get("purl") + cve = vulnerability.get("id") + + if not purl or not cve: + continue + + component = components_by_purl.get(purl) + + if not component: + continue + + bom_ref = component.get("bom-ref") + + if not bom_ref: + continue + + vex_components[bom_ref] = component + + vulnerabilities.append({ + "id": cve, + "analysis": { + "state": "in_triage" + }, + "affects": [ + { + "ref": bom_ref + } + ] + }) + + grype_matches = grype.get("matches", []) + + if grype_matches and not vulnerabilities: + raise RuntimeError( + "Grype found vulnerabilities, but none could be matched " + "to components in the CycloneDX SBOM." + ) + + vex = { + "bomFormat": "CycloneDX", + "specVersion": sbom.get("specVersion", "1.6"), + "version": 1, + "components": list(vex_components.values()), + "vulnerabilities": vulnerabilities + } + + output = Path(".cyclonedx-vex/docker/cyclonedx-vex.json") + + with output.open("w") as f: + json.dump(vex, f, indent=2) + PY + + jq -e ' + .bomFormat == "CycloneDX" + and (.components | type == "array") + and (.vulnerabilities | type == "array") + and all( + .vulnerabilities[]; + (.id | type == "string") + and (.analysis.state == "in_triage") + and (.affects | type == "array") + and (.affects | length > 0) + ) + ' .cyclonedx-vex/docker/cyclonedx-vex.json >/dev/null + + echo "CycloneDX VEX report validated successfully." + + - name: Generate Docker OpenVEX report if: inputs.dockerfile != '' && inputs.generate-openvex From 70969e9b1550fe6ce5541990ee81ec3a93f36486 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 15 Sep 2026 13:30:53 +0200 Subject: [PATCH 11/37] Integrate Dependency-Track VEX reporting --- .../actions/generate-cyclonedx-vex/action.yml | 36 +++ .../generate-cyclonedx-vex.py | 80 +++++ .github/workflows/sbom-vulnerability-scan.yml | 282 +++++++++++------- 3 files changed, 292 insertions(+), 106 deletions(-) create mode 100644 .github/actions/generate-cyclonedx-vex/action.yml create mode 100644 .github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py diff --git a/.github/actions/generate-cyclonedx-vex/action.yml b/.github/actions/generate-cyclonedx-vex/action.yml new file mode 100644 index 0000000..6c8f3f7 --- /dev/null +++ b/.github/actions/generate-cyclonedx-vex/action.yml @@ -0,0 +1,36 @@ +name: Generate CycloneDX VEX +description: Generate a CycloneDX VEX document from Dependency-Track findings + +inputs: + findings: + description: Path to Dependency-Track findings JSON + required: true + + output: + description: Path for the generated CycloneDX VEX document + required: true + + project-uuid: + description: Dependency-Track project UUID + required: true + + project-name: + description: Dependency-Track project name + required: true + + project-version: + description: Dependency-Track project version + required: true + +runs: + using: composite + steps: + - shell: bash + run: | + python "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \ + --findings "${{ inputs.findings }}" \ + --output "${{ inputs.output }}" \ + --project-uuid "${{ inputs.project-uuid }}" \ + --project-name "${{ inputs.project-name }}" \ + --project-version "${{ inputs.project-version }}" + \ No newline at end of file diff --git a/.github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py b/.github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py new file mode 100644 index 0000000..4cc1133 --- /dev/null +++ b/.github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py @@ -0,0 +1,80 @@ +import argparse +import json +import uuid +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--findings", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--project-uuid", required=True) + parser.add_argument("--project-name", required=True) + parser.add_argument("--project-version", required=True) + + args = parser.parse_args() + + findings_path = Path(args.findings) + output_path = Path(args.output) + + with findings_path.open() as f: + findings = json.load(f) + + components = {} + vulnerabilities = [] + + for finding in findings: + component = finding["component"] + vulnerability = finding["vulnerability"] + + component_uuid = component["uuid"] + + components[component_uuid] = { + "type": "library", + "bom-ref": component_uuid, + "name": component["name"], + "version": component["version"], + "purl": component.get("purl"), + } + + vulnerabilities.append({ + "id": vulnerability["vulnId"], + "source": { + "name": vulnerability["source"], + }, + "analysis": { + "state": "in_triage", + }, + "affects": [ + { + "ref": component_uuid, + } + ], + }) + + vex = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": f"urn:uuid:{uuid.uuid4()}", + "version": 1, + "metadata": { + "component": { + "type": "container", + "bom-ref": args.project_uuid, + "name": args.project_name, + "version": args.project_version, + } + }, + "components": list(components.values()), + "vulnerabilities": vulnerabilities, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with output_path.open("w") as f: + json.dump(vex, f, indent=2) + + +if __name__ == "__main__": + main() + \ No newline at end of file diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index eb8044f..f160e3b 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -46,13 +46,31 @@ on: description: "Product identifier (PURL or IRI) used in the OpenVEX document" required: false type: string - - generate-cyclonedx-vex: - description: "Generate a CycloneDX VEX document from detected vulnerabilities" + + dependency-track: + description: "Upload SBOM and manage VEX with Dependency-Track" required: false default: false type: boolean + dependency-track-url: + description: "Dependency-Track base URL" + required: false + type: string + + dependency-track-project-name: + description: "Project name in Dependency-Track" + required: false + type: string + + dependency-track-project-version: + description: "Project version in Dependency-Track" + required: false + default: "latest" + type: string + secrets: + dependency-track-api-key: + required: false jobs: sbom-vulnerability-scan: runs-on: ubuntu-latest @@ -76,6 +94,23 @@ jobs: exit 1 fi + if [ "${{ inputs.dependency-track }}" = "true" ]; then + if [ -z "${{ inputs.dependency-track-url }}" ]; then + echo "dependency-track-url is required when dependency-track is enabled." + exit 1 + fi + + if [ -z "${{ inputs.dependency-track-project-name }}" ]; then + echo "dependency-track-project-name is required when dependency-track is enabled." + exit 1 + fi + + if [ -z "${{ secrets.dependency-track-api-key }}" ]; then + echo "dependency-track-api-key secret is required when dependency-track is enabled." + exit 1 + fi + fi + - name: Checkout the code uses: actions/checkout@v7 with: @@ -100,6 +135,143 @@ jobs: artifact-name: docker.cyclonedx.json output-file: docker.cyclonedx.json format: cyclonedx-json + + - name: Upload Docker SBOM to Dependency-Track + if: inputs.dockerfile != '' && inputs.dependency-track + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} + DTRACK_PROJECT_NAME: ${{ inputs.dependency-track-project-name }} + DTRACK_PROJECT_VERSION: ${{ inputs.dependency-track-project-version }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-bom-response.json \ + -w "%{http_code}" \ + -X POST \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + -F "autoCreate=true" \ + -F "projectName=$DTRACK_PROJECT_NAME" \ + -F "projectVersion=$DTRACK_PROJECT_VERSION" \ + -F "bom=@docker.cyclonedx.json" \ + "${DTRACK_URL%/}/api/v1/bom") + + if [ "$http_code" != "200" ]; then + echo "Dependency-Track BOM upload failed with HTTP $http_code." + exit 1 + fi + + processing_token=$(jq -r '.token // empty' /tmp/dtrack-bom-response.json) + + if [ -z "$processing_token" ]; then + echo "Dependency-Track did not return a BOM processing token." + exit 1 + fi + + echo "DTRACK_BOM_TOKEN=$processing_token" >> "$GITHUB_ENV" + + echo "SBOM uploaded to Dependency-Track successfully." + + - name: Get Dependency-Track project UUID + if: inputs.dockerfile != '' && inputs.dependency-track + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} + DTRACK_PROJECT_NAME: ${{ inputs.dependency-track-project-name }} + DTRACK_PROJECT_VERSION: ${{ inputs.dependency-track-project-version }} + run: | + project_uuid=$(curl -sS \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + --get \ + --data-urlencode "name=$DTRACK_PROJECT_NAME" \ + --data-urlencode "version=$DTRACK_PROJECT_VERSION" \ + "${DTRACK_URL%/}/api/v1/project/lookup" \ + | jq -r '.uuid // empty') + + if [ -z "$project_uuid" ]; then + echo "Dependency-Track project could not be found." + exit 1 + fi + + echo "DTRACK_PROJECT_UUID=$project_uuid" >> "$GITHUB_ENV" + echo "Dependency-Track project found successfully." + + - name: Wait for Dependency-Track analysis + if: inputs.dockerfile != '' && inputs.dependency-track + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} + run: | + max_attempts=30 + attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + processing=$(curl -sS \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + "${DTRACK_URL%/}/api/v1/event/token/$DTRACK_BOM_TOKEN" \ + | jq -r '.processing // false') + + if [ "$processing" = "false" ]; then + echo "Dependency-Track analysis completed." + exit 0 + fi + + sleep 10 + attempt=$((attempt + 1)) + done + + echo "Dependency-Track analysis did not complete within the expected time." + exit 1 + + - name: Download Dependency-Track findings + if: inputs.dockerfile != '' && inputs.dependency-track + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-findings.json \ + -w "%{http_code}" \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + "${DTRACK_URL%/}/api/v1/finding/project/$DTRACK_PROJECT_UUID") + + if [ "$http_code" != "200" ]; then + echo "Failed to retrieve findings from Dependency-Track." + exit 1 + fi + + echo "Dependency-Track findings retrieved successfully." + + - name: Generate CycloneDX VEX from Dependency-Track findings + if: inputs.dockerfile != '' && inputs.dependency-track + uses: $/.github/actions/generate-cyclonedx-vex + with: + findings: /tmp/dtrack-findings.json + output: /tmp/dtrack-vex.json + project-uuid: ${{ env.DTRACK_PROJECT_UUID }} + project-name: ${{ inputs.dependency-track-project-name }} + project-version: ${{ inputs.dependency-track-project-version }} + + - name: Upload CycloneDX VEX to Dependency-Track + if: inputs.dockerfile != '' && inputs.dependency-track + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-vex-response.json \ + -w "%{http_code}" \ + -X POST \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + -F "project=$DTRACK_PROJECT_UUID" \ + -F "vex=@/tmp/dtrack-vex.json" \ + "${DTRACK_URL%/}/api/v1/vex") + + if [ "$http_code" != "200" ]; then + echo "Dependency-Track VEX upload failed with HTTP $http_code." + exit 1 + fi + + echo "CycloneDX VEX uploaded to Dependency-Track successfully." - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' @@ -112,115 +284,13 @@ jobs: output-format: sarif - name: Generate Docker vulnerability JSON report - if: inputs.dockerfile != '' && (inputs.generate-openvex || inputs.generate-cyclonedx-vex) + if: inputs.dockerfile != '' && inputs.generate-openvex id: docker-vulnerability-json uses: anchore/scan-action@v7 with: sbom: docker.cyclonedx.json fail-build: false output-format: json - - - name: Generate Docker CycloneDX VEX report - if: inputs.dockerfile != '' && inputs.generate-cyclonedx-vex - env: - GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.json }} - run: | - mkdir -p .cyclonedx-vex/docker - - python <<'PY' - import json - import os - from pathlib import Path - - with open("docker.cyclonedx.json") as f: - sbom = json.load(f) - - with open(os.environ["GRYPE_REPORT"]) as f: - grype = json.load(f) - - components = sbom.get("components", []) - - components_by_purl = { - component.get("purl"): component - for component in components - if component.get("purl") - } - - vex_components = {} - vulnerabilities = [] - - for match in grype.get("matches", []): - artifact = match.get("artifact", {}) - vulnerability = match.get("vulnerability", {}) - - purl = artifact.get("purl") - cve = vulnerability.get("id") - - if not purl or not cve: - continue - - component = components_by_purl.get(purl) - - if not component: - continue - - bom_ref = component.get("bom-ref") - - if not bom_ref: - continue - - vex_components[bom_ref] = component - - vulnerabilities.append({ - "id": cve, - "analysis": { - "state": "in_triage" - }, - "affects": [ - { - "ref": bom_ref - } - ] - }) - - grype_matches = grype.get("matches", []) - - if grype_matches and not vulnerabilities: - raise RuntimeError( - "Grype found vulnerabilities, but none could be matched " - "to components in the CycloneDX SBOM." - ) - - vex = { - "bomFormat": "CycloneDX", - "specVersion": sbom.get("specVersion", "1.6"), - "version": 1, - "components": list(vex_components.values()), - "vulnerabilities": vulnerabilities - } - - output = Path(".cyclonedx-vex/docker/cyclonedx-vex.json") - - with output.open("w") as f: - json.dump(vex, f, indent=2) - PY - - jq -e ' - .bomFormat == "CycloneDX" - and (.components | type == "array") - and (.vulnerabilities | type == "array") - and all( - .vulnerabilities[]; - (.id | type == "string") - and (.analysis.state == "in_triage") - and (.affects | type == "array") - and (.affects | length > 0) - ) - ' .cyclonedx-vex/docker/cyclonedx-vex.json >/dev/null - - echo "CycloneDX VEX report validated successfully." - - - name: Generate Docker OpenVEX report if: inputs.dockerfile != '' && inputs.generate-openvex From e0dbef8027c199e971fee934a7f17f8a81c1b915 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 15 Sep 2026 14:26:34 +0200 Subject: [PATCH 12/37] Refactor Dependency-Track integration --- .../dependency-track-report/action.yml | 158 ++++++++++++++++++ .../generate-cyclonedx-vex.py | 8 +- .../actions/generate-cyclonedx-vex/action.yml | 36 ---- .github/workflows/sbom-vulnerability-scan.yml | 137 +-------------- 4 files changed, 169 insertions(+), 170 deletions(-) create mode 100644 .github/actions/dependency-track-report/action.yml rename .github/actions/{generate-cyclonedx-vex => dependency-track-report}/generate-cyclonedx-vex.py (91%) delete mode 100644 .github/actions/generate-cyclonedx-vex/action.yml diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml new file mode 100644 index 0000000..9539ffb --- /dev/null +++ b/.github/actions/dependency-track-report/action.yml @@ -0,0 +1,158 @@ +name: Dependency-Track Report +description: Upload an SBOM to Dependency-Track, generate CycloneDX VEX, and apply it + +inputs: + sbom-file: + description: Path to the CycloneDX SBOM file + required: true + + dependency-track-url: + description: Dependency-Track base URL + required: true + + api-key: + description: Dependency-Track API key + required: true + + project-name: + description: Dependency-Track project name + required: true + + project-version: + description: Dependency-Track project version + required: true + +runs: + using: composite + steps: + - name: Upload SBOM to Dependency-Track + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + DTRACK_PROJECT_NAME: ${{ inputs.project-name }} + DTRACK_PROJECT_VERSION: ${{ inputs.project-version }} + SBOM_FILE: ${{ inputs.sbom-file }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-bom-response.json \ + -w "%{http_code}" \ + -X POST \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + -F "autoCreate=true" \ + -F "projectName=$DTRACK_PROJECT_NAME" \ + -F "projectVersion=$DTRACK_PROJECT_VERSION" \ + -F "bom=@$SBOM_FILE" \ + "${DTRACK_URL%/}/api/v1/bom") + + if [ "$http_code" != "200" ]; then + echo "Dependency-Track BOM upload failed with HTTP $http_code." + exit 1 + fi + + processing_token=$(jq -r '.token // empty' /tmp/dtrack-bom-response.json) + + if [ -z "$processing_token" ]; then + echo "Dependency-Track did not return a BOM processing token." + exit 1 + fi + + echo "DTRACK_BOM_TOKEN=$processing_token" >> "$GITHUB_ENV" + + - name: Get Dependency-Track project UUID + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + DTRACK_PROJECT_NAME: ${{ inputs.project-name }} + DTRACK_PROJECT_VERSION: ${{ inputs.project-version }} + run: | + project_uuid=$(curl -sS \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + --get \ + --data-urlencode "name=$DTRACK_PROJECT_NAME" \ + --data-urlencode "version=$DTRACK_PROJECT_VERSION" \ + "${DTRACK_URL%/}/api/v1/project/lookup" \ + | jq -r '.uuid // empty') + + if [ -z "$project_uuid" ]; then + echo "Dependency-Track project could not be found." + exit 1 + fi + + echo "DTRACK_PROJECT_UUID=$project_uuid" >> "$GITHUB_ENV" + + - name: Wait for Dependency-Track analysis + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + run: | + max_attempts=30 + attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + processing=$(curl -sS \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + "${DTRACK_URL%/}/api/v1/event/token/$DTRACK_BOM_TOKEN" \ + | jq -r '.processing // false') + + if [ "$processing" = "false" ]; then + break + fi + + sleep 10 + attempt=$((attempt + 1)) + done + + if [ "$processing" != "false" ]; then + echo "Dependency-Track analysis did not complete within the expected time." + exit 1 + fi + + - name: Download Dependency-Track findings + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-findings.json \ + -w "%{http_code}" \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + "${DTRACK_URL%/}/api/v1/finding/project/$DTRACK_PROJECT_UUID") + + if [ "$http_code" != "200" ]; then + echo "Failed to retrieve findings from Dependency-Track." + exit 1 + fi + + - name: Generate CycloneDX VEX + shell: bash + run: | + python "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \ + --findings /tmp/dtrack-findings.json \ + --output /tmp/dtrack-vex.json \ + --project-uuid "$DTRACK_PROJECT_UUID" \ + --project-name "${{ inputs.project-name }}" \ + --project-version "${{ inputs.project-version }}" + + - name: Upload CycloneDX VEX to Dependency-Track + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + run: | + http_code=$(curl -sS \ + -o /tmp/dtrack-vex-response.json \ + -w "%{http_code}" \ + -X POST \ + -H "X-Api-Key: $DTRACK_API_KEY" \ + -F "project=$DTRACK_PROJECT_UUID" \ + -F "vex=@/tmp/dtrack-vex.json" \ + "${DTRACK_URL%/}/api/v1/vex") + + if [ "$http_code" != "200" ]; then + echo "Dependency-Track VEX upload failed with HTTP $http_code." + exit 1 + fi \ No newline at end of file diff --git a/.github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py similarity index 91% rename from .github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py rename to .github/actions/dependency-track-report/generate-cyclonedx-vex.py index 4cc1133..2736e52 100644 --- a/.github/actions/generate-cyclonedx-vex/generate-cyclonedx-vex.py +++ b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py @@ -26,6 +26,11 @@ def main(): for finding in findings: component = finding["component"] vulnerability = finding["vulnerability"] + analysis = finding.get("analysis", {}) + + # Do not overwrite an existing manual or previous analysis state. + if analysis.get("state"): + continue component_uuid = component["uuid"] @@ -51,7 +56,7 @@ def main(): } ], }) - + vex = { "bomFormat": "CycloneDX", "specVersion": "1.5", @@ -77,4 +82,3 @@ def main(): if __name__ == "__main__": main() - \ No newline at end of file diff --git a/.github/actions/generate-cyclonedx-vex/action.yml b/.github/actions/generate-cyclonedx-vex/action.yml deleted file mode 100644 index 6c8f3f7..0000000 --- a/.github/actions/generate-cyclonedx-vex/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Generate CycloneDX VEX -description: Generate a CycloneDX VEX document from Dependency-Track findings - -inputs: - findings: - description: Path to Dependency-Track findings JSON - required: true - - output: - description: Path for the generated CycloneDX VEX document - required: true - - project-uuid: - description: Dependency-Track project UUID - required: true - - project-name: - description: Dependency-Track project name - required: true - - project-version: - description: Dependency-Track project version - required: true - -runs: - using: composite - steps: - - shell: bash - run: | - python "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \ - --findings "${{ inputs.findings }}" \ - --output "${{ inputs.output }}" \ - --project-uuid "${{ inputs.project-uuid }}" \ - --project-name "${{ inputs.project-name }}" \ - --project-version "${{ inputs.project-version }}" - \ No newline at end of file diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index f160e3b..532af86 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -135,144 +135,17 @@ jobs: artifact-name: docker.cyclonedx.json output-file: docker.cyclonedx.json format: cyclonedx-json - - - name: Upload Docker SBOM to Dependency-Track - if: inputs.dockerfile != '' && inputs.dependency-track - env: - DTRACK_URL: ${{ inputs.dependency-track-url }} - DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} - DTRACK_PROJECT_NAME: ${{ inputs.dependency-track-project-name }} - DTRACK_PROJECT_VERSION: ${{ inputs.dependency-track-project-version }} - run: | - http_code=$(curl -sS \ - -o /tmp/dtrack-bom-response.json \ - -w "%{http_code}" \ - -X POST \ - -H "X-Api-Key: $DTRACK_API_KEY" \ - -F "autoCreate=true" \ - -F "projectName=$DTRACK_PROJECT_NAME" \ - -F "projectVersion=$DTRACK_PROJECT_VERSION" \ - -F "bom=@docker.cyclonedx.json" \ - "${DTRACK_URL%/}/api/v1/bom") - - if [ "$http_code" != "200" ]; then - echo "Dependency-Track BOM upload failed with HTTP $http_code." - exit 1 - fi - - processing_token=$(jq -r '.token // empty' /tmp/dtrack-bom-response.json) - - if [ -z "$processing_token" ]; then - echo "Dependency-Track did not return a BOM processing token." - exit 1 - fi - - echo "DTRACK_BOM_TOKEN=$processing_token" >> "$GITHUB_ENV" - echo "SBOM uploaded to Dependency-Track successfully." - - - name: Get Dependency-Track project UUID + - name: Process Docker SBOM in Dependency-Track if: inputs.dockerfile != '' && inputs.dependency-track - env: - DTRACK_URL: ${{ inputs.dependency-track-url }} - DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} - DTRACK_PROJECT_NAME: ${{ inputs.dependency-track-project-name }} - DTRACK_PROJECT_VERSION: ${{ inputs.dependency-track-project-version }} - run: | - project_uuid=$(curl -sS \ - -H "X-Api-Key: $DTRACK_API_KEY" \ - --get \ - --data-urlencode "name=$DTRACK_PROJECT_NAME" \ - --data-urlencode "version=$DTRACK_PROJECT_VERSION" \ - "${DTRACK_URL%/}/api/v1/project/lookup" \ - | jq -r '.uuid // empty') - - if [ -z "$project_uuid" ]; then - echo "Dependency-Track project could not be found." - exit 1 - fi - - echo "DTRACK_PROJECT_UUID=$project_uuid" >> "$GITHUB_ENV" - echo "Dependency-Track project found successfully." - - - name: Wait for Dependency-Track analysis - if: inputs.dockerfile != '' && inputs.dependency-track - env: - DTRACK_URL: ${{ inputs.dependency-track-url }} - DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} - run: | - max_attempts=30 - attempt=1 - - while [ "$attempt" -le "$max_attempts" ]; do - processing=$(curl -sS \ - -H "X-Api-Key: $DTRACK_API_KEY" \ - "${DTRACK_URL%/}/api/v1/event/token/$DTRACK_BOM_TOKEN" \ - | jq -r '.processing // false') - - if [ "$processing" = "false" ]; then - echo "Dependency-Track analysis completed." - exit 0 - fi - - sleep 10 - attempt=$((attempt + 1)) - done - - echo "Dependency-Track analysis did not complete within the expected time." - exit 1 - - - name: Download Dependency-Track findings - if: inputs.dockerfile != '' && inputs.dependency-track - env: - DTRACK_URL: ${{ inputs.dependency-track-url }} - DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} - run: | - http_code=$(curl -sS \ - -o /tmp/dtrack-findings.json \ - -w "%{http_code}" \ - -H "X-Api-Key: $DTRACK_API_KEY" \ - "${DTRACK_URL%/}/api/v1/finding/project/$DTRACK_PROJECT_UUID") - - if [ "$http_code" != "200" ]; then - echo "Failed to retrieve findings from Dependency-Track." - exit 1 - fi - - echo "Dependency-Track findings retrieved successfully." - - - name: Generate CycloneDX VEX from Dependency-Track findings - if: inputs.dockerfile != '' && inputs.dependency-track - uses: $/.github/actions/generate-cyclonedx-vex + uses: $/.github/actions/dependency-track-report with: - findings: /tmp/dtrack-findings.json - output: /tmp/dtrack-vex.json - project-uuid: ${{ env.DTRACK_PROJECT_UUID }} + sbom-file: docker.cyclonedx.json + dependency-track-url: ${{ inputs.dependency-track-url }} + api-key: ${{ secrets.dependency-track-api-key }} project-name: ${{ inputs.dependency-track-project-name }} project-version: ${{ inputs.dependency-track-project-version }} - - name: Upload CycloneDX VEX to Dependency-Track - if: inputs.dockerfile != '' && inputs.dependency-track - env: - DTRACK_URL: ${{ inputs.dependency-track-url }} - DTRACK_API_KEY: ${{ secrets.dependency-track-api-key }} - run: | - http_code=$(curl -sS \ - -o /tmp/dtrack-vex-response.json \ - -w "%{http_code}" \ - -X POST \ - -H "X-Api-Key: $DTRACK_API_KEY" \ - -F "project=$DTRACK_PROJECT_UUID" \ - -F "vex=@/tmp/dtrack-vex.json" \ - "${DTRACK_URL%/}/api/v1/vex") - - if [ "$http_code" != "200" ]; then - echo "Dependency-Track VEX upload failed with HTTP $http_code." - exit 1 - fi - - echo "CycloneDX VEX uploaded to Dependency-Track successfully." - - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' id: docker-vulnerability-scan From 5229a9ca64ab32289742bc6c4b0a3183154a3e90 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 15 Sep 2026 15:18:54 +0200 Subject: [PATCH 13/37] Extend Dependency-Track VEX reporting to Python --- .../dependency-track-report/action.yml | 16 +++++++++++++++- .../generate-cyclonedx-vex.py | 19 ++++++++++++++----- .github/workflows/sbom-vulnerability-scan.yml | 12 ++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 9539ffb..24d77e9 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -22,6 +22,10 @@ inputs: description: Dependency-Track project version required: true + project-type: + description: CycloneDX component type for the project + required: true + runs: using: composite steps: @@ -128,6 +132,7 @@ runs: fi - name: Generate CycloneDX VEX + id: generate-vex shell: bash run: | python "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \ @@ -135,9 +140,18 @@ runs: --output /tmp/dtrack-vex.json \ --project-uuid "$DTRACK_PROJECT_UUID" \ --project-name "${{ inputs.project-name }}" \ - --project-version "${{ inputs.project-version }}" + --project-version "${{ inputs.project-version }}" \ + --project-type "${{ inputs.project-type }}" + + if [ -f /tmp/dtrack-vex.json ]; then + echo "has-vex=true" >> "$GITHUB_OUTPUT" + else + echo "has-vex=false" >> "$GITHUB_OUTPUT" + echo "No findings require a new VEX analysis." + fi - name: Upload CycloneDX VEX to Dependency-Track + if: steps.generate-vex.outputs.has-vex == 'true' shell: bash env: DTRACK_URL: ${{ inputs.dependency-track-url }} diff --git a/.github/actions/dependency-track-report/generate-cyclonedx-vex.py b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py index 2736e52..f7ac2fe 100644 --- a/.github/actions/dependency-track-report/generate-cyclonedx-vex.py +++ b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py @@ -11,6 +11,7 @@ def main(): parser.add_argument("--project-uuid", required=True) parser.add_argument("--project-name", required=True) parser.add_argument("--project-version", required=True) + parser.add_argument("--project-type", required=True) args = parser.parse_args() @@ -29,19 +30,23 @@ def main(): analysis = finding.get("analysis", {}) # Do not overwrite an existing manual or previous analysis state. - if analysis.get("state"): + if analysis.get("state") or analysis.get("isSuppressed"): continue component_uuid = component["uuid"] - components[component_uuid] = { + component_data = { "type": "library", "bom-ref": component_uuid, "name": component["name"], "version": component["version"], - "purl": component.get("purl"), } + if component.get("purl"): + component_data["purl"] = component["purl"] + + components[component_uuid] = component_data + vulnerabilities.append({ "id": vulnerability["vulnId"], "source": { @@ -56,7 +61,11 @@ def main(): } ], }) - + + if not vulnerabilities: + output_path.unlink(missing_ok=True) + return + vex = { "bomFormat": "CycloneDX", "specVersion": "1.5", @@ -64,7 +73,7 @@ def main(): "version": 1, "metadata": { "component": { - "type": "container", + "type": args.project_type, "bom-ref": args.project_uuid, "name": args.project_name, "version": args.project_version, diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 532af86..61f489f 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -145,6 +145,7 @@ jobs: api-key: ${{ secrets.dependency-track-api-key }} project-name: ${{ inputs.dependency-track-project-name }} project-version: ${{ inputs.dependency-track-project-version }} + project-type: container - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' @@ -269,6 +270,17 @@ jobs: output-file: python.cyclonedx.json format: cyclonedx-json + - name: Process Python SBOM in Dependency-Track + if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.dependency-track + uses: $/.github/actions/dependency-track-report + with: + sbom-file: python.cyclonedx.json + dependency-track-url: ${{ inputs.dependency-track-url }} + api-key: ${{ secrets.dependency-track-api-key }} + project-name: ${{ inputs.dependency-track-project-name }} + project-version: ${{ inputs.dependency-track-project-version }} + project-type: application + - name: Scan Python environment for vulnerabilities if: inputs.requirements != '' || inputs.pyproject != '' id: python-vulnerability-scan From 132d7ebf1b77a28806da295b6f006f09e0dff422 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 15 Sep 2026 15:50:32 +0200 Subject: [PATCH 14/37] Remove legacy OpenVEX workflow steps --- .github/workflows/sbom-vulnerability-scan.yml | 157 ------------------ 1 file changed, 157 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 61f489f..a3d9f6f 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -35,17 +35,6 @@ on: required: false default: false type: boolean - - generate-openvex: - description: "Generate an OpenVEX document from detected vulnerabilities" - required: false - default: false - type: boolean - - product-id: - description: "Product identifier (PURL or IRI) used in the OpenVEX document" - required: false - type: string dependency-track: description: "Upload SBOM and manage VEX with Dependency-Track" @@ -89,11 +78,6 @@ jobs: exit 1 fi - if [ "${{ inputs.generate-openvex }}" = "true" ] && [ -z "${{ inputs.product-id }}" ]; then - echo "product-id is required when generate-openvex is enabled." - exit 1 - fi - if [ "${{ inputs.dependency-track }}" = "true" ]; then if [ -z "${{ inputs.dependency-track-url }}" ]; then echo "dependency-track-url is required when dependency-track is enabled." @@ -116,12 +100,6 @@ jobs: with: fetch-depth: ${{ inputs.fetch_depth }} - - name: Install vexctl - if: inputs.generate-openvex - uses: openvex/setup-vexctl@e85ca48f3c8a376289f6476129d59cda82147e71 # v0.1.1 - with: - vexctl-release: '0.3.0' - # Docker - name: Build the Docker image if: inputs.dockerfile != '' @@ -152,76 +130,10 @@ jobs: id: docker-vulnerability-scan uses: anchore/scan-action@v7 with: - image: sbom: docker.cyclonedx.json fail-build: ${{ inputs.fail-build }} output-format: sarif - - name: Generate Docker vulnerability JSON report - if: inputs.dockerfile != '' && inputs.generate-openvex - id: docker-vulnerability-json - uses: anchore/scan-action@v7 - with: - sbom: docker.cyclonedx.json - fail-build: false - output-format: json - - - name: Generate Docker OpenVEX report - if: inputs.dockerfile != '' && inputs.generate-openvex - env: - GRYPE_REPORT: ${{ steps.docker-vulnerability-json.outputs.json }} - PRODUCT_ID: ${{ inputs.product-id }} - run: | - mkdir -p .openvex/docker - - jq -r '.matches[].vulnerability.id' "$GRYPE_REPORT" \ - | sort -u > .openvex/docker/vulnerabilities.txt - - count=0 - - while IFS= read -r vuln; do - [ -z "$vuln" ] && continue - - vexctl create \ - --author "mundialis" \ - --author-role "Software Dev" \ - --file ".openvex/docker/vex-${count}.json" \ - "$PRODUCT_ID" \ - "$vuln" \ - "under_investigation" \ - >/dev/null 2>&1 - - count=$((count + 1)) - done < .openvex/docker/vulnerabilities.txt - - if [ "$count" -gt 0 ]; then - vexctl merge \ - --author "mundialis" \ - --author-role "Software Dev" \ - .openvex/docker/vex-*.json \ - > .openvex/docker/openvex.json 2>/dev/null - - if [ ! -s .openvex/docker/openvex.json ]; then - echo "OpenVEX report generation failed." - exit 1 - fi - - jq -e ' - .["@context"] == "https://openvex.dev/ns/v0.2.0" - and (.statements | type == "array") - and (.statements | length > 0) - and all( - .statements[]; - (.vulnerability.name | type == "string") - and (.products | type == "array") - and (.products | length > 0) - and (.status == "under_investigation") - ) - ' .openvex/docker/openvex.json >/dev/null - - echo "OpenVEX report validated successfully." - fi - - name: Upload Docker vulnerability results to GitHub Security if: inputs.dockerfile != '' uses: github/codeql-action/upload-sarif@v4 @@ -257,10 +169,6 @@ jobs: pip install --upgrade pip --quiet pip install . - - name: Ensure pip version - if: inputs.requirements != '' || inputs.pyproject != '' - run: .sbom_venv/bin/pip install --upgrade pip - - name: Generate SBOM from Python environment if: inputs.requirements != '' || inputs.pyproject != '' uses: anchore/sbom-action@v0.24.2 @@ -293,71 +201,6 @@ jobs: fail-build: ${{ inputs.fail-build }} output-format: sarif - - name: Generate Python vulnerability JSON report - if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex - id: python-vulnerability-json - uses: anchore/scan-action@v7 - with: - path: .sbom_venv - fail-build: false - output-format: json - - - name: Generate Python OpenVEX report - if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.generate-openvex - env: - GRYPE_REPORT: ${{ steps.python-vulnerability-json.outputs.json }} - PRODUCT_ID: ${{ inputs.product-id }} - run: | - mkdir -p .openvex/python - - jq -r '.matches[].vulnerability.id' "$GRYPE_REPORT" \ - | sort -u > .openvex/python/vulnerabilities.txt - - count=0 - - while IFS= read -r vuln; do - [ -z "$vuln" ] && continue - - vexctl create \ - --author "mundialis" \ - --author-role "Software Dev" \ - --file ".openvex/python/vex-${count}.json" \ - "$PRODUCT_ID" \ - "$vuln" \ - "under_investigation" \ - >/dev/null 2>&1 - - count=$((count + 1)) - done < .openvex/python/vulnerabilities.txt - - if [ "$count" -gt 0 ]; then - vexctl merge \ - --author "mundialis" \ - --author-role "Software Dev" \ - .openvex/python/vex-*.json \ - > .openvex/python/openvex.json 2>/dev/null - - if [ ! -s .openvex/python/openvex.json ]; then - echo "OpenVEX report generation failed." - exit 1 - fi - - jq -e ' - .["@context"] == "https://openvex.dev/ns/v0.2.0" - and (.statements | type == "array") - and (.statements | length > 0) - and all( - .statements[]; - (.vulnerability.name | type == "string") - and (.products | type == "array") - and (.products | length > 0) - and (.status == "under_investigation") - ) - ' .openvex/python/openvex.json >/dev/null - - echo "OpenVEX report validated successfully." - fi - - name: Upload Python vulnerability results to GitHub Security if: inputs.requirements != '' || inputs.pyproject != '' uses: github/codeql-action/upload-sarif@v4 From 003f5d52d056854590f4c0c3aa7057d111cfe105 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 15:37:13 +0200 Subject: [PATCH 15/37] Add CSAF VEX generation to Dependency-Track reporting --- .../dependency-track-report/action.yml | 24 +- .../dependency_track.py | 64 +++ .../dependency-track-report/generate_csaf.py | 508 ++++++++++++++++++ 3 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 .github/actions/dependency-track-report/dependency_track.py create mode 100644 .github/actions/dependency-track-report/generate_csaf.py diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 24d77e9..84c7c10 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -169,4 +169,26 @@ runs: if [ "$http_code" != "200" ]; then echo "Dependency-Track VEX upload failed with HTTP $http_code." exit 1 - fi \ No newline at end of file + fi + - name: Generate CSAF VEX + shell: bash + env: + DTRACK_URL: ${{ inputs.dependency-track-url }} + DTRACK_API_KEY: ${{ inputs.api-key }} + DTRACK_FINDINGS_FILE: /tmp/dtrack-findings.json + SBOM_PATH: ${{ inputs.sbom-file }} + CSAF_OUTPUT: /tmp/csaf-vex.json + CSAF_PUBLISHER_NAME: mundialis + CSAF_PUBLISHER_NAMESPACE: https://mundialis.de + run: | + python "$GITHUB_ACTION_PATH/generate_csaf.py" + + - name: Verify CSAF VEX generation + shell: bash + run: | + if [ ! -s /tmp/csaf-vex.json ]; then + echo "CSAF VEX generation failed." + exit 1 + fi + + echo "CSAF VEX generated successfully." \ No newline at end of file diff --git a/.github/actions/dependency-track-report/dependency_track.py b/.github/actions/dependency-track-report/dependency_track.py new file mode 100644 index 0000000..a9b05ad --- /dev/null +++ b/.github/actions/dependency-track-report/dependency_track.py @@ -0,0 +1,64 @@ +import json +import os +import urllib.error +import urllib.parse +import urllib.request + + +DTRACK_URL = os.environ.get( + "DTRACK_URL", + "http://localhost:8080", +) + +DTRACK_API_KEY = os.environ.get("DTRACK_API_KEY") + + +def validate_config(): + if not DTRACK_API_KEY: + raise SystemExit("DTRACK_API_KEY is not set") + + +def get_analysis(finding): + component = finding["component"] + vulnerability = finding["vulnerability"] + + params = urllib.parse.urlencode( + { + "project": component["project"], + "component": component["uuid"], + "vulnerability": vulnerability["uuid"], + } + ) + + url = f"{DTRACK_URL}/api/v1/analysis?{params}" + + request = urllib.request.Request( + url, + headers={ + "X-Api-Key": DTRACK_API_KEY, + "Accept": "application/json", + }, + ) + + try: + with urllib.request.urlopen( + request, + timeout=15, + ) as response: + return json.load(response) + + except urllib.error.HTTPError as error: + print( + f"Warning: Dependency-Track returned HTTP " + f"{error.code} for " + f'{vulnerability.get("vulnId", "unknown")}.' + ) + + except urllib.error.URLError as error: + print( + f"Warning: Could not reach Dependency-Track for " + f'{vulnerability.get("vulnId", "unknown")}: ' + f"{error.reason}" + ) + + return {} \ No newline at end of file diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py new file mode 100644 index 0000000..382c996 --- /dev/null +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -0,0 +1,508 @@ +import hashlib +import json +import os + +from datetime import datetime, timezone +from pathlib import Path + +from dependency_track import get_analysis, validate_config + +INPUT = Path( + os.environ.get( + "DTRACK_FINDINGS_FILE", + "/tmp/dtrack-findings.json", + ) +) +OUTPUT = Path( + os.environ.get( + "CSAF_OUTPUT", + "/tmp/csaf-vex.json", + ) +) + +SBOM_PATH = Path( + os.environ.get( + "SBOM_PATH", + "docker.cyclonedx.json", + ) +) + +STATE_MAP = { + "IN_TRIAGE": "under_investigation", + "NOT_AFFECTED": "known_not_affected", + "EXPLOITABLE": "known_affected", + "RESOLVED": "fixed", +} + +CSAF_SELF_URL = os.environ.get("CSAF_SELF_URL") +CSAF_PUBLISHER_NAME = os.environ.get( + "CSAF_PUBLISHER_NAME", + "mundialis" +) + +CSAF_PUBLISHER_NAMESPACE = os.environ.get( + "CSAF_PUBLISHER_NAMESPACE", + "https://mundialis.de" +) +CSAF_DOCUMENT_ID = os.environ.get("CSAF_DOCUMENT_ID") + + +def build_product_tree( + project_name, + project_version, + product_id, + sbom_path, + sbom_sha256, +): + return { + "branches": [ + { + "category": "vendor", + "name": CSAF_PUBLISHER_NAME, + "branches": [ + { + "category": "product_name", + "name": project_name, + "branches": [ + { + "category": "product_version", + "name": project_version, + "product": { + "name": f"{project_name} {project_version}", + "product_id": product_id, + "product_identification_helper": { + "hashes": [ + { + "filename": sbom_path.name, + "file_hashes": [ + { + "algorithm": "sha256", + "value": sbom_sha256, + } + ], + } + ] + }, + }, + } + ], + } + ], + } + ] + } + + +def build_document_content( + project_name, + project_version, + product_id, + sbom_path, + sbom_sha256, + vulnerabilities, +): + references = [] + + if CSAF_SELF_URL: + references.append( + { + "category": "self", + "summary": "Canonical URL for this CSAF advisory", + "url": CSAF_SELF_URL, + } + ) + + return { + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "distribution": { + "tlp": { + "label": "WHITE" + } + }, + "lang": "en", + "notes": [ + { + "category": "description", + "title": "Description", + "text": f"VEX document for {project_name}.", + } + ], + "publisher": { + "category": "vendor", + "name": CSAF_PUBLISHER_NAME, + "namespace": CSAF_PUBLISHER_NAMESPACE, + }, + "references": references, + "title": f"VEX for {project_name}", + }, + "product_tree": build_product_tree( + project_name, + project_version, + product_id, + sbom_path, + sbom_sha256, + ), + "vulnerabilities": vulnerabilities, + } + + +def sha256_file(path): + sha256 = hashlib.sha256() + + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + sha256.update(chunk) + + return sha256.hexdigest() + + + +def load_previous_document(): + if not OUTPUT.exists(): + return {} + + try: + with OUTPUT.open() as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def build_tracking(previous_document, current_content, document_id, now): + previous_tracking = ( + previous_document + .get("document", {}) + .get("tracking", {}) + ) + + previous_version = previous_tracking.get("version") + + previous_content = { + "document": { + key: value + for key, value in previous_document.get("document", {}).items() + if key != "tracking" + }, + "product_tree": previous_document.get("product_tree"), + "vulnerabilities": previous_document.get("vulnerabilities"), + } + + content_changed = current_content != previous_content + + initial_release_date = previous_tracking.get( + "initial_release_date", + now, + ) + + if not previous_version: + revision_number = "1" + revision_history = [ + { + "date": now, + "number": "1", + "summary": "Initial release", + } + ] + current_release_date = now + + elif content_changed: + try: + revision_number = str(int(previous_version) + 1) + except ValueError: + revision_number = previous_version + + revision_history = list( + previous_tracking.get("revision_history", []) + ) + + revision_history.append( + { + "date": now, + "number": revision_number, + "summary": "Updated vulnerability analysis", + } + ) + + current_release_date = now + + else: + revision_number = previous_version + revision_history = previous_tracking.get( + "revision_history", + [], + ) + current_release_date = previous_tracking.get( + "current_release_date", + now, + ) + + return { + "current_release_date": current_release_date, + "id": document_id, + "initial_release_date": initial_release_date, + "revision_history": revision_history, + "status": "draft", + "version": revision_number, + } + + +def apply_in_triage(entry, analysis, product_id, _): + details = ( + analysis.get("analysisDetails") + or "The vulnerability is currently under investigation." + ) + + entry["notes"].append( + { + "category": "details", + "title": "Investigation status", + "text": details, + } + ) + + entry["remediations"] = [ + { + "category": "mitigation", + "details": ( + "The vulnerability is currently under investigation. " + "No final remediation decision has been made yet." + ), + "product_ids": [product_id], + } + ] + + +def apply_not_affected(entry, analysis, product_id, _): + justification = analysis.get("analysisJustification") + details = analysis.get("analysisDetails") + + parts = [] + + if justification and justification != "NOT_SET": + parts.append(f"Justification: {justification}.") + + if details: + parts.append(details) + + if not parts: + parts.append("No additional analysis details available.") + + entry["threats"] = [ + { + "category": "impact", + "details": " ".join(parts), + "product_ids": [product_id], + } + ] + + +def apply_exploitable(entry, analysis, product_id, vulnerability): + details = ( + analysis.get("analysisDetails") + or "The vulnerability has been assessed as exploitable." + ) + + entry["notes"].append( + { + "category": "details", + "title": "Exploitability analysis", + "text": details, + } + ) + + entry["remediations"] = [ + { + "category": "vendor_fix", + "details": ( + "The vulnerability is considered exploitable. " + "A fixed version or other remediation should be applied." + ), + "product_ids": [product_id], + } + ] + + if ( + vulnerability.get("cvssV3BaseScore") is not None + and vulnerability.get("cvssV3Vector") + ): + vector = vulnerability["cvssV3Vector"] + + cvss_version = "3.1" + if vector.startswith("CVSS:3.0/"): + cvss_version = "3.0" + + entry["scores"] = [ + { + "cvss_v3": { + "baseScore": vulnerability["cvssV3BaseScore"], + "baseSeverity": vulnerability["severity"], + "vectorString": vector, + "version": cvss_version, + }, + "products": [product_id], + } + ] + + +def apply_resolved(entry, analysis, product_id, _): + details = ( + analysis.get("analysisDetails") + or "The vulnerability has been resolved." + ) + + entry["notes"].append( + { + "category": "details", + "title": "Resolution", + "text": details, + } + ) + + entry["remediations"] = [ + { + "category": "vendor_fix", + "details": "The vulnerability has been resolved.", + "product_ids": [product_id], + } + ] + +STATE_HANDLERS = { + "IN_TRIAGE": apply_in_triage, + "NOT_AFFECTED": apply_not_affected, + "EXPLOITABLE": apply_exploitable, + "RESOLVED": apply_resolved, +} + + +def build_vulnerability_entry(finding, analysis): + component = finding["component"] + vulnerability = finding["vulnerability"] + + state = analysis.get("analysisState") + + if not state or state == "NOT_SET": + print( + f'Skipping {vulnerability["vulnId"]}: ' + "no analysis state has been assigned." + ) + return None + + if state not in STATE_MAP: + print( + f'Skipping {vulnerability["vulnId"]}: ' + f"unsupported analysis state {state}." + ) + return None + + product_id = ( + f'{component["projectName"]}-' + f'{component["projectVersion"]}' + ) + + entry = { + "cve": vulnerability["vulnId"], + "notes": [ + { + "category": "description", + "title": "Analysis status", + "text": vulnerability.get( + "description", + "No description available.", + ), + } + ], + "product_status": { + STATE_MAP[state]: [product_id] + }, + "title": vulnerability["vulnId"], + } + + handler = STATE_HANDLERS[state] + handler( + entry, + analysis, + product_id, + vulnerability, + ) + + cwes = vulnerability.get("cwes", []) + if cwes: + entry["cwe"] = { + "id": f'CWE-{cwes[0]["cweId"]}', + "name": cwes[0]["name"], + } + + return entry + +def main(): + + validate_config() + + with INPUT.open() as f: + findings = json.load(f) + + now = ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + valid_findings = [] + vulnerabilities = [] + + for finding in findings: + full_analysis = get_analysis(finding) + + vulnerability_entry = build_vulnerability_entry( + finding, + full_analysis, + ) + + if vulnerability_entry: + vulnerabilities.append(vulnerability_entry) + valid_findings.append(finding) + + if not vulnerabilities: + raise SystemExit("No analyzed findings found.") + + first = valid_findings[0] + project_name = first["component"]["projectName"] + project_version = first["component"]["projectVersion"] + product_id = f"{project_name}-{project_version}" + + if not SBOM_PATH.exists(): + raise SystemExit(f"SBOM file not found: {SBOM_PATH}") + + sbom_sha256 = sha256_file(SBOM_PATH) + document_id = CSAF_DOCUMENT_ID or f"{project_name}-csaf-vex" + + previous_document = load_previous_document() + + document_content = build_document_content( + project_name, + project_version, + product_id, + SBOM_PATH, + sbom_sha256, + vulnerabilities, + ) + + tracking = build_tracking( + previous_document, + document_content, + document_id, + now, + ) + document = document_content + document["document"]["tracking"] = tracking + + with OUTPUT.open("w") as f: + json.dump(document, f, indent=2) + + print(f"Generated {OUTPUT}") + +if __name__ == "__main__": + main() \ No newline at end of file From 74118a6b172b21f242e83c0fa573c56da9081a3f Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 16:13:01 +0200 Subject: [PATCH 16/37] Retry Dependency-Track analysis when generating CSAF --- .../dependency_track.py | 44 +++++++++++-------- .../dependency-track-report/generate_csaf.py | 7 ++- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/.github/actions/dependency-track-report/dependency_track.py b/.github/actions/dependency-track-report/dependency_track.py index a9b05ad..0ca87bf 100644 --- a/.github/actions/dependency-track-report/dependency_track.py +++ b/.github/actions/dependency-track-report/dependency_track.py @@ -3,7 +3,7 @@ import urllib.error import urllib.parse import urllib.request - +import time DTRACK_URL = os.environ.get( "DTRACK_URL", @@ -40,25 +40,31 @@ def get_analysis(finding): }, ) - try: - with urllib.request.urlopen( - request, - timeout=15, - ) as response: - return json.load(response) + max_attempts = 5 + + for attempt in range(1, max_attempts + 1): + try: + with urllib.request.urlopen( + request, + timeout=15, + ) as response: + return json.load(response) + + except urllib.error.HTTPError as error: + if error.code == 404 and attempt < max_attempts: + time.sleep(2) + continue - except urllib.error.HTTPError as error: - print( - f"Warning: Dependency-Track returned HTTP " - f"{error.code} for " - f'{vulnerability.get("vulnId", "unknown")}.' - ) + print( + "Warning: Dependency-Track analysis " + f"request failed with HTTP {error.code}." + ) + return {} - except urllib.error.URLError as error: - print( - f"Warning: Could not reach Dependency-Track for " - f'{vulnerability.get("vulnId", "unknown")}: ' - f"{error.reason}" - ) + except urllib.error.URLError: + print( + "Warning: Could not reach Dependency-Track." + ) + return {} return {} \ No newline at end of file diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py index 382c996..735c92e 100644 --- a/.github/actions/dependency-track-report/generate_csaf.py +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -384,15 +384,14 @@ def build_vulnerability_entry(finding, analysis): if not state or state == "NOT_SET": print( - f'Skipping {vulnerability["vulnId"]}: ' - "no analysis state has been assigned." + "Skipping finding: no analysis state has been assigned." ) return None if state not in STATE_MAP: print( - f'Skipping {vulnerability["vulnId"]}: ' - f"unsupported analysis state {state}." + f"Skipping finding: unsupported analysis state {state}." + ) return None From 99171157ce3742f5f8e3ac905022594237301ed1 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 16:39:19 +0200 Subject: [PATCH 17/37] Add CSAF validation to Dependency-Track reporting --- .../dependency-track-report/action.yml | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 84c7c10..d50ab66 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -170,7 +170,9 @@ runs: echo "Dependency-Track VEX upload failed with HTTP $http_code." exit 1 fi + - name: Generate CSAF VEX + id: generate-csaf shell: bash env: DTRACK_URL: ${{ inputs.dependency-track-url }} @@ -181,9 +183,19 @@ runs: CSAF_PUBLISHER_NAME: mundialis CSAF_PUBLISHER_NAMESPACE: https://mundialis.de run: | + finding_count=$(jq 'length' /tmp/dtrack-findings.json) + + if [ "$finding_count" -eq 0 ]; then + echo "No findings available for CSAF generation." + echo "has-csaf=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + python "$GITHUB_ACTION_PATH/generate_csaf.py" - + echo "has-csaf=true" >> "$GITHUB_OUTPUT" + - name: Verify CSAF VEX generation + if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | if [ ! -s /tmp/csaf-vex.json ]; then @@ -191,4 +203,58 @@ runs: exit 1 fi - echo "CSAF VEX generated successfully." \ No newline at end of file + echo "CSAF VEX generated successfully." + + - name: Set up Node.js for CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Start CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' + shell: bash + run: | + npx --yes @secvisogram/csaf-validator-service@2.0.31 \ + > /tmp/csaf-validator.log 2>&1 & + + echo $! > /tmp/csaf-validator.pid + + for attempt in {1..30}; do + if curl -sSf http://localhost:8082/api/v1/tests > /dev/null; then + exit 0 + fi + + sleep 1 + done + + echo "CSAF validator did not start." + exit 1 + + - name: Validate CSAF VEX + if: steps.generate-csaf.outputs.has-csaf == 'true' + shell: bash + run: | + jq -n \ + --slurpfile doc /tmp/csaf-vex.json \ + '{ + document: $doc[0], + tests: [ + { + name: "full", + type: "preset" + } + ] + }' \ + | curl -sS -X POST \ + "http://localhost:8082/api/v1/validate" \ + -H "Content-Type: application/json" \ + --data-binary @- \ + -o /tmp/csaf-validation-result.json + + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then + echo "CSAF VEX validation failed." + exit 1 + fi + + echo "CSAF VEX validation succeeded." \ No newline at end of file From eff62f73feb5965c19c94520ef0707aaf3fb6b42 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 16:49:19 +0200 Subject: [PATCH 18/37] CSAF validator startup diagnostics --- .github/actions/dependency-track-report/action.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index d50ab66..f768e90 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -222,6 +222,7 @@ runs: for attempt in {1..30}; do if curl -sSf http://localhost:8082/api/v1/tests > /dev/null; then + echo "CSAF validator started successfully." exit 0 fi @@ -229,6 +230,8 @@ runs: done echo "CSAF validator did not start." + echo "Validator startup log:" + cat /tmp/csaf-validator.log exit 1 - name: Validate CSAF VEX From dab9bc6807bb39b9bd23156c19e4919545f32054 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 16:59:42 +0200 Subject: [PATCH 19/37] Fix CSAF validator startup --- .github/actions/dependency-track-report/action.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index f768e90..b091310 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -212,17 +212,15 @@ runs: node-version: '24' - name: Start CSAF validator - if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | - npx --yes @secvisogram/csaf-validator-service@2.0.31 \ + npx --yes @secvisogram/csaf-validator-service \ > /tmp/csaf-validator.log 2>&1 & echo $! > /tmp/csaf-validator.pid for attempt in {1..30}; do - if curl -sSf http://localhost:8082/api/v1/tests > /dev/null; then - echo "CSAF validator started successfully." + if curl -sSf http://localhost:8082/docs > /dev/null; then exit 0 fi @@ -230,7 +228,6 @@ runs: done echo "CSAF validator did not start." - echo "Validator startup log:" cat /tmp/csaf-validator.log exit 1 From 65bdf02ac9010e92632642ecf2ec8086c22a67c9 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Wed, 16 Sep 2026 17:13:56 +0200 Subject: [PATCH 20/37] Improve CSAF validation error reporting --- .github/actions/dependency-track-report/action.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index b091310..bfc7c85 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -232,7 +232,6 @@ runs: exit 1 - name: Validate CSAF VEX - if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | jq -n \ @@ -254,6 +253,14 @@ runs: if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then echo "CSAF VEX validation failed." + + echo "Validation error count:" + jq '.errors | length' /tmp/csaf-validation-result.json + + echo "Validation error paths:" + jq -r '.errors[]? | .instancePath // empty' \ + /tmp/csaf-validation-result.json + exit 1 fi From 2cab6c9f70dceae86997c2cc7ffd793a827cccb2 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 13:27:28 +0200 Subject: [PATCH 21/37] Inspect CSAF validator response structure --- .github/actions/dependency-track-report/action.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index bfc7c85..70c8e02 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -254,12 +254,11 @@ runs: if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then echo "CSAF VEX validation failed." - echo "Validation error count:" - jq '.errors | length' /tmp/csaf-validation-result.json - - echo "Validation error paths:" - jq -r '.errors[]? | .instancePath // empty' \ - /tmp/csaf-validation-result.json + echo "Validator response structure:" + jq -r ' + to_entries[] + | "\(.key): \(.value | type)" + ' /tmp/csaf-validation-result.json exit 1 fi From 118d55c9193403ecf1e4867481768a1d7b7f6c72 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 13:40:43 +0200 Subject: [PATCH 22/37] Fix CSAF validator request --- .../dependency-track-report/action.yml | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 70c8e02..46a4380 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -234,32 +234,25 @@ runs: - name: Validate CSAF VEX shell: bash run: | - jq -n \ + http_code=$(jq -n \ --slurpfile doc /tmp/csaf-vex.json \ '{ - document: $doc[0], - tests: [ - { - name: "full", - type: "preset" - } - ] + document: $doc[0] }' \ | curl -sS -X POST \ "http://localhost:8082/api/v1/validate" \ -H "Content-Type: application/json" \ --data-binary @- \ - -o /tmp/csaf-validation-result.json + -o /tmp/csaf-validation-result.json \ + -w "%{http_code}") + + if [ "$http_code" != "200" ]; then + echo "CSAF validator request failed with HTTP $http_code." + exit 1 + fi if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then echo "CSAF VEX validation failed." - - echo "Validator response structure:" - jq -r ' - to_entries[] - | "\(.key): \(.value | type)" - ' /tmp/csaf-validation-result.json - exit 1 fi From b6209a68557e04ee24eb06b539510684923d3065 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 13:51:09 +0200 Subject: [PATCH 23/37] Show CSAF validator request errors --- .github/actions/dependency-track-report/action.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 46a4380..96830d6 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -248,12 +248,10 @@ runs: if [ "$http_code" != "200" ]; then echo "CSAF validator request failed with HTTP $http_code." - exit 1 - fi - if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then - echo "CSAF VEX validation failed." - exit 1 - fi + echo "Validator error:" + jq -r '.message // .error // "Unknown validator error"' \ + /tmp/csaf-validation-result.json - echo "CSAF VEX validation succeeded." \ No newline at end of file + exit 1 + fi \ No newline at end of file From b3fd95e1146f2ff420eabe7f9fa15fe2f0a7bf4c Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:05:09 +0200 Subject: [PATCH 24/37] Fix CSAF validator request payload --- .../actions/dependency-track-report/action.yml | 17 ++++++++++++++--- .../dependency-track-report/generate_csaf.py | 6 +++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 96830d6..73c3c07 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -237,7 +237,13 @@ runs: http_code=$(jq -n \ --slurpfile doc /tmp/csaf-vex.json \ '{ - document: $doc[0] + document: $doc[0], + tests: [ + { + name: "full", + type: "preset" + } + ] }' \ | curl -sS -X POST \ "http://localhost:8082/api/v1/validate" \ @@ -248,10 +254,15 @@ runs: if [ "$http_code" != "200" ]; then echo "CSAF validator request failed with HTTP $http_code." - echo "Validator error:" jq -r '.message // .error // "Unknown validator error"' \ /tmp/csaf-validation-result.json + exit 1 + fi + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then + echo "CSAF VEX validation failed." exit 1 - fi \ No newline at end of file + fi + + echo "CSAF VEX validation succeeded." \ No newline at end of file diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py index 735c92e..e1d0b2b 100644 --- a/.github/actions/dependency-track-report/generate_csaf.py +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -211,7 +211,7 @@ def build_tracking(previous_document, current_content, document_id, now): try: revision_number = str(int(previous_version) + 1) except ValueError: - revision_number = previous_version + revision_number = str(previous_version) revision_history = list( previous_tracking.get("revision_history", []) @@ -228,7 +228,7 @@ def build_tracking(previous_document, current_content, document_id, now): current_release_date = now else: - revision_number = previous_version + revision_number = str(previous_version) revision_history = previous_tracking.get( "revision_history", [], @@ -240,7 +240,7 @@ def build_tracking(previous_document, current_content, document_id, now): return { "current_release_date": current_release_date, - "id": document_id, + "id": str(document_id), "initial_release_date": initial_release_date, "revision_history": revision_history, "status": "draft", From 98b0a2e738c5402b6132b63d613b4b99bbab5f66 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:12:13 +0200 Subject: [PATCH 25/37] check csaf validation --- .../dependency-track-report/action.yml | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 73c3c07..c080eeb 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -234,35 +234,49 @@ runs: - name: Validate CSAF VEX shell: bash run: | - http_code=$(jq -n \ + # 1. + jq -n \ --slurpfile doc /tmp/csaf-vex.json \ '{ - document: $doc[0], - tests: [ - { - name: "full", - type: "preset" - } - ] - }' \ - | curl -sS -X POST \ - "http://localhost:8082/api/v1/validate" \ + validatorServiceSelection: { + modules: [ + "schema", + "mandatory" + ] + }, + document: $doc[0] + }' > /tmp/validator-request.json + + # 2. + http_code=$(curl -s -S \ + -X POST "http://localhost:8082/api/v1/validate" \ -H "Content-Type: application/json" \ - --data-binary @- \ + --data-binary @/tmp/validator-request.json \ -o /tmp/csaf-validation-result.json \ -w "%{http_code}") + # 3. + rm -f /tmp/validator-request.json + + # 4. if [ "$http_code" != "200" ]; then - echo "CSAF validator request failed with HTTP $http_code." - echo "Validator error:" - jq -r '.message // .error // "Unknown validator error"' \ - /tmp/csaf-validation-result.json + echo "::error::The CSAF Validator service request failed with HTTP code $http_code." exit 1 fi - if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then - echo "CSAF VEX validation failed." + # 5. + is_valid=$(jq '.isValid // false' /tmp/csaf-validation-result.json) + + # 6. + if [ "$is_valid" != "true" ]; then + echo "::error::CSAF validation failed. The generated document does not comply with the standard specification rules." + echo "Detailed validation findings have been suppressed from execution logs to protect sensitive vulnerability details and internal project paths." + + # Fail the step safely without exposing data to the terminal logs. exit 1 fi - echo "CSAF VEX validation succeeded." \ No newline at end of file + # 7. + rm -f /tmp/csaf-validation-result.json + + echo "CSAF VEX validation completed successfully. The document is structurally valid and secure." From 372164337d1922b52d3a9b8ab7da537cde5b179e Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:20:54 +0200 Subject: [PATCH 26/37] Install Hunspell for CSAF validation --- .../dependency-track-report/action.yml | 58 ++++++++----------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index c080eeb..4fee794 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -211,6 +211,12 @@ runs: with: node-version: '24' + - name: Install Hunspell for CSAF validator + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y hunspell hunspell-en-us + - name: Start CSAF validator shell: bash run: | @@ -234,49 +240,35 @@ runs: - name: Validate CSAF VEX shell: bash run: | - # 1. - jq -n \ + http_code=$(jq -n \ --slurpfile doc /tmp/csaf-vex.json \ '{ - validatorServiceSelection: { - modules: [ - "schema", - "mandatory" - ] - }, - document: $doc[0] - }' > /tmp/validator-request.json - - # 2. - http_code=$(curl -s -S \ - -X POST "http://localhost:8082/api/v1/validate" \ + document: $doc[0], + tests: [ + { + name: "full", + type: "preset" + } + ] + }' \ + | curl -sS -X POST \ + "http://localhost:8082/api/v1/validate" \ -H "Content-Type: application/json" \ - --data-binary @/tmp/validator-request.json \ + --data-binary @- \ -o /tmp/csaf-validation-result.json \ -w "%{http_code}") - # 3. - rm -f /tmp/validator-request.json - - # 4. if [ "$http_code" != "200" ]; then - echo "::error::The CSAF Validator service request failed with HTTP code $http_code." + echo "CSAF validator request failed with HTTP $http_code." + echo "Validator error:" + jq -r '.message // .error // "Unknown validator error"' \ + /tmp/csaf-validation-result.json exit 1 fi - # 5. - is_valid=$(jq '.isValid // false' /tmp/csaf-validation-result.json) - - # 6. - if [ "$is_valid" != "true" ]; then - echo "::error::CSAF validation failed. The generated document does not comply with the standard specification rules." - echo "Detailed validation findings have been suppressed from execution logs to protect sensitive vulnerability details and internal project paths." - - # Fail the step safely without exposing data to the terminal logs. + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then + echo "CSAF VEX validation failed." exit 1 fi - # 7. - rm -f /tmp/csaf-validation-result.json - - echo "CSAF VEX validation completed successfully. The document is structurally valid and secure." + echo "CSAF VEX validation succeeded." From ff449f2dbee904285bd270f960f20ed9b73f54c6 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:34:05 +0200 Subject: [PATCH 27/37] Improve CSAF validator error handling --- .github/actions/dependency-track-report/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 4fee794..7ac4b5f 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -259,7 +259,7 @@ runs: -w "%{http_code}") if [ "$http_code" != "200" ]; then - echo "CSAF validator request failed with HTTP $http_code." + echo "CSAF Validator service request failed with HTTP code $http_code." echo "Validator error:" jq -r '.message // .error // "Unknown validator error"' \ /tmp/csaf-validation-result.json @@ -271,4 +271,4 @@ runs: exit 1 fi - echo "CSAF VEX validation succeeded." + echo "CSAF VEX validation succeeded." \ No newline at end of file From 19f9d17f11491d3ac5b1384aebe49ba2ff4de765 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:45:14 +0200 Subject: [PATCH 28/37] Configure Hunspell dictionary for CSAF validation --- .github/actions/dependency-track-report/action.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 7ac4b5f..f72fa48 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -217,6 +217,9 @@ runs: sudo apt-get update sudo apt-get install -y hunspell hunspell-en-us + sudo ln -sf /usr/share/hunspell/en_US.aff /usr/share/hunspell/en.aff + sudo ln -sf /usr/share/hunspell/en_US.dic /usr/share/hunspell/en.dic + - name: Start CSAF validator shell: bash run: | From 9d9601a50bb359bcd2064b83215ec738a1105854 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 14:54:31 +0200 Subject: [PATCH 29/37] Report failed CSAF validation tests --- .../actions/dependency-track-report/action.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index f72fa48..370c57e 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -271,6 +271,21 @@ runs: if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then echo "CSAF VEX validation failed." + + echo "Failed validation tests:" + jq -r ' + .tests[]? + | select(.isValid == false) + | (.name // .test // .id // "unknown test") + ' /tmp/csaf-validation-result.json + + echo "Validation error paths:" + jq -r ' + .tests[]? + | .errors[]? + | .instancePath // empty + ' /tmp/csaf-validation-result.json + exit 1 fi From 692a40949bda8d030c924b8a7dfc1db97334a527 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 15:11:35 +0200 Subject: [PATCH 30/37] Report CSAF validation error messages --- .github/actions/dependency-track-report/action.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 370c57e..7f1d583 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -279,11 +279,15 @@ runs: | (.name // .test // .id // "unknown test") ' /tmp/csaf-validation-result.json - echo "Validation error paths:" + echo "Validation errors:" jq -r ' .tests[]? | .errors[]? - | .instancePath // empty + | [ + (.instancePath // ""), + (.message // "") + ] + | @tsv ' /tmp/csaf-validation-result.json exit 1 From 9fb4c0b3c93e47858c81bad515ab145d87224627 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 15:21:36 +0200 Subject: [PATCH 31/37] Check CSAF validation errors excluding references --- .../dependency-track-report/action.yml | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 7f1d583..b22b172 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -269,20 +269,37 @@ runs: exit 1 fi - if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then - echo "CSAF VEX validation failed." + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" = "true" ]; then + echo "CSAF VEX validation succeeded." + exit 0 + fi - echo "Failed validation tests:" - jq -r ' + error_count=$(jq ' + [ .tests[]? - | select(.isValid == false) - | (.name // .test // .id // "unknown test") - ' /tmp/csaf-validation-result.json + | .errors[]? + ] + | length + ' /tmp/csaf-validation-result.json) + + non_reference_error_count=$(jq ' + [ + .tests[]? + | .errors[]? + | select((.instancePath // "") != "/document/references") + ] + | length + ' /tmp/csaf-validation-result.json) + + echo "CSAF VEX validation reported $error_count error(s)." + + if [ "$non_reference_error_count" -gt 0 ]; then + echo "CSAF VEX contains validation errors unrelated to document references." - echo "Validation errors:" jq -r ' .tests[]? | .errors[]? + | select((.instancePath // "") != "/document/references") | [ (.instancePath // ""), (.message // "") @@ -293,4 +310,5 @@ runs: exit 1 fi - echo "CSAF VEX validation succeeded." \ No newline at end of file + echo "CSAF VEX passed all validation checks except document references." + echo "The publication reference still needs to be defined." \ No newline at end of file From c4c0070da0e29c5651d48a8e8fcdf4df1b8ab980 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 15:40:30 +0200 Subject: [PATCH 32/37] Omit empty references from CSAF document --- .../dependency-track-report/generate_csaf.py | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py index e1d0b2b..a9de612 100644 --- a/.github/actions/dependency-track-report/generate_csaf.py +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -101,42 +101,41 @@ def build_document_content( sbom_sha256, vulnerabilities, ): - references = [] + document = { + "category": "csaf_vex", + "csaf_version": "2.0", + "distribution": { + "tlp": { + "label": "WHITE" + } + }, + "lang": "en", + "notes": [ + { + "category": "description", + "title": "Description", + "text": f"VEX document for {project_name}.", + } + ], + "publisher": { + "category": "vendor", + "name": CSAF_PUBLISHER_NAME, + "namespace": CSAF_PUBLISHER_NAMESPACE, + }, + "title": f"VEX for {project_name}", + } if CSAF_SELF_URL: - references.append( + document["references"] = [ { "category": "self", "summary": "Canonical URL for this CSAF advisory", "url": CSAF_SELF_URL, } - ) + ] return { - "document": { - "category": "csaf_vex", - "csaf_version": "2.0", - "distribution": { - "tlp": { - "label": "WHITE" - } - }, - "lang": "en", - "notes": [ - { - "category": "description", - "title": "Description", - "text": f"VEX document for {project_name}.", - } - ], - "publisher": { - "category": "vendor", - "name": CSAF_PUBLISHER_NAME, - "namespace": CSAF_PUBLISHER_NAMESPACE, - }, - "references": references, - "title": f"VEX for {project_name}", - }, + "document": document, "product_tree": build_product_tree( project_name, project_version, @@ -147,7 +146,6 @@ def build_document_content( "vulnerabilities": vulnerabilities, } - def sha256_file(path): sha256 = hashlib.sha256() From 98113d5ab6013ecdd4e15dee965cf22f5829644a Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 15:53:56 +0200 Subject: [PATCH 33/37] Simplify CSAF validation handling --- .../dependency-track-report/action.yml | 43 ++----------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index b22b172..f72fa48 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -269,46 +269,9 @@ runs: exit 1 fi - if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" = "true" ]; then - echo "CSAF VEX validation succeeded." - exit 0 - fi - - error_count=$(jq ' - [ - .tests[]? - | .errors[]? - ] - | length - ' /tmp/csaf-validation-result.json) - - non_reference_error_count=$(jq ' - [ - .tests[]? - | .errors[]? - | select((.instancePath // "") != "/document/references") - ] - | length - ' /tmp/csaf-validation-result.json) - - echo "CSAF VEX validation reported $error_count error(s)." - - if [ "$non_reference_error_count" -gt 0 ]; then - echo "CSAF VEX contains validation errors unrelated to document references." - - jq -r ' - .tests[]? - | .errors[]? - | select((.instancePath // "") != "/document/references") - | [ - (.instancePath // ""), - (.message // "") - ] - | @tsv - ' /tmp/csaf-validation-result.json - + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then + echo "CSAF VEX validation failed." exit 1 fi - echo "CSAF VEX passed all validation checks except document references." - echo "The publication reference still needs to be defined." \ No newline at end of file + echo "CSAF VEX validation succeeded." \ No newline at end of file From 8930fd259a4c71976d015f4bd4fb2bca724bc6f5 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 16:25:03 +0200 Subject: [PATCH 34/37] Skip CSAF validation when no report is generated --- .github/actions/dependency-track-report/action.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index f72fa48..599ee53 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -186,13 +186,20 @@ runs: finding_count=$(jq 'length' /tmp/dtrack-findings.json) if [ "$finding_count" -eq 0 ]; then - echo "No findings available for CSAF generation." + echo "No findings were reported by Dependency-Track. CSAF VEX generation is not required." echo "has-csaf=false" >> "$GITHUB_OUTPUT" exit 0 fi python "$GITHUB_ACTION_PATH/generate_csaf.py" - echo "has-csaf=true" >> "$GITHUB_OUTPUT" + + if [ -s /tmp/csaf-vex.json ]; then + echo "has-csaf=true" >> "$GITHUB_OUTPUT" + echo "CSAF VEX generated successfully." + else + echo "has-csaf=false" >> "$GITHUB_OUTPUT" + echo "Dependency-Track reported findings, but none had an analysis state that could be included in the CSAF VEX." + fi - name: Verify CSAF VEX generation if: steps.generate-csaf.outputs.has-csaf == 'true' @@ -212,6 +219,7 @@ runs: node-version: '24' - name: Install Hunspell for CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | sudo apt-get update @@ -221,6 +229,7 @@ runs: sudo ln -sf /usr/share/hunspell/en_US.dic /usr/share/hunspell/en.dic - name: Start CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | npx --yes @secvisogram/csaf-validator-service \ @@ -241,6 +250,7 @@ runs: exit 1 - name: Validate CSAF VEX + if: steps.generate-csaf.outputs.has-csaf == 'true' shell: bash run: | http_code=$(jq -n \ From 492b48db84681945215d9591d3c8eec8128bdcb3 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Mon, 21 Sep 2026 17:05:30 +0200 Subject: [PATCH 35/37] Add Dependency-Track VEX and CSAF reporting workflow --- .../actions/dependency-track-report/action.yml | 17 +++-------------- .../dependency-track-report/dependency_track.py | 3 ++- .../dependency-track-report/generate_csaf.py | 4 +++- .github/workflows/sbom-vulnerability-scan.yml | 9 +++++---- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 599ee53..791994c 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -1,5 +1,5 @@ name: Dependency-Track Report -description: Upload an SBOM to Dependency-Track, generate CycloneDX VEX, and apply it +description: Upload an SBOM to Dependency-Track, manage CycloneDX VEX, and generate and validate CSAF VEX inputs: sbom-file: @@ -201,20 +201,9 @@ runs: echo "Dependency-Track reported findings, but none had an analysis state that could be included in the CSAF VEX." fi - - name: Verify CSAF VEX generation - if: steps.generate-csaf.outputs.has-csaf == 'true' - shell: bash - run: | - if [ ! -s /tmp/csaf-vex.json ]; then - echo "CSAF VEX generation failed." - exit 1 - fi - - echo "CSAF VEX generated successfully." - - name: Set up Node.js for CSAF validator if: steps.generate-csaf.outputs.has-csaf == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '24' @@ -238,7 +227,7 @@ runs: echo $! > /tmp/csaf-validator.pid for attempt in {1..30}; do - if curl -sSf http://localhost:8082/docs > /dev/null; then + if curl -sf http://localhost:8082/docs > /dev/null 2>&1; then exit 0 fi diff --git a/.github/actions/dependency-track-report/dependency_track.py b/.github/actions/dependency-track-report/dependency_track.py index 0ca87bf..87f39a1 100644 --- a/.github/actions/dependency-track-report/dependency_track.py +++ b/.github/actions/dependency-track-report/dependency_track.py @@ -1,9 +1,10 @@ import json import os +import time import urllib.error import urllib.parse import urllib.request -import time + DTRACK_URL = os.environ.get( "DTRACK_URL", diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py index a9de612..29acdc9 100644 --- a/.github/actions/dependency-track-report/generate_csaf.py +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -463,7 +463,9 @@ def main(): valid_findings.append(finding) if not vulnerabilities: - raise SystemExit("No analyzed findings found.") + OUTPUT.unlink(missing_ok=True) + print("No analyzed findings available for CSAF generation.") + return first = valid_findings[0] project_name = first["component"]["projectName"] diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 6743e36..f35c817 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -62,7 +62,7 @@ on: required: false jobs: sbom-vulnerability-scan: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Validate inputs @@ -116,7 +116,7 @@ jobs: - name: Process Docker SBOM in Dependency-Track if: inputs.dockerfile != '' && inputs.dependency-track - uses: $/.github/actions/dependency-track-report + uses: ./.github/actions/dependency-track-report with: sbom-file: docker.cyclonedx.json dependency-track-url: ${{ inputs.dependency-track-url }} @@ -167,7 +167,8 @@ jobs: python -m venv .sbom_venv source .sbom_venv/bin/activate pip install --upgrade pip --quiet - pip install . + pyproject_dir=$(dirname "${{ inputs.pyproject }}") + pip install "$pyproject_dir" - name: Generate SBOM from Python environment if: inputs.requirements != '' || inputs.pyproject != '' @@ -180,7 +181,7 @@ jobs: - name: Process Python SBOM in Dependency-Track if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.dependency-track - uses: $/.github/actions/dependency-track-report + uses: ./.github/actions/dependency-track-report with: sbom-file: python.cyclonedx.json dependency-track-url: ${{ inputs.dependency-track-url }} From 9ee86c6fb328f00a8ae560ca57d2dd3eef8e1721 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 22 Sep 2026 08:24:28 +0200 Subject: [PATCH 36/37] Fix Dependency-Track action reference --- .github/workflows/sbom-vulnerability-scan.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index f35c817..626899b 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -116,7 +116,7 @@ jobs: - name: Process Docker SBOM in Dependency-Track if: inputs.dockerfile != '' && inputs.dependency-track - uses: ./.github/actions/dependency-track-report + uses: $/.github/actions/dependency-track-report with: sbom-file: docker.cyclonedx.json dependency-track-url: ${{ inputs.dependency-track-url }} @@ -181,7 +181,7 @@ jobs: - name: Process Python SBOM in Dependency-Track if: (inputs.requirements != '' || inputs.pyproject != '') && inputs.dependency-track - uses: ./.github/actions/dependency-track-report + uses: $/.github/actions/dependency-track-report with: sbom-file: python.cyclonedx.json dependency-track-url: ${{ inputs.dependency-track-url }} From 3fc0e727531997932a741eba8a2e32ea685c1f13 Mon Sep 17 00:00:00 2001 From: TaniaG Date: Tue, 22 Sep 2026 11:18:40 +0200 Subject: [PATCH 37/37] Update SBOM vulnerability scan documentation --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a54479e..a881b8f 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,18 @@ For Python projects, a virtual environment is created from either directly because this provides valid SARIF artifact locations for GitHub Code Scanning. -The vulnerability results are uploaded to GitHub Code Scanning at category grype-python/grype-docker. +The vulnerability results are uploaded to GitHub Code Scanning using the +categories `grype-docker` or `grype-python`. + +Optionally, the generated SBOM can also be uploaded to Dependency-Track. +When Dependency-Track integration is enabled, new findings without an existing +analysis state are documented as `IN_TRIAGE` using CycloneDX VEX. Existing +manual or previous analysis states are preserved. + +Based on the Dependency-Track analysis states, the workflow also generates a +CSAF 2.0 VEX document. The generated CSAF document is validated automatically. +If no findings are reported, or no findings contain an analysis state that can +be included in the CSAF document, CSAF generation and validation are skipped. You can use it e.g. like this: @@ -270,15 +281,25 @@ jobs: # requirements: requirements.txt # pyproject: pyproject.toml # additional-packages: "libgdal-dev gdal-bin build-essential" + + + fail-build: false + + dependency-track: true + dependency-track-url: https://dependency-track.example.com + dependency-track-project-name: example-project + dependency-track-project-version: latest + + secrets: + dependency-track-api-key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} ``` Provide exactly one of the following inputs: - `dockerfile`: Path to the Dockerfile. -- `requirements`: Path to the requirements.txt file. -- `pyproject`: Path to the pyproject.toml file. +- `requirements`: Path to the `requirements.txt` file. +- `pyproject`: Path to the `pyproject.toml` file. -If none exists, an empty requirements.txt needs to be created. The calling job requires the following permissions: @@ -287,13 +308,23 @@ The calling job requires the following permissions: Optional inputs: - `fetch_depth`: Number of commits to fetch during checkout. Use `0` to fetch the full history and tags. Default: `1`. -- `fail-build`: Set to `true` if the workflow should fail when vulnerabilities above the severity -cutoff are found. Default: `false`. -- `additional-packages`: a list with additional packages can be installed e.g. -for gdal see example. In the requirements.txt the gdal version not not be set -to a fixed version, because the system version of GDAL is used. +- `fail-build`: Set to `true` if the workflow should fail when vulnerabilities above the severity cutoff are found. Default: `false`. +- `additional-packages`: Space-separated list of additional system packages + to install before creating a Python environment. +- `dependency-track`: Enable Dependency-Track integration. Default: `false`. +- `dependency-track-url`: Dependency-Track base URL. Required when `dependency-track` is enabled. +- `dependency-track-project-name`: Project name used in Dependency-Track. Required when + `dependency-track` is enabled. +- `dependency-track-project-version`: Project version used in Dependency-Track. Default: `latest`. + +When Dependency-Track integration is enabled, the secret `dependency-track-api-key` must also +be provided by the calling workflow. + The generated Docker or Python SBOM is uploaded as a workflow artifact. +The generated CSAF VEX document is currently generated and validated within the +workflow and is not uploaded as a workflow artifact. + The vulnerability results are available under **Security and quality** → **Code scanning**. ## Generate Third-Party-License list on release