Skip to content

Commit 7ca0896

Browse files
feat: integrate SonarQube and bump version to 2.0.0
- Add SonarQube service to docker-compose.yml - Implement SonarQubeManager for handling scans - Update Engine to include SonarQube integration - Add SonarQube configuration in config.py - Bump version to 2.0.0 in pyproject.toml - Add tests for SonarQube integration
1 parent 518f46c commit 7ca0896

File tree

6 files changed

+302
-11
lines changed

6 files changed

+302
-11
lines changed

docker/docker-compose.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,21 @@ services:
2222
- mobsf-data:/root/.MobSF
2323
restart: unless-stopped
2424

25+
sonarqube:
26+
image: sonarqube:community
27+
container_name: secuscan-sonarqube
28+
ports:
29+
- "9000:9000"
30+
environment:
31+
- SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
32+
volumes:
33+
- sonarqube_data:/opt/sonarqube/data
34+
- sonarqube_extensions:/opt/sonarqube/extensions
35+
- sonarqube_logs:/opt/sonarqube/logs
36+
restart: unless-stopped
37+
2538
volumes:
2639
mobsf-data:
40+
sonarqube_data:
41+
sonarqube_extensions:
42+
sonarqube_logs:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "secuscan"
7-
version = "1.1.3"
7+
version = "2.0.0"
88
description = "A dual-platform static vulnerability scanner for Android and Web applications."
99
readme = "README.md"
1010
authors = [

secuscan/core/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ def __init__(self):
2222
self.mobsf_api_key = None
2323
self.output_dir = "reports"
2424

25+
# SonarQube settings
26+
self.sonar_url = "http://localhost:9000"
27+
self.sonar_login = "admin"
28+
self.sonar_password = "Secuscan@2026"
29+
self.sonar_token = "squ_62e4d6fcedaa5e177e545cbb30eaf5e48259fe0f"
30+
2531
self.load_config()
2632
self.load_from_env()
2733
self._initialized = True
@@ -42,6 +48,13 @@ def load_config(self):
4248

4349
reports = yaml_config.get('reports', {})
4450
self.output_dir = reports.get('output_dir', self.output_dir)
51+
52+
# SonarQube config
53+
sonar = yaml_config.get('sonarqube', {})
54+
self.sonar_url = sonar.get('url', self.sonar_url)
55+
self.sonar_login = sonar.get('login', self.sonar_login)
56+
self.sonar_password = sonar.get('password', self.sonar_password)
57+
self.sonar_token = sonar.get('token', self.sonar_token)
4558
except Exception as e:
4659
print(f"Warning: Failed to load config file: {e}")
4760
# raise ConfigError(f"Failed to load config file: {e}") from e
@@ -51,5 +64,9 @@ def load_from_env(self):
5164
self.debug = os.getenv("SECUSCAN_DEBUG", "false").lower() == "true"
5265
self.mobsf_url = os.getenv("MOBSF_URL", self.mobsf_url)
5366
self.mobsf_api_key = os.getenv("MOBSF_API_KEY", self.mobsf_api_key)
67+
self.sonar_url = os.getenv("SONAR_URL", self.sonar_url)
68+
self.sonar_login = os.getenv("SONAR_LOGIN", self.sonar_login)
69+
self.sonar_password = os.getenv("SONAR_PASSWORD", self.sonar_password)
70+
self.sonar_token = os.getenv("SONAR_TOKEN", self.sonar_token)
5471

5572
config = Config()

secuscan/core/engine.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from secuscan.core.detection import detect_project_type, ProjectType
77
from secuscan.core.docker_manager import DockerManager
8+
from secuscan.core.sonarqube_manager import SonarQubeManager
89
from secuscan.scanners.factory import ScannerFactory
910
from secuscan.scanners.secrets import SecretScanner
1011

@@ -16,6 +17,7 @@ class ScanEngine:
1617
def __init__(self, target: str):
1718
self.target = target
1819
self.docker_manager = DockerManager()
20+
self.sonarqube_manager = SonarQubeManager()
1921

2022
def start(self):
2123
"""Starts the vulnerability scan and returns findings."""
@@ -58,6 +60,17 @@ def start(self):
5860

5961
results = scanner.scan()
6062

63+
# Run SonarQube Scan (Global)
64+
if self.sonarqube_manager.is_available():
65+
with console.status("[bold green]Running SonarQube Analysis...[/bold green]"):
66+
try:
67+
self.sonarqube_manager.ensure_sonarqube()
68+
self.sonarqube_manager.run_scan(self.target)
69+
except Exception as e:
70+
console.print(f"[yellow]Warning: SonarQube scan failed: {e}[/yellow]")
71+
else:
72+
console.print("[dim]Docker not available - skipping SonarQube scan.[/dim]")
73+
6174
# Run Secret Scanner (Global)
6275
with console.status("[bold green]Scanning for Hardcoded Secrets...[/bold green]"):
6376
secret_scanner = SecretScanner(self.target)
@@ -66,14 +79,4 @@ def start(self):
6679
console.print(f"[bold red]Found {len(secret_results)} potential secrets![/bold red]")
6780
results.extend(secret_results)
6881

69-
# 4. Report Results (Handled by Caller/CLI via Reporter, or return results for CLI to handle)
70-
# Actually, refactoring plan said Engine should delegate.
71-
# But CLI needs to pass format options.
72-
# Better design: Engine returns results. CLI handles reporting.
73-
# However, to keep 'start()' API simple for now, let's accept format/output in start() or __init__.
74-
75-
# Let's return results here so CLI can decide what to do.
76-
# Wait, start() current logic does printing.
77-
# Let's change start() to return List[Vulnerability] and print nothing (or minimal).
78-
7982
return results

secuscan/core/sonarqube_manager.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""SonarQube Manager - Handles SonarQube container and scanning operations."""
2+
3+
import docker
4+
import time
5+
import requests
6+
import os
7+
from typing import Optional
8+
from rich.console import Console
9+
from secuscan.core.config import config
10+
11+
console = Console()
12+
13+
14+
class SonarQubeManager:
15+
"""Manages SonarQube container interactions and scanning."""
16+
17+
SONARQUBE_IMAGE = "sonarqube:community"
18+
SCANNER_IMAGE = "sonarsource/sonar-scanner-cli:latest"
19+
CONTAINER_NAME = "secuscan-sonarqube"
20+
21+
def __init__(self):
22+
try:
23+
self.client = docker.from_env()
24+
self._available = True
25+
except docker.errors.DockerException:
26+
self.client = None
27+
self._available = False
28+
29+
def is_available(self) -> bool:
30+
"""Checks if Docker daemon is running and accessible."""
31+
if not self._available:
32+
return False
33+
34+
try:
35+
self.client.ping()
36+
return True
37+
except docker.errors.DockerException:
38+
return False
39+
40+
def pull_image(self, image_name: str):
41+
"""Pulls a Docker image if not present."""
42+
if not self.is_available():
43+
return
44+
45+
try:
46+
self.client.images.get(image_name)
47+
console.print(f"[green]Image {image_name} found locally.[/green]")
48+
except docker.errors.ImageNotFound:
49+
console.print(f"[yellow]Pulling {image_name}...[/yellow]")
50+
with console.status(f"[bold green]Downloading {image_name}... This may take a while..."):
51+
self.client.images.pull(image_name)
52+
console.print("[green]Image pulled successfully.[/green]")
53+
54+
def is_container_running(self) -> bool:
55+
"""Checks if the SonarQube container is currently running."""
56+
if not self.is_available():
57+
return False
58+
59+
try:
60+
container = self.client.containers.get(self.CONTAINER_NAME)
61+
return container.status == "running"
62+
except docker.errors.NotFound:
63+
return False
64+
65+
def start_sonarqube(self):
66+
"""Starts the SonarQube container."""
67+
if not self.is_available():
68+
raise RuntimeError("Docker is not available.")
69+
70+
# Ensure image is pulled
71+
self.pull_image(self.SONARQUBE_IMAGE)
72+
73+
if self.is_container_running():
74+
console.print("[green]SonarQube container is already running.[/green]")
75+
return
76+
77+
console.print("[yellow]Starting SonarQube container...[/yellow]")
78+
try:
79+
# Check if stopped container exists and remove it
80+
try:
81+
old_container = self.client.containers.get(self.CONTAINER_NAME)
82+
old_container.remove(force=True)
83+
except docker.errors.NotFound:
84+
pass
85+
86+
self.client.containers.run(
87+
self.SONARQUBE_IMAGE,
88+
name=self.CONTAINER_NAME,
89+
ports={'9000/tcp': 9000},
90+
environment={"SONAR_ES_BOOTSTRAP_CHECKS_DISABLE": "true"},
91+
volumes={
92+
'sonarqube_data': {'bind': '/opt/sonarqube/data', 'mode': 'rw'},
93+
'sonarqube_extensions': {'bind': '/opt/sonarqube/extensions', 'mode': 'rw'},
94+
'sonarqube_logs': {'bind': '/opt/sonarqube/logs', 'mode': 'rw'}
95+
},
96+
detach=True
97+
)
98+
console.print("[green]SonarQube container started on port 9000.[/green]")
99+
self.wait_for_sonarqube()
100+
101+
except Exception as e:
102+
console.print(f"[bold red]Failed to start SonarQube container: {e}[/bold red]")
103+
raise
104+
105+
def wait_for_sonarqube(self, timeout: int = 180):
106+
"""Waits for SonarQube API to become available."""
107+
start_time = time.time()
108+
109+
with console.status("[bold green]Waiting for SonarQube to initialize (this may take 1-2 minutes)...") as status:
110+
while time.time() - start_time < timeout:
111+
try:
112+
response = requests.get(
113+
f"{config.sonar_url}/api/system/status",
114+
timeout=5
115+
)
116+
if response.status_code == 200:
117+
data = response.json()
118+
if data.get("status") == "UP":
119+
console.print("[green]SonarQube is ready![/green]")
120+
return
121+
except requests.exceptions.RequestException:
122+
pass
123+
time.sleep(3)
124+
125+
raise TimeoutError("SonarQube failed to start within the timeout period.")
126+
127+
def ensure_sonarqube(self):
128+
"""Ensures SonarQube is running and ready."""
129+
if not self.is_available():
130+
raise RuntimeError("Docker is not available.")
131+
132+
self.start_sonarqube()
133+
134+
def run_scan(self, target_dir: str):
135+
"""Runs the SonarQube scanner on the target directory."""
136+
if not self.is_available():
137+
raise RuntimeError("Docker is not available.")
138+
139+
target_dir = os.path.abspath(target_dir)
140+
self.pull_image(self.SCANNER_IMAGE)
141+
142+
console.print(f"[yellow]Running SonarQube Scan on {target_dir}...[/yellow]")
143+
144+
try:
145+
# Run sonar-scanner container linked to SonarQube
146+
self.client.containers.run(
147+
self.SCANNER_IMAGE,
148+
remove=True,
149+
volumes={target_dir: {'bind': '/usr/src', 'mode': 'rw'}},
150+
environment={
151+
'SONAR_HOST_URL': f'http://{self.CONTAINER_NAME}:9000',
152+
'SONAR_TOKEN': config.sonar_token,
153+
} if config.sonar_token else {
154+
'SONAR_HOST_URL': f'http://{self.CONTAINER_NAME}:9000',
155+
'SONAR_LOGIN': config.sonar_login,
156+
'SONAR_PASSWORD': config.sonar_password
157+
},
158+
links={self.CONTAINER_NAME: self.CONTAINER_NAME},
159+
command=['-Dsonar.projectKey=secuscan_project', '-Dsonar.sources=.', '-Dsonar.java.binaries=.']
160+
)
161+
console.print("[green]SonarQube Scan completed successfully.[/green]")
162+
console.print(f"Results available at: {config.sonar_url}/dashboard?id=secuscan_project")
163+
164+
except docker.errors.ContainerError as e:
165+
console.print(f"[bold red]SonarQube Scanner failed: {e}[/bold red]")
166+
except Exception as e:
167+
console.print(f"[bold red]Error running SonarQube scan: {e}[/bold red]")

tests/test_sonarqube.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for SonarQube manager."""
2+
3+
import pytest
4+
from unittest.mock import MagicMock, patch
5+
from secuscan.core.sonarqube_manager import SonarQubeManager
6+
import docker
7+
8+
9+
@pytest.fixture
10+
def mock_docker_client():
11+
"""Fixture to mock Docker client."""
12+
with patch('docker.from_env') as mock_env:
13+
mock_client = MagicMock()
14+
mock_env.return_value = mock_client
15+
yield mock_client
16+
17+
18+
def test_sonarqube_available(mock_docker_client):
19+
"""Test SonarQube manager when Docker is available."""
20+
manager = SonarQubeManager()
21+
assert manager.is_available() is True
22+
23+
24+
def test_sonarqube_not_available():
25+
"""Test SonarQube manager when Docker is not available."""
26+
with patch('docker.from_env', side_effect=docker.errors.DockerException):
27+
manager = SonarQubeManager()
28+
assert manager.is_available() is False
29+
30+
31+
def test_pull_image(mock_docker_client):
32+
"""Test image pulling when image is not found locally."""
33+
manager = SonarQubeManager()
34+
mock_docker_client.images.get.side_effect = docker.errors.ImageNotFound("msg")
35+
36+
manager.pull_image("test:image")
37+
38+
mock_docker_client.images.pull.assert_called_with("test:image")
39+
40+
41+
def test_is_container_running_true(mock_docker_client):
42+
"""Test container running check when container is running."""
43+
manager = SonarQubeManager()
44+
mock_docker_client.containers.get.return_value.status = "running"
45+
46+
assert manager.is_container_running() is True
47+
48+
49+
def test_is_container_running_false(mock_docker_client):
50+
"""Test container running check when container is not found."""
51+
manager = SonarQubeManager()
52+
mock_docker_client.containers.get.side_effect = docker.errors.NotFound("msg")
53+
54+
assert manager.is_container_running() is False
55+
56+
57+
def test_start_sonarqube_already_running(mock_docker_client):
58+
"""Test starting SonarQube when it's already running."""
59+
manager = SonarQubeManager()
60+
mock_docker_client.containers.get.return_value.status = "running"
61+
62+
manager.start_sonarqube()
63+
64+
mock_docker_client.containers.run.assert_not_called()
65+
66+
67+
def test_start_sonarqube_not_running(mock_docker_client):
68+
"""Test starting SonarQube when it's not running."""
69+
manager = SonarQubeManager()
70+
mock_docker_client.containers.get.side_effect = docker.errors.NotFound("msg")
71+
72+
with patch.object(manager, 'wait_for_sonarqube'):
73+
manager.start_sonarqube()
74+
75+
mock_docker_client.containers.run.assert_called_once()
76+
assert mock_docker_client.containers.run.call_args[1]['name'] == 'secuscan-sonarqube'
77+
78+
79+
def test_run_scan(mock_docker_client):
80+
"""Test running a SonarQube scan."""
81+
manager = SonarQubeManager()
82+
83+
manager.run_scan("/tmp/scan_target")
84+
85+
mock_docker_client.containers.run.assert_called()
86+
call_args = mock_docker_client.containers.run.call_args
87+
assert call_args[0][0] == manager.SCANNER_IMAGE
88+
assert "SONAR_HOST_URL" in call_args[1]['environment']

0 commit comments

Comments
 (0)