diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml new file mode 100644 index 0000000..24d77e9 --- /dev/null +++ b/.github/actions/dependency-track-report/action.yml @@ -0,0 +1,172 @@ +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 + + project-type: + description: CycloneDX component type for the project + 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 + id: generate-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 }}" \ + --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 }} + 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/dependency-track-report/generate-cyclonedx-vex.py b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py new file mode 100644 index 0000000..f7ac2fe --- /dev/null +++ b/.github/actions/dependency-track-report/generate-cyclonedx-vex.py @@ -0,0 +1,93 @@ +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) + parser.add_argument("--project-type", 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"] + analysis = finding.get("analysis", {}) + + # Do not overwrite an existing manual or previous analysis state. + if analysis.get("state") or analysis.get("isSuppressed"): + continue + + component_uuid = component["uuid"] + + component_data = { + "type": "library", + "bom-ref": component_uuid, + "name": component["name"], + "version": component["version"], + } + + if component.get("purl"): + component_data["purl"] = component["purl"] + + components[component_uuid] = component_data + + vulnerabilities.append({ + "id": vulnerability["vulnId"], + "source": { + "name": vulnerability["source"], + }, + "analysis": { + "state": "in_triage", + }, + "affects": [ + { + "ref": component_uuid, + } + ], + }) + + if not vulnerabilities: + output_path.unlink(missing_ok=True) + return + + vex = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": f"urn:uuid:{uuid.uuid4()}", + "version": 1, + "metadata": { + "component": { + "type": args.project_type, + "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() diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index c5ed981..6743e36 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -35,7 +35,31 @@ on: required: false default: false type: boolean + + 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 @@ -54,6 +78,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -73,12 +114,22 @@ jobs: output-file: docker.cyclonedx.json format: cyclonedx-json + - name: Process Docker SBOM in Dependency-Track + if: inputs.dockerfile != '' && inputs.dependency-track + uses: $/.github/actions/dependency-track-report + with: + 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 }} + project-type: container + - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' id: docker-vulnerability-scan uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 with: - image: sbom: docker.cyclonedx.json fail-build: ${{ inputs.fail-build }} output-format: sarif @@ -118,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@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 @@ -131,6 +178,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