mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(image): replace sys.exit calls with exceptions, fix mutable defaults, add tests
- Create exceptions module (codes 9000-9005) following OCI provider pattern - Replace all sys.exit(1) calls with typed exceptions - Fix mutable default arguments in ImageProvider and CheckReportImage - Add return type hints to all properties and methods - Add ResourceGroup field to metadata dict - Add test suite with 23 test cases covering initialization, finding processing, scan execution, error handling, and connection testing - Update CHANGELOG with container image provider entry
This commit is contained in:
@@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778)
|
||||
- CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779)
|
||||
- CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777)
|
||||
- Container Image provider (POC) using Trivy for vulnerability and secret scanning
|
||||
|
||||
### Changed
|
||||
- Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432)
|
||||
|
||||
@@ -804,7 +804,10 @@ class CheckReportImage(Check_Report):
|
||||
fixed_version: str
|
||||
|
||||
def __init__(
|
||||
self, metadata: dict = {}, finding: dict = {}, image_name: str = ""
|
||||
self,
|
||||
metadata: Optional[dict] = None,
|
||||
finding: Optional[dict] = None,
|
||||
image_name: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Container Image Check's finding information from a Trivy vulnerability/secret dict.
|
||||
@@ -814,6 +817,10 @@ class CheckReportImage(Check_Report):
|
||||
finding (dict): A single vulnerability/secret result from Trivy's JSON output.
|
||||
image_name (str): The container image name being scanned.
|
||||
"""
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
if finding is None:
|
||||
finding = {}
|
||||
super().__init__(metadata, finding)
|
||||
|
||||
self.resource = finding
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageBaseException,
|
||||
ImageFindingProcessingError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageScanError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ImageBaseException",
|
||||
"ImageFindingProcessingError",
|
||||
"ImageListFileNotFoundError",
|
||||
"ImageListFileReadError",
|
||||
"ImageNoImagesProvidedError",
|
||||
"ImageScanError",
|
||||
"ImageTrivyBinaryNotFoundError",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
# Exceptions codes from 9000 to 9999 are reserved for Image exceptions
|
||||
class ImageBaseException(ProwlerException):
|
||||
"""Base class for Image provider errors."""
|
||||
|
||||
IMAGE_ERROR_CODES = {
|
||||
(9000, "ImageNoImagesProvidedError"): {
|
||||
"message": "No container images provided for scanning.",
|
||||
"remediation": "Provide at least one image using --image or --image-list-file.",
|
||||
},
|
||||
(9001, "ImageListFileNotFoundError"): {
|
||||
"message": "Image list file not found.",
|
||||
"remediation": "Ensure the image list file exists at the specified path.",
|
||||
},
|
||||
(9002, "ImageListFileReadError"): {
|
||||
"message": "Error reading image list file.",
|
||||
"remediation": "Check file permissions and format. The file should contain one image per line.",
|
||||
},
|
||||
(9003, "ImageFindingProcessingError"): {
|
||||
"message": "Error processing image scan finding.",
|
||||
"remediation": "Check the Trivy output format and ensure the finding structure is valid.",
|
||||
},
|
||||
(9004, "ImageTrivyBinaryNotFoundError"): {
|
||||
"message": "Trivy binary not found.",
|
||||
"remediation": "Install Trivy from https://trivy.dev/latest/getting-started/installation/",
|
||||
},
|
||||
(9005, "ImageScanError"): {
|
||||
"message": "Error scanning container image.",
|
||||
"remediation": "Check the image name and ensure it is accessible.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
error_info = self.IMAGE_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if message:
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
code,
|
||||
source="Image",
|
||||
file=file,
|
||||
original_exception=original_exception,
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
|
||||
class ImageNoImagesProvidedError(ImageBaseException):
|
||||
"""Exception raised when no container images are provided for scanning."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9000, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageListFileNotFoundError(ImageBaseException):
|
||||
"""Exception raised when the image list file is not found."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9001, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageListFileReadError(ImageBaseException):
|
||||
"""Exception raised when the image list file cannot be read."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9002, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageFindingProcessingError(ImageBaseException):
|
||||
"""Exception raised when a finding cannot be processed."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9003, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageTrivyBinaryNotFoundError(ImageBaseException):
|
||||
"""Exception raised when the Trivy binary is not found."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9004, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageScanError(ImageBaseException):
|
||||
"""Exception raised when a general scan error occurs."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9005, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -15,6 +17,13 @@ from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageFindingProcessingError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class ImageProvider(Provider):
|
||||
@@ -30,22 +39,22 @@ class ImageProvider(Provider):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images: list[str] = [],
|
||||
images: list[str] | None = None,
|
||||
image_list_file: str = None,
|
||||
scanners: list[str] = ["vuln", "secret"],
|
||||
trivy_severity: list[str] = [],
|
||||
scanners: list[str] | None = None,
|
||||
trivy_severity: list[str] | None = None,
|
||||
ignore_unfixed: bool = False,
|
||||
timeout: str = "5m",
|
||||
config_path: str = None,
|
||||
config_content: dict = None,
|
||||
fixer_config: dict = {},
|
||||
fixer_config: dict | None = None,
|
||||
):
|
||||
logger.info("Instantiating Image Provider...")
|
||||
|
||||
self.images = images or []
|
||||
self.images = images if images is not None else []
|
||||
self.image_list_file = image_list_file
|
||||
self.scanners = scanners
|
||||
self.trivy_severity = trivy_severity
|
||||
self.scanners = scanners if scanners is not None else ["vuln", "secret"]
|
||||
self.trivy_severity = trivy_severity if trivy_severity is not None else []
|
||||
self.ignore_unfixed = ignore_unfixed
|
||||
self.timeout = timeout
|
||||
self.region = "container"
|
||||
@@ -59,8 +68,10 @@ class ImageProvider(Provider):
|
||||
self._load_images_from_file(image_list_file)
|
||||
|
||||
if not self.images:
|
||||
logger.error("No images provided for scanning")
|
||||
sys.exit(1)
|
||||
raise ImageNoImagesProvidedError(
|
||||
file=__file__,
|
||||
message="No images provided for scanning.",
|
||||
)
|
||||
|
||||
# Audit Config
|
||||
if config_content:
|
||||
@@ -71,7 +82,7 @@ class ImageProvider(Provider):
|
||||
self._audit_config = load_and_validate_config_file(self._type, config_path)
|
||||
|
||||
# Fixer Config
|
||||
self._fixer_config = fixer_config
|
||||
self._fixer_config = fixer_config if fixer_config is not None else {}
|
||||
|
||||
# Mutelist (not needed for Image provider since Trivy has its own logic)
|
||||
self._mutelist = None
|
||||
@@ -99,37 +110,42 @@ class ImageProvider(Provider):
|
||||
self.images.append(line)
|
||||
logger.info(f"Loaded {len(self.images)} images from {file_path}")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Image list file not found: {file_path}")
|
||||
sys.exit(1)
|
||||
raise ImageListFileNotFoundError(
|
||||
file=file_path,
|
||||
message=f"Image list file not found: {file_path}",
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"Error reading image list file: {error}")
|
||||
sys.exit(1)
|
||||
raise ImageListFileReadError(
|
||||
file=file_path,
|
||||
original_exception=error,
|
||||
message=f"Error reading image list file: {error}",
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self):
|
||||
def auth_method(self) -> str:
|
||||
return self._auth_method
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
def type(self) -> str:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def identity(self):
|
||||
def identity(self) -> str:
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
def session(self) -> None:
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def audit_config(self):
|
||||
def audit_config(self) -> dict:
|
||||
return self._audit_config
|
||||
|
||||
@property
|
||||
def fixer_config(self):
|
||||
def fixer_config(self) -> dict:
|
||||
return self._fixer_config
|
||||
|
||||
def setup_session(self):
|
||||
def setup_session(self) -> None:
|
||||
"""Image provider doesn't need a session since it uses Trivy directly"""
|
||||
return None
|
||||
|
||||
@@ -187,6 +203,7 @@ class ImageProvider(Provider):
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": trivy_severity,
|
||||
"ResourceType": "container-image",
|
||||
"ResourceGroup": "container",
|
||||
"Description": finding_description,
|
||||
"Risk": finding.get(
|
||||
"Description", "Vulnerability detected in container image"
|
||||
@@ -222,10 +239,11 @@ class ImageProvider(Provider):
|
||||
return report
|
||||
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
raise ImageFindingProcessingError(
|
||||
file=__file__,
|
||||
original_exception=error,
|
||||
message=f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _build_status_extended(self, finding: dict) -> str:
|
||||
"""Build a detailed status message for the finding."""
|
||||
@@ -369,11 +387,11 @@ class ImageProvider(Provider):
|
||||
|
||||
except Exception as error:
|
||||
if "No such file or directory: 'trivy'" in str(error):
|
||||
logger.critical(
|
||||
"Trivy binary not found. Please install Trivy from "
|
||||
"https://trivy.dev/latest/getting-started/installation/"
|
||||
raise ImageTrivyBinaryNotFoundError(
|
||||
file=__file__,
|
||||
original_exception=error,
|
||||
message="Trivy binary not found. Please install Trivy from https://trivy.dev/latest/getting-started/installation/",
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.error(f"Error scanning image {image}: {error}")
|
||||
|
||||
def _execute_trivy(self, command: list, image: str) -> subprocess.CompletedProcess:
|
||||
@@ -429,7 +447,7 @@ class ImageProvider(Provider):
|
||||
else:
|
||||
logger.info(line)
|
||||
|
||||
def print_credentials(self):
|
||||
def print_credentials(self) -> None:
|
||||
"""Print scan configuration."""
|
||||
report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}"
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import json
|
||||
|
||||
# Sample vulnerability finding from Trivy
|
||||
SAMPLE_VULNERABILITY_FINDING = {
|
||||
"VulnerabilityID": "CVE-2024-1234",
|
||||
"PkgID": "openssl@1.1.1k-r0",
|
||||
"PkgName": "openssl",
|
||||
"InstalledVersion": "1.1.1k-r0",
|
||||
"FixedVersion": "1.1.1l-r0",
|
||||
"Severity": "HIGH",
|
||||
"Title": "OpenSSL Buffer Overflow",
|
||||
"Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234",
|
||||
}
|
||||
|
||||
# Sample secret finding from Trivy
|
||||
SAMPLE_SECRET_FINDING = {
|
||||
"RuleID": "aws-access-key-id",
|
||||
"Category": "AWS",
|
||||
"Severity": "CRITICAL",
|
||||
"Title": "AWS Access Key ID",
|
||||
"StartLine": 10,
|
||||
"EndLine": 10,
|
||||
"Match": "AKIA...",
|
||||
}
|
||||
|
||||
# Sample misconfiguration finding from Trivy
|
||||
SAMPLE_MISCONFIGURATION_FINDING = {
|
||||
"ID": "DS001",
|
||||
"Title": "Dockerfile should not use latest tag",
|
||||
"Description": "Using latest tag can cause unpredictable builds.",
|
||||
"Severity": "MEDIUM",
|
||||
"Resolution": "Use a specific version tag instead of latest",
|
||||
"PrimaryURL": "https://avd.aquasec.com/misconfig/ds001",
|
||||
}
|
||||
|
||||
# Sample finding with UNKNOWN severity
|
||||
SAMPLE_UNKNOWN_SEVERITY_FINDING = {
|
||||
"VulnerabilityID": "CVE-2024-9999",
|
||||
"PkgID": "test-pkg@0.0.1",
|
||||
"PkgName": "test-pkg",
|
||||
"InstalledVersion": "0.0.1",
|
||||
"Severity": "UNKNOWN",
|
||||
"Title": "Unknown severity issue",
|
||||
"Description": "An issue with unknown severity.",
|
||||
}
|
||||
|
||||
# Full Trivy JSON output structure with a single vulnerability
|
||||
SAMPLE_TRIVY_IMAGE_OUTPUT = {
|
||||
"Results": [
|
||||
{
|
||||
"Target": "alpine:3.18 (alpine 3.18.0)",
|
||||
"Type": "alpine",
|
||||
"Vulnerabilities": [SAMPLE_VULNERABILITY_FINDING],
|
||||
"Secrets": [],
|
||||
"Misconfigurations": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Full Trivy JSON output with mixed finding types
|
||||
SAMPLE_TRIVY_MULTI_TYPE_OUTPUT = {
|
||||
"Results": [
|
||||
{
|
||||
"Target": "myimage:latest (debian 12)",
|
||||
"Type": "debian",
|
||||
"Vulnerabilities": [SAMPLE_VULNERABILITY_FINDING],
|
||||
"Secrets": [SAMPLE_SECRET_FINDING],
|
||||
"Misconfigurations": [SAMPLE_MISCONFIGURATION_FINDING],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_sample_trivy_json_output():
|
||||
"""Return sample Trivy JSON output as string."""
|
||||
return json.dumps(SAMPLE_TRIVY_IMAGE_OUTPUT)
|
||||
|
||||
|
||||
def get_empty_trivy_output():
|
||||
"""Return empty Trivy output as string."""
|
||||
return json.dumps({"Results": []})
|
||||
|
||||
|
||||
def get_invalid_trivy_output():
|
||||
"""Return invalid JSON output as string."""
|
||||
return "invalid json output"
|
||||
|
||||
|
||||
def get_multi_type_trivy_output():
|
||||
"""Return Trivy output with multiple finding types as string."""
|
||||
return json.dumps(SAMPLE_TRIVY_MULTI_TYPE_OUTPUT)
|
||||
@@ -0,0 +1,354 @@
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.lib.check.models import CheckReportImage
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageListFileNotFoundError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
)
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from tests.providers.image.image_fixtures import (
|
||||
SAMPLE_MISCONFIGURATION_FINDING,
|
||||
SAMPLE_SECRET_FINDING,
|
||||
SAMPLE_UNKNOWN_SEVERITY_FINDING,
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
get_empty_trivy_output,
|
||||
get_invalid_trivy_output,
|
||||
get_multi_type_trivy_output,
|
||||
get_sample_trivy_json_output,
|
||||
)
|
||||
|
||||
|
||||
def _make_provider(**kwargs):
|
||||
"""Helper to create an ImageProvider with test defaults."""
|
||||
defaults = {
|
||||
"images": ["alpine:3.18"],
|
||||
"config_content": {},
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ImageProvider(**defaults)
|
||||
|
||||
|
||||
class TestImageProvider:
|
||||
def test_image_provider(self):
|
||||
"""Test default initialization."""
|
||||
provider = _make_provider()
|
||||
|
||||
assert provider._type == "image"
|
||||
assert provider.type == "image"
|
||||
assert provider.images == ["alpine:3.18"]
|
||||
assert provider.scanners == ["vuln", "secret"]
|
||||
assert provider.trivy_severity == []
|
||||
assert provider.ignore_unfixed is False
|
||||
assert provider.timeout == "5m"
|
||||
assert provider.region == "container"
|
||||
assert provider.audited_account == "image-scan"
|
||||
assert provider.identity == "prowler"
|
||||
assert provider.auth_method == "No auth"
|
||||
assert provider.session is None
|
||||
assert provider.audit_config == {}
|
||||
assert provider.fixer_config == {}
|
||||
assert provider._mutelist is None
|
||||
|
||||
def test_image_provider_custom_params(self):
|
||||
"""Test initialization with custom parameters."""
|
||||
provider = _make_provider(
|
||||
images=["nginx:1.25", "redis:7"],
|
||||
scanners=["vuln", "secret", "misconfig"],
|
||||
trivy_severity=["HIGH", "CRITICAL"],
|
||||
ignore_unfixed=True,
|
||||
timeout="10m",
|
||||
fixer_config={"key": "value"},
|
||||
)
|
||||
|
||||
assert provider.images == ["nginx:1.25", "redis:7"]
|
||||
assert provider.scanners == ["vuln", "secret", "misconfig"]
|
||||
assert provider.trivy_severity == ["HIGH", "CRITICAL"]
|
||||
assert provider.ignore_unfixed is True
|
||||
assert provider.timeout == "10m"
|
||||
assert provider.fixer_config == {"key": "value"}
|
||||
|
||||
def test_image_provider_with_image_list_file(self):
|
||||
"""Test loading images from a file, skipping comments and blank lines."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("# Comment line\n")
|
||||
f.write("alpine:3.18\n")
|
||||
f.write("\n")
|
||||
f.write(" nginx:latest \n")
|
||||
f.write("# Another comment\n")
|
||||
f.write("redis:7\n")
|
||||
f.name
|
||||
|
||||
provider = _make_provider(
|
||||
images=None,
|
||||
image_list_file=f.name,
|
||||
)
|
||||
|
||||
assert "alpine:3.18" in provider.images
|
||||
assert "nginx:latest" in provider.images
|
||||
assert "redis:7" in provider.images
|
||||
assert len(provider.images) == 3
|
||||
|
||||
def test_image_provider_no_images(self):
|
||||
"""Test that ImageNoImagesProvidedError is raised when no images are given."""
|
||||
with pytest.raises(ImageNoImagesProvidedError):
|
||||
_make_provider(images=[])
|
||||
|
||||
def test_image_provider_image_list_file_not_found(self):
|
||||
"""Test that ImageListFileNotFoundError is raised for missing file."""
|
||||
with pytest.raises(ImageListFileNotFoundError):
|
||||
_make_provider(
|
||||
images=None,
|
||||
image_list_file="/nonexistent/path/images.txt",
|
||||
)
|
||||
|
||||
def test_process_finding_vulnerability(self):
|
||||
"""Test processing a vulnerability finding."""
|
||||
provider = _make_provider()
|
||||
report = provider._process_finding(
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
"alpine",
|
||||
)
|
||||
|
||||
assert isinstance(report, CheckReportImage)
|
||||
assert report.status == "FAIL"
|
||||
assert report.check_metadata.CheckID == "CVE-2024-1234"
|
||||
assert report.check_metadata.Severity == "high"
|
||||
assert report.check_metadata.ServiceName == "alpine"
|
||||
assert report.check_metadata.ResourceType == "container-image"
|
||||
assert report.check_metadata.ResourceGroup == "container"
|
||||
assert report.package_name == "openssl"
|
||||
assert report.installed_version == "1.1.1k-r0"
|
||||
assert report.fixed_version == "1.1.1l-r0"
|
||||
assert report.resource_name == "alpine:3.18 (alpine 3.18.0)"
|
||||
assert report.region == "container"
|
||||
|
||||
def test_process_finding_secret(self):
|
||||
"""Test processing a secret finding (identified by RuleID)."""
|
||||
provider = _make_provider()
|
||||
report = provider._process_finding(
|
||||
SAMPLE_SECRET_FINDING,
|
||||
"myimage:latest",
|
||||
"secret",
|
||||
)
|
||||
|
||||
assert isinstance(report, CheckReportImage)
|
||||
assert report.status == "FAIL"
|
||||
assert report.check_metadata.CheckID == "aws-access-key-id"
|
||||
assert report.check_metadata.Severity == "critical"
|
||||
assert report.check_metadata.ServiceName == "secret"
|
||||
|
||||
def test_process_finding_misconfiguration(self):
|
||||
"""Test processing a misconfiguration finding (identified by ID)."""
|
||||
provider = _make_provider()
|
||||
report = provider._process_finding(
|
||||
SAMPLE_MISCONFIGURATION_FINDING,
|
||||
"myimage:latest",
|
||||
"misconfiguration",
|
||||
)
|
||||
|
||||
assert isinstance(report, CheckReportImage)
|
||||
assert report.check_metadata.CheckID == "DS001"
|
||||
assert report.check_metadata.Severity == "medium"
|
||||
assert report.check_metadata.ServiceName == "misconfiguration"
|
||||
|
||||
def test_process_finding_unknown_severity(self):
|
||||
"""Test that UNKNOWN severity is mapped to informational."""
|
||||
provider = _make_provider()
|
||||
report = provider._process_finding(
|
||||
SAMPLE_UNKNOWN_SEVERITY_FINDING,
|
||||
"myimage:latest",
|
||||
"alpine",
|
||||
)
|
||||
|
||||
assert report.check_metadata.Severity == "informational"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_success(self, mock_subprocess):
|
||||
"""Test successful scan with mocked subprocess."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_sample_trivy_json_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = []
|
||||
for batch in provider.run_scan():
|
||||
reports.extend(batch)
|
||||
|
||||
assert len(reports) == 1
|
||||
assert reports[0].check_metadata.CheckID == "CVE-2024-1234"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_empty_output(self, mock_subprocess):
|
||||
"""Test scan with empty Trivy output produces no findings."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_empty_trivy_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = []
|
||||
for batch in provider.run_scan():
|
||||
reports.extend(batch)
|
||||
|
||||
assert len(reports) == 0
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_invalid_json(self, mock_subprocess):
|
||||
"""Test scan with malformed output doesn't crash."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_invalid_trivy_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = []
|
||||
for batch in provider.run_scan():
|
||||
reports.extend(batch)
|
||||
|
||||
assert len(reports) == 0
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_trivy_not_found(self, mock_subprocess):
|
||||
"""Test that ImageTrivyBinaryNotFoundError is raised when trivy is missing."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.side_effect = FileNotFoundError(
|
||||
"[Errno 2] No such file or directory: 'trivy'"
|
||||
)
|
||||
|
||||
with pytest.raises(ImageTrivyBinaryNotFoundError):
|
||||
for _ in provider._scan_single_image("alpine:3.18"):
|
||||
pass
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_multiple_images(self, mock_subprocess):
|
||||
"""Test scanning multiple images makes separate subprocess calls."""
|
||||
provider = _make_provider(images=["alpine:3.18", "nginx:latest"])
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_sample_trivy_json_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = []
|
||||
for batch in provider.run_scan():
|
||||
reports.extend(batch)
|
||||
|
||||
assert mock_subprocess.call_count == 2
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_scan_multi_type_output(self, mock_subprocess):
|
||||
"""Test scan with vulnerabilities, secrets, and misconfigurations."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_multi_type_trivy_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = []
|
||||
for batch in provider.run_scan():
|
||||
reports.extend(batch)
|
||||
|
||||
assert len(reports) == 3
|
||||
|
||||
check_ids = [r.check_metadata.CheckID for r in reports]
|
||||
assert "CVE-2024-1234" in check_ids
|
||||
assert "aws-access-key-id" in check_ids
|
||||
assert "DS001" in check_ids
|
||||
|
||||
def test_print_credentials(self):
|
||||
"""Test that print_credentials outputs image names."""
|
||||
provider = _make_provider()
|
||||
with mock.patch("builtins.print") as mock_print:
|
||||
provider.print_credentials()
|
||||
output = " ".join(
|
||||
str(call.args[0]) for call in mock_print.call_args_list if call.args
|
||||
)
|
||||
assert "alpine:3.18" in output
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_test_connection_success(self, mock_subprocess):
|
||||
"""Test successful connection returns is_connected=True."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stderr="")
|
||||
|
||||
result = ImageProvider.test_connection(image="alpine:3.18")
|
||||
|
||||
assert result.is_connected is True
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_test_connection_auth_failure(self, mock_subprocess):
|
||||
"""Test 401 error returns auth failure."""
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=1, stderr="401 unauthorized"
|
||||
)
|
||||
|
||||
result = ImageProvider.test_connection(image="private/image:latest")
|
||||
|
||||
assert result.is_connected is False
|
||||
assert "Authentication failed" in result.error
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_test_connection_not_found(self, mock_subprocess):
|
||||
"""Test 404 error returns not found."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=1, stderr="404 not found")
|
||||
|
||||
result = ImageProvider.test_connection(image="nonexistent/image:latest")
|
||||
|
||||
assert result.is_connected is False
|
||||
assert "not found" in result.error
|
||||
|
||||
def test_build_status_extended(self):
|
||||
"""Test status message content for different finding types."""
|
||||
provider = _make_provider()
|
||||
|
||||
# Vulnerability with fix
|
||||
status = provider._build_status_extended(SAMPLE_VULNERABILITY_FINDING)
|
||||
assert "CVE-2024-1234" in status
|
||||
assert "openssl" in status
|
||||
assert "fix available" in status
|
||||
|
||||
# Finding with no special fields
|
||||
status = provider._build_status_extended({"Description": "Simple finding"})
|
||||
assert status == "Simple finding"
|
||||
|
||||
# Finding with will_not_fix status
|
||||
finding_no_fix = {
|
||||
"VulnerabilityID": "CVE-2024-0000",
|
||||
"PkgName": "libc",
|
||||
"Status": "will_not_fix",
|
||||
"Title": "Some vuln",
|
||||
}
|
||||
status = provider._build_status_extended(finding_no_fix)
|
||||
assert "no fix available" in status
|
||||
|
||||
def test_validate_arguments(self):
|
||||
"""Test valid and invalid argument combinations."""
|
||||
# Valid: images provided
|
||||
provider = _make_provider(images=["alpine:3.18"])
|
||||
assert provider.images == ["alpine:3.18"]
|
||||
|
||||
# Invalid: empty images and no file
|
||||
with pytest.raises(ImageNoImagesProvidedError):
|
||||
_make_provider(images=[])
|
||||
|
||||
# Valid: custom scanners
|
||||
provider = _make_provider(scanners=["vuln"])
|
||||
assert provider.scanners == ["vuln"]
|
||||
|
||||
def test_setup_session(self):
|
||||
"""Test that setup_session returns None."""
|
||||
provider = _make_provider()
|
||||
assert provider.setup_session() is None
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_method(self, mock_subprocess):
|
||||
"""Test that run() collects all batches into a list."""
|
||||
provider = _make_provider()
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout=get_sample_trivy_json_output(), stderr=""
|
||||
)
|
||||
|
||||
reports = provider.run()
|
||||
|
||||
assert isinstance(reports, list)
|
||||
assert len(reports) == 1
|
||||
Reference in New Issue
Block a user