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
20 changes: 20 additions & 0 deletions .github/workflows/pr_prek.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: prek

on:
pull_request:

permissions: {}

jobs:
prek:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
submodules: recursive
fetch-depth: 0
- uses: stackabletech/actions/run-prek@0fe048f952bee522474903a925ea48dc706a0bb4 # v0.17.5
1 change: 1 addition & 0 deletions .github/workflows/scan.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Scan all images
on:
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/scan_release.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Scan specific release
on:
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/scan_single_image.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Scan single image
on:
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/upload_sbom_release.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Upload SBOMs for specific release
on:
workflow_dispatch:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
20 changes: 20 additions & 0 deletions .markdownlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
# All defaults or options can be checked here:
# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml

# Default state for all rules
default: true

# MD013/line-length - Line length
MD013:
# Number of characters
line_length: 9999
# Number of characters for headings
heading_line_length: 9999
# Number of characters for code blocks
code_block_line_length: 9999

# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content
MD024:
# Only check sibling headings
siblings_only: true
41 changes: 41 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
default_language_version:
node: system

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # 6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: detect-aws-credentials
args: ["--allow-missing-credentials"]
- id: detect-private-key

- repo: https://github.com/adrienverge/yamllint
rev: 79a6b2b1392eaf49cdd32ac4f14be1a809bbd8f7 # 1.37.1
hooks:
- id: yamllint
args: ["--strict"]

- repo: https://github.com/igorshubovych/markdownlint-cli
rev: 192ad822316c3a22fb3d3cc8aa6eafa0b8488360 # 0.45.0
hooks:
- id: markdownlint

- repo: https://github.com/koalaman/shellcheck-precommit
rev: 99470f5e12208ff0fb17ab81c3c494f7620a1d8d # 0.11.0
hooks:
- id: shellcheck
args: ["--severity=info"]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 3db93a2be6f214ed722bf7bce095ec1b1715422a # 0.14.2
hooks:
- id: ruff-check
- id: ruff-format

- repo: https://github.com/rhysd/actionlint
rev: e7d448ef7507c20fc4c88a95d0c448b848cd6127 # 1.7.8
hooks:
- id: actionlint
9 changes: 9 additions & 0 deletions .yamllint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
extends: default

rules:
line-length: disable
truthy:
check-keys: false
comments:
min-spaces-from-content: 1 # Needed due to https://github.com/adrienverge/yamllint/issues/443
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Here's the action to scan a single image: https://github.com/stackabletech/stack_scanner/actions/workflows/scan_single_image.yml
# stack_scanner

A daily scan of all our images runs on one of our servers. Search for "stack scanner" in Nuclino for more information.
Here's the action to scan a single image: [scan_single_image.yml](https://github.com/stackabletech/stack_scanner/actions/workflows/scan_single_image.yml)

