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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Upcoming changes...

## [1.31.3] - 2025-08-19
### Fixed
- Added handling for empty results files

## [1.31.2] - 2025-08-12
### Fixed
- Removed an unnecessary print statement from the policy checker
Expand Down Expand Up @@ -638,3 +642,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[1.31.0]: https://github.com/scanoss/scanoss.py/compare/v1.30.0...v1.31.0
[1.31.1]: https://github.com/scanoss/scanoss.py/compare/v1.31.0...v1.31.1
[1.31.2]: https://github.com/scanoss/scanoss.py/compare/v1.31.1...v1.31.2
[1.31.2]: https://github.com/scanoss/scanoss.py/compare/v1.31.2...v1.31.3
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM python:3.10-slim AS base
FROM --platform=$BUILDPLATFORM python:3.10-slim-bookworm AS base

LABEL maintainer="SCANOSS <[email protected]>"
LABEL org.opencontainers.image.source=https://github.com/scanoss/scanoss.py
Expand Down
2 changes: 1 addition & 1 deletion src/scanoss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
THE SOFTWARE.
"""

__version__ = '1.31.2'
__version__ = '1.31.3'
20 changes: 13 additions & 7 deletions src/scanoss/csvoutput.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""

import csv
import json
import os.path
import sys
import csv

from .scanossbase import ScanossBase

Expand All @@ -44,16 +43,20 @@ def __init__(self, debug: bool = False, output_file: str = None):
self.output_file = output_file
self.debug = debug

def parse(self, data: json):
# TODO Refactor (fails linter)
def parse(self, data: json): #noqa PLR0912, PLR0915
"""
Parse the given input (raw/plain) JSON string and return CSV summary
:param data: json - JSON object
:return: CSV dictionary
"""
if not data:
if data is None:
self.print_stderr('ERROR: No JSON data provided to parse.')
return None
self.print_debug(f'Processing raw results into CSV format...')
if len(data) == 0:
self.print_msg('Warning: Empty scan results provided. Returning empty CSV list.')
return []
self.print_debug('Processing raw results into CSV format...')
csv_dict = []
row_id = 1
for f in data:
Expand Down Expand Up @@ -92,7 +95,8 @@ def parse(self, data: json):
detected['licenses'] = ''
else:
detected['licenses'] = ';'.join(dc)
# inventory_id,path,usage,detected_component,detected_license,detected_version,detected_latest,purl
# inventory_id,path,usage,detected_component,detected_license,
# detected_version,detected_latest,purl
csv_dict.append(
{
'inventory_id': row_id,
Expand Down Expand Up @@ -183,9 +187,11 @@ def produce_from_json(self, data: json, output_file: str = None) -> bool:
:return: True if successful, False otherwise
"""
csv_data = self.parse(data)
if not csv_data:
if csv_data is None:
self.print_stderr('ERROR: No CSV data returned for the JSON string provided.')
return False
if len(csv_data) == 0:
self.print_msg('Warning: Empty scan results - generating CSV with headers only.')
# Header row/column details
fields = [
'inventory_id',
Expand Down
11 changes: 8 additions & 3 deletions src/scanoss/cyclonedx.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ def parse(self, data: dict): # noqa: PLR0912, PLR0915
:param data: dict - JSON object
:return: CycloneDX dictionary, and vulnerability dictionary
"""
if not data:
if data is None:
self.print_stderr('ERROR: No JSON data provided to parse.')
return None, None
if len(data) == 0:
self.print_msg('Warning: Empty scan results provided. Returning empty component dictionary.')
return {}, {}
self.print_debug('Processing raw results into CycloneDX format...')
cdx = {}
vdx = {}
Expand Down Expand Up @@ -186,9 +189,11 @@ def produce_from_json(self, data: dict, output_file: str = None) -> tuple[bool,
json: The CycloneDX output
"""
cdx, vdx = self.parse(data)
if not cdx:
if cdx is None:
self.print_stderr('ERROR: No CycloneDX data returned for the JSON string provided.')
return False, None
return False, {}
if len(cdx) == 0:
self.print_msg('Warning: Empty scan results - generating minimal CycloneDX SBOM with no components.')
self._spdx.load_license_data() # Load SPDX license name data for later reference
#
# Using CDX version 1.4: https://cyclonedx.org/docs/1.4/json/
Expand Down
7 changes: 6 additions & 1 deletion src/scanoss/export/dependency_track.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ def _encode_sbom(self, sbom_content: dict) -> str:
Base64 encoded string
"""
if not sbom_content:
self.print_stderr('Warning: Empty SBOM content')
self.print_stderr('Warning: Empty SBOM content provided')
return ''
# Check if SBOM has no components (empty scan results)
components = sbom_content.get('components', [])
if len(components) == 0:
self.print_msg('Notice: SBOM contains no components (empty scan results)')
Comment on lines 120 to +126
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Don’t upload an empty BOM to Dependency Track

When sbom_content is falsy, _encode_sbom returns an empty string. The caller (upload_sbom_contents) will still build a payload and attempt the upload, which is likely to be rejected by the API.

Add a guard in upload_sbom_contents to skip the upload if encoding produced an empty string.

@@
-            self.print_debug('Encoding SBOM to base64')
-            payload = _build_payload(self._encode_sbom(sbom_content), project_id, project_name, project_version)
+            self.print_debug('Encoding SBOM to base64')
+            encoded_bom = self._encode_sbom(sbom_content)
+            if not encoded_bom:
+                self.print_msg('Empty SBOM content detected. Skipping upload to Dependency Track.')
+                return False
+            payload = _build_payload(encoded_bom, project_id, project_name, project_version)

🏁 Script executed:

#!/bin/bash
# Locate the upload_sbom_contents method and inspect its implementation
rg -nP "def upload_sbom_contents" -C10 src/scanoss/export/dependency_track.py

# Confirm where and how _encode_sbom is invoked within that method
rg -nP "_encode_sbom" -C10 src/scanoss/export/dependency_track.py

Length of output: 3311


Add guard to skip upload when SBOM encoding yields an empty string

To prevent attempting to send an empty BOM payload (which the API will reject), update upload_sbom_contents in src/scanoss/export/dependency_track.py as follows:

• File: src/scanoss/export/dependency_track.py
• Method: upload_sbom_contents (around line 186)

@@ -186,7 +186,12 @@
             self.print_debug('Encoding SBOM to base64')
-            payload = _build_payload(self._encode_sbom(sbom_content), project_id, project_name, project_version)
+            # Skip sending if SBOM content is empty or invalid
+            encoded_bom = self._encode_sbom(sbom_content)
+            if not encoded_bom:
+                self.print_msg('Empty SBOM content detected. Skipping upload to Dependency Track.')
+                return False
+            payload = _build_payload(encoded_bom, project_id, project_name, project_version)
 
             url = f'{self.url}/api/v1/bom'
             headers = {'Content-Type': 'application/json', 'X-Api-Key': self.apikey}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not sbom_content:
self.print_stderr('Warning: Empty SBOM content')
self.print_stderr('Warning: Empty SBOM content provided')
return ''
# Check if SBOM has no components (empty scan results)
components = sbom_content.get('components', [])
if len(components) == 0:
self.print_msg('Notice: SBOM contains no components (empty scan results)')
self.print_debug('Encoding SBOM to base64')
# Skip sending if SBOM content is empty or invalid
encoded_bom = self._encode_sbom(sbom_content)
if not encoded_bom:
self.print_msg('Empty SBOM content detected. Skipping upload to Dependency Track.')
return False
payload = _build_payload(encoded_bom, project_id, project_name, project_version)
url = f'{self.url}/api/v1/bom'
headers = {'Content-Type': 'application/json', 'X-Api-Key': self.apikey}
🤖 Prompt for AI Agents
In src/scanoss/export/dependency_track.py around lines 120-126 (and adjust in
upload_sbom_contents near line ~186), add a guard that prevents attempting to
upload when SBOM encoding results in an empty string: after encoding/serializing
the SBOM (the variable that becomes the payload), check if the encoded_payload
is falsy or empty and if so log/print a warning/notice and return early without
calling the upload API; ensure the function returns an empty string or
appropriate sentinel consistent with existing returns and does not proceed to
send the empty BOM to the API.

json_str = json.dumps(sbom_content, separators=(',', ':'))
encoded = base64.b64encode(json_str.encode('utf-8')).decode('utf-8')
return encoded
Expand Down
30 changes: 26 additions & 4 deletions src/scanoss/inspection/dependency_track/project_violation.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,34 @@ def is_project_updated(self, dt_project: Dict[str, Any]) -> bool:
if not dt_project:
self.print_stderr('Warning: No project details supplied. Returning False.')
return False
last_import = dt_project.get('lastBomImport', 0)
last_vulnerability_analysis = dt_project.get('lastVulnerabilityAnalysis', 0)

# Safely extract and normalise timestamp values to numeric types
def _safe_timestamp(field, value=None, default=0) -> float:
"""Convert timestamp value to float, handling string/numeric types safely."""
if value is None:
return float(default)
try:
return float(value)
except (ValueError, TypeError):
self.print_stderr(f'Warning: Invalid timestamp for {field}, value: {value}, using default: {default}')
return float(default)

last_import = _safe_timestamp('lastBomImport', dt_project.get('lastBomImport'), 0)
last_vulnerability_analysis = _safe_timestamp('lastVulnerabilityAnalysis',
dt_project.get('lastVulnerabilityAnalysis'), 0
)
metrics = dt_project.get('metrics', {})
last_occurrence = metrics.get('lastOccurrence', 0) if isinstance(metrics, dict) else 0
last_occurrence = _safe_timestamp('lastOccurrence',
metrics.get('lastOccurrence', 0)
if isinstance(metrics, dict) else 0, 0
)
if self.debug:
self.print_msg(f'last_import: {last_import}')
self.print_msg(f'last_vulnerability_analysis: {last_vulnerability_analysis}')
self.print_msg(f'last_occurrence: {last_occurrence}')
self.print_msg(f'last_vulnerability_analysis is updated: {last_vulnerability_analysis >= last_import}')
self.print_msg(f'last_occurrence is updated: {last_occurrence >= last_import}')
# If all timestamps are zero, this indicates no processing has occurred
if last_vulnerability_analysis == 0 or last_occurrence == 0 or last_import == 0:
self.print_stderr(f'Warning: Some project data appears to be unset. Returning False: {dt_project}')
return False
Expand Down Expand Up @@ -434,12 +452,16 @@ def run(self) -> int:
return PolicyStatus.ERROR.value
# Get project violations from Dependency Track
dt_project_violations = self.dep_track_service.get_project_violations(self.project_id)
# Handle case where service returns None (API error) vs empty list (no violations)
if dt_project_violations is None:
self.print_stderr('Error: Failed to retrieve project violations from Dependency Track')
return PolicyStatus.ERROR.value
# Sort violations by priority and format output
formatter = self._get_formatter()
if formatter is None:
self.print_stderr('Error: Invalid format specified.')
return PolicyStatus.ERROR.value
# Format and output data
# Format and output data - handle empty results gracefully
data = formatter(self._sort_project_violations(dt_project_violations))
self.print_to_file_or_stdout(data['details'], self.output)
self.print_to_file_or_stderr(data['summary'], self.status)
Expand Down
1 change: 1 addition & 0 deletions src/scanoss/services/dependency_track_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def get_project_violations(self,project_id:str):
if not project_id:
self.print_stderr('Error: Missing project id. Cannot search for project violations.')
return None
# Return the result as-is - None indicates API failure, empty list means no violations
return self.get_dep_track_data(f'{self.url}/api/v1/violation/project/{project_id}')

def get_project_by_id(self, project_id:str):
Expand Down
9 changes: 7 additions & 2 deletions src/scanoss/spdxlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ def parse(self, data: json):
:param data: json - JSON object
:return: summary dictionary
"""
if not data:
if data is None:
self.print_stderr('ERROR: No JSON data provided to parse.')
return None
if len(data) == 0:
self.print_debug('Warning: Empty scan results provided. Returning empty summary.')
return {}

self.print_debug('Processing raw results into summary format...')
return self._process_files(data)
Expand Down Expand Up @@ -277,9 +280,11 @@ def produce_from_json(self, data: json, output_file: str = None) -> bool:
:return: True if successful, False otherwise
"""
raw_data = self.parse(data)
if not raw_data:
if raw_data is None:
self.print_stderr('ERROR: No SPDX data returned for the JSON string provided.')
return False
if len(raw_data) == 0:
self.print_debug('Warning: Empty scan results - generating minimal SPDX Lite document with no packages.')

self.load_license_data()
spdx_document = self._create_base_document(raw_data)
Expand Down