diff --git a/Dockerfile b/Dockerfile index e6c24ad..9deea75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive \ PYTHONUNBUFFERED=1 \ VIRTUAL_ENV=/opt/venv \ + LANG=C.UTF-8 LC_ALL=C.UTF-8 \ PATH="/opt/venv/bin:$PATH" # copy only requirements first so pip install can be cached @@ -13,9 +14,12 @@ COPY app/requirements.txt /app/requirements.txt RUN apt-get update && \ apt-get install -y --no-install-recommends \ python3 python3-venv python3-pip \ - nmap ca-certificates tzdata && \ + nmap ca-certificates tzdata wkhtmltopdf fonts-dejavu ca-certificates && \ rm -rf /var/lib/apt/lists/* +# Debug Print version of wkhtmltopdf +RUN wkhtmltopdf --version + # ---- Create & activate venv ---- RUN python3 -m venv $VIRTUAL_ENV diff --git a/app/main.py b/app/main.py index 204b066..d96e48c 100644 --- a/app/main.py +++ b/app/main.py @@ -45,8 +45,8 @@ def results_to_open_sets( out[hr.address] = {"tcp": tcp, "udp": udp} return out -# Build the "reports" dict (what the HTML renderer expects) -def build_reports( +# Build the grouped_reports (what the HTML renderer expects) +def build_grouped_reports( scan_config: "ScanConfigFile", discovered: Dict[str, Dict[str, Set[int]]], ) -> GroupedReports: @@ -149,34 +149,34 @@ def run_repo_scan(scan_config:ScanConfigFile): # 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 - if scan_config.reporting.full_details: show_only_issues = False else: show_only_issues = True - logger.info(f"Reporting Template Set to: {template}") + logger.info(f"Reporting Dark Mode set to: {scan_config.reporting.dark_mode}") logger.info(f"Reporting Only Issues: {show_only_issues}") 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) + grouped_reports = build_grouped_reports(scan_config, discovered_sets) # build the HTML report - write_html_report_jinja(reports=reports, + # write_html_report_jinja(grouped=grouped_reports, + # host_results=scan_results, + # out_path=file_out_path, + # title=scan_config.reporting.report_name, + # template_name=template, + # only_issues=show_only_issues) + + write_html_report_jinja(grouped=grouped_reports, host_results=scan_results, out_path=file_out_path, title=scan_config.reporting.report_name, - template_name=template, + dark_mode=scan_config.reporting.dark_mode, only_issues=show_only_issues) + scanner.cleanup() def main(): diff --git a/app/reporting_jinja.py b/app/reporting_jinja.py index b236728..aabe012 100644 --- a/app/reporting_jinja.py +++ b/app/reporting_jinja.py @@ -1,157 +1,207 @@ # utils/reporting_jinja.py from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime from html import escape +from ipaddress import ip_address from pathlib import Path -from typing import Any, Dict, List, Set, Union +from typing import Dict, List from jinja2 import Environment, FileSystemLoader, select_autoescape -from utils.models import HostResult, HostReport, GroupedReports # <-- add HostReport, GroupedReports -from ipaddress import ip_address +from utils.models import HostResult, HostReport, GroupedReports + + +# --------------------------- +# Helper render-time functions +# --------------------------- def fmt_ports(ports: List[int]) -> str: + """ + Render a simple, comma-separated list of port numbers, deduped and sorted. + """ if not ports: return "none" - return ", ".join(str(p) for p in sorted(set(int(x) for x in ports))) + unique_ports: List[int] = [] + seen: set[int] = set() + for x in ports: + p = int(x) + if p not in seen: + seen.add(p) + unique_ports.append(p) + + unique_ports.sort() + return ", ".join(str(p) for p in unique_ports) + + +def badge(text: str, kind: str = "") -> str: + """ + Return a class-based badge element (no inline color). + 'kind' should map to CSS variants defined in your stylesheet (e.g., 'ok', 'issue'). + Unknown kinds fall back to the base 'badge' styling. + + Examples: + badge("OK", "ok") -> OK + badge("ISSUE", "issue") -> ISSUE + badge("info") -> info + """ + text_s = escape(str(text)) + kind_s = str(kind).strip() + if kind_s: + return f'{text_s}' + return f'{text_s}' -def badge(text: str, bg: str, fg: str = "#ffffff") -> str: - return ( - f'{escape(text)}' - ) def pill_state(state: str) -> str: - s = (state or "").lower() + """ + Map port states to neutral/semantic classes (no inline color). + Keeps semantics simple so CSS controls appearance. + """ + s = (state or "").strip().lower() + if s == "open": - return badge("open", "#16a34a") - if s in ("open|filtered",): - return badge("open|filtered", "#0ea5e9") - if s == "filtered": - return badge("filtered", "#f59e0b", "#111111") + return badge("open", "ok") # green-ish in CSS if s == "closed": - return badge("closed", "#ef4444") - return badge(s, "#6b7280") + return badge("closed", "issue") # red-ish in CSS + + # For less common states, keep a neutral chip/badge so style stays consistent + if s in ("open|filtered", "filtered"): + return f'{escape(s)}' + + # Fallback + return f'{escape(s or "-")}' + def pill_proto(proto: str) -> str: - return badge((proto or "").lower(), "#334155") + """ + Render protocol as a neutral 'chip'. + """ + return f'{escape((proto or "").lower())}' + def _env(templates_dir: Path) -> Environment: + """ + Build a Jinja2 environment with our helpers registered as globals. + """ env = Environment( loader=FileSystemLoader(str(templates_dir)), - autoescape=select_autoescape(["html", "xml"]), + autoescape=select_autoescape(["html", "xml", "j2"]), trim_blocks=True, lstrip_blocks=True, ) - env.globals.update(badge=badge, pill_state=pill_state, pill_proto=pill_proto, fmt_ports=fmt_ports) + env.globals.update( + badge=badge, + pill_state=pill_state, + pill_proto=pill_proto, + fmt_ports=fmt_ports, + ) return env -# --- NEW: compatibility shim so renderer can accept either shape --- - -ReportsArg = Union[GroupedReports, Dict[str, Dict[str, List[int]]]] - -def _coerce_to_grouped(reports: ReportsArg) -> GroupedReports: - """ - Accept either: - - GroupedReports (new, preferred), or - - legacy dict: { ip: {"unexpected_tcp":[], "missing_tcp":[], "unexpected_udp":[], "missing_udp":[] } } - and return a GroupedReports instance. - """ - if isinstance(reports, GroupedReports): - return reports - - # Legacy dict -> build HostReport objects - by_ip: Dict[str, HostReport] = {} - for ip, d in reports.items(): - hr = HostReport( - ip=str(ip), - unexpected_tcp=list(d.get("unexpected_tcp", []) or []), - missing_tcp=list(d.get("missing_tcp", []) or []), - unexpected_udp=list(d.get("unexpected_udp", []) or []), - missing_udp=list(d.get("missing_udp", []) or []), - ) - by_ip[ip] = hr - - # Split & sort by IP - issues: List[HostReport] = [] - expected: List[HostReport] = [] - for hr in by_ip.values(): - (issues if hr.has_issues() else expected).append(hr) - - def _ip_key(hr: HostReport): - try: - return ip_address(hr.ip) - except ValueError: - return hr.ip - - issues.sort(key=_ip_key) - expected.sort(key=_ip_key) - - return GroupedReports(issues=issues, expected=expected, by_ip=by_ip) - +# --------------------------- +# Rendering entry points +# --------------------------- def render_html_report_jinja( - reports: ReportsArg, + grouped: GroupedReports, host_results: List[HostResult], templates_dir: Path, - template_name: str = "report.html.j2", + *, + template_name: str = "report_body.html.j2", title: str = "Port Compliance Report", only_issues: bool = False, + dark_mode: bool = True, ) -> str: + """ + Render the HTML report using the new structure/palette templates. + + Args: + grouped: GroupedReports with .issues, .expected, .by_ip already populated. + host_results: Raw scan results (per-host) used for the detailed port tables. + templates_dir: Path to the Jinja templates root (contains base_report.html.j2, palettes/, etc.). + template_name: Structure template to render (defaults to 'report_body.html.j2'). + title: Report title. + only_issues: If True, suppresses the 'expected/OK' section. + dark_mode: Controls which palette is included by base_report.html.j2. + + Returns: + Rendered HTML as a string. + """ env = _env(templates_dir) template = env.get_template(template_name) - grouped = _coerce_to_grouped(reports) - total_hosts = len(grouped.by_ip) ok_hosts = len(grouped.expected) hosts_with_issues = [hr.ip for hr in grouped.issues] - # Build a mapping of IP -> HostResult and sort port rows for stable output - by_ip_results = {hr.address: hr for hr in host_results} + # Build mapping of IP -> HostResult and sort port rows for stable output + by_ip_results: Dict[str, HostResult] = {} + for hr in host_results: + by_ip_results[hr.address] = hr + for hr in by_ip_results.values(): if hr and hr.ports: - hr.ports.sort(key=lambda p: (p.protocol, p.port)) + # Sort by (protocol, then port, then service) + hr.ports.sort(key=lambda p: (str(p.protocol).lower(), int(p.port), str(p.service or ""))) html = template.render( + # Base metadata title=title, generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + # Palette switch (consumed by base_report.html.j2) + dark_mode=dark_mode, + # Summary bar total_hosts=total_hosts, ok_hosts=ok_hosts, hosts_with_issues=hosts_with_issues, - # New grouped context for simple “issues first, expected later” loops - issues=grouped.issues, # list[HostReport] - expected=grouped.expected, # list[HostReport] - by_ip=grouped.by_ip, # dict[str, HostReport] + # Sections + issues=grouped.issues, # list[HostReport] + expected=grouped.expected, # list[HostReport] + by_ip=grouped.by_ip, # dict[str, HostReport] - # Legacy context (kept for compatibility if your template still uses it) - reports=reports, - - # Host scan details + # Host scan details (used for port tables) host_results_by_ip=by_ip_results, - # Existing behavior switch + # Behavior flag only_issues=only_issues, ) return html def write_html_report_jinja( - reports: ReportsArg, + grouped: GroupedReports, host_results: List[HostResult], out_path: Path, + *, templates_dir: Path = Path("templates"), - template_name: str = "report.html.j2", + template_name: str = "report_body.html.j2", title: str = "Port Compliance Report", only_issues: bool = False, + dark_mode: bool = True, ) -> Path: + """ + Render and write the HTML report to disk. + + Notes: + - Assumes your templates directory contains: + base_report.html.j2 + report_body.html.j2 + palettes/light.css.j2 + palettes/dark.css.j2 + - 'dark_mode' toggles which palette is inlined by the base template. + """ html = render_html_report_jinja( - reports, host_results, templates_dir, template_name, title, only_issues + grouped=grouped, + host_results=host_results, + templates_dir=templates_dir, + template_name=template_name, + title=title, + only_issues=only_issues, + dark_mode=dark_mode, ) out_path.write_text(html, encoding="utf-8") return out_path diff --git a/app/templates/base_report.html.j2 b/app/templates/base_report.html.j2 new file mode 100644 index 0000000..f1f289b --- /dev/null +++ b/app/templates/base_report.html.j2 @@ -0,0 +1,220 @@ + + + + + + {{ title or "Port Report" }} + + + + +
+ {% block body %}{% endblock %} +
+ + diff --git a/app/templates/palettes/dark.css.j2 b/app/templates/palettes/dark.css.j2 new file mode 100644 index 0000000..e878c3f --- /dev/null +++ b/app/templates/palettes/dark.css.j2 @@ -0,0 +1,30 @@ +:root { + --bg: #0b1220; /* slate-950ish */ + --text: #e5e7eb; /* slate-200 */ + --muted: #94a3b8; /* slate-400 */ + + --card-bg: #0f172a; /* slate-900 */ + --border: #334155; /* slate-700 */ + + --accent: #38bdf8; + + --ok: #16a34a; + --ok-on: #052e16; + --issue: #ef4444; + --issue-on: #450a0a; + + --chip-bg: #1f2937; /* gray-800 */ + + /* Summary bar emphasis */ + --summary-bg: #101a33; /* a touch brighter than --card-bg */ + --summary-border: #2c3a56; /* slightly brighter border */ + --summary-shadow: rgba(0, 0, 0, 0.5); + + /* KPI chip fills (derived from status colors) */ + --ok-bg-soft: color-mix(in srgb, var(--ok) 18%, transparent); + --issue-bg-soft: color-mix(in srgb, var(--issue) 18%, transparent); + --accent-soft: color-mix(in srgb, var(--accent) 18%, transparent); + + /* Port table border accent (dark) */ + --card-border: #2b3b5c; /* slightly brighter than main border */ +} diff --git a/app/templates/palettes/light.css.j2 b/app/templates/palettes/light.css.j2 new file mode 100644 index 0000000..3295f13 --- /dev/null +++ b/app/templates/palettes/light.css.j2 @@ -0,0 +1,35 @@ +:root { + /* Surface & text */ + --bg: #f8fafc; + --text: #0f172a; + --muted: #334155; + + /* Cards & borders */ + --card-bg: #ffffff; + --border: #e2e8f0; + + /* Accents */ + --accent: #0ea5e9; + + /* Status colors */ + --ok: #16a34a; + --ok-on: #ecfdf5; + --issue: #ef4444; + --issue-on: #fef2f2; + + /* Misc */ + --chip-bg: #e5e7eb; + + /* Summary bar emphasis (light) */ + --summary-bg: #f1f5f9; /* slightly darker than page bg for contrast */ + --summary-border: #cbd5e1; /* soft cool-gray border */ + --summary-shadow: rgba(0, 0, 0, 0.08); + + /* KPI chip fills (derived from status colors) */ + --ok-bg-soft: color-mix(in srgb, var(--ok) 12%, white); + --issue-bg-soft: color-mix(in srgb, var(--issue) 12%, white); + --accent-soft: color-mix(in srgb, var(--accent) 12%, white); + + /* Port table border accent (light) */ + --card-border: #cbd5e1; /* cool gray for gentle contrast */ +} diff --git a/app/templates/report_body.html.j2 b/app/templates/report_body.html.j2 new file mode 100644 index 0000000..4738bc4 --- /dev/null +++ b/app/templates/report_body.html.j2 @@ -0,0 +1,164 @@ +{% extends "base_report.html.j2" %} + +{% block body %} + + {# ===== Title Card ===== #} + + + + + + + {# ===== Summary Bar ===== #} + + + + + + + {% 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 %} + + {# ===== Helpers ===== #} + {% macro delta_row(label, ports) -%} + + + {{ label }}: + {{ fmt_ports(ports) }} + + + {%- endmacro %} + + {# ================= 1) Issues (already sorted by IP in Python) ================= #} + {% for hr in issues %} + {% set ip_key = hr.ip %} + {% set r = hr %} + {% set host_row = host_results_by_ip.get(ip_key) %} + {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} + + + + + + + {{ 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 host_row and host_row.ports %} + {% set ns = namespace(visible_rows=0) %} + + {% for p in host_row.ports %} + {% set proto = (p.protocol|string|lower) %} + {% set state = (p.state|string|lower) %} + + {# hide closed UDP lines to reduce noise #} + {% set skip_row = (proto == 'udp' and ('closed' in state)) %} + + {% 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 []))) + )) %} + + {% if is_issue %} + {% set expectation_badge %}Issue{% endset %} + {% elif is_open %} + {% set expectation_badge %}Expected{% endset %} + {% else %} + {% set expectation_badge %}{% endset %} + {% endif %} + + {% if not skip_row %} + {% set ns.visible_rows = ns.visible_rows + 1 %} + + + + + + + + {% endif %} + {% endfor %} + + {% if ns.visible_rows == 0 %} + + + + {% endif %} + {% else %} + + + + {% endif %} + + {% endfor %} + + {# ================= 2) Expected / OK hosts (only if not only_issues) ================= #} + {% if not only_issues %} + {% for hr in expected %} + {% set ip_key = hr.ip %} + {% set r = hr %} + {% set host_row = host_results_by_ip.get(ip_key) %} + {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} + + + + + + + + + + + {% endfor %} + {% endif %} + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/report_dark.html.j2 b/app/templates/report_dark.html.j2 deleted file mode 100644 index fc8a823..0000000 --- a/app/templates/report_dark.html.j2 +++ /dev/null @@ -1,188 +0,0 @@ - - - - - {{ 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 (Issues first, then Expected) ===== #} - - {# A small macro reused for delta lines #} - {% macro delta_row(label, ports) -%} - - - {{ label }}: - {{ fmt_ports(ports) }} - - - {%- endmacro %} - - {# ---------- 1) Issues (already sorted by IP in Python) ---------- #} - {% for hr in issues %} - {% set ip_key = hr.ip %} - {% set r = hr %} {# keep "r" alias so the rest of the block looks familiar #} - {% set has_issues = true %} - {% set host_row = host_results_by_ip.get(ip_key) %} - {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} - - - - - - - - - - - {{ 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 host_row and host_row.ports %} - {% set ns = namespace(visible_rows=0) %} - - {% for p in host_row.ports %} - {% set proto = (p.protocol|string|lower) %} - {% set state = (p.state|string|lower) %} - {% set skip_row = (proto == 'udp' and ('closed' in state)) %} - - {% 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 %} -
- {{ header_title }} {{ badge('ISSUES', '#ef4444') }} -
- {{ badge('ISSUES', '#ef4444') }} -
-
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. -
- {% endfor %} - - {# ---------- 2) Expected / OK hosts (only if not only_issues) ---------- #} - {% if not only_issues %} - {% for hr in expected %} - {% set ip_key = hr.ip %} - {% set r = hr %} - {% set has_issues = false %} - {% set host_row = host_results_by_ip.get(ip_key) %} - {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} - - - - - - - - - -
- {{ header_title }} {{ badge('OK', '#16a34a') }} -
- {{ badge('OK', '#16a34a') }}   Matches expected ports. -
- {% endfor %} - {% endif %} - - -
- Report generated by mass-scan-v2 • {{ generated }} -
-
- - diff --git a/app/templates/report_light.html.j2 b/app/templates/report_light.html.j2 deleted file mode 100644 index da283ad..0000000 --- a/app/templates/report_light.html.j2 +++ /dev/null @@ -1,163 +0,0 @@ - - - - - {{ title }} - - - -
- - - - -
-
{{ title }}
-
Generated: {{ generated }}
-
- - - - - -
- 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 (LIGHT theme) ===== #} - - {# Reusable delta row #} - {% macro delta_row(label, ports) -%} - - - {{ label }}: {{ fmt_ports(ports) }} - - - {%- endmacro %} - - {# ---------- 1) Issues first (already IP-sorted in Python) ---------- #} - {% for hr in issues %} - {% set ip_key = hr.ip %} - {% set r = hr %} - {% set host_row = host_results_by_ip.get(ip_key) %} - {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} - - - - - - - - - - - {{ 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 host_row and host_row.ports %} - {% set ns = namespace(visible_rows=0) %} - - {% for p in host_row.ports %} - {% set proto = (p.protocol|string|lower) %} - {% set state = (p.state|string|lower) %} - {% set skip_row = (proto == 'udp' and ('closed' in state)) %} - - {% 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 %} -
- {{ header_title }} {{ badge('ISSUES', '#ef4444') }} -
- {{ badge('ISSUES', '#ef4444') }} -
-
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. -
- {% endfor %} - - {# ---------- 2) Expected / OK (only if not only_issues) ---------- #} - {% if not only_issues %} - {% for hr in expected %} - {% set ip_key = hr.ip %} - {% set host_row = host_results_by_ip.get(ip_key) %} - {% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %} - - - - - - - - -
- {{ header_title }} {{ badge('OK', '#16a34a') }} -
- {{ badge('OK', '#16a34a') }}   Matches expected ports. -
- {% endfor %} - {% endif %} - -
- Report generated by mass-scan-v2 • {{ generated }} -
diff --git a/data/output/corp-wan.html b/data/output/corp-wan.html index 286fc01..d84d2b8 100644 --- a/data/output/corp-wan.html +++ b/data/output/corp-wan.html @@ -1,159 +1,410 @@ - + - + + Corporate WAN Perimeter - + + - -
- - - - -
-
Corporate WAN Perimeter
-
Generated: 2025-10-22 02:33:35
-
+ +
- +
+ + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - -
- 10.10.20.4 OK -
- OK   Matches expected ports. -
+ + + OK +   Matches expected ports. + + + - - - - - - - -
- 10.10.20.5 OK -
- OK   Matches expected ports. -
+ -
- Report generated by mass-scan-v2 • 2025-10-22 02:33:35 -
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/data/scan_targets/corp-wan.yaml b/data/scan_targets/corp-wan.yaml index 3d41e80..3afc6b8 100644 --- a/data/scan_targets/corp-wan.yaml +++ b/data/scan_targets/corp-wan.yaml @@ -9,7 +9,7 @@ reporting: report_name: Corporate WAN Perimeter report_filename: corp-wan.html full_details: true - dark_mode: false + dark_mode: true email_to: soc@example.com # single string is fine; or a list email_cc: [] # explicitly none diff --git a/punchlist.md b/punchlist.md index 1863647..0995dc5 100644 --- a/punchlist.md +++ b/punchlist.md @@ -7,7 +7,7 @@ **Decisions** -* PDF engine: `wkhtmltopdf` via `subprocess` (wrapper optional). +* PDF engine: `wkhtmltopdf` via `subprocess` (wrapper optional). Decide if we want pdfkit * Page size: **Letter** (or A4 if you prefer). * Output layout: `/data/output///report.pdf`. * ZIP per run: `/data/output/reports_.zip`.