From f394e268da7be12947299d1a04905e9bd991fd4f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 21 Oct 2025 21:00:48 -0500 Subject: [PATCH] template restructure --- app/main.py | 40 ++-- app/reporting_jinja.py | 1 - app/templates/report_dark.html.j2 | 177 ++++++++++++++++ .../{report.html.j2 => report_light.html.j2} | 47 ++++- app/utils/models.py | 72 ++++++- app/utils/scan_config_loader.py | 1 + data/expected.json | 5 - data/output/corp-wan.html | 190 ++++++++++++++++++ data/report.html | 64 ------ data/rw-expected.json | 34 ---- data/scan_targets/corp-wan.yaml | 7 +- 11 files changed, 512 insertions(+), 126 deletions(-) create mode 100644 app/templates/report_dark.html.j2 rename app/templates/{report.html.j2 => report_light.html.j2} (70%) delete mode 100644 data/expected.json create mode 100644 data/output/corp-wan.html delete mode 100644 data/report.html delete mode 100644 data/rw-expected.json diff --git a/app/main.py b/app/main.py index 3d5ea80..e3a02cb 100644 --- a/app/main.py +++ b/app/main.py @@ -3,8 +3,6 @@ import logging logging.basicConfig(level=logging.INFO) # TODO: -# LOGGING -# REPORT - use the scan config names for the names of the report and file # REPORT - make darkmode # TLS SCANNING # TLS Version PROBE @@ -12,13 +10,14 @@ logging.basicConfig(level=logging.INFO) import time from pathlib import Path -from typing import Dict, List, Set - +from dataclasses import asdict +from ipaddress import ip_address +from typing import Any, Dict, List, Set from utils.scan_config_loader import ScanConfigRepository, ScanConfigFile from utils.schedule_manager import ScanScheduler from utils.scanner import nmap_scanner -from utils.models import HostResult +from utils.models import HostResult, HostReport, GroupedReports from reporting_jinja import write_html_report_jinja from utils.settings import get_settings @@ -29,8 +28,6 @@ logger = logging.getLogger(__file__) utils = get_common_utils() settings = get_settings() -HTML_REPORT_FILE = Path() / "data" / "report.html" - def results_to_open_sets( results: List[HostResult], count_as_open: Set[str] = frozenset({"open", "open|filtered"})) -> Dict[str, Dict[str, Set[int]]]: @@ -125,16 +122,35 @@ def build_reports( def run_repo_scan(scan_config:ScanConfigFile): logger.info(f"Starting scan for {scan_config.name}") - logger.info("Options: udp=%s tls_sec=%s tls_exp=%s", - scan_config.scan_options.udp_scan, - scan_config.scan_options.tls_security_scan, - scan_config.scan_options.tls_exp_check) + logger.info(f"Options: udp={scan_config.scan_options.udp_scan} tls_sec={scan_config.scan_options.tls_security_scan} tls_exp={scan_config.scan_options.tls_exp_check}",) + logger.info(f"Reporting Options: Dark Mode: {scan_config.reporting.dark_mode}") logger.info("Targets: %d hosts", len(scan_config.scan_targets)) + + # tack the filename on the end of our data path + file_out_path = Path() / "data" / "output" / scan_config.reporting.report_filename + + LIGHT_TEMPLATE = "report_light.html.j2" + DARK_TEMPLATE = "report_dark.html.j2" + + template = LIGHT_TEMPLATE + + if scan_config.reporting.dark_mode: + template = DARK_TEMPLATE + + logger.info(f"Reporting Template Set to: {template}") + scanner = nmap_scanner(scan_config) scan_results = scanner.scan_targets() discovered_sets = results_to_open_sets(scan_results, count_as_open={"open", "open|filtered"}) reports = build_reports(scan_config, discovered_sets) - write_html_report_jinja(reports=reports,host_results=scan_results,out_path=HTML_REPORT_FILE,title="Compliance Report",only_issues=True) + + # build the HTML report + write_html_report_jinja(reports=reports, + host_results=scan_results, + out_path=file_out_path, + title=scan_config.reporting.report_name, + template_name=template, + only_issues=True) scanner.cleanup() def main(): diff --git a/app/reporting_jinja.py b/app/reporting_jinja.py index e674f0d..1ee7f50 100644 --- a/app/reporting_jinja.py +++ b/app/reporting_jinja.py @@ -78,7 +78,6 @@ def render_html_report_jinja( ) return html - def write_html_report_jinja( reports: Dict[str, Dict[str, List[int]]], host_results: List[HostResult], diff --git a/app/templates/report_dark.html.j2 b/app/templates/report_dark.html.j2 new file mode 100644 index 0000000..242e030 --- /dev/null +++ b/app/templates/report_dark.html.j2 @@ -0,0 +1,177 @@ + + + + + {{ title }} + + {# Dark mode variant of compliance report. Filename: report_dark.html.j2 #} + + +
+ + {# ===== Title Card ===== #} + + + + +
+
{{ title }}
+
Generated: {{ generated }}
+
+ + + {# ===== Summary Bar ===== #} + + + + +
+ Total hosts: + {{ total_hosts }}   + Matching expected: + {{ ok_hosts }}   + With issues: + {{ hosts_with_issues|length }} +
+ + + {% if not only_issues and ok_hosts == total_hosts and total_hosts > 0 %} +
+ All hosts matched expected ports. +
+ {% endif %} + + {% if only_issues and hosts_with_issues|length == 0 %} +
+ No hosts with issues found. ✅ +
+ {% endif %} + + {# ===== Host Sections ===== #} + {% for ip_key, r in reports|dictsort %} + {% set has_issues = r.unexpected_tcp or r.missing_tcp or r.unexpected_udp or r.missing_udp %} + {% set hr = host_results_by_ip.get(ip_key) %} + {% set header_title = ip_key ~ ((' (' ~ hr.host ~ ')') if hr and hr.host else '') %} + + + + + + + {% if has_issues %} + + + + + {% macro delta_row(label, ports) -%} + + + + {%- endmacro %} + + {{ delta_row('Unexpected TCP open ports', r.unexpected_tcp) }} + {{ delta_row('Expected TCP ports not seen', r.missing_tcp) }} + {{ delta_row('Unexpected UDP open ports', r.unexpected_udp) }} + {{ delta_row('Expected UDP ports not seen', r.missing_udp) }} + + + + + + + + + + + + + {% if hr and hr.ports %} + {# Track visible rows after filtering (e.g., skip closed UDP) #} + {% set ns = namespace(visible_rows=0) %} + + {% for p in hr.ports %} + {# --- Normalize helpers --- #} + {% set proto = (p.protocol|string|lower) %} + {% set state = (p.state|string|lower) %} + + {# --- Skip rule: hide UDP rows that are "closed" (substring match) --- #} + {% set skip_row = (proto == 'udp' and ('closed' in state)) %} + + {# --- Expectation labeling --- #} + {% set is_tcp = (proto == 'tcp') %} + {% set is_udp = (proto == 'udp') %} + {% set is_open = ('open' in state) %} + {% set is_issue = (is_open and ( + (is_tcp and (p.port in (r.unexpected_tcp or []))) or + (is_udp and (p.port in (r.unexpected_udp or []))) + )) %} + {% set expectation_badge = ( + badge('Issue', '#ef4444') if is_issue + else (badge('Expected', '#16a34a') if is_open else '') + ) %} + + {% if not skip_row %} + {% set ns.visible_rows = ns.visible_rows + 1 %} + + + + + + + + {% endif %} + {% endfor %} + + {% if ns.visible_rows == 0 %} + + + + {% endif %} + {% else %} + + + + {% endif %} + {% else %} + {# Host has no issues #} + + + + {% endif %} +
+ + {{ header_title }} + {% if has_issues %} + {{ badge('ISSUES', '#ef4444') }} + {% else %} + {{ badge('OK', '#16a34a') }} + {% endif %} +
+ {{ badge('ISSUES', '#ef4444') }} +
+ {{ label }}: + {{ fmt_ports(ports) }} +
+
Discovered Ports
+
ProtocolPortStateServiceExpectation
{{ pill_proto(p.protocol) }}{{ p.port }}{{ pill_state(p.state) }}{{ p.service or '-' }}{{ expectation_badge | safe }}
+ No per-port details to display after filtering closed UDP results. +
+ No per-port details available for this host. +
+ {{ badge('OK', '#16a34a') }}   Matches expected ports. +
+ {% endfor %} + +
+ Report generated by mass-scan-v2 • {{ generated }} +
+
+ + diff --git a/app/templates/report.html.j2 b/app/templates/report_light.html.j2 similarity index 70% rename from app/templates/report.html.j2 rename to app/templates/report_light.html.j2 index d184272..736ce22 100644 --- a/app/templates/report.html.j2 +++ b/app/templates/report_light.html.j2 @@ -81,24 +81,61 @@ Port State Service + Expectation {% if hr and hr.ports %} - {% for p in hr.ports %} + {# Track whether we rendered any visible rows after filtering #} + {% set ns = namespace(visible_rows=0) %} + + {% for p in hr.ports %} + {# Normalize helpers #} + {% set proto = (p.protocol|string|lower) %} + {% set state = (p.state|string|lower) %} + + {# Skip rule: hide UDP rows that are 'closed' #} + {% set skip_row = (proto == 'udp' and ('closed' in state)) %} + + {# Compute expectation labeling (only matters for displayed rows) #} + {% set is_tcp = (proto == 'tcp') %} + {% set is_udp = (proto == 'udp') %} + {% set is_open = ('open' in state) %} + {% set is_issue = (is_open and ( + (is_tcp and (p.port in (r.unexpected_tcp or []))) or + (is_udp and (p.port in (r.unexpected_udp or []))) + )) %} + {% set expectation_badge = ( + badge('Issue', '#ef4444') if is_issue + else (badge('Expected', '#16a34a') if is_open else '') + ) %} + + {% if not skip_row %} + {% set ns.visible_rows = ns.visible_rows + 1 %} {{ pill_proto(p.protocol) }} {{ p.port }} {{ pill_state(p.state) }} {{ p.service or '-' }} + {{ expectation_badge | safe }} - {% endfor %} - {% else %} + {% endif %} + {% endfor %} + + {# If everything was filtered out, show a friendly note #} + {% if ns.visible_rows == 0 %} - - No per-port details available for this host. + + No per-port details to display after filtering closed UDP results. {% endif %} + {% else %} + + + No per-port details available for this host. + + + {% endif %} {% else %} {# Host has no issues #} diff --git a/app/utils/models.py b/app/utils/models.py index 55669b8..0040d48 100644 --- a/app/utils/models.py +++ b/app/utils/models.py @@ -1,7 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import List, Optional - +from dataclasses import dataclass, field, asdict +from typing import Dict, List, Set, Optional, Any +from ipaddress import ip_address @dataclass class PortFinding: @@ -16,7 +16,6 @@ class PortFinding: state: str service: Optional[str] = None - @dataclass class HostResult: """ @@ -28,3 +27,68 @@ class HostResult: address: str host: Optional[str] = None ports: List[PortFinding] = field(default_factory=list) + +@dataclass +class HostReport: + """ + Delta result for a single host. + """ + ip: str + unexpected_tcp: List[int] + missing_tcp: List[int] + unexpected_udp: List[int] + missing_udp: List[int] + + def has_issues(self) -> bool: + """ + Returns True if any delta list is non-empty. + """ + if self.unexpected_tcp: + return True + if self.missing_tcp: + return True + if self.unexpected_udp: + return True + if self.missing_udp: + return True + return False + + def to_dict(self) -> Dict[str, Any]: + """ + Convert to a plain dict for JSON/Jinja contexts. + """ + return asdict(self) + +@dataclass +class GroupedReports: + """ + Final, template-friendly structure: + - issues: list of HostReport with any deltas (sorted by IP) + - expected: list of HostReport with no deltas (sorted by IP) + - by_ip: mapping for random access if needed + """ + issues: List[HostReport] + expected: List[HostReport] + by_ip: Dict[str, HostReport] + + def to_context(self) -> Dict[str, Any]: + """ + Produce plain-dict context for Jinja render() if you prefer dicts. + """ + issues_dicts: List[Dict[str, Any]] = [] + for hr in self.issues: + issues_dicts.append(hr.to_dict()) + + expected_dicts: List[Dict[str, Any]] = [] + for hr in self.expected: + expected_dicts.append(hr.to_dict()) + + by_ip_dict: Dict[str, Dict[str, Any]] = {} + for ip, hr in self.by_ip.items(): + by_ip_dict[ip] = hr.to_dict() + + return { + "issues": issues_dicts, + "expected": expected_dicts, + "by_ip": by_ip_dict, + } \ No newline at end of file diff --git a/app/utils/scan_config_loader.py b/app/utils/scan_config_loader.py index 19d6326..78efbe5 100644 --- a/app/utils/scan_config_loader.py +++ b/app/utils/scan_config_loader.py @@ -41,6 +41,7 @@ class Reporting: report_name: str = "Scan Report" report_filename: str = "report.html" full_details: bool = False + dark_mode: bool = True email_to: List[str] = field(default_factory=list) email_cc: List[str] = field(default_factory=list) diff --git a/data/expected.json b/data/expected.json deleted file mode 100644 index 5b3781d..0000000 --- a/data/expected.json +++ /dev/null @@ -1,5 +0,0 @@ -[ -{"ip": "10.10.99.6", "expected_tcp": [22,222,3000], "expected_udp": []}, -{"ip": "10.10.99.2", "expected_tcp": [22,222,3000], "expected_udp": []}, -{"ip": "10.10.99.10", "expected_tcp": [22,80,443], "expected_udp": []} -] \ No newline at end of file diff --git a/data/output/corp-wan.html b/data/output/corp-wan.html new file mode 100644 index 0000000..7ec1e13 --- /dev/null +++ b/data/output/corp-wan.html @@ -0,0 +1,190 @@ + + + + + Corporate WAN Perimeter + + + +
+ + + + + +
+
Corporate WAN Perimeter
+
Generated: 2025-10-22 01:45:32
+
+ + + + + + +
+ Total hosts: + 3   + Matching expected: + 2   + With issues: + 1 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 10.10.20.12 + ISSUES +
+ ISSUES +
+ Unexpected TCP open ports: + 3128 +
+ Expected TCP ports not seen: + none +
+ Unexpected UDP open ports: + none +
+ Expected UDP ports not seen: + none +
+
Discovered Ports
+
ProtocolPortStateServiceExpectation
tcp22opensshExpected
tcp111openrpcbindExpected
tcp3128opensquid-httpIssue
tcp8006openwpl-analyticsExpected
udp111openrpcbindExpected
+ + + + + + + + + +
+ + 10.10.20.4 + OK +
+ OK   Matches expected ports. +
+ + + + + + + + + +
+ + 10.10.20.5 + OK +
+ OK   Matches expected ports. +
+ +
+ Report generated by mass-scan-v2 • 2025-10-22 01:45:32 +
+
+ + \ No newline at end of file diff --git a/data/report.html b/data/report.html deleted file mode 100644 index 2f36b93..0000000 --- a/data/report.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - Compliance Report - - - -
- - - - -
-
Compliance Report
-
Generated: 2025-10-17 21:42:08
-
- - - - - -
- Total hosts: 2  - Matching expected: 2  - With issues: 0 -
- - -
- No hosts with issues found. ✅ -
- - - - - - - - - - -
- 10.10.20.4 OK
- OK   Matches expected ports. -
- - - - - - - - - -
- 10.10.20.5 OK
- OK   Matches expected ports. -
- - -
- Report generated by mass-scan-v2 • 2025-10-17 21:42:08 -
\ No newline at end of file diff --git a/data/rw-expected.json b/data/rw-expected.json deleted file mode 100644 index e33db70..0000000 --- a/data/rw-expected.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - {"ip": "81.246.102.192", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.193", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.194", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.195", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.196", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.197", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.198", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.199", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.200", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.201", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.202", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.203", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.204", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.205", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.206", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.207", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.208", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.209", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.210", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.211", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.212", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.213", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.214", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.215", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.216", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.217", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.218", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.219", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.220", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.221", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.222", "expected_tcp": [], "expected_udp": []}, - {"ip": "81.246.102.223", "expected_tcp": [], "expected_udp": []} -] \ No newline at end of file diff --git a/data/scan_targets/corp-wan.yaml b/data/scan_targets/corp-wan.yaml index 5660833..104971c 100644 --- a/data/scan_targets/corp-wan.yaml +++ b/data/scan_targets/corp-wan.yaml @@ -8,6 +8,7 @@ scan_options: reporting: report_name: Corporate WAN Perimeter report_filename: corp-wan.html + dark_mode: true full_details: true email_to: soc@example.com # single string is fine; or a list email_cc: [] # explicitly none @@ -19,4 +20,8 @@ scan_targets: - ip: 10.10.20.5 expected_tcp: [22, 53, 80] - expected_udp: [53] \ No newline at end of file + expected_udp: [53] + + - ip: 10.10.20.12 + expected_tcp: [22, 111, 8006] + expected_udp: [111] \ No newline at end of file