Skip to content

feat: No-Scan SBOM generation #5286

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
14 changes: 9 additions & 5 deletions cve_bin_tool/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,10 @@ def main(argv=None):
LOGGER.info(
f"The number of products to process from SBOM - {len(parsed_data)}"
)
if args["no_scan"]:
LOGGER.info(
"Processing SBOM in no-scan mode - CVE analysis will be skipped"
)
for product_info, triage_data in parsed_data.items():
LOGGER.debug(f"{product_info}, {triage_data}")
cve_scanner.get_cves(product_info, triage_data)
Expand Down Expand Up @@ -1230,11 +1234,11 @@ def main(argv=None):
LOGGER.info(
f"Product: {product_info.product} with Version: {product_info.version} not found in Parsed Data, is valid vex file being used?"
)

LOGGER.info("Overall CVE summary: ")
LOGGER.info(
f"There are {cve_scanner.products_with_cve} products with known CVEs detected"
)
if not args["no_scan"]:
LOGGER.info("Overall CVE summary: ")
LOGGER.info(
f"There are {cve_scanner.products_with_cve} products with known CVEs detected"
)

if cve_scanner.products_with_cve > 0 or args["report"]:
affected_string = ", ".join(
Expand Down
1 change: 1 addition & 0 deletions cve_bin_tool/output_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ def output_cves(self, outfile, output_type="console"):
self.sbom_root,
self.strip_scan_dir,
self.logger,
self.no_scan,
)
sbomgen.generate_sbom()

