@@ -468,77 +468,171 @@ def get_latest_github_release(owner: str, repo: str) -> str | None:
468468 return None
469469
470470
471- _STACKABLECTL_SBOMS = [
472- "stackablectl-x86_64-unknown-linux-gnu.cdx.xml" ,
473- "stackablectl-aarch64-unknown-linux-gnu.cdx.xml" ,
471+ # CycloneDX SBOMs that are published as GitHub release assets instead of being
472+ # attached to a container image.
473+ #
474+ # Asset file names may contain the placeholders {tag} (the release tag as
475+ # published, e.g. "v0.1.1") and {version} (the tag without a leading "v").
476+ #
477+ # "branch_suffix" is appended to the version to form the SecObserve branch name.
478+ # It is required whenever a project publishes SBOMs for several build targets
479+ # whose component sets differ: importing a report resolves every observation of
480+ # the branch that the report does not contain, so targets sharing a branch would
481+ # keep resolving each other's findings.
482+ GITHUB_SBOM_RELEASES = [
483+ {
484+ "repository" : "stackable-cockpit" ,
485+ "product_name" : "stackablectl" ,
486+ # Both binaries are built from the same lockfile and their SBOMs list
487+ # identical components, so they can share a branch.
488+ "assets" : [
489+ {"file" : "stackablectl-x86_64-unknown-linux-gnu.cdx.xml" },
490+ {"file" : "stackablectl-aarch64-unknown-linux-gnu.cdx.xml" },
491+ ],
492+ },
493+ {
494+ "repository" : "stackable-odbc-trino" ,
495+ "product_name" : "stackable-odbc-trino" ,
496+ # The release also ships an SBOM for the Power BI connector
497+ # (StackableTrinoODBC-<version>.cdx.json), which describes the .mez
498+ # archive itself and lists no components, so there is nothing to scan.
499+ "assets" : [
500+ {
501+ "file" : "stackable-odbc-trino-{version}-linux-x64.cdx.json" ,
502+ "branch_suffix" : "-linux-x64" ,
503+ },
504+ {
505+ "file" : "stackable-odbc-trino-{version}-windows-x64.cdx.json" ,
506+ "branch_suffix" : "-windows-x64" ,
507+ },
508+ ],
509+ },
510+ {
511+ "repository" : "stackable-odbc-sqlite" ,
512+ "product_name" : "stackable-odbc-sqlite" ,
513+ "assets" : [
514+ {
515+ "file" : "stackable-odbc-sqlite-{version}-linux-x64.cdx.json" ,
516+ "branch_suffix" : "-linux-x64" ,
517+ },
518+ {
519+ "file" : "stackable-odbc-sqlite-{version}-windows-x64.cdx.json" ,
520+ "branch_suffix" : "-windows-x64" ,
521+ },
522+ ],
523+ },
474524]
475525
526+ _GITHUB_SBOM_OWNER = "stackabletech"
527+
528+ # Downloads are kept out of /tmp/stackable itself so that an asset that is
529+ # already CycloneDX JSON does not collide with its converted counterpart.
530+ _SBOM_DOWNLOAD_DIR = "/tmp/stackable/downloads"
531+
532+
533+ def _download_file (url : str , path : str ) -> bool :
534+ """Download a URL to a local path and report whether it succeeded."""
535+ request = urllib .request .Request (url )
536+ request .add_header ("User-Agent" , "stack-scanner" )
537+
538+ try :
539+ with urllib .request .urlopen (request ) as response :
540+ with open (path , "wb" ) as f :
541+ f .write (response .read ())
542+ except urllib .error .URLError as error :
543+ print (f"Failed to download { url } : { error } " )
544+ return False
545+
546+ print (f"Downloaded { url } to { path } " )
547+ return True
548+
549+
550+ def _convert_to_cyclonedx_json (input_path : str , output_path : str ) -> bool :
551+ """Convert an SBOM to CycloneDX JSON 1.5 and report whether it succeeded.
552+
553+ Both scanners need this normalisation: Trivy does not read CycloneDX XML at
554+ all, and Grype rejects documents that declare a spec version it does not know
555+ yet, failing with "sbom format not recognized" on the 1.7 documents the ODBC
556+ drivers publish. 1.5 is understood by every scanner version in use.
557+ """
558+ input_format = "xml" if input_path .endswith (".xml" ) else "json"
559+
560+ result = subprocess .run (
561+ [
562+ "cyclonedx" ,
563+ "convert" ,
564+ "--input-file" ,
565+ input_path ,
566+ "--input-format" ,
567+ input_format ,
568+ "--output-file" ,
569+ output_path ,
570+ "--output-format" ,
571+ "json" ,
572+ "--output-version" ,
573+ "v1_5" ,
574+ ],
575+ )
576+ if result .returncode != 0 :
577+ print (f"Failed to convert { input_path } to CycloneDX JSON 1.5" )
578+ return False
476579
477- def scan_stackablectl (
580+ print (f"Converted { input_path } to { output_path } " )
581+ return True
582+
583+
584+ def scan_github_release_sboms (
478585 secobserve_api_token : str , upload_sbom : Optional [bool ] = False
479586) -> None :
480- """Download and scan the latest stackablectl SBOMs from GitHub releases .
587+ """Download and scan the SBOMs of the latest release of each GitHub project .
481588
482- The stackable-cockpit project publishes CycloneDX SBOMs alongside each
483- binary. We download the SBOM files and scan them with Trivy and Grype in
484- SBOM mode.
589+ The projects in GITHUB_SBOM_RELEASES publish CycloneDX SBOMs as release
590+ assets next to their binaries. Each asset is downloaded, normalised to
591+ CycloneDX JSON 1.5 and scanned with Trivy and Grype in SBOM mode.
485592 """
486- version = get_latest_github_release ("stackabletech" , "stackable-cockpit" )
487- if version is None :
488- print ("WARNING: Could not determine latest stackablectl version, skipping." )
489- return
490-
491- print (f"Scanning stackablectl { version } " )
593+ os .makedirs (_SBOM_DOWNLOAD_DIR , exist_ok = True )
492594
493- for sbom_name in _STACKABLECTL_SBOMS :
494- download_url = (
495- f"https://github.com/stackabletech/stackable-cockpit/releases/download"
496- f"/{ version } /{ sbom_name } "
497- )
498- xml_path = f"/tmp/stackable/{ sbom_name } "
595+ for project in GITHUB_SBOM_RELEASES :
596+ repository = project ["repository" ]
597+ product_name = project ["product_name" ]
499598
500- request = urllib .request .Request (download_url )
501- request .add_header ("User-Agent" , "stack-scanner" )
502- try :
503- with urllib .request .urlopen (request ) as response :
504- with open (xml_path , "wb" ) as f :
505- f .write (response .read ())
506- print (f"Downloaded SBOM to { xml_path } " )
507- except urllib .error .URLError as error :
508- print (f"Failed to download SBOM { sbom_name } : { error } " )
599+ tag = get_latest_github_release (_GITHUB_SBOM_OWNER , repository )
600+ if tag is None :
601+ print (
602+ f"WARNING: Could not determine latest { repository } release, skipping."
603+ )
509604 continue
510605
511- # Trivy does not support CycloneDX XML, so convert to JSON first.
512- json_name = sbom_name .replace (".cdx.xml" , ".cdx.json" )
513- json_path = f"/tmp/stackable/{ json_name } "
514- result = subprocess .run (
515- [
516- "cyclonedx" ,
517- "convert" ,
518- "--input-file" ,
519- xml_path ,
520- "--input-format" ,
521- "xml" ,
522- "--output-file" ,
523- json_path ,
524- "--output-format" ,
525- "json" ,
526- "--output-version" ,
527- "v1_5" ,
528- ],
529- )
530- if result .returncode != 0 :
531- print (f"Failed to convert { sbom_name } from XML to JSON" )
532- continue
533- print (f"Converted { xml_path } to { json_path } " )
534-
535- scan_sbom (
536- secobserve_api_token ,
537- json_name ,
538- "stackablectl" ,
539- version ,
540- upload_sbom = upload_sbom ,
541- )
606+ # The ODBC drivers tag their releases "v<version>" but name the assets
607+ # after the bare version, which also makes for a nicer branch name.
608+ version = tag .removeprefix ("v" )
609+
610+ print (f"Scanning { product_name } { tag } " )
611+
612+ for asset in project ["assets" ]:
613+ file_name = asset ["file" ].format (tag = tag , version = version )
614+ download_url = (
615+ f"https://github.com/{ _GITHUB_SBOM_OWNER } /{ repository } "
616+ f"/releases/download/{ tag } /{ file_name } "
617+ )
618+ download_path = f"{ _SBOM_DOWNLOAD_DIR } /{ file_name } "
619+
620+ if not _download_file (download_url , download_path ):
621+ continue
622+
623+ json_name = re .sub (r"\.cdx\.(xml|json)$" , ".cdx.json" , file_name )
624+ if not _convert_to_cyclonedx_json (
625+ download_path , f"/tmp/stackable/{ json_name } "
626+ ):
627+ continue
628+
629+ scan_sbom (
630+ secobserve_api_token ,
631+ json_name ,
632+ product_name ,
633+ f"{ version } { asset .get ('branch_suffix' , '' )} " ,
634+ upload_sbom = upload_sbom ,
635+ )
542636
543637
544638def _build_base_env (
@@ -910,11 +1004,12 @@ def scan_release(
9101004 # already or are arch-agnostic manifests.
9111005 scan_additional_images (secobserve_api_token , release , upload_sbom = upload_sbom )
9121006
913- # Scan the latest stackablectl binary from GitHub releases.
914- # Only run for the dev release to avoid redundant scans when multiple releases
915- # are processed in the same workflow run (stackablectl is release-independent).
1007+ # Scan the SBOMs published as GitHub release assets (stackablectl, the ODBC
1008+ # drivers). Only run for the dev release to avoid redundant scans when
1009+ # multiple releases are processed in the same workflow run: these projects
1010+ # are versioned independently of the SDP release.
9161011 if release == DEV_RELEASE :
917- scan_stackablectl (secobserve_api_token , upload_sbom = upload_sbom )
1012+ scan_github_release_sboms (secobserve_api_token , upload_sbom = upload_sbom )
9181013
9191014
9201015def scan_image (
0 commit comments