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
178 changes: 148 additions & 30 deletions .github/actions/dependency-track-report/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,53 @@ inputs:
description: CycloneDX component type for the project
required: true

email-notification:
description: "Enable CSAF email notifications"
required: false
default: "false"

email-address:
description: "Email address used for CSAF notifications"
required: false
default: ""

smtp-password:
description: "SMTP app password"
required: false
default: ""

notification-property-group:
description: "Dependency-Track project property group used for notification state"
required: true

csaf-publisher-name:
description: "CSAF publisher name"
required: true

csaf-publisher-namespace:
description: "CSAF publisher namespace"
required: true

runs:
using: composite
steps:
- name: Validate email notification inputs
if: inputs.email-notification == 'true'
shell: bash
env:
EMAIL_ADDRESS: ${{ inputs.email-address }}
SMTP_PASSWORD: ${{ inputs.smtp-password }}
run: |
if [ -z "$EMAIL_ADDRESS" ]; then
echo "email-address is required when email notifications are enabled."
exit 1
fi

if [ -z "$SMTP_PASSWORD" ]; then
echo "smtp-password is required when email notifications are enabled."
exit 1
fi

- name: Upload SBOM to Dependency-Track
shell: bash
env:
Expand Down Expand Up @@ -71,16 +115,25 @@ runs:
DTRACK_PROJECT_NAME: ${{ inputs.project-name }}
DTRACK_PROJECT_VERSION: ${{ inputs.project-version }}
run: |
project_uuid=$(curl -sS \
http_code=$(curl -sS \
-o /tmp/dtrack-project-response.json \
-w "%{http_code}" \
-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')
"${DTRACK_URL%/}/api/v1/project/lookup")

if [ "$http_code" != "200" ]; then
echo "Dependency-Track project lookup failed with HTTP $http_code."
exit 1
fi

project_uuid=$(jq -r '.uuid // empty' \
/tmp/dtrack-project-response.json)

if [ -z "$project_uuid" ]; then
echo "Dependency-Track project could not be found."
echo "Dependency-Track project UUID was not returned."
exit 1
fi

Expand All @@ -96,23 +149,30 @@ runs:
attempt=1

while [ "$attempt" -le "$max_attempts" ]; do
processing=$(curl -sS \
http_code=$(curl -sS \
-o /tmp/dtrack-event-response.json \
-w "%{http_code}" \
-H "X-Api-Key: $DTRACK_API_KEY" \
"${DTRACK_URL%/}/api/v1/event/token/$DTRACK_BOM_TOKEN" \
| jq -r '.processing // false')
"${DTRACK_URL%/}/api/v1/event/token/$DTRACK_BOM_TOKEN")

if [ "$http_code" != "200" ]; then
echo "Dependency-Track processing status request failed with HTTP $http_code."
exit 1
fi

processing=$(jq -r '.processing // false' \
/tmp/dtrack-event-response.json)

if [ "$processing" = "false" ]; then
break
exit 0
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
echo "Dependency-Track analysis did not complete within the expected time."
exit 1

- name: Download Dependency-Track findings
shell: bash
Expand All @@ -127,15 +187,31 @@ runs:
"${DTRACK_URL%/}/api/v1/finding/project/$DTRACK_PROJECT_UUID")

if [ "$http_code" != "200" ]; then
echo "Failed to retrieve findings from Dependency-Track."
echo "Failed to retrieve findings from Dependency-Track with HTTP $http_code."
exit 1
fi

if ! jq -e 'type == "array"' /tmp/dtrack-findings.json > /dev/null; then
echo "Dependency-Track returned an invalid findings response."
exit 1
fi

- name: Initialize CSAF notification baseline
if: inputs.email-notification == 'true'
shell: bash
env:
DTRACK_URL: ${{ inputs.dependency-track-url }}
DTRACK_API_KEY: ${{ inputs.api-key }}
NOTIFICATION_PROPERTY_GROUP: ${{ inputs.notification-property-group }}
NOTIFICATION_ACTION: initialize
run: |
python3 "$GITHUB_ACTION_PATH/dependency_track.py"

- name: Generate CycloneDX VEX
id: generate-vex
shell: bash
run: |
python "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \
python3 "$GITHUB_ACTION_PATH/generate-cyclonedx-vex.py" \
--findings /tmp/dtrack-findings.json \
--output /tmp/dtrack-vex.json \
--project-uuid "$DTRACK_PROJECT_UUID" \
Expand Down Expand Up @@ -170,35 +246,26 @@ 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 }}
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
CSAF_PUBLISHER_NAME: ${{ inputs.csaf-publisher-name }}
CSAF_PUBLISHER_NAMESPACE: ${{ inputs.csaf-publisher-namespace }}
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"
python3 "$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."
echo "No analyzed findings available for CSAF VEX generation."
fi

- name: Set up Node.js for CSAF validator
Expand All @@ -224,8 +291,6 @@ runs:
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
Expand Down Expand Up @@ -274,3 +339,56 @@ runs:
fi

echo "CSAF VEX validation succeeded."

