Skip to content

Commit 8b64595

Browse files
committed
feat: scan the CycloneDX SBOMs of the ODBC driver releases
1 parent a5c6553 commit 8b64595

1 file changed

Lines changed: 159 additions & 64 deletions

File tree

stack_scanner/main.py

Lines changed: 159 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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

544638
def _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

9201015
def scan_image(

0 commit comments

Comments
 (0)