Expand Down
7 changes: 5 additions & 2 deletions cve_bin_tool/output_engine/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def _output_console_nowrap(
Panel(
"[yellow]⚠️ NO-SCAN MODE[/yellow]\n"
"CVE scanning was disabled. This report shows only the products and versions "
"that were detected, without any vulnerability analysis.",
"that were detected, without any vulnerability analysis. SBOM generation "
"will include all detected products without CVE information.",
title="[yellow]No-Scan Mode Active[/yellow]",
border_style="yellow",
)
Expand Down Expand Up @@ -158,7 +159,9 @@ def _output_console_nowrap(
color = summary_color[severity.split("-")[0]]

if all_product_data[product_data] != 0 or no_scan:
if offline:
if no_scan:
latest_stable_version = "NA"
elif offline:
latest_stable_version = "UNKNOWN (offline mode)"
else:
latest_stable_version = get_latest_upstream_stable_version(
Expand Down
13 changes: 12 additions & 1 deletion cve_bin_tool/sbom_manager/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def __init__(
sbom_root="CVE-SCAN",
strip_scan_dir=False,
logger: Optional[Logger] = None,
no_scan: bool = False,
):
self.all_product_data = all_product_data
self.all_cve_data = all_cve_data
Expand All @@ -42,6 +43,7 @@ def __init__(
self.sbom_root = sbom_root
self.strip_scan_dir = strip_scan_dir
self.logger = logger or LOGGER.getChild(self.__class__.__name__)
self.no_scan = no_scan
self.sbom_packages = {}

def generate_sbom(self) -> None:
Expand All @@ -64,6 +66,12 @@ def generate_sbom(self) -> None:
my_package.set_licenseconcluded(license)
my_package.set_supplier("UNKNOWN", "NOASSERTION")

# Add no-scan mode information if applicable
if self.no_scan:
my_package.set_description(
"SBOM generated in no-scan mode - CVE analysis was not performed"
)

# Store package data
self.sbom_packages[
(
Expand Down Expand Up @@ -100,7 +108,10 @@ def generate_sbom(self) -> None:
in self.sbom_packages
and product_data.vendor == "unknown"
):
if self.all_cve_data.get(product_data):
# In no-scan mode, we still want to include path information if available
if self.all_cve_data.get(product_data) and self.all_cve_data[
product_data
].get("paths"):
for path in self.all_cve_data[product_data]["paths"]:
if self.strip_scan_dir:
evidence = strip_path(path, self.sbom_root)
Expand Down
62 changes: 60 additions & 2 deletions test/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def test_version(self):
def test_invalid_file_or_directory(self):
"""Test behaviour with an invalid file/directory"""
with pytest.raises(SystemExit) as e:
main(["cve-bin-tool", "non-existant"])
main(["cve-bin-tool", "non-existent"])
assert e.value.args[0] == ERROR_CODES[FileNotFoundError]

def test_null_byte_in_filename(self):
Expand All @@ -138,7 +138,7 @@ def test_null_byte_in_filename(self):
assert e.value.args[0] == ERROR_CODES[FileNotFoundError]

def test_invalid_parameter(self):
"""Test that invalid parmeters exit with expected error code.
"""Test that invalid parameters exit with expected error code.
ArgParse calls sys.exit(2) for all errors"""

# no directory specified
Expand Down Expand Up @@ -738,6 +738,64 @@ def test_sbom_detection(self, caplog):
"Using CVE Binary Tool SBOM Auto Detection",
) in caplog.record_tuples

def test_sbom_no_scan_mode(self, caplog):
"""Test SBOM processing in no-scan mode"""
SBOM_PATH = Path(__file__).parent.resolve() / "sbom"

with caplog.at_level(logging.INFO):
main(
[
"cve-bin-tool",
"--no-scan",
"--sbom",
"spdx",
"--sbom-file",
str(SBOM_PATH / "spdx_test.spdx"),
]
)

# Check that no-scan mode message is logged
sbom_no_scan_message_found = False

for _, _, log_message in caplog.record_tuples:
if "Processing SBOM in no-scan mode" in log_message:
sbom_no_scan_message_found = True

assert (
sbom_no_scan_message_found
), "Expected SBOM no-scan mode message not found"

# The no-scan mode message is displayed in the console output, not in logs
# We can see from the captured stdout that it's working correctly

def test_directory_no_scan_mode_sbom_generation(self, caplog):
"""Test directory scanning in no-scan mode with SBOM generation"""
# Create a temporary directory with some test files
test_dir = Path(self.tempdir) / "test_scan"
test_dir.mkdir()

# Create a simple test file that would be detected by a checker
test_file = test_dir / "test_file"
test_file.write_text("test content")

with caplog.at_level(logging.INFO):
main(
[
"cve-bin-tool",
"--no-scan",
str(test_dir),
"--sbom",
"spdx",
"--sbom-file",
str(test_dir / "output.spdx"),
]
)

# Check that the scan completed without errors
# In no-scan mode, we expect the scan to complete and generate an SBOM
# even if no products are found
assert "Total files:" in caplog.text

@pytest.mark.skipif(not LONG_TESTS(), reason="Skipping long tests")
def test_console_output_depending_reportlab_existence(self, caplog):
import subprocess
Expand Down
87 changes: 87 additions & 0 deletions test/test_output_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,7 @@ def test_generate_sbom(self):
sbom_type="spdx",
sbom_format="tag",
sbom_root="CVE-SCAN",
no_scan=False,
)
sbomgen.generate_sbom()

Expand Down Expand Up @@ -1027,6 +1028,92 @@ def test_generate_sbom(self):
actual_packages = [package for package in sbomgen.sbom_packages.values()]
self.assertEqual(actual_packages, list(expected_packages))

def test_generate_sbom_no_scan_mode(self):
"""Test SBOM generation in no-scan mode"""
with patch(
"cve_bin_tool.sbom_manager.generate.SBOMPackage"
) as mock_sbom_package, patch(
"cve_bin_tool.sbom_manager.generate.SBOMRelationship"
):
mock_package_instance = MagicMock()
mock_sbom_package.return_value = mock_package_instance

sbomgen = SBOMGenerate(
all_product_data=self.all_product_data,
all_cve_data=self.MOCK_OUTPUT,
filename="test.sbom",
sbom_type="spdx",
sbom_format="tag",
sbom_root="CVE-SCAN",
no_scan=True,
)

# Verify no-scan mode is set
self.assertTrue(sbomgen.no_scan)

sbomgen.generate_sbom()

# In no-scan mode, we should still set the description
mock_package_instance.set_description.assert_called_with(
"SBOM generated in no-scan mode - CVE analysis was not performed"
)

def test_console_output_no_scan_mode_latest_version(self):
"""Test that console output shows 'NA' for latest upstream version in no-scan mode"""
from datetime import datetime

from rich.console import Console

from cve_bin_tool.output_engine.console import _output_console_nowrap

# Create mock data
all_product_data = {
ProductInfo(
vendor="test_vendor", product="test_product", version="1.0.0"
): 0,
}

all_cve_data = {
ProductInfo(
vendor="test_vendor", product="test_product", version="1.0.0"
): {"cves": [], "paths": {"/path/to/test"}}
}

# Test with no-scan mode
with patch(
"cve_bin_tool.output_engine.console.get_latest_upstream_stable_version"
) as mock_get_version:
mock_get_version.return_value = "2.0.0"

# Capture the output
from io import StringIO

output = StringIO()

_output_console_nowrap(
all_cve_data=all_cve_data,
all_cve_version_info={},
scanned_dir="/test",
time_of_last_update=datetime(2024, 1, 1),
affected_versions=0,
exploits=False,
metrics=False,
strip_scan_dir=False,
all_product_data=all_product_data,
offline=False,
width=None,
console=Console(file=output),
no_scan=True,
)

output_content = output.getvalue()

# Verify that 'NA' appears in the output for latest upstream version
self.assertIn("NA", output_content)

# Verify that get_latest_upstream_stable_version was NOT called
mock_get_version.assert_not_called()

def tearDown(self) -> None:
self.mock_file.close()

Expand Down
46 changes: 46 additions & 0 deletions test/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,49 @@ def test_invalid_xml(self, filename: str, sbom_type: str, validate: bool):
)
def test_sbom_detection(self, filename: str, expected_sbom_type: str):
assert sbom_detection(filename) == expected_sbom_type

def test_sbom_generate_no_scan_mode(self):
"""Test SBOM generation in no-scan mode"""
from cve_bin_tool.sbom_manager.generate import SBOMGenerate

# Create sample product data
all_product_data = {
ProductInfo(
vendor="test_vendor", product="test_product", version="1.0.0"
): 0,
ProductInfo(
vendor="another_vendor", product="another_product", version="2.0.0"
): 0,
}

# Create sample CVE data with paths
all_cve_data = {
ProductInfo(
vendor="test_vendor", product="test_product", version="1.0.0"
): {"cves": [], "paths": {"/path/to/test_product"}},
ProductInfo(
vendor="another_vendor", product="another_product", version="2.0.0"
): {"cves": [], "paths": {"/path/to/another_product"}},
}

# Test SBOM generation in no-scan mode
sbom_gen = SBOMGenerate(
all_product_data=all_product_data,
all_cve_data=all_cve_data,
filename="",
sbom_type="spdx",
sbom_format="tag",
sbom_root="TEST-SCAN",
strip_scan_dir=False,
logger=None,
no_scan=True,
)

# Verify no-scan mode is set
assert sbom_gen.no_scan is True

# Test that generate_sbom doesn't crash in no-scan mode
# We can't easily test the actual output without file I/O, but we can test the setup
assert sbom_gen.sbom_packages == {}
assert sbom_gen.all_product_data == all_product_data
assert sbom_gen.all_cve_data == all_cve_data
Loading