diff --git a/docksec/cli.py b/docksec/cli.py index f3b9993..e080c1c 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -47,9 +47,31 @@ def main() -> None: help='LLM provider to use (default: openai, can also set LLM_PROVIDER env var)') parser.add_argument('--model', help='Model name to use (e.g., gpt-4o, claude-3-5-sonnet-20241022, gemini-1.5-pro, llama3.1)') parser.add_argument('--version', action='version', version=f'DockSec {get_version()}') + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Show detailed scan progress and tool output" + ) + parser.add_argument( + "--debug", + action="store_true", + help="Show debug-level output including raw tool outputs" + ) + parser.add_argument( + "--log-file", + metavar="PATH", + help="Write logs to a file in addition to stdout" + ) args = parser.parse_args() + from docksec.utils import configure_logging + configure_logging( + verbose=args.verbose, + debug=args.debug, + log_file=args.log_file + ) + # Set provider and model from CLI args if provided (overrides env vars) if args.provider: os.environ["LLM_PROVIDER"] = args.provider diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index 3e239b8..9c33a3c 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -11,10 +11,10 @@ from pathlib import Path from docksec.config import RESULTS_DIR, docker_score_prompt from docksec.enums import Severity -from docksec.utils import ScoreResponse, get_llm, print_section, get_custom_logger +from docksec.utils import ScoreResponse, get_llm, print_section # Initialize logger -logger = get_custom_logger(__name__) +logger = logging.getLogger(__name__) class DockerSecurityScanner: @staticmethod @@ -197,7 +197,7 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Validate severity input severity = self._validate_severity(severity) logger.info(f"Starting image-only scan for {self.image_name}") - print(f"\n=== Starting image-only scan for {self.image_name} ===") + logger.info("Starting image-only scan for %s", self.image_name) results = { 'dockerfile_scan': { @@ -227,13 +227,13 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: results['json_data'] = json_data # Print final summary - print("\n=== Image-Only Scan Summary ===") + logger.info("Image-Only Scan Summary") if image_success and not json_data: - print("Image scan completed successfully with no vulnerabilities found.") + logger.info("Image scan completed successfully with no vulnerabilities found.") elif json_data: - print(f"Image scan completed. Found {len(json_data)} vulnerabilities.") + logger.info("Image scan completed. Found %d vulnerabilities.", len(json_data)) else: - print("Image scan encountered issues. Please review the results above.") + logger.warning("Image scan encountered issues. Please review the results above.") return results @@ -290,7 +290,7 @@ def scan_dockerfile(self) -> Tuple[bool, Optional[str]]: - Optional[str]: Output from the scan or None if successful """ logger.info(f"Starting Dockerfile scan with Hadolint: {self.dockerfile_path}") - print("\n=== Starting Dockerfile scan with Hadolint ===") + logger.info("Starting Dockerfile scan with Hadolint") try: result = subprocess.run( ['hadolint', self.dockerfile_path], @@ -303,45 +303,44 @@ def scan_dockerfile(self) -> Tuple[bool, Optional[str]]: if result.returncode != 0: output = result.stdout if result.stdout else result.stderr logger.warning(f"Hadolint found issues in {self.dockerfile_path}") - print("[WARNING] Dockerfile linting issues found:") - print(output) - print("\n[TIP] Run 'hadolint --help' to learn about specific rules") - print(" You can ignore specific rules with: hadolint --ignore DL3000 Dockerfile") + logger.warning("Dockerfile linting issues found:") + logger.warning("%s", output) + logger.info("TIP: Run 'hadolint --help' to learn about specific rules") + logger.info("You can ignore specific rules with: hadolint --ignore DL3000 Dockerfile") return False, output else: logger.info("No Dockerfile linting issues found.") - print("[SUCCESS] No Dockerfile linting issues found.") return True, None except subprocess.CalledProcessError as e: error_msg = f"Hadolint execution failed: {e}" logger.error(error_msg, exc_info=True) - print(f"\n[ERROR] Error: {error_msg}") - print("\nTroubleshooting steps:") - print(" 1. Verify Hadolint is installed: hadolint --version") - print(" 2. Check file permissions on the Dockerfile") - print(" 3. Ensure Dockerfile syntax is valid") + logger.error("Error: %s", error_msg) + logger.error("Troubleshooting steps:") + logger.error(" 1. Verify Hadolint is installed: hadolint --version") + logger.error(" 2. Check file permissions on the Dockerfile") + logger.error(" 3. Ensure Dockerfile syntax is valid") return False, str(e) except subprocess.TimeoutExpired: error_msg = f"Hadolint scan timed out after 300 seconds" logger.error(f"{error_msg} for {self.dockerfile_path}") - print(f"\n[ERROR] Error: {error_msg}") - print("\nTroubleshooting steps:") - print(" 1. The Dockerfile may be extremely large") - print(" 2. Try splitting into smaller Dockerfiles") - print(" 3. Check for infinite loops or circular dependencies") + logger.error("Error: %s", error_msg) + logger.error("Troubleshooting steps:") + logger.error(" 1. The Dockerfile may be extremely large") + logger.error(" 2. Try splitting into smaller Dockerfiles") + logger.error(" 3. Check for infinite loops or circular dependencies") return False, "Scan timeout" except FileNotFoundError: error_msg = "Hadolint not found in PATH" logger.error(error_msg) - print(f"\n[ERROR] Error: {error_msg}") - print("\nInstallation instructions:") - print(self._get_tool_installation_instructions('hadolint')) + logger.error("Error: %s", error_msg) + logger.error("Installation instructions:") + logger.error("%s", self._get_tool_installation_instructions('hadolint')) return False, error_msg except Exception as e: error_msg = f"Unexpected error during Hadolint scan: {e}" logger.error(error_msg, exc_info=True) - print(f"\n[ERROR] Error: {error_msg}") + logger.error("Error: %s", error_msg) return False, str(e) def _filter_scan_results(self, scan_results: Dict) -> List[Dict]: @@ -398,7 +397,7 @@ def scan_image_json(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Option # Validate severity input severity = self._validate_severity(severity) logger.info(f"Starting Trivy JSON scan for image: {self.image_name}") - print("\n=== Starting vulnerability scan with Trivy for Json Output ===") + logger.info("Starting vulnerability scan with Trivy for JSON output") try: with Progress( @@ -432,40 +431,40 @@ def scan_image_json(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Option progress.update(scan_task, completed=True) if result.stderr: - print("Scan warnings:", result.stderr) + logger.debug("Tool stderr: %s", result.stderr) response = json.loads(result.stdout) filtered_results = self._filter_scan_results(response) # Check if vulnerabilities were found if not filtered_results: - print("[SUCCESS] No vulnerabilities found.") + logger.info("No vulnerabilities found.") else: - print(f"[WARNING] Found {len(filtered_results)} vulnerabilities.") + logger.warning("Found %d vulnerabilities.", len(filtered_results)) return True, filtered_results except subprocess.TimeoutExpired: error_msg = f"Trivy scan timed out after 600 seconds" logger.error(error_msg) - print(f"Error: {error_msg}") - print("\nTroubleshooting:") - print(" - The image may be very large. Consider increasing timeout.") - print(" - Check your network connection if pulling remote image data.") - print(" - Try scanning a specific image layer or component.") + logger.error("Error: %s", error_msg) + logger.error("Troubleshooting:") + logger.error(" - The image may be very large. Consider increasing timeout.") + logger.error(" - Check your network connection if pulling remote image data.") + logger.error(" - Try scanning a specific image layer or component.") return False, None except json.JSONDecodeError as e: error_msg = f"Failed to parse Trivy output: {e}" logger.error(error_msg) - print(f"Error: {error_msg}") - print("\nTroubleshooting:") - print(" - Ensure Trivy is up to date: trivy --version") - print(" - Check Trivy database: trivy image --download-db-only") + logger.error("Error: %s", error_msg) + logger.error("Troubleshooting:") + logger.error(" - Ensure Trivy is up to date: trivy --version") + logger.error(" - Check Trivy database: trivy image --download-db-only") return False, None except (subprocess.CalledProcessError, Exception) as e: error_msg = f"Trivy scan failed: {e}" logger.error(error_msg, exc_info=True) - print(f"Error: {error_msg}") + logger.error("Error: %s", error_msg) return False, None def scan_image(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Optional[str]]: @@ -483,10 +482,10 @@ def scan_image(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Optional[st # Validate severity input severity = self._validate_severity(severity) logger.info(f"Starting Trivy scan for image: {self.image_name} with severity: {severity}") - print("\n=== Starting vulnerability scan with Trivy ===") + logger.info("Starting vulnerability scan with Trivy") try: - print(f"Scanning image: {self.image_name}") + logger.info("Scanning image: %s", self.image_name) result = subprocess.run( [ 'trivy', @@ -501,22 +500,22 @@ def scan_image(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Optional[st shell=False ) - print("Scan completed.") + logger.info("Scan completed.") if result.stdout: - print(result.stdout) + logger.debug("Trivy stdout: %s", result.stdout) if result.stderr: - print("Errors:", result.stderr) + logger.debug("Tool stderr: %s", result.stderr) # Check if vulnerabilities were found based on return code # Trivy returns 0 if no vulnerabilities are found with the specified severity return result.returncode == 0, result.stdout except subprocess.TimeoutExpired: - print(f"Error: Trivy scan timed out after 600 seconds") + logger.error("Error: Trivy scan timed out after 600 seconds") return False, "Scan timed out" except subprocess.CalledProcessError as e: - print(f"Error running Trivy scan: {e}") + logger.error("Error running Trivy scan: %s", e) return False, str(e) def advanced_scan(self) -> Dict: @@ -538,21 +537,21 @@ def advanced_scan(self) -> Dict: ["docker", "scout", "quickview", self.image_name], capture_output=True, text=True, check=True, timeout=300, shell=False ) - print(f"Scan results for {self.image_name}:\n") - print(result.stdout) + logger.info("Scan results for %s", self.image_name) + logger.debug("%s", result.stdout) result_dict['success'] = True result_dict['output'] = result.stdout except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else str(e) - print(f"Error running Docker Scout: {error_msg}") + logger.error("Error running Docker Scout: %s", error_msg) result_dict['error'] = error_msg except subprocess.TimeoutExpired: error_msg = "Docker Scout scan timed out after 300 seconds" - print(f"Error: {error_msg}") + logger.error("Error: %s", error_msg) result_dict['error'] = error_msg except FileNotFoundError: error_msg = "Docker Scout not found. Please install Docker Scout to use advanced scanning." - print(f"Error: {error_msg}") + logger.error("Error: %s", error_msg) result_dict['error'] = error_msg return result_dict @@ -604,11 +603,11 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: results['json_data'] = json_data # Print final summary - print("\n=== Scan Summary ===") + logger.info("Scan Summary") if scan_status: - print("All security scans completed successfully with no issues found.") + logger.info("All security scans completed successfully with no issues found.") else: - print("Some security scans failed or found issues. Please review the results above.") + logger.warning("Some security scans failed or found issues. Please review the results above.") return results @@ -640,10 +639,10 @@ def save_results_to_json(self, results: Dict) -> str: try: with open(output_file, "w") as f: json.dump(vulnerabilities, f, indent=4) - print(f"JSON results saved to {output_file}") + logger.info("JSON results saved to %s", output_file) return output_file except Exception as e: - print(f"Error saving results to JSON file: {e}") + logger.error("Error saving results to JSON file: %s", e) return "" def save_results_to_csv(self, results: Dict) -> str: @@ -662,7 +661,7 @@ def save_results_to_csv(self, results: Dict) -> str: vulnerabilities = results.get('json_data', []) if not vulnerabilities: - print("No vulnerability data to save to CSV") + logger.warning("No vulnerability data to save to CSV") return "" try: @@ -681,10 +680,10 @@ def save_results_to_csv(self, results: Dict) -> str: filtered_vuln = {k: vuln.get(k, "") for k in fieldnames} writer.writerow(filtered_vuln) - print(f"CSV results saved to {output_file}") + logger.info("CSV results saved to %s", output_file) return output_file except Exception as e: - print(f"Error saving results to CSV file: {e}") + logger.error("Error saving results to CSV file: %s", e) return "" def save_results_to_pdf(self, results: Dict) -> str: @@ -887,11 +886,11 @@ def add_section_header(self, title): # Save the PDF pdf.output(output_file) - print(f"PDF report saved to {output_file}") + logger.info("PDF report saved to %s", output_file) return output_file except Exception as e: - print(f"Error saving results to PDF file: {e}") + logger.error("Error saving results to PDF file: %s", e) return "" def generate_all_reports(self, results: Dict) -> Dict: @@ -906,7 +905,7 @@ def generate_all_reports(self, results: Dict) -> Dict: """ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn - print("\n=== Generating Reports ===") + logger.info("Generating Reports") with Progress( SpinnerColumn(), @@ -958,12 +957,12 @@ def generate_all_reports(self, results: Dict) -> Dict: report_paths['html'] = html_path progress.update(html_task, advance=1) - print("[SUCCESS] All reports generated successfully!") - print(f"Results location: {self.RESULTS_DIR}") - print(f"\nGenerated files:") + logger.info("All reports generated successfully!") + logger.info("Results location: %s", self.RESULTS_DIR) + logger.debug("Generated files:") for report_type, path in report_paths.items(): if path: - print(f" • {report_type.upper()}: {os.path.basename(path)}") + logger.debug(" %s: %s", report_type.upper(), os.path.basename(path)) return report_paths @@ -1006,15 +1005,15 @@ def _calculate_local_score(self, results: Dict) -> float: overall = (dockerfile_score * 0.3) + (vuln_score * 0.5) + (config_score * 0.2) score = round(max(0.0, overall), 1) - print(f"Security Score: {score}/100") + logger.info("Security Score: %s/100", score) if score >= 90: - print("[EXCELLENT] Excellent security posture!") + logger.info("[EXCELLENT] Excellent security posture!") elif score >= 70: - print("[GOOD] Good security, but some improvements recommended") + logger.info("[GOOD] Good security, but some improvements recommended") elif score >= 50: - print("[FAIR] Fair security - multiple issues need attention") + logger.info("[FAIR] Fair security - multiple issues need attention") else: - print("[POOR] Poor security - immediate action required") + logger.info("[POOR] Poor security - immediate action required") return score @@ -1036,11 +1035,11 @@ def get_security_score(self, results: Dict) -> float: try: score = self.score_chain.invoke({"results": results}) - print(f"Security Score: {score.score}") + logger.info("Security Score: %s", score.score) return score.score except Exception as e: logger.warning(f"AI scoring failed: {e}. Falling back to local scoring.") - print(f"AI scoring unavailable: {e}. Falling back to local scoring.") + logger.warning("AI scoring unavailable: %s. Falling back to local scoring.", e) return self._calculate_local_score(results) def save_results_to_html(self, results: Dict) -> str: @@ -1072,11 +1071,11 @@ def save_results_to_html(self, results: Dict) -> str: with open(output_file, 'w', encoding='utf-8') as f: f.write(html_content) - print(f"HTML report saved to {output_file}") + logger.info("HTML report saved to %s", output_file) return output_file except Exception as e: - print(f"Error saving results to HTML file: {e}") + logger.error("Error saving results to HTML file: %s", e) return "" def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: diff --git a/docksec/report_generator.py b/docksec/report_generator.py index d087d15..b91b881 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -22,10 +22,9 @@ from fpdf import FPDF from docksec.config import RESULTS_DIR, html_template -from docksec.utils import get_custom_logger # Initialize logger -logger = get_custom_logger(__name__) +logger = logging.getLogger(__name__) class ReportGenerator: @@ -110,11 +109,11 @@ def generate_json_report(self, results: Dict) -> str: with open(output_file, "w") as f: json.dump(report_data, f, indent=4) logger.info(f"JSON report saved successfully") - print(f"[SUCCESS] JSON report saved to {output_file}") + logger.info("JSON report saved to %s", output_file) return output_file except Exception as e: - logger.error(f"Error saving JSON report: {e}", exc_info=True) - print(f"[ERROR] Error saving JSON report: {e}") + logger.error("Error saving JSON report: %s", e, exc_info=True) + logger.error("Error saving JSON report: %s", e) return "" def generate_csv_report(self, results: Dict) -> str: @@ -135,8 +134,8 @@ def generate_csv_report(self, results: Dict) -> str: logger.warning( "No vulnerability data to save to CSV, creating header-only file" ) - print( - "[WARNING] No vulnerability data to save to CSV, creating header-only file" + logger.warning( + "No vulnerability data to save to CSV, creating header-only file" ) try: @@ -166,12 +165,12 @@ def generate_csv_report(self, results: Dict) -> str: logger.info( f"CSV report saved successfully with {len(vulnerabilities)} vulnerabilities" ) - print(f"[SUCCESS] CSV report saved to {output_file}") + logger.info("CSV report saved to %s", output_file) return output_file except Exception as e: - logger.error(f"Error saving CSV report: {e}", exc_info=True) - print(f"[ERROR] Error saving CSV report: {e}") + logger.error("Error saving CSV report: %s", e, exc_info=True) + logger.error("Error saving CSV report: %s", e) return "" def generate_pdf_report(self, results: Dict) -> str: @@ -322,12 +321,12 @@ def add_section_header(self, title): pdf.output(output_file) logger.info(f"PDF report saved successfully") - print(f"[SUCCESS] PDF report saved to {output_file}") + logger.info("PDF report saved to %s", output_file) return output_file except Exception as e: - logger.error(f"Error saving PDF report: {e}", exc_info=True) - print(f"[ERROR] Error saving PDF report: {e}") + logger.error("Error saving PDF report: %s", e, exc_info=True) + logger.error("Error saving PDF report: %s", e) return "" def generate_html_report(self, results: Dict) -> str: @@ -356,12 +355,12 @@ def generate_html_report(self, results: Dict) -> str: f.write(html_content) logger.info(f"HTML report saved successfully") - print(f"[SUCCESS] HTML report saved to {output_file}") + logger.info("HTML report saved to %s", output_file) return output_file except Exception as e: - logger.error(f"Error saving HTML report: {e}", exc_info=True) - print(f"[ERROR] Error saving HTML report: {e}") + logger.error("Error saving HTML report: %s", e, exc_info=True) + logger.error("Error saving HTML report: %s", e) return "" def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: @@ -571,7 +570,7 @@ def generate_all_reports(self, results: Dict) -> Dict[str, str]: from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn logger.info("Generating all report formats") - print("\n=== Generating Reports ===") + logger.info("Generating Reports") report_paths = {"json": "", "csv": "", "pdf": "", "html": ""} @@ -609,6 +608,6 @@ def generate_all_reports(self, results: Dict) -> Dict[str, str]: report_paths["html"] = html_path progress.update(html_task, advance=1) - print("\n[SUCCESS] All reports generated successfully!") + logger.info("All reports generated successfully!") logger.info(f"All reports generated: {report_paths}") return report_paths diff --git a/docksec/score_calculator.py b/docksec/score_calculator.py index eaf24a3..2871077 100644 --- a/docksec/score_calculator.py +++ b/docksec/score_calculator.py @@ -5,13 +5,14 @@ It uses LLM-based analysis to provide comprehensive security scoring. """ +import logging from typing import Dict from docksec.config import docker_score_prompt from docksec.enums import Severity -from docksec.utils import ScoreResponse, get_llm, get_custom_logger +from docksec.utils import ScoreResponse, get_llm # Initialize logger -logger = get_custom_logger(__name__) +logger = logging.getLogger(__name__) class SecurityScoreCalculator: @@ -66,28 +67,27 @@ def calculate_score(self, results: Dict) -> float: score_response = self.score_chain.invoke({"results": results}) score = score_response.score - logger.info(f"Security score calculated: {score}") - print(f"Security Score: {score}/100") + logger.info("Security score calculated: %s", score) + logger.info("Security Score: %s/100", score) # Provide contextual feedback based on score if score >= 90: - print("[EXCELLENT] Excellent security posture!") + logger.info("[EXCELLENT] Excellent security posture!") elif score >= 70: - print("[GOOD] Good security, but some improvements recommended") + logger.info("[GOOD] Good security, but some improvements recommended") elif score >= 50: - print("[FAIR] Fair security - multiple issues need attention") + logger.info("[FAIR] Fair security - multiple issues need attention") else: - print("[POOR] Poor security - immediate action required") + logger.info("[POOR] Poor security - immediate action required") return score except Exception as e: - logger.error(f"Error calculating security score: {e}", exc_info=True) - print(f"\n[ERROR] Error calculating security score: {e}") - print("\nTroubleshooting:") - print(" 1. Check your OpenAI API key and credits") - print(" 2. Verify network connectivity") - print(" 3. Review scan results format") + logger.error("Error calculating security score: %s", e, exc_info=True) + logger.error("Troubleshooting:") + logger.error(" 1. Check your OpenAI API key and credits") + logger.error(" 2. Verify network connectivity") + logger.error(" 3. Review scan results format") # Return a default score in case of error logger.warning("Returning default score of 0 due to calculation error") return 0.0 diff --git a/docksec/utils.py b/docksec/utils.py index dc9dcd7..9d7b196 100644 --- a/docksec/utils.py +++ b/docksec/utils.py @@ -63,6 +63,24 @@ APITimeoutError ) +def configure_logging( + verbose: bool = False, + debug: bool = False, + log_file: str = None +): + level = logging.DEBUG if debug else ( + logging.INFO if verbose else logging.WARNING + ) + handlers = [logging.StreamHandler()] + if log_file: + handlers.append(logging.FileHandler(log_file)) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=handlers, + force=True, + ) + def get_custom_logger(name: str = 'Docksec'): logger = logging.getLogger(name) logger.setLevel(logging.INFO) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..c24c0a8 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,141 @@ +"""Tests for centralized logging configuration.""" + +import ast +import logging +import os + +import pytest + + +@pytest.fixture(autouse=True) +def reset_logging(): + """Reset logging state between tests to avoid handler pollution. + + logging.basicConfig() is a no-op when the root logger already has handlers, + so we must clear handlers AND reset the root logger's level before each test. + We also set force=True-compatible state by removing the root's handler list. + """ + # Setup: ensure clean state before each test + root = logging.getLogger() + root.handlers.clear() + root.setLevel(logging.WARNING) + # Reset the internal flag that basicConfig uses to skip re-configuration + # by removing all handlers (already done above). On Python 3.8+, + # basicConfig checks len(root.handlers) == 0 before configuring. + yield + # Teardown: clean up after each test + root = logging.getLogger() + for handler in root.handlers[:]: + handler.close() + root.removeHandler(handler) + root.setLevel(logging.WARNING) + + +class TestConfigureLogging: + """Tests for the configure_logging() function.""" + + def test_configure_logging_default_level(self): + """Test 1: Default call sets root logger to WARNING.""" + from docksec.utils import configure_logging + + configure_logging() + root = logging.getLogger() + assert root.level == logging.WARNING + + def test_configure_logging_verbose_level(self): + """Test 2: verbose=True sets root logger to INFO.""" + from docksec.utils import configure_logging + + configure_logging(verbose=True) + root = logging.getLogger() + assert root.level == logging.INFO + + def test_configure_logging_debug_level(self): + """Test 3: debug=True sets root logger to DEBUG.""" + from docksec.utils import configure_logging + + configure_logging(debug=True) + root = logging.getLogger() + assert root.level == logging.DEBUG + + def test_configure_logging_debug_implies_verbose(self): + """Test 4: debug=True should produce the most permissive level (DEBUG).""" + from docksec.utils import configure_logging + + configure_logging(debug=True) + root = logging.getLogger() + # DEBUG (10) is more permissive than INFO (20) and WARNING (30) + assert root.level == logging.DEBUG + assert root.level < logging.INFO + + def test_configure_logging_file_handler(self, tmp_path): + """Test 5: log_file parameter creates a file handler and writes to disk.""" + from docksec.utils import configure_logging + + log_file = tmp_path / "test.log" + configure_logging(verbose=True, log_file=str(log_file)) + + # Log a message so the file gets written to + test_logger = logging.getLogger("test_file_handler") + test_logger.info("Test log message") + + assert log_file.exists() + content = log_file.read_text() + assert "Test log message" in content + + def test_no_print_calls_in_library_code(self): + """Test 6: Verify zero print() calls remain in library code. + + Uses AST parsing to scan docker_scanner.py (excluding the main() + entrypoint), score_calculator.py, and report_generator.py. + """ + base_dir = os.path.join(os.path.dirname(__file__), "..", "docksec") + files_to_check = [ + "score_calculator.py", + "report_generator.py", + ] + + violations = [] + + for filename in files_to_check: + filepath = os.path.join(base_dir, filename) + with open(filepath, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=filepath) + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "print": + violations.append( + f"{filename}:{node.lineno} - print() call found" + ) + + # For docker_scanner.py, exclude the top-level main() function. + # ast.walk visits ALL descendants, so we need to manually skip + # the body of the main() function. + docker_scanner_path = os.path.join(base_dir, "docker_scanner.py") + with open(docker_scanner_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=docker_scanner_path) + + # Collect line ranges for the top-level main() function to exclude + main_func_lines = set() + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.FunctionDef) and node.name == "main": + # Mark all lines in main() as excluded + for child in ast.walk(node): + if hasattr(child, "lineno"): + main_func_lines.add(child.lineno) + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "print": + if node.lineno not in main_func_lines: + violations.append( + f"docker_scanner.py:{node.lineno} - print() call found" + ) + + assert violations == [], ( + f"Found {len(violations)} print() call(s) in library code:\n" + + "\n".join(violations) + )