- name: Check if email notification is required
id: email-notification
if: >
inputs.email-notification == 'true' &&
steps.generate-csaf.outputs.has-csaf == 'true'
shell: bash
env:
DTRACK_URL: ${{ inputs.dependency-track-url }}
DTRACK_API_KEY: ${{ inputs.api-key }}
NOTIFICATION_PROPERTY_GROUP: ${{ inputs.notification-property-group }}
DTRACK_FINDINGS_FILE: /tmp/dtrack-findings.json
run: |
status=$(python3 "$GITHUB_ACTION_PATH/dependency_track.py")

send_email=$(echo "$status" | jq -r '.send_email')
new_count=$(echo "$status" | jq -r '.new_count')
state_change_count=$(echo "$status" | jq -r '.state_change_count')
latest_event_timestamp=$(echo "$status" | jq -r '.latest_event_timestamp // empty')

echo "send-email=$send_email" >> "$GITHUB_OUTPUT"
echo "new-count=$new_count" >> "$GITHUB_OUTPUT"
echo "state-change-count=$state_change_count" >> "$GITHUB_OUTPUT"
echo "latest-event-timestamp=$latest_event_timestamp" >> "$GITHUB_OUTPUT"

- name: Send CSAF email notification
if: steps.email-notification.outputs.send-email == 'true'
shell: bash
env:
SMTP_SERVER: smtp.mailbox.org
SMTP_PORT: "587"
SMTP_USERNAME: ${{ inputs.email-address }}
SMTP_PASSWORD: ${{ inputs.smtp-password }}
EMAIL_FROM: ${{ inputs.email-address }}
EMAIL_TO: ${{ inputs.email-address }}
PROJECT_NAME: ${{ inputs.project-name }}
CSAF_FILE: /tmp/csaf-vex.json
NEW_COUNT: ${{ steps.email-notification.outputs.new-count }}
STATE_CHANGE_COUNT: ${{ steps.email-notification.outputs.state-change-count }}
run: |
python3 "${{ github.action_path }}/csaf_notification.py"

- name: Update CSAF notification timestamp
if: steps.email-notification.outputs.send-email == 'true'
shell: bash
env:
DTRACK_URL: ${{ inputs.dependency-track-url }}
DTRACK_API_KEY: ${{ inputs.api-key }}
NOTIFICATION_PROPERTY_GROUP: ${{ inputs.notification-property-group }}
NOTIFICATION_ACTION: update
LATEST_EVENT_TIMESTAMP: ${{ steps.email-notification.outputs.latest-event-timestamp }}
run: |
python3 "$GITHUB_ACTION_PATH/dependency_track.py"
87 changes: 87 additions & 0 deletions .github/actions/dependency-track-report/csaf_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import os
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path


def get_required_env(name):
value = os.environ.get(name)
if not value:
raise SystemExit(f"{name} is not set")
return value


def main():
smtp_server = get_required_env("SMTP_SERVER")
smtp_port = int(os.environ.get("SMTP_PORT", "587"))
smtp_username = get_required_env("SMTP_USERNAME")
smtp_password = get_required_env("SMTP_PASSWORD")
project_name = os.environ.get("PROJECT_NAME", "Unknown project")
new_count = int(os.environ.get("NEW_COUNT", "0"))
state_change_count = int(
os.environ.get("STATE_CHANGE_COUNT", "0")
)
email_from = get_required_env("EMAIL_FROM")
email_to = get_required_env("EMAIL_TO")

csaf_file = Path(
os.environ.get("CSAF_FILE", "/tmp/csaf-vex.json")
)

if not csaf_file.is_file():
raise SystemExit("CSAF report does not exist.")

message = EmailMessage()
message["From"] = email_from
message["To"] = email_to

if new_count > 0 and state_change_count > 0:
subject = f"[{project_name}] Vulnerability report updated"
elif new_count > 0:
subject = f"[{project_name}] New vulnerabilities detected"
else:
subject = f"[{project_name}] Vulnerability status updated"

message["Subject"] = subject

body_lines = []

if new_count > 0:
body_lines.append(
f"{new_count} new vulnerabilit"
f"{'y was' if new_count == 1 else 'ies were'} "
f"detected for {project_name}."
)

if state_change_count > 0:
body_lines.append(
f"{state_change_count} vulnerability analysis "
f"{'state change was' if state_change_count == 1 else 'state changes were'} "
f"detected for {project_name}."
)
body_lines.append("")
body_lines.append(
"The updated CSAF vulnerability report is attached."
)

message.set_content("\n".join(body_lines))
message.add_attachment(
csaf_file.read_bytes(),
maintype="application",
subtype="json",
filename=csaf_file.name,
)

context = ssl.create_default_context()

with smtplib.SMTP(smtp_server, smtp_port, timeout=30) as smtp:
smtp.starttls(context=context)
smtp.login(smtp_username, smtp_password)
smtp.send_message(message)

print("CSAF email notification sent successfully.")


if __name__ == "__main__":
main()
Loading
Loading