diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 24d77e9..0b4ada8 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: @@ -169,4 +169,108 @@ 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 + id: generate-csaf + 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: | + finding_count=$(jq 'length' /tmp/dtrack-findings.json) + + if [ "$finding_count" -eq 0 ]; then + 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" + + 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: Set up Node.js for CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' + uses: actions/setup-node@v7 + with: + node-version: '24' + + - name: Install Hunspell for CSAF validator + if: steps.generate-csaf.outputs.has-csaf == 'true' + shell: bash + run: | + 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 + if: steps.generate-csaf.outputs.has-csaf == 'true' + shell: bash + run: | + 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 -sf http://localhost:8082/docs > /dev/null 2>&1; then + exit 0 + fi + + sleep 1 + done + + echo "CSAF validator did not start." + cat /tmp/csaf-validator.log + exit 1 + + - name: Validate CSAF VEX + if: steps.generate-csaf.outputs.has-csaf == 'true' + shell: bash + run: | + http_code=$(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 \ + -w "%{http_code}") + + if [ "$http_code" != "200" ]; then + 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 + exit 1 + fi + + if [ "$(jq -r '.isValid' /tmp/csaf-validation-result.json)" != "true" ]; then + echo "CSAF VEX validation failed." + exit 1 + fi + + echo "CSAF VEX validation succeeded." 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..87f39a1 --- /dev/null +++ b/.github/actions/dependency-track-report/dependency_track.py @@ -0,0 +1,71 @@ +import json +import os +import time +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", + }, + ) + + 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 + + print( + "Warning: Dependency-Track analysis " + f"request failed with HTTP {error.code}." + ) + return {} + + 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 new file mode 100644 index 0000000..29acdc9 --- /dev/null +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -0,0 +1,507 @@ +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, +): + 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: + document["references"] = [ + { + "category": "self", + "summary": "Canonical URL for this CSAF advisory", + "url": CSAF_SELF_URL, + } + ] + + return { + "document": document, + "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 = str(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 = str(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": str(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( + "Skipping finding: no analysis state has been assigned." + ) + return None + + if state not in STATE_MAP: + print( + f"Skipping finding: 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: + OUTPUT.unlink(missing_ok=True) + print("No analyzed findings available for CSAF generation.") + return + + 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 diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 6743e36..626899b 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 @@ -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 != '' 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