A daily scan of all our images runs on one of our servers. Search for "stack scanner" in Nuclino for more information.
Binary file removed stack_scanner/__pycache__/main.cpython-312.pyc
Binary file not shown.
113 changes: 83 additions & 30 deletions stack_scanner/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ def _iter_harbor_tagged_artifacts(
page_size = 100

while True:
artifacts = harbor_api_request(path, {"page": page, "page_size": page_size, "with_tag": "true"})
artifacts = harbor_api_request(
path, {"page": page, "page_size": page_size, "with_tag": "true"}
)
if artifacts is None:
return None

Expand All @@ -234,7 +236,9 @@ def _iter_harbor_tagged_artifacts(
push_time_str = artifact.get("push_time")
if push_time_str:
try:
push_time = datetime.datetime.fromisoformat(push_time_str.replace("Z", "+00:00"))
push_time = datetime.datetime.fromisoformat(
push_time_str.replace("Z", "+00:00")
)
except ValueError:
pass

Expand All @@ -261,7 +265,9 @@ def get_harbor_tags(
if artifact_data is None:
return None

cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=MAX_AGE_DAYS)
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
days=MAX_AGE_DAYS
)
recent_tags: list[str] = []
latest_tag: str | None = None
latest_time: datetime.datetime | None = None
Expand Down Expand Up @@ -348,7 +354,9 @@ def repository_exists(project: str, repository: str) -> bool:
# image can be published to another project in another release.
_image_projects: dict[str, str] = {}

_REGISTRY_NAMESPACE_RE = re.compile(r"^\s*registry-namespace:\s*(\S+)\s*$", re.MULTILINE)
_REGISTRY_NAMESPACE_RE = re.compile(
r"^\s*registry-namespace:\s*(\S+)\s*$", re.MULTILINE
)


def _project_from_boil_config(image_name: str, docker_images_dir: str) -> str | None:
Expand Down Expand Up @@ -420,7 +428,9 @@ def get_image_project(image_name: str, docker_images_dir: str = "docker-images")
return project


def get_latest_releases(count: int, docker_images_dir: str = "docker-images") -> list[str]:
def get_latest_releases(
count: int, docker_images_dir: str = "docker-images"
) -> list[str]:
"""Return the most recent stable SDP release tags from the docker-images repo.

Releases are calendar-versioned git tags (e.g. "26.3.0"). Pre-release tags
Expand Down Expand Up @@ -464,7 +474,9 @@ def get_latest_github_release(owner: str, repo: str) -> str | None:
]


def scan_stackablectl(secobserve_api_token: str, upload_sbom: Optional[bool] = False) -> None:
def scan_stackablectl(
secobserve_api_token: str, upload_sbom: Optional[bool] = False
) -> None:
"""Download and scan the latest stackablectl SBOMs from GitHub releases.

The stackable-cockpit project publishes CycloneDX SBOMs alongside each
Expand Down Expand Up @@ -501,23 +513,37 @@ def scan_stackablectl(secobserve_api_token: str, upload_sbom: Optional[bool] = F
json_path = f"/tmp/stackable/{json_name}"
result = subprocess.run(
[
"cyclonedx", "convert",
"--input-file", xml_path,
"--input-format", "xml",
"--output-file", json_path,
"--output-format", "json",
"--output-version", "v1_5",
"cyclonedx",
"convert",
"--input-file",
xml_path,
"--input-format",
"xml",
"--output-file",
json_path,
"--output-format",
"json",
"--output-version",
"v1_5",
],
)
if result.returncode != 0:
print(f"Failed to convert {sbom_name} from XML to JSON")
continue
print(f"Converted {xml_path} to {json_path}")

scan_sbom(secobserve_api_token, json_name, "stackablectl", version, upload_sbom=upload_sbom)
scan_sbom(
secobserve_api_token,
json_name,
"stackablectl",
version,
upload_sbom=upload_sbom,
)


def _build_base_env(secobserve_api_token: str, product_name: str, branch_name: str) -> dict:
def _build_base_env(
secobserve_api_token: str, product_name: str, branch_name: str
) -> dict:
return {
"SO_PRODUCT_NAME": product_name,
"SO_API_BASE_URL": SECOBSERVE_API_BASE_URL,
Expand All @@ -536,7 +562,9 @@ def _build_base_env(secobserve_api_token: str, product_name: str, branch_name: s
_GRYPE_REPORT = "grype.json"


def _combined_scan_script(env: dict, mode: str, upload_sbom: Optional[bool] = False) -> str:
def _combined_scan_script(
env: dict, mode: str, upload_sbom: Optional[bool] = False
) -> str:
"""Return a shell script that scans with Trivy and Grype, then uploads results.

Trivy and Grype scan the same target independently, so they are launched
Expand Down Expand Up @@ -577,13 +605,19 @@ def _combined_scan_script(env: dict, mode: str, upload_sbom: Optional[bool] = Fa
return script


def _run_combined_scan(env: dict, mode: str, upload_sbom: Optional[bool] = False) -> None:
def _run_combined_scan(
env: dict, mode: str, upload_sbom: Optional[bool] = False
) -> None:
"""Run Trivy and Grype in a single container for one target, then upload."""
cmd = [
"docker", "run",
"--entrypoint", "/bin/sh",
"-v", "/tmp/stackable:/tmp",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"docker",
"run",
"--entrypoint",
"/bin/sh",
"-v",
"/tmp/stackable:/tmp",
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
]
for key, value in env.items():
cmd.extend(["-e", f"{key}={value}"])
Expand Down Expand Up @@ -626,7 +660,9 @@ def _filter_redundant_manifest_tags(tags: list[str]) -> list[str]:
for suffix in _ARCH_SUFFIXES
if tag.endswith(suffix)
}
return [tag for tag in tags if tag not in arch_bases or tag.endswith(_ARCH_SUFFIXES)]
return [
tag for tag in tags if tag not in arch_bases or tag.endswith(_ARCH_SUFFIXES)
]


def scan_additional_images(
Expand All @@ -649,7 +685,9 @@ def scan_additional_images(

helm_values_image = image_config.get("helm_values_image")
deployed_tags = (
get_deployed_sidecar_tags(helm_values_image, release) if helm_values_image else []
get_deployed_sidecar_tags(helm_values_image, release)
if helm_values_image
else []
)
deployed_arch_tags = [
f"{tag}{suffix}" for tag in deployed_tags for suffix in _ARCH_SUFFIXES
Expand All @@ -674,7 +712,9 @@ def scan_additional_images(
recent_tags, latest_tag = result
if recent_tags:
tags = _filter_redundant_manifest_tags(recent_tags)
print(f"Found {len(tags)} recent tag(s) for {project}/{repository}: {tags}")
print(
f"Found {len(tags)} recent tag(s) for {project}/{repository}: {tags}"
)
elif latest_tag is not None:
print(
f"No tags pushed within the last {MAX_AGE_DAYS} days for {project}/{repository}, "
Expand All @@ -688,7 +728,9 @@ def scan_additional_images(
)
tags = []
else:
print(f"WARNING: No tagged artifacts found for {project}/{repository}, skipping.")
print(
f"WARNING: No tagged artifacts found for {project}/{repository}, skipping."
)
continue

for tag in deployed_arch_tags:
Expand All @@ -703,7 +745,9 @@ def scan_additional_images(

for tag in tags:
image = f"{REGISTRY_URL}/{project}/{repository}:{tag}"
scan_image(secobserve_api_token, image, product_name, tag, upload_sbom=upload_sbom)
scan_image(
secobserve_api_token, image, product_name, tag, upload_sbom=upload_sbom
)


def main():
Expand Down Expand Up @@ -763,7 +807,9 @@ def _load_product_versions() -> dict[str, list[str]]:

config = load_configuration(conf_py_path)
return {
product["name"]: [version["product"] for version in product.get("versions", [])]
product["name"]: [
version["product"] for version in product.get("versions", [])
]
for product in config.products
}

Expand All @@ -789,7 +835,9 @@ def _load_product_versions() -> dict[str, list[str]]:
return json.loads(result.stdout)


def scan_release(secobserve_api_token: str, release: str, upload_sbom: Optional[bool] = False) -> None:
def scan_release(
secobserve_api_token: str, release: str, upload_sbom: Optional[bool] = False
) -> None:
"""Scan all operator and product images of a single SDP release."""
checkout = "main" if release == DEV_RELEASE else "tags/" + release

Expand Down Expand Up @@ -831,8 +879,10 @@ def scan_release(secobserve_api_token: str, release: str, upload_sbom: Optional[
continue
products.extend((project, product_name, version) for version in versions)

print(f"Scanning {release}: {len(operators)} operator and "
f"{len(products)} product image(s) per arch")
print(
f"Scanning {release}: {len(operators)} operator and "
f"{len(products)} product image(s) per arch"
)

for arch in ["amd64", "arm64"]:
for operator_name in operators:
Expand Down Expand Up @@ -898,7 +948,10 @@ def scan_image(

# Required workaround for Trivy to recognize the OS
for component in sbom.get("components", []):
if component.get("type") == "operating-system" and component.get("name") == "rhel":
if (
component.get("type") == "operating-system"
and component.get("name") == "rhel"
):
component["name"] = "redhat"

with open("/tmp/stackable/bom.json", "w") as f:
Expand Down