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 ===== #}
+
+
+ |
+ {{ 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 %}
+
+ {# ===== 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) }}
+
+
+ |
+ Discovered Ports
+ |
+
+
+
+ | Protocol |
+ Port |
+ State |
+ Service |
+ Expectation |
+
+
+ {% 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 %}
+
+ | {{ pill_proto(p.protocol) | safe }} |
+ {{ p.port }} |
+ {{ pill_state(p.state) | safe }} |
+ {{ p.service or '-' }} |
+ {{ expectation_badge | safe }} |
+
+ {% endif %}
+ {% endfor %}
+
+ {% if ns.visible_rows == 0 %}
+
+ |
+ No per-port details to display after filtering closed UDP results.
+ |
+
+ {% endif %}
+ {% else %}
+
+ |
+ No per-port details available for this host.
+ |
+
+ {% 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 '') %}
+
+
+
+
+
+
+
+ |
+ OK
+ Matches expected ports.
+ |
+
+
+ {% 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 '') %}
-
-
-
- |
- {{ header_title }} {{ badge('ISSUES', '#ef4444') }}
- |
-
-
-
- |
- {{ badge('ISSUES', '#ef4444') }}
- |
-
-
- {{ 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) }}
-
-
- |
- Discovered Ports
- |
-
-
- | Protocol |
- Port |
- State |
- Service |
- Expectation |
-
-
- {% 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 %}
-
- | {{ pill_proto(p.protocol) }} |
- {{ p.port }} |
- {{ pill_state(p.state) }} |
- {{ p.service or '-' }} |
- {{ expectation_badge | safe }} |
-
- {% endif %}
- {% endfor %}
-
- {% if ns.visible_rows == 0 %}
-
- |
- No per-port details to display after filtering closed UDP results.
- |
-
- {% endif %}
- {% else %}
-
- |
- No per-port details available for this host.
- |
-
- {% 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 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 '') %}
-
-
-
- |
- {{ header_title }} {{ badge('ISSUES', '#ef4444') }}
- |
-
-
-
- |
- {{ badge('ISSUES', '#ef4444') }}
- |
-
-
- {{ 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) }}
-
-
- |
- Discovered Ports
- |
-
-
- | Protocol |
- Port |
- State |
- Service |
- Expectation |
-
-
- {% 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 %}
-
- | {{ pill_proto(p.protocol) }} |
- {{ p.port }} |
- {{ pill_state(p.state) }} |
- {{ p.service or '-' }} |
- {{ expectation_badge | safe }} |
-
- {% endif %}
- {% endfor %}
-
- {% if ns.visible_rows == 0 %}
-
- |
- No per-port details to display after filtering closed UDP results.
- |
-
- {% endif %}
- {% else %}
-
- |
- No per-port details available for this host.
- |
-
- {% endif %}
-
- {% 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
- |
-
-
+
+
-
+
+
+ |
+ Corporate WAN Perimeter
+ Generated: 2025-10-22 04:02:59
+ |
+
+
+
+
+
+ |
+ Total hosts:
+ 3
+ Matching expected:
+ 2
+ With issues:
+ 1
+ |
+
+
+
+
+
+
+
+
- |
- Total hosts: 3
- Matching expected: 2
- With issues: 1
+ |
+
+
+ |
+ Unexpected TCP open ports:
+ 3128
+ |
+
+
+ |
+ Expected TCP ports not seen:
+ none
+ |
+
+
+ |
+ Unexpected UDP open ports:
+ none
+ |
+
+
+ |
+ Expected UDP ports not seen:
+ none
+ |
+
+
+
+ |
+ Discovered Ports
+ |
+
+
+
+ | Protocol |
+ Port |
+ State |
+ Service |
+ Expectation |
+
+
+
+
+
+
+
+
+ | tcp |
+ 22 |
+ open |
+ ssh |
+ Expected |
+
+
+
+
+
+
+ | tcp |
+ 111 |
+ open |
+ rpcbind |
+ Expected |
+
+
+
+
+
+
+ | tcp |
+ 3128 |
+ open |
+ squid-http |
+ Issue |
+
+
+
+
+
+
+ | tcp |
+ 8006 |
+ open |
+ wpl-analytics |
+ Expected |
+
+
+
+
+
+
+ | udp |
+ 111 |
+ open |
+ rpcbind |
+ Expected |
+
+
-
-
-
-
-
+
- |
- 10.10.20.12 ISSUES
+ |
- |
- ISSUES
+ |
+ OK
+ Matches expected ports.
|
-
-
- |
- Unexpected TCP open ports: 3128
- |
-
-
- |
- Expected TCP ports not seen: none
- |
-
-
- |
- Unexpected UDP open ports: none
- |
-
-
- |
- Expected UDP ports not seen: none
- |
-
-
-
- |
- Discovered Ports
- |
-
-
- | Protocol |
- Port |
- State |
- Service |
- Expectation |
-
-
-
-
-
-
- | tcp |
- 22 |
- open |
- ssh |
- Expected |
-
-
-
-
- | tcp |
- 111 |
- open |
- rpcbind |
- Expected |
-
-
-
-
- | tcp |
- 3128 |
- open |
- squid-http |
- Issue |
-
-
-
-
- | tcp |
- 8006 |
- open |
- wpl-analytics |
- Expected |
-
-
-
-
- | udp |
- 111 |
- open |
- rpcbind |
- Expected |
-
-
+
+
+
+
-
-
- |
- 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`.