diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6af6a..a422aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Dockerfile b/Dockerfile index 14d18c2..f1fb464 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " LABEL org.opencontainers.image.source=https://github.com/scanoss/scanoss.py diff --git a/src/scanoss/__init__.py b/src/scanoss/__init__.py index a16668c..40a8be7 100644 --- a/src/scanoss/__init__.py +++ b/src/scanoss/__init__.py @@ -22,4 +22,4 @@ THE SOFTWARE. """ -__version__ = '1.31.2' +__version__ = '1.31.3' diff --git a/src/scanoss/csvoutput.py b/src/scanoss/csvoutput.py index 5b36284..5a7b197 100644 --- a/src/scanoss/csvoutput.py +++ b/src/scanoss/csvoutput.py @@ -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 @@ -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: @@ -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, @@ -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', diff --git a/src/scanoss/cyclonedx.py b/src/scanoss/cyclonedx.py index d6ad389..f3685a9 100644 --- a/src/scanoss/cyclonedx.py +++ b/src/scanoss/cyclonedx.py @@ -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 = {} @@ -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/ diff --git a/src/scanoss/export/dependency_track.py b/src/scanoss/export/dependency_track.py index 38dccd4..f3969f4 100644 --- a/src/scanoss/export/dependency_track.py +++ b/src/scanoss/export/dependency_track.py @@ -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)') json_str = json.dumps(sbom_content, separators=(',', ':')) encoded = base64.b64encode(json_str.encode('utf-8')).decode('utf-8') return encoded diff --git a/src/scanoss/inspection/dependency_track/project_violation.py b/src/scanoss/inspection/dependency_track/project_violation.py index 0216ab8..a587668 100644 --- a/src/scanoss/inspection/dependency_track/project_violation.py +++ b/src/scanoss/inspection/dependency_track/project_violation.py @@ -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 @@ -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) diff --git a/src/scanoss/services/dependency_track_service.py b/src/scanoss/services/dependency_track_service.py index e32c245..7a367a0 100644 --- a/src/scanoss/services/dependency_track_service.py +++ b/src/scanoss/services/dependency_track_service.py @@ -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): diff --git a/src/scanoss/spdxlite.py b/src/scanoss/spdxlite.py index fe864eb..7313b27 100644 --- a/src/scanoss/spdxlite.py +++ b/src/scanoss/spdxlite.py @@ -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) @@ -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)