diff --git a/.github/actions/dependency-track-report/action.yml b/.github/actions/dependency-track-report/action.yml index 0b4ada8..165e994 100644 --- a/.github/actions/dependency-track-report/action.yml +++ b/.github/actions/dependency-track-report/action.yml @@ -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: @@ -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 @@ -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 @@ -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" \ @@ -170,7 +246,7 @@ runs: echo "Dependency-Track VEX upload failed with HTTP $http_code." exit 1 fi - + - name: Generate CSAF VEX id: generate-csaf shell: bash @@ -178,27 +254,18 @@ runs: 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 @@ -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 @@ -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" diff --git a/.github/actions/dependency-track-report/csaf_notification.py b/.github/actions/dependency-track-report/csaf_notification.py new file mode 100644 index 0000000..ae34ba4 --- /dev/null +++ b/.github/actions/dependency-track-report/csaf_notification.py @@ -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() diff --git a/.github/actions/dependency-track-report/dependency_track.py b/.github/actions/dependency-track-report/dependency_track.py index 87f39a1..521fe3a 100644 --- a/.github/actions/dependency-track-report/dependency_track.py +++ b/.github/actions/dependency-track-report/dependency_track.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import json import os import time @@ -12,7 +13,31 @@ ) DTRACK_API_KEY = os.environ.get("DTRACK_API_KEY") +NOTIFICATION_PROPERTY_NAME = "last-csaf-notification-time" +ANALYSIS_COMMENT_PREFIX = "Analysis: " +ANALYSIS_TRANSITION_SEPARATOR = " → " +INITIAL_ANALYSIS_STATE = "NOT_SET" +TRIAGE_ANALYSIS_STATE = "IN_TRIAGE" +CYCLONEDX_VEX_COMMENTER = "CycloneDX VEX" +NOTIFICATION_PROPERTY_GROUP = os.environ.get( + "NOTIFICATION_PROPERTY_GROUP" +) + +DTRACK_FINDINGS_FILE = os.environ.get( + "DTRACK_FINDINGS_FILE", + "/tmp/dtrack-findings.json", +) + +DTRACK_PROJECT_UUID = os.environ.get("DTRACK_PROJECT_UUID") +NOTIFICATION_ACTION = os.environ.get( + "NOTIFICATION_ACTION", + "status", +) + +LATEST_EVENT_TIMESTAMP = os.environ.get( + "LATEST_EVENT_TIMESTAMP" +) def validate_config(): if not DTRACK_API_KEY: @@ -56,16 +81,301 @@ def get_analysis(finding): time.sleep(2) continue - print( - "Warning: Dependency-Track analysis " - f"request failed with HTTP {error.code}." + raise SystemExit( + "Dependency-Track analysis request failed " + f"with HTTP {error.code}." + ) from error + + except urllib.error.URLError as error: + raise SystemExit( + "Could not reach Dependency-Track." + ) from error + + +def get_project_properties(project_uuid): + url = f"{DTRACK_URL}/api/v1/project/{project_uuid}/property" + + 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: + raise SystemExit( + "Dependency-Track project properties request failed " + f"with HTTP {error.code}." + ) from error + + except urllib.error.URLError as error: + raise SystemExit( + "Could not reach Dependency-Track." + ) from error + + +def get_last_notification_time(project_uuid): + properties = get_project_properties(project_uuid) + + for prop in properties: + if ( + prop.get("groupName") == NOTIFICATION_PROPERTY_GROUP + and prop.get("propertyName") == NOTIFICATION_PROPERTY_NAME + ): + value = prop.get("propertyValue") + + if not value: + return None + + return datetime.fromisoformat( + value.replace("Z", "+00:00") ) - return {} - except urllib.error.URLError: - print( - "Warning: Could not reach Dependency-Track." + return None + + +def set_notification_property( + project_uuid, + timestamp, + property_exists, +): + payload = json.dumps( + { + "groupName": NOTIFICATION_PROPERTY_GROUP, + "propertyName": NOTIFICATION_PROPERTY_NAME, + "propertyValue": timestamp, + "propertyType": "TIMESTAMP", + } + ).encode() + + url = f"{DTRACK_URL}/api/v1/project/{project_uuid}/property" + + request = urllib.request.Request( + url, + data=payload, + method="POST" if property_exists else "PUT", + headers={ + "X-Api-Key": DTRACK_API_KEY, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + try: + with urllib.request.urlopen(request, timeout=15): + pass + except urllib.error.HTTPError as error: + raise SystemExit( + "Dependency-Track notification property update failed " + f"with HTTP {error.code}." + ) from error + except urllib.error.URLError as error: + raise SystemExit( + "Could not reach Dependency-Track." + ) from error + + +def set_last_notification_time(project_uuid, timestamp_ms): + properties = get_project_properties(project_uuid) + + property_exists = any( + prop.get("groupName") == NOTIFICATION_PROPERTY_GROUP + and prop.get("propertyName") == NOTIFICATION_PROPERTY_NAME + for prop in properties + ) + + timestamp = datetime.fromtimestamp( + timestamp_ms / 1000, + tz=timezone.utc, + ).isoformat().replace("+00:00", "Z") + + set_notification_property( + project_uuid, + timestamp, + property_exists, + ) + + +def initialize_notification_baseline(project_uuid): + properties = get_project_properties(project_uuid) + + property_exists = any( + prop.get("groupName") == NOTIFICATION_PROPERTY_GROUP + and prop.get("propertyName") == NOTIFICATION_PROPERTY_NAME + for prop in properties + ) + + if property_exists: + return False + + current_time = ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + set_notification_property( + project_uuid, + current_time, + property_exists=False, + ) + + return True + + +def get_notification_events(findings, since_time): + events = [] + + for finding in findings: + analysis = get_analysis(finding) + + for entry in analysis.get("analysisComments", []): + timestamp_ms = entry.get("timestamp") + comment = entry.get("comment", "") + + if not timestamp_ms: + continue + + if not comment.startswith(ANALYSIS_COMMENT_PREFIX): + continue + + event_time = datetime.fromtimestamp( + timestamp_ms / 1000, + tz=timezone.utc, + ) + + if since_time and event_time <= since_time: + continue + + transition = comment.removeprefix( + ANALYSIS_COMMENT_PREFIX + ) + + if ANALYSIS_TRANSITION_SEPARATOR not in transition: + continue + + previous_state, current_state = transition.split( + ANALYSIS_TRANSITION_SEPARATOR, + 1, + ) + + if ( + previous_state == INITIAL_ANALYSIS_STATE + and current_state == TRIAGE_ANALYSIS_STATE + and entry.get("commenter") == CYCLONEDX_VEX_COMMENTER + ): + event_type = "new" + else: + event_type = "state_change" + + events.append( + { + "type": event_type, + "timestamp": timestamp_ms, + "previous_state": previous_state, + "current_state": current_state, + } + ) + + return events + + +def get_notification_status(project_uuid, findings): + last_notification_time = get_last_notification_time(project_uuid) + + if last_notification_time is None: + return { + "send_email": False, + "new_count": 0, + "state_change_count": 0, + "latest_event_timestamp": None, + } + + events = get_notification_events( + findings, + last_notification_time, + ) + + if not events: + return { + "send_email": False, + "new_count": 0, + "state_change_count": 0, + "latest_event_timestamp": None, + } + + new_count = sum( + 1 for event in events + if event["type"] == "new" + ) + + state_change_count = sum( + 1 for event in events + if event["type"] == "state_change" + ) + + latest_event_timestamp = max( + event["timestamp"] + for event in events + ) + + return { + "send_email": True, + "new_count": new_count, + "state_change_count": state_change_count, + "latest_event_timestamp": latest_event_timestamp, + } + + +if __name__ == "__main__": + validate_config() + + if not DTRACK_PROJECT_UUID: + raise SystemExit("DTRACK_PROJECT_UUID is not set") + + if not NOTIFICATION_PROPERTY_GROUP: + raise SystemExit("NOTIFICATION_PROPERTY_GROUP is not set") + + if NOTIFICATION_ACTION == "status": + with open(DTRACK_FINDINGS_FILE) as file: + findings = json.load(file) + + status = get_notification_status( + DTRACK_PROJECT_UUID, + findings, + ) + + print(json.dumps(status)) + + elif NOTIFICATION_ACTION == "update": + if not LATEST_EVENT_TIMESTAMP: + raise SystemExit( + "LATEST_EVENT_TIMESTAMP is not set" ) - return {} - return {} \ No newline at end of file + set_last_notification_time( + DTRACK_PROJECT_UUID, + int(LATEST_EVENT_TIMESTAMP), + ) + + print("CSAF notification timestamp updated.") + + elif NOTIFICATION_ACTION == "initialize": + initialized = initialize_notification_baseline( + DTRACK_PROJECT_UUID, + ) + + if initialized: + print("CSAF notification baseline initialized.") + else: + print("CSAF notification baseline already exists.") + + else: + raise SystemExit( + f"Unknown NOTIFICATION_ACTION: {NOTIFICATION_ACTION}" + ) diff --git a/.github/actions/dependency-track-report/generate_csaf.py b/.github/actions/dependency-track-report/generate_csaf.py index 29acdc9..af7bd92 100644 --- a/.github/actions/dependency-track-report/generate_csaf.py +++ b/.github/actions/dependency-track-report/generate_csaf.py @@ -1,10 +1,7 @@ -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( @@ -20,30 +17,20 @@ ) ) -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_NAME = os.environ.get("CSAF_PUBLISHER_NAME") CSAF_PUBLISHER_NAMESPACE = os.environ.get( - "CSAF_PUBLISHER_NAMESPACE", - "https://mundialis.de" + "CSAF_PUBLISHER_NAMESPACE" ) + CSAF_DOCUMENT_ID = os.environ.get("CSAF_DOCUMENT_ID") @@ -51,8 +38,6 @@ def build_product_tree( project_name, project_version, product_id, - sbom_path, - sbom_sha256, ): return { "branches": [ @@ -70,19 +55,6 @@ def build_product_tree( "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, - } - ], - } - ] - }, }, } ], @@ -97,8 +69,6 @@ def build_document_content( project_name, project_version, product_id, - sbom_path, - sbom_sha256, vulnerabilities, ): document = { @@ -140,113 +110,28 @@ def build_document_content( 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 = [ +def build_tracking(document_id, now): + return { + "current_release_date": now, + "id": str(document_id), + "initial_release_date": now, + "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, + "version": "1", } -def apply_in_triage(entry, analysis, product_id, _): +def apply_in_triage(entry, analysis, _product_id, _vulnerability): details = ( analysis.get("analysisDetails") or "The vulnerability is currently under investigation." @@ -260,19 +145,13 @@ def apply_in_triage(entry, analysis, product_id, _): } ) - 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, _): +def apply_not_affected( + entry, + analysis, + product_id, + _vulnerability, +): justification = analysis.get("analysisJustification") details = analysis.get("analysisDetails") @@ -285,7 +164,9 @@ def apply_not_affected(entry, analysis, product_id, _): parts.append(details) if not parts: - parts.append("No additional analysis details available.") + parts.append( + "The product has been assessed as not affected." + ) entry["threats"] = [ { @@ -296,7 +177,12 @@ def apply_not_affected(entry, analysis, product_id, _): ] -def apply_exploitable(entry, analysis, product_id, vulnerability): +def apply_exploitable( + entry, + analysis, + product_id, + vulnerability, +): details = ( analysis.get("analysisDetails") or "The vulnerability has been assessed as exploitable." @@ -312,10 +198,10 @@ def apply_exploitable(entry, analysis, product_id, vulnerability): entry["remediations"] = [ { - "category": "vendor_fix", + "category": "mitigation", "details": ( - "The vulnerability is considered exploitable. " - "A fixed version or other remediation should be applied." + "The vulnerability is known to affect this product. " + "Appropriate remediation or mitigation should be evaluated." ), "product_ids": [product_id], } @@ -344,33 +230,10 @@ def apply_exploitable(entry, analysis, product_id, vulnerability): ] -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, } @@ -381,16 +244,9 @@ def build_vulnerability_entry(finding, analysis): 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 = ( @@ -434,9 +290,14 @@ def build_vulnerability_entry(finding, analysis): return entry def main(): + if not CSAF_PUBLISHER_NAME: + raise SystemExit("CSAF_PUBLISHER_NAME is not set") + + if not CSAF_PUBLISHER_NAMESPACE: + raise SystemExit("CSAF_PUBLISHER_NAMESPACE is not set") validate_config() - + with INPUT.open() as f: findings = json.load(f) @@ -471,27 +332,15 @@ def main(): 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, ) @@ -503,5 +352,6 @@ def main(): print(f"Generated {OUTPUT}") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/.github/workflows/sbom-vulnerability-scan.yml b/.github/workflows/sbom-vulnerability-scan.yml index 626899b..4d91a0d 100644 --- a/.github/workflows/sbom-vulnerability-scan.yml +++ b/.github/workflows/sbom-vulnerability-scan.yml @@ -35,7 +35,7 @@ on: required: false default: false type: boolean - + dependency-track: description: "Upload SBOM and manage VEX with Dependency-Track" required: false @@ -57,9 +57,35 @@ on: required: false default: "latest" type: string + + email-notification: + description: "Enable CSAF email notifications" + required: false + type: boolean + default: false + + email-address: + description: "Email address used for CSAF notifications" + required: false + type: string + default: "" + + csaf-publisher-name: + description: "CSAF publisher name" + required: false + type: string + + csaf-publisher-namespace: + description: "CSAF publisher namespace" + required: false + type: string + secrets: dependency-track-api-key: required: false + + smtp-password: + required: false jobs: sbom-vulnerability-scan: runs-on: ubuntu-24.04 @@ -89,12 +115,28 @@ jobs: exit 1 fi + if [ -z "${{ inputs.csaf-publisher-name }}" ]; then + echo "csaf-publisher-name is required when dependency-track is enabled." + exit 1 + fi + + if [ -z "${{ inputs.csaf-publisher-namespace }}" ]; then + echo "csaf-publisher-namespace 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 + if [ "${{ inputs.email-notification }}" = "true" ] && \ + [ "${{ inputs.dependency-track }}" != "true" ]; then + echo "dependency-track must be enabled when email notifications are enabled." + exit 1 + fi + - name: Checkout the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -124,6 +166,12 @@ jobs: project-name: ${{ inputs.dependency-track-project-name }} project-version: ${{ inputs.dependency-track-project-version }} project-type: container + notification-property-group: ${{ github.repository_owner }} + csaf-publisher-name: ${{ inputs.csaf-publisher-name }} + csaf-publisher-namespace: ${{ inputs.csaf-publisher-namespace }} + email-notification: ${{ inputs.email-notification }} + email-address: ${{ inputs.email-address }} + smtp-password: ${{ secrets.smtp-password }} - name: Scan Docker SBOM for vulnerabilities if: inputs.dockerfile != '' @@ -151,7 +199,7 @@ jobs: - name: Create Python environment from requirements if: inputs.requirements != '' run: | - python -m venv .sbom_venv + python3 -m venv .sbom_venv source .sbom_venv/bin/activate pip install --upgrade pip --quiet if grep -qi '^gdal' "${{ inputs.requirements }}"; then @@ -164,7 +212,7 @@ jobs: - name: Create Python environment from pyproject if: inputs.pyproject != '' run: | - python -m venv .sbom_venv + python3 -m venv .sbom_venv source .sbom_venv/bin/activate pip install --upgrade pip --quiet pyproject_dir=$(dirname "${{ inputs.pyproject }}") @@ -189,6 +237,12 @@ jobs: project-name: ${{ inputs.dependency-track-project-name }} project-version: ${{ inputs.dependency-track-project-version }} project-type: application + notification-property-group: ${{ github.repository_owner }} + csaf-publisher-name: ${{ inputs.csaf-publisher-name }} + csaf-publisher-namespace: ${{ inputs.csaf-publisher-namespace }} + email-notification: ${{ inputs.email-notification }} + email-address: ${{ inputs.email-address }} + smtp-password: ${{ secrets.smtp-password }} - name: Scan Python environment for vulnerabilities if: inputs.requirements != '' || inputs.pyproject != '' diff --git a/.gitignore b/.gitignore index db87a49..56c4742 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .pylintrc_allowed_to_fail ruff-github-workflows.toml ruff-merged.toml + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index a881b8f..8041c04 100644 --- a/README.md +++ b/README.md @@ -233,29 +233,43 @@ jobs: ## SBOM Vulnerability Scan The SBOM vulnerability scan workflow generates a CycloneDX SBOM and scans -dependencies for known vulnerabilities with Grype. The workflow can be used -with a Dockerfile, a `requirements.txt` file, or a `pyproject.toml` file. +dependencies for known vulnerabilities with Grype. The workflow supports +Docker images as well as Python projects using either `requirements.txt` +or `pyproject.toml`. -For Docker-based projects, Grype scans the SBOM generated from the Docker image. -For Python projects, a virtual environment is created from either -`requirements.txt` or `pyproject.toml`. Grype scans the virtual environment -directly because this provides valid SARIF artifact locations for GitHub Code -Scanning. +For Docker-based projects, the SBOM is generated from the built container +image and scanned with Grype. For Python projects, a virtual environment is +created from the provided dependency file and scanned directly to provide +valid SARIF artifact locations for GitHub Code Scanning. -The vulnerability results are uploaded to GitHub Code Scanning using the -categories `grype-docker` or `grype-python`. +The vulnerability scan 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. +When Dependency-Track integration is enabled, findings without an existing +analysis state are initialized as `IN_TRIAGE` through a CycloneDX VEX +document. 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. +Based on the Dependency-Track analysis states, the workflow generates a +CSAF 2.0 VEX document and validates it automatically. The currently supported +Dependency-Track analysis states are mapped as follows: -You can use it e.g. like this: +- `IN_TRIAGE` → `under_investigation` +- `NOT_AFFECTED` → `known_not_affected` +- `EXPLOITABLE` → `known_affected` + +Findings without a supported analysis state are not included in the CSAF +document. If no applicable findings are available, CSAF generation and +validation are skipped. + +Optionally, the workflow can send email notifications when new findings are +detected or when the analysis state of an existing finding changes and a +CSAF VEX report can be generated. The validated CSAF VEX document is attached +to the notification email. The timestamp of the last successfully notified +event is stored as a Dependency-Track project property to prevent duplicate +notifications. + +You can use the workflow for a Docker-based project e.g. like this: ```yaml name: SBOM Vulnerability Scan @@ -263,12 +277,16 @@ name: SBOM Vulnerability Scan on: push: branches: [ "main" ] + schedule: # Check every Monday at 04:36 - cron: "36 04 * * 1" + release: types: [published] + workflow_dispatch: + jobs: sbom-scan: permissions: @@ -276,56 +294,114 @@ jobs: security-events: write uses: mundialis/github-workflows/.github/workflows/sbom-vulnerability-scan.yml@main + with: dockerfile: docker/actinia-core-alpine/Dockerfile - # 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 + dependency-track-url: ${{ vars.DEPENDENCY_TRACK_API_URL }} + dependency-track-project-name: ${{ github.event.repository.name }}-container + dependency-track-project-version: ${{ github.ref_name }} + csaf-publisher-name: ${{ vars.CSAF_PUBLISHER_NAME }} + csaf-publisher-namespace: ${{ vars.CSAF_PUBLISHER_NAMESPACE }} + email-notification: true + email-address: ${{ vars.CYBERSECURITY_EMAIL }} secrets: dependency-track-api-key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} + smtp-password: ${{ secrets.CYBERSECURITY_EMAIL_APP_PASSWORD }} ``` +For a Python project using `pyproject.toml`, the `with` section can look like this: +```yaml +with: + pyproject: pyproject.toml + fail-build: false + + dependency-track: true + dependency-track-url: ${{ vars.DEPENDENCY_TRACK_API_URL }} + dependency-track-project-name: ${{ github.event.repository.name }}-application + dependency-track-project-version: ${{ github.ref_name }} + csaf-publisher-name: ${{ vars.CSAF_PUBLISHER_NAME }} + csaf-publisher-namespace: ${{ vars.CSAF_PUBLISHER_NAMESPACE }} + email-notification: true + email-address: ${{ vars.CYBERSECURITY_EMAIL }} +``` 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. - The calling job requires the following permissions: - `contents: read` to check out the repository. - `security-events: write` to upload the vulnerability results to GitHub Code Scanning. 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`. +- `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`: Space-separated list of additional system packages - to install before creating a Python environment. + to install before creating a Python environment. For projects requiring + GDAL, the required system packages can be provided through this input. + The GDAL version in `requirements.txt` should not be pinned to a conflicting + fixed version, because the workflow aligns it with the installed system + version. - `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`. +- `dependency-track-url`: Dependency-Track base URL. In mundialis and actinia-org + repositories, use the organization variable `DEPENDENCY_TRACK_API_URL`. +- `dependency-track-project-name`: Project name used in Dependency-Track. + The recommended convention is `${{ github.event.repository.name }}-container` + for Docker scans and `${{ github.event.repository.name }}-application` + for Python scans. +- `csaf-publisher-name`: Name of the CSAF publisher. Required when + `dependency-track` is enabled. In mundialis and actinia-org repositories, + use the organization variable `CSAF_PUBLISHER_NAME`. +- `csaf-publisher-namespace`: Namespace URI of the CSAF publisher. Required when + `dependency-track` is enabled. In mundialis and actinia-org repositories, + use the organization variable `CSAF_PUBLISHER_NAMESPACE`. +- `dependency-track-project-version`: Project version used in Dependency-Track. + The recommended value is `${{ github.ref_name }}` so the project version + follows the current branch, tag, or release reference. +- `email-notification`: Enable CSAF email notifications. Requires + `dependency-track` to be enabled. Default: `false`. +- `email-address`: Email address used as the SMTP account, sender, and + notification recipient. In mundialis and actinia-org repositories, use + the organization variable `CYBERSECURITY_EMAIL`. + +When Dependency-Track integration is enabled, the secret `dependency-track-api-key` must be +provided by the calling workflow. + +When email notifications are enabled, `email-address` and the secret `smtp-password` must also +be provided. The mundialis and actinia-org organizations provide the following configuration +for this purpose: + +- `CYBERSECURITY_EMAIL` as an organization variable. +- `CYBERSECURITY_EMAIL_APP_PASSWORD` as an organization secret. + +The caller can pass them to the reusable workflow as follows: + +```yaml +with: + email-notification: true + email-address: ${{ vars.CYBERSECURITY_EMAIL }} -When Dependency-Track integration is enabled, the secret `dependency-track-api-key` must also -be provided by the calling workflow. +secrets: + smtp-password: ${{ secrets.CYBERSECURITY_EMAIL_APP_PASSWORD }} +``` 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 generated CSAF VEX document is validated within the workflow and is not +uploaded as a workflow artifact. When email notifications are enabled and a +relevant Dependency-Track event is detected, the validated CSAF document is +attached to the notification email. -The vulnerability results are available under **Security and quality** → **Code scanning**. +The vulnerability scan results are available under +**Security and quality** → **Code scanning**. ## Generate Third-Party-License list on release