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
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,48 @@ def _dominant_input_file(self, components: list[dict]) -> str | None:
files[normalized] += 1
return files.most_common(1)[0][0] if files else None

def _build_minimal_empty_sbom(self) -> dict:
"""Build a minimal valid GitLab-flavored CycloneDX document.

Used when no components are detected (e.g., infrastructure-only repos).
Always emitting an artifact ensures GitLab pipelines that require the
``gl-dependency-scanning-report.cdx.json`` artifact don't fail when a
repo legitimately has no software dependencies.

The minimal document includes the CycloneDX envelope and the GitLab
schema version metadata property required by GitLab's parser.
"""
return {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"metadata": {
"properties": [
{
"name": "gitlab:meta:schema_version",
"value": GITLAB_SCHEMA_VERSION,
}
],
},
"components": [],
}

def report(self, model: "AshAggregatedResults") -> str | None:
"""Enrich CycloneDX with GitLab properties and serialize."""
"""Enrich CycloneDX with GitLab properties and serialize.

When no CycloneDX data or components are present, emit a minimal valid
GitLab-compatible CycloneDX document instead of skipping. This ensures
downstream GitLab pipelines that require the artifact to exist (e.g.,
for policy validation) don't fail on repos with no dependencies.
"""

# Guard: nothing to enrich if there's no CycloneDX data
# Guard: emit a minimal empty SBOM when no CycloneDX data is present
if not model.cyclonedx:
ASH_LOGGER.debug("gitlab-cyclonedx: No CycloneDX model present, skipping.")
return None
ASH_LOGGER.debug(
"gitlab-cyclonedx: No CycloneDX model present, "
"emitting minimal empty SBOM."
)
return json.dumps(self._build_minimal_empty_sbom(), separators=(",", ":"))

# Serialize to dict for manipulation
doc = model.cyclonedx.model_dump(
Expand All @@ -396,9 +431,10 @@ def report(self, model: "AshAggregatedResults") -> str | None:
components = doc.get("components")
if not components:
ASH_LOGGER.debug(
"gitlab-cyclonedx: CycloneDX model has no components, skipping."
"gitlab-cyclonedx: CycloneDX model has no components, "
"emitting minimal empty SBOM."
)
return None
return json.dumps(self._build_minimal_empty_sbom(), separators=(",", ":"))

# --- Inject metadata-level schema version property ---
metadata = doc.setdefault("metadata", {})
Expand Down
44 changes: 42 additions & 2 deletions tests/unit/reporters/test_gitlab_cyclonedx_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,50 @@ def test_components_have_input_file_path(reporter, model):
assert paths[0]["value"] == "yarn.lock"


def test_returns_none_when_no_components(reporter):
def test_emits_minimal_empty_sbom_when_no_components(reporter):
"""When the model has no CycloneDX data, emit a minimal valid SBOM
rather than skipping. This ensures GitLab pipelines that require the
artifact to exist don't fail on repos with no dependencies.
"""
model = AshAggregatedResults()
# Force-clear cyclonedx to simulate a scan that produced no SBOM
model.cyclonedx = None
result = reporter.report(model)
assert result is None
assert result is not None
doc = json.loads(result)
assert doc["bomFormat"] == "CycloneDX"
assert doc["specVersion"] == "1.4"
assert doc["version"] == 1
assert doc["components"] == []
schema_props = [
p
for p in doc["metadata"]["properties"]
if p["name"] == "gitlab:meta:schema_version"
]
assert len(schema_props) == 1
assert schema_props[0]["value"] == "1"


def test_emits_minimal_empty_sbom_when_components_list_empty(reporter):
"""When CycloneDX is present but contains no components, still emit
a minimal valid SBOM. This is the common case for infrastructure-only
or no-code repos where Syft finds no packages.
"""
model = AshAggregatedResults() # default CycloneDXReport() has no components
result = reporter.report(model)
assert result is not None
doc = json.loads(result)
assert doc["bomFormat"] == "CycloneDX"
assert doc["specVersion"] == "1.4"
assert doc["version"] == 1
assert doc["components"] == []
schema_props = [
p
for p in doc["metadata"]["properties"]
if p["name"] == "gitlab:meta:schema_version"
]
assert len(schema_props) == 1
assert schema_props[0]["value"] == "1"


def test_extract_purl_type():
Expand Down
Loading