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

## [Unreleased]

## [1.50.1] - 2026-03-23
### Fixed
- Fixed `bom.replace` rules with a `license` field: the license is now applied to the replaced result instead of being silently dropped

## [1.50.0] - 2026-03-17
### Fixed
- Fixed `requirement` field being lost during dependency decoration in scan command
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.50.0'
__version__ = '1.50.1'
89 changes: 55 additions & 34 deletions src/scanoss/scanpostprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@
THE SOFTWARE.
"""

from typing import List, Tuple
from typing import List, Optional

from packageurl import PackageURL
from packageurl.contrib import purl2url

from .scanoss_settings import BomEntry, ReplaceRule, ScanossSettings, find_best_match
from .scanossbase import ScanossBase

COMPONENT_LEVEL_FIELDS = (
'component', 'vendor', 'url', 'url_hash', 'version', 'latest',
'release_date', 'licenses', 'url_stats', 'cryptography',
'vulnerabilities', 'provenance', 'dependencies', 'health',
'quality',
)


def _get_match_type_message(result_path: str, bom_entry: BomEntry, action: str) -> str:
"""
Expand Down Expand Up @@ -117,7 +124,7 @@ def post_process(self):
)
return self.results
self._remove_dismissed_files()
self._replace_purls()
self._apply_replace_rules()
return self.results

def _remove_dismissed_files(self):
Expand All @@ -133,68 +140,83 @@ def _remove_dismissed_files(self):
if not self._should_remove_result(result_path, result, to_remove_entries)
}

def _replace_purls(self):
def _apply_replace_rules(self):
"""
Replace purls in the results based on the SCANOSS settings file
Apply BOM replace rules from the SCANOSS settings file to the scan results
"""
to_replace_entries = self.scanoss_settings.get_bom_replace()
if not to_replace_entries:
return

for result_path, result in self.results.items():
entry = result[0] if isinstance(result, list) else result
should_replace, to_replace_with_purl = self._should_replace_result(result_path, entry, to_replace_entries)
if should_replace:
self.results[result_path] = [self._update_replaced_result(entry, to_replace_with_purl)]
replace_rule = self._find_replace_rule(result_path, entry, to_replace_entries)
if replace_rule:
self.results[result_path] = [self._apply_replace_rule(entry, replace_rule)]

Comment thread
isasmendiagus marked this conversation as resolved.
def _update_replaced_result(self, result: dict, to_replace_with_purl: str) -> dict:
def _apply_replace_rule(self, result: dict, replace_rule: ReplaceRule) -> dict:
"""
Update the result with the new purl and component information if available,
otherwise removes the old component information

Args:
result (dict): The result to update
to_replace_with_purl (str): The purl to replace with
replace_rule (ReplaceRule): The replace rule to apply

Returns:
dict: Updated result
"""
if self.component_info_map.get(to_replace_with_purl):
result.update(self.component_info_map[to_replace_with_purl])
if self.component_info_map.get(replace_rule.replace_with):
# Only copy component-level fields from the map entry, leaving
# per-file fields (file, file_hash, lines, matched, etc.) untouched.
source = self.component_info_map[replace_rule.replace_with]
for key in COMPONENT_LEVEL_FIELDS:
if key in source:
result[key] = source[key]
else:
try:
new_component = PackageURL.from_string(to_replace_with_purl).to_dict()
new_component_url = purl2url.get_repo_url(to_replace_with_purl)
except RuntimeError:
new_component = PackageURL.from_string(replace_rule.replace_with).to_dict()
new_component_url = purl2url.get_repo_url(replace_rule.replace_with)
except (ValueError, RuntimeError):
self.print_stderr(
f"ERROR: Issue while replacing: Invalid PURL '{to_replace_with_purl}' in settings file. Skipping."
f"ERROR: Issue while replacing: Invalid PURL '{replace_rule.replace_with}'"
' in settings file. Skipping.'
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return result
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Reset component-level fields to defaults
result['licenses'] = []
result['cryptography'] = []
result['dependencies'] = []
result['quality'] = []
result['vulnerabilities'] = []
result['health'] = {}
result['provenance'] = ''
result['latest'] = ''
result['release_date'] = ''
result['version'] = ''
result['url_hash'] = ''
result['url_stats'] = {}

# Set what we know from the PURL
result['component'] = new_component.get('name')
result['url'] = new_component_url
result['vendor'] = new_component.get('namespace')

result.pop('licenses', None)
result.pop('file', None)
result.pop('file_hash', None)
result.pop('file_url', None)
result.pop('latest', None)
result.pop('release_date', None)
result.pop('source_hash', None)
result.pop('url_hash', None)
result.pop('url_stats', None)
result.pop('url_stats', None)
result.pop('version', None)

result['purl'] = [to_replace_with_purl]

if replace_rule.license:
result['licenses'] = [{'name': replace_rule.license}]
elif not self.component_info_map.get(replace_rule.replace_with):
result['licenses'] = []

result['purl'] = [replace_rule.replace_with]
result['status'] = 'identified'

return result

def _should_replace_result(
def _find_replace_rule(
self, result_path: str, result: dict, to_replace_entries: List[ReplaceRule]
) -> Tuple[bool, str]:
) -> Optional[ReplaceRule]:
"""
Check if a result should be replaced based on the SCANOSS settings.
Uses priority-based matching: most specific rule wins.
Expand All @@ -205,16 +227,15 @@ def _should_replace_result(
to_replace_entries (List[ReplaceRule]): Replace rules from the settings file

Returns:
bool: True if the result should be replaced, False otherwise
str: The purl to replace with
Optional[ReplaceRule]: The matching replace rule, or None if no match
"""
result_purls = result.get('purl', [])
match = find_best_match(result_path, result_purls, to_replace_entries)
if match and isinstance(match, ReplaceRule) and match.replace_with:
if self.debug:
self._print_message(result_path, result_purls, match, 'Replacing')
return True, match.replace_with
return False, None
return match
return None

def _should_remove_result(self, result_path: str, result: dict, to_remove_entries: List[BomEntry]) -> bool:
"""
Expand Down
Loading
Loading