Skip to content

Commit 15090e3

Browse files
devatsecureclaude
andcommitted
test: Add 11 test files covering 470+ tests for previously untested modules
New test coverage for: - chain_visualizer (33 tests, 85% coverage) - disclosure_generator (38 tests, 82%) - nuclei_template_scanner (33 tests, 95%) - sign_release (54 tests, 70%) - reachability_analyzer (65 tests) - risk_scorer (98 tests) - noise_scorer (47 tests) - scanner_registry (48 tests) - threat_intel_enricher, trivy_scanner, zap_baseline_scanner Documents known bugs: noise_scorer foundation_sec attr mismatch, reachability_analyzer ZeroDivisionError on empty VULN list, zap_baseline PHP regex limitation (xfail) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bcfa09e commit 15090e3

11 files changed

+7135
-0
lines changed

tests/test_chain_visualizer.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""Tests for the Chain Visualizer module.
2+
3+
Covers ChainVisualizer: initialization, markdown report generation,
4+
console output, ASCII graph, JSON summary, and edge cases.
5+
6+
All file I/O is mocked or uses tmp_path.
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
import pytest
14+
15+
from scripts.chain_visualizer import ChainVisualizer
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Sample data
20+
# ---------------------------------------------------------------------------
21+
22+
def _make_chain(
23+
risk_score=8.5,
24+
exploitability="high",
25+
complexity="low",
26+
chain_id="chain-001",
27+
chain_length=2,
28+
final_impact="Full system compromise",
29+
):
30+
return {
31+
"chain_id": chain_id,
32+
"risk_score": risk_score,
33+
"exploitability": exploitability,
34+
"complexity": complexity,
35+
"chain_length": chain_length,
36+
"base_risk": 5.0,
37+
"amplification_factor": risk_score / 5.0,
38+
"estimated_exploit_time": "2 hours",
39+
"final_impact": final_impact,
40+
"mitigation_priority": 8,
41+
"vulnerabilities": [
42+
{
43+
"category": "sql_injection",
44+
"severity": "critical",
45+
"file_path": "app/models.py",
46+
"title": "SQL Injection in user query",
47+
},
48+
{
49+
"category": "privilege_escalation",
50+
"severity": "high",
51+
"file_path": "app/auth.py",
52+
"title": "Privilege escalation via role bypass",
53+
},
54+
],
55+
}
56+
57+
58+
def _make_chains_data(n_chains=3):
59+
chains = [_make_chain(chain_id=f"chain-{i:03d}", risk_score=9.0 - i) for i in range(n_chains)]
60+
return {
61+
"timestamp": "2026-02-16T12:00:00Z",
62+
"duration_seconds": 42.5,
63+
"total_vulnerabilities": 15,
64+
"total_chains": n_chains,
65+
"statistics": {
66+
"critical_chains": 1,
67+
"high_chains": 2,
68+
"avg_chain_length": 2.3,
69+
"avg_risk_score": 7.5,
70+
"max_risk_score": 9.0,
71+
"by_exploitability": {"high": 2, "medium": 1},
72+
},
73+
"chains": chains,
74+
}
75+
76+
77+
@pytest.fixture
78+
def visualizer():
79+
return ChainVisualizer()
80+
81+
82+
@pytest.fixture
83+
def chains_data():
84+
return _make_chains_data()
85+
86+
87+
# ---------------------------------------------------------------------------
88+
# 1. Initialisation
89+
# ---------------------------------------------------------------------------
90+
91+
class TestInit:
92+
def test_color_map_populated(self, visualizer):
93+
assert "critical" in visualizer.color_map
94+
assert "reset" in visualizer.color_map
95+
assert len(visualizer.color_map) == 6
96+
97+
def test_color_map_has_ansi_codes(self, visualizer):
98+
for key, value in visualizer.color_map.items():
99+
assert value.startswith("\033["), f"Missing ANSI code for {key}"
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# 2. generate_markdown_report
104+
# ---------------------------------------------------------------------------
105+
106+
class TestGenerateMarkdownReport:
107+
def test_creates_file(self, visualizer, chains_data, tmp_path):
108+
out = str(tmp_path / "report.md")
109+
visualizer.generate_markdown_report(chains_data, out)
110+
assert Path(out).exists()
111+
112+
def test_report_contains_header(self, visualizer, chains_data, tmp_path):
113+
out = str(tmp_path / "report.md")
114+
visualizer.generate_markdown_report(chains_data, out)
115+
content = Path(out).read_text()
116+
assert "Vulnerability Chaining Analysis Report" in content
117+
118+
def test_report_contains_statistics(self, visualizer, chains_data, tmp_path):
119+
out = str(tmp_path / "report.md")
120+
visualizer.generate_markdown_report(chains_data, out)
121+
content = Path(out).read_text()
122+
assert "Total Vulnerabilities Analyzed" in content
123+
assert str(chains_data["total_vulnerabilities"]) in content
124+
125+
def test_report_contains_chain_details(self, visualizer, chains_data, tmp_path):
126+
out = str(tmp_path / "report.md")
127+
visualizer.generate_markdown_report(chains_data, out)
128+
content = Path(out).read_text()
129+
assert "Chain #1" in content
130+
assert "Attack Scenario" in content
131+
132+
def test_creates_parent_dirs(self, visualizer, chains_data, tmp_path):
133+
out = str(tmp_path / "deep" / "nested" / "report.md")
134+
visualizer.generate_markdown_report(chains_data, out)
135+
assert Path(out).exists()
136+
137+
def test_limits_to_top_10_chains(self, visualizer, tmp_path):
138+
data = _make_chains_data(n_chains=15)
139+
out = str(tmp_path / "report.md")
140+
visualizer.generate_markdown_report(data, out)
141+
content = Path(out).read_text()
142+
assert "Chain #10" in content
143+
assert "Chain #11" not in content
144+
145+
146+
# ---------------------------------------------------------------------------
147+
# 3. print_console_report
148+
# ---------------------------------------------------------------------------
149+
150+
class TestPrintConsoleReport:
151+
def test_prints_without_error(self, visualizer, chains_data, capsys):
152+
visualizer.print_console_report(chains_data, max_chains=2)
153+
captured = capsys.readouterr()
154+
assert "VULNERABILITY CHAINING ANALYSIS REPORT" in captured.out
155+
156+
def test_respects_max_chains(self, visualizer, chains_data, capsys):
157+
visualizer.print_console_report(chains_data, max_chains=1)
158+
captured = capsys.readouterr()
159+
assert "Chain #1" in captured.out
160+
# Chain #2 should not appear
161+
assert "Chain #2" not in captured.out
162+
163+
def test_shows_statistics(self, visualizer, chains_data, capsys):
164+
visualizer.print_console_report(chains_data)
165+
captured = capsys.readouterr()
166+
assert "Total Vulnerabilities" in captured.out
167+
assert "Attack Chains Found" in captured.out
168+
169+
170+
# ---------------------------------------------------------------------------
171+
# 4. _print_chain
172+
# ---------------------------------------------------------------------------
173+
174+
class TestPrintChain:
175+
def test_prints_chain_info(self, visualizer, capsys):
176+
chain = _make_chain()
177+
visualizer._print_chain(1, chain)
178+
captured = capsys.readouterr()
179+
assert "Chain #1" in captured.out
180+
assert "Full system compromise" in captured.out
181+
182+
def test_chain_without_final_impact(self, visualizer, capsys):
183+
chain = _make_chain()
184+
del chain["final_impact"]
185+
visualizer._print_chain(1, chain)
186+
captured = capsys.readouterr()
187+
assert "Chain #1" in captured.out
188+
189+
190+
# ---------------------------------------------------------------------------
191+
# 5. Color helpers
192+
# ---------------------------------------------------------------------------
193+
194+
class TestColorHelpers:
195+
def test_color_wraps_text(self, visualizer):
196+
result = visualizer._color("critical", "ALERT")
197+
assert "ALERT" in result
198+
assert "\033[" in result
199+
200+
def test_color_unknown_severity(self, visualizer):
201+
result = visualizer._color("unknown", "text")
202+
assert "text" in result
203+
204+
def test_get_risk_color_critical(self, visualizer):
205+
assert visualizer._get_risk_color(9.5) == visualizer.color_map["critical"]
206+
207+
def test_get_risk_color_high(self, visualizer):
208+
assert visualizer._get_risk_color(7.5) == visualizer.color_map["high"]
209+
210+
def test_get_risk_color_medium(self, visualizer):
211+
assert visualizer._get_risk_color(6.0) == visualizer.color_map["medium"]
212+
213+
def test_get_risk_color_low(self, visualizer):
214+
assert visualizer._get_risk_color(3.0) == visualizer.color_map["low"]
215+
216+
def test_get_severity_color(self, visualizer):
217+
assert visualizer._get_severity_color("critical") == visualizer.color_map["critical"]
218+
219+
def test_get_severity_color_unknown(self, visualizer):
220+
assert visualizer._get_severity_color("unknown") == visualizer.color_map["reset"]
221+
222+
223+
# ---------------------------------------------------------------------------
224+
# 6. generate_ascii_graph
225+
# ---------------------------------------------------------------------------
226+
227+
class TestGenerateAsciiGraph:
228+
def test_returns_string(self, visualizer):
229+
chain = _make_chain()
230+
result = visualizer.generate_ascii_graph(chain)
231+
assert isinstance(result, str)
232+
233+
def test_contains_severity(self, visualizer):
234+
chain = _make_chain()
235+
result = visualizer.generate_ascii_graph(chain)
236+
assert "[CRITICAL]" in result
237+
assert "[HIGH]" in result
238+
239+
def test_contains_final_impact(self, visualizer):
240+
chain = _make_chain()
241+
result = visualizer.generate_ascii_graph(chain)
242+
assert "Full system compromise" in result
243+
244+
def test_default_impact_when_missing(self, visualizer):
245+
chain = _make_chain()
246+
del chain["final_impact"]
247+
result = visualizer.generate_ascii_graph(chain)
248+
assert "High Impact" in result
249+
250+
def test_contains_arrows_for_multi_step(self, visualizer):
251+
chain = _make_chain()
252+
result = visualizer.generate_ascii_graph(chain)
253+
# Should contain down arrow between steps
254+
assert "\u25bc" in result # ▼ character
255+
256+
257+
# ---------------------------------------------------------------------------
258+
# 7. generate_json_summary
259+
# ---------------------------------------------------------------------------
260+
261+
class TestGenerateJsonSummary:
262+
def test_creates_valid_json(self, visualizer, chains_data, tmp_path):
263+
out = str(tmp_path / "summary.json")
264+
visualizer.generate_json_summary(chains_data, out)
265+
with open(out) as f:
266+
data = json.load(f)
267+
assert "metadata" in data
268+
assert "summary" in data
269+
assert "top_chains" in data
270+
271+
def test_summary_fields(self, visualizer, chains_data, tmp_path):
272+
out = str(tmp_path / "summary.json")
273+
visualizer.generate_json_summary(chains_data, out)
274+
with open(out) as f:
275+
data = json.load(f)
276+
assert data["summary"]["total_vulnerabilities"] == 15
277+
assert data["summary"]["total_chains"] == 3
278+
279+
def test_limits_to_10_chains(self, visualizer, tmp_path):
280+
data = _make_chains_data(n_chains=15)
281+
out = str(tmp_path / "summary.json")
282+
visualizer.generate_json_summary(data, out)
283+
with open(out) as f:
284+
parsed = json.load(f)
285+
assert len(parsed["top_chains"]) == 10
286+
287+
def test_creates_parent_dirs(self, visualizer, chains_data, tmp_path):
288+
out = str(tmp_path / "a" / "b" / "summary.json")
289+
visualizer.generate_json_summary(chains_data, out)
290+
assert Path(out).exists()
291+
292+
293+
# ---------------------------------------------------------------------------
294+
# 8. Edge cases
295+
# ---------------------------------------------------------------------------
296+
297+
class TestEdgeCases:
298+
def test_empty_chains(self, visualizer, tmp_path):
299+
data = _make_chains_data(n_chains=0)
300+
out = str(tmp_path / "empty.md")
301+
visualizer.generate_markdown_report(data, out)
302+
assert Path(out).exists()
303+
304+
def test_chain_with_no_estimated_time(self, visualizer, capsys):
305+
chain = _make_chain()
306+
del chain["estimated_exploit_time"]
307+
visualizer._print_chain(1, chain)
308+
captured = capsys.readouterr()
309+
assert "Unknown" in captured.out
310+
311+
def test_json_summary_preserves_statistics(self, visualizer, chains_data, tmp_path):
312+
out = str(tmp_path / "summary.json")
313+
visualizer.generate_json_summary(chains_data, out)
314+
with open(out) as f:
315+
data = json.load(f)
316+
assert data["statistics"] == chains_data["statistics"]

0 commit comments

Comments
 (0)