Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions .github/actions/dependency-track-report/action.yml
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions .github/actions/dependency-track-report/generate-cyclonedx-vex.py
Original file line number Diff line number Diff line change
@@ -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()
68 changes: 63 additions & 5 deletions .github/workflows/sbom-vulnerability-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down