Compare commits

...

7 Commits

Author SHA1 Message Date
Phillip Tarrant
d307a37a5b adding targets 2025-10-22 10:35:03 -05:00
d5f10e4bfb updating plans and save points 2025-10-21 23:14:21 -05:00
4d3599149f updating target.example 2025-10-21 23:07:10 -05:00
ed3b1de1cd template refactor 2025-10-21 23:05:43 -05:00
4846af66c4 adding punch list for improvement plan 2025-10-21 22:07:50 -05:00
68aa25993d templates and data classes are done 2025-10-21 21:57:43 -05:00
f394e268da template restructure 2025-10-21 21:00:48 -05:00
18 changed files with 1179 additions and 310 deletions

View File

@@ -4,6 +4,7 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
VIRTUAL_ENV=/opt/venv \ VIRTUAL_ENV=/opt/venv \
LANG=C.UTF-8 LC_ALL=C.UTF-8 \
PATH="/opt/venv/bin:$PATH" PATH="/opt/venv/bin:$PATH"
# copy only requirements first so pip install can be cached # 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 && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
python3 python3-venv python3-pip \ python3 python3-venv python3-pip \
nmap ca-certificates tzdata && \ nmap ca-certificates tzdata wkhtmltopdf fonts-dejavu ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Debug Print version of wkhtmltopdf
RUN wkhtmltopdf --version
# ---- Create & activate venv ---- # ---- Create & activate venv ----
RUN python3 -m venv $VIRTUAL_ENV RUN python3 -m venv $VIRTUAL_ENV

View File

@@ -3,22 +3,20 @@ import logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
# TODO: # TODO:
# LOGGING # LOGGING - make better format
# REPORT - use the scan config names for the names of the report and file
# REPORT - make darkmode
# TLS SCANNING # TLS SCANNING
# TLS Version PROBE # TLS Version PROBE
# EMAIL # EMAIL
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, Set from ipaddress import ip_address
from typing import Any, Dict, List, Set
from utils.scan_config_loader import ScanConfigRepository, ScanConfigFile from utils.scan_config_loader import ScanConfigRepository, ScanConfigFile
from utils.schedule_manager import ScanScheduler from utils.schedule_manager import ScanScheduler
from utils.scanner import nmap_scanner 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 reporting_jinja import write_html_report_jinja
from utils.settings import get_settings from utils.settings import get_settings
@@ -29,8 +27,6 @@ logger = logging.getLogger(__file__)
utils = get_common_utils() utils = get_common_utils()
settings = get_settings() settings = get_settings()
HTML_REPORT_FILE = Path() / "data" / "report.html"
def results_to_open_sets( def results_to_open_sets(
results: List[HostResult], results: List[HostResult],
count_as_open: Set[str] = frozenset({"open", "open|filtered"})) -> Dict[str, Dict[str, Set[int]]]: count_as_open: Set[str] = frozenset({"open", "open|filtered"})) -> Dict[str, Dict[str, Set[int]]]:
@@ -49,37 +45,33 @@ def results_to_open_sets(
out[hr.address] = {"tcp": tcp, "udp": udp} out[hr.address] = {"tcp": tcp, "udp": udp}
return out return out
# Build the "reports" dict (what the HTML renderer expects) # Build the grouped_reports (what the HTML renderer expects)
def build_reports( def build_grouped_reports(
scan_config: "ScanConfigFile", scan_config: "ScanConfigFile",
discovered: Dict[str, Dict[str, Set[int]]], discovered: Dict[str, Dict[str, Set[int]]],
) -> Dict[str, Dict[str, List[int]]]: ) -> GroupedReports:
""" """
Create the per-IP delta structure using expected ports from `scan_config.scan_targets` Build per-IP deltas and return a grouped, template-friendly result.
and discovered ports from `discovered`.
Output format: Returns:
{ GroupedReports:
ip: { - issues: hosts with any deltas (sorted by IP)
"unexpected_tcp": [...], - expected: hosts with no deltas (sorted by IP)
"missing_tcp": [...], - by_ip: mapping of ip -> HostReport for random access
"unexpected_udp": [...],
"missing_udp": [...]
}
}
Notes: Notes:
- If a host has no expected UDP ports in the config, `expected_udp` is empty here. - Works with `scan_config.scan_targets` where each target has:
(This function reflects *expectations*, not what to scan. Your scan logic can still ip, expected_tcp (List[int]), expected_udp (List[int]).
choose 'top UDP ports' for those hosts.) - `discovered` is expected to be { ip: { "tcp": Set[int], "udp": Set[int] } }.
- The `discovered` dict is expected to use keys "tcp" / "udp" per host. Lists are accepted and coerced to sets.
- Supports IPv4 and IPv6 sorting. Falls back to string compare if ip parsing fails.
""" """
# Build `expected` from scan_config.scan_targets # ---- 1) Build expectations from scan_config ----
expected: Dict[str, Dict[str, Set[int]]] = {} expected: Dict[str, Dict[str, Set[int]]] = {}
cfg_targets = getattr(scan_config, "scan_targets", []) or [] cfg_targets = getattr(scan_config, "scan_targets", []) or []
for t in cfg_targets: for t in cfg_targets:
# Works whether ScanTarget is a dataclass or a dict-like object # Support dataclass-like or dict-like objects
ip = getattr(t, "ip", None) if hasattr(t, "ip") else t.get("ip") ip = getattr(t, "ip", None) if hasattr(t, "ip") else t.get("ip")
if not ip: if not ip:
continue continue
@@ -95,46 +87,96 @@ def build_reports(
"expected_udp": exp_udp, "expected_udp": exp_udp,
} }
# Union of IPs present in either expectations or discoveries # ---- 2) Union of IPs present in either expectations or discoveries ----
all_ips = set(expected.keys()) | set(discovered.keys()) all_ips = set(expected.keys()) | set(discovered.keys())
reports: Dict[str, Dict[str, List[int]]] = {} # ---- 3) Compute per-host deltas into HostReport objects ----
for ip in sorted(all_ips): by_ip: Dict[str, HostReport] = {}
# Expected sets (default to empty sets if not present)
exp_tcp = expected.get(ip, {}).get("expected_tcp", set())
exp_udp = expected.get(ip, {}).get("expected_udp", set())
# Discovered sets (default to empty sets if not present) for ip in all_ips:
# Expected sets (default to empty sets if not present)
exp_tcp: Set[int] = expected.get(ip, {}).get("expected_tcp", set()) or set()
exp_udp: Set[int] = expected.get(ip, {}).get("expected_udp", set()) or set()
# Discovered sets (default to empty sets if not present); coerce lists -> sets
disc_tcp = discovered.get(ip, {}).get("tcp", set()) or set() disc_tcp = discovered.get(ip, {}).get("tcp", set()) or set()
disc_udp = discovered.get(ip, {}).get("udp", set()) or set() disc_udp = discovered.get(ip, {}).get("udp", set()) or set()
# Ensure sets in case caller provided lists
if not isinstance(disc_tcp, set): if not isinstance(disc_tcp, set):
disc_tcp = set(disc_tcp) disc_tcp = set(disc_tcp)
if not isinstance(disc_udp, set): if not isinstance(disc_udp, set):
disc_udp = set(disc_udp) disc_udp = set(disc_udp)
reports[ip] = { hr = HostReport(
"unexpected_tcp": sorted(disc_tcp - exp_tcp), ip=ip,
"missing_tcp": sorted(exp_tcp - disc_tcp), unexpected_tcp=sorted(disc_tcp - exp_tcp),
"unexpected_udp": sorted(disc_udp - exp_udp), missing_tcp=sorted(exp_tcp - disc_tcp),
"missing_udp": sorted(exp_udp - disc_udp), unexpected_udp=sorted(disc_udp - exp_udp),
} missing_udp=sorted(exp_udp - disc_udp),
)
by_ip[ip] = hr
return reports # ---- 4) Split into issues vs expected ----
issues: List[HostReport] = []
expected_clean: List[HostReport] = []
for hr in by_ip.values():
if hr.has_issues():
issues.append(hr)
else:
expected_clean.append(hr)
# ---- 5) Sort both lists by numeric IP (IPv4/IPv6); fallback to string ----
def ip_sort_key(hr: HostReport):
try:
return ip_address(hr.ip)
except ValueError:
return hr.ip # non-IP strings (unlikely) fall back to lexical
issues.sort(key=ip_sort_key)
expected_clean.sort(key=ip_sort_key)
return GroupedReports(
issues=issues,
expected=expected_clean,
by_ip=by_ip,
)
def run_repo_scan(scan_config:ScanConfigFile): def run_repo_scan(scan_config:ScanConfigFile):
logger.info(f"Starting scan for {scan_config.name}") logger.info(f"Starting scan for {scan_config.name}")
logger.info("Options: udp=%s tls_sec=%s tls_exp=%s", 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}",)
scan_config.scan_options.udp_scan,
scan_config.scan_options.tls_security_scan,
scan_config.scan_options.tls_exp_check)
logger.info("Targets: %d hosts", len(scan_config.scan_targets)) 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
if scan_config.reporting.full_details:
show_only_issues = False
else:
show_only_issues = True
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) scanner = nmap_scanner(scan_config)
scan_results = scanner.scan_targets() scan_results = scanner.scan_targets()
discovered_sets = results_to_open_sets(scan_results, count_as_open={"open", "open|filtered"}) 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)
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(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,
dark_mode=scan_config.reporting.dark_mode,
only_issues=show_only_issues)
scanner.cleanup() scanner.cleanup()
def main(): def main():

View File

@@ -1,95 +1,207 @@
# utils/reporting_jinja.py
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from html import escape from html import escape
from ipaddress import ip_address
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from utils.models import HostResult from utils.models import HostResult, HostReport, GroupedReports
# ---------------------------
# Helper render-time functions
# ---------------------------
def fmt_ports(ports: List[int]) -> str: def fmt_ports(ports: List[int]) -> str:
"""
Render a simple, comma-separated list of port numbers, deduped and sorted.
"""
if not ports: if not ports:
return "none" 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") -> <span class="badge ok">OK</span>
badge("ISSUE", "issue") -> <span class="badge issue">ISSUE</span>
badge("info") -> <span class="badge">info</span>
"""
text_s = escape(str(text))
kind_s = str(kind).strip()
if kind_s:
return f'<span class="badge {kind_s}">{text_s}</span>'
return f'<span class="badge">{text_s}</span>'
def badge(text: str, bg: str, fg: str = "#ffffff") -> str:
return (
f'<span style="display:inline-block;padding:2px 6px;border-radius:12px;'
f'font-size:12px;line-height:16px;background:{bg};color:{fg};'
f'font-family:Segoe UI,Arial,sans-serif">{escape(text)}</span>'
)
def pill_state(state: str) -> str: 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": if s == "open":
return badge("open", "#16a34a") return badge("open", "ok") # green-ish in CSS
if s in ("open|filtered",):
return badge("open|filtered", "#0ea5e9")
if s == "filtered":
return badge("filtered", "#f59e0b", "#111111")
if s == "closed": if s == "closed":
return badge("closed", "#ef4444") return badge("closed", "issue") # red-ish in CSS
return badge(s, "#6b7280")
# For less common states, keep a neutral chip/badge so style stays consistent
if s in ("open|filtered", "filtered"):
return f'<span class="chip">{escape(s)}</span>'
# Fallback
return f'<span class="chip">{escape(s or "-")}</span>'
def pill_proto(proto: str) -> str: def pill_proto(proto: str) -> str:
return badge((proto or "").lower(), "#334155") """
Render protocol as a neutral 'chip'.
"""
return f'<span class="chip">{escape((proto or "").lower())}</span>'
def _env(templates_dir: Path) -> Environment: def _env(templates_dir: Path) -> Environment:
"""
Build a Jinja2 environment with our helpers registered as globals.
"""
env = Environment( env = Environment(
loader=FileSystemLoader(str(templates_dir)), loader=FileSystemLoader(str(templates_dir)),
autoescape=select_autoescape(["html", "xml"]), autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True, trim_blocks=True,
lstrip_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 return env
# ---------------------------
# Rendering entry points
# ---------------------------
def render_html_report_jinja( def render_html_report_jinja(
reports: Dict[str, Dict[str, List[int]]], grouped: GroupedReports,
host_results: List[HostResult], host_results: List[HostResult],
templates_dir: Path, templates_dir: Path,
template_name: str = "report.html.j2", *,
template_name: str = "report_body.html.j2",
title: str = "Port Compliance Report", title: str = "Port Compliance Report",
only_issues: bool = False, only_issues: bool = False,
dark_mode: bool = True,
) -> str: ) -> 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) env = _env(templates_dir)
template = env.get_template(template_name) template = env.get_template(template_name)
total_hosts = len(reports) total_hosts = len(grouped.by_ip)
hosts_with_issues = [ip for ip, r in reports.items() if any(r.values())] ok_hosts = len(grouped.expected)
ok_hosts = total_hosts - len(hosts_with_issues) hosts_with_issues = [hr.ip for hr in grouped.issues]
# No filtering — we'll show all, but only_issues changes template behavior. # Build mapping of IP -> HostResult and sort port rows for stable output
by_ip = {hr.address: hr for hr in host_results} by_ip_results: Dict[str, HostResult] = {}
for hr in by_ip.values(): for hr in host_results:
by_ip_results[hr.address] = hr
for hr in by_ip_results.values():
if hr and hr.ports: 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( html = template.render(
# Base metadata
title=title, title=title,
generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 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, total_hosts=total_hosts,
ok_hosts=ok_hosts, ok_hosts=ok_hosts,
hosts_with_issues=hosts_with_issues, hosts_with_issues=hosts_with_issues,
reports=reports,
host_results_by_ip=by_ip, # Sections
issues=grouped.issues, # list[HostReport]
expected=grouped.expected, # list[HostReport]
by_ip=grouped.by_ip, # dict[str, HostReport]
# Host scan details (used for port tables)
host_results_by_ip=by_ip_results,
# Behavior flag
only_issues=only_issues, only_issues=only_issues,
) )
return html return html
def write_html_report_jinja( def write_html_report_jinja(
reports: Dict[str, Dict[str, List[int]]], grouped: GroupedReports,
host_results: List[HostResult], host_results: List[HostResult],
out_path: Path, out_path: Path,
*,
templates_dir: Path = Path("templates"), templates_dir: Path = Path("templates"),
template_name: str = "report.html.j2", template_name: str = "report_body.html.j2",
title: str = "Port Compliance Report", title: str = "Port Compliance Report",
only_issues: bool = False, only_issues: bool = False,
dark_mode: bool = True,
) -> Path: ) -> 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( 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") out_path.write_text(html, encoding="utf-8")
return out_path return out_path

View File

@@ -0,0 +1,220 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<title>{{ title or "Port Report" }}</title>
<style>
/* Reset-ish */
html, body { margin:0; padding:0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans",
"Liberation Sans", sans-serif, "Apple Color Emoji","Segoe UI Emoji";
background: var(--bg);
color: var(--text);
line-height: 1.45;
font-size: 14px;
}
table { border-collapse: collapse; width: 100%; }
th, td { vertical-align: top; }
/* Include palette */
{% if dark_mode %}
{% include "palettes/dark.css.j2" %}
{% else %}
{% include "palettes/light.css.j2" %}
{% endif %}
/* Layout & components */
.container { max-width: 900px; margin: 24px auto; padding: 0 12px; }
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
}
.header {
background: var(--card-bg);
border-radius: 10px;
padding: 12px;
font-weight: 600;
}
.muted { color: var(--muted); }
.dash { color: var(--muted); } /* for em-dash placeholder */
.badge {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.6;
border: 1px solid transparent;
margin-left: 6px;
}
.badge.ok { background: var(--ok); color: var(--ok-on); }
.badge.issue { background: var(--issue); color: var(--issue-on); }
.chip { display:inline-block; background: var(--chip-bg); padding: 2px 6px; border-radius: 6px; }
.section { margin: 0 0 18px 0; }
.table-clean { width:100%; }
.table-clean td { padding: 8px 10px; }
.host-title { font-size: 14px; }
.summary-row { padding: 10px; }
/* Utility helpers referenced by report_body.html.j2 */
.title-xl { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.meta { font-size: 12px; }
.note { margin: 6px 0 12px 0; font-size: 12px; }
.subhead { font-weight: 600; margin: 8px 0; }
.pad-s { padding: 10px; }
.footer { margin-top: 18px; font-size: 11px; }
/* ===== Summary Bar (prominent) ===== */
.summary-row {
position: relative;
background: var(--summary-bg, var(--card-bg));
border: 1px solid var(--summary-border, var(--border));
border-radius: 12px;
padding: 14px 16px;
font-size: 14px;
color: var(--text);
box-shadow:
0 0 0 1px var(--summary-border, var(--border)) inset,
0 8px 24px var(--summary-shadow, rgba(0,0,0,0.25));
}
/* left accent bar */
.summary-row::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 6px;
border-radius: 12px 0 0 12px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 90%, transparent) 0%,
color-mix(in srgb, var(--accent) 60%, transparent) 100%
);
opacity: 0.9;
}
/* tighten spacing between metrics */
.summary-row strong { margin-right: 8px; }
/* KPI “chips” for the numbers (no HTML changes needed) */
.text-strong,
.text-ok,
.text-issue {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-weight: 700;
line-height: 1.6;
border: 1px solid transparent;
font-variant-numeric: tabular-nums;
}
/* Neutral total */
.text-strong {
background: var(--accent-soft, color-mix(in srgb, var(--accent) 12%, transparent));
border-color: color-mix(in srgb, var(--accent) 45%, var(--summary-border, var(--border)));
}
/* OK / Issue map to your existing status colors */
.text-ok {
color: var(--text);
background: var(--ok-bg-soft, color-mix(in srgb, var(--ok) 18%, transparent));
border-color: color-mix(in srgb, var(--ok) 45%, var(--summary-border, var(--border)));
}
.text-issue {
color: var(--text);
background: var(--issue-bg-soft, color-mix(in srgb, var(--issue) 18%, transparent));
border-color: color-mix(in srgb, var(--issue) 45%, var(--summary-border, var(--border)));
}
/* ===== Port Table Card (bordered) ===== */
.table-ports {
position: relative;
margin-bottom: 24px;
border: 1px solid var(--card-border, var(--border));
border-radius: 12px;
background: var(--card-bg);
box-shadow:
0 0 0 1px var(--card-border, var(--border)) inset,
0 4px 12px rgba(0,0,0,0.25);
overflow: hidden;
}
.table-ports th,
.table-ports td {
border: 1px solid var(--border);
padding: 6px 10px;
}
.table-ports .th {
background: var(--bg);
color: var(--text);
font-weight: 600;
font-size: 13px;
border-bottom: 2px solid var(--border);
}
.table-ports .td {
font-size: 13px;
}
.table-ports .td.num { white-space: nowrap; }
.table-ports .td.proto,
.table-ports .td.state,
.table-ports .td.expect {
text-align: center;
}
/* optional: subtle row separation */
.table-ports tr:nth-child(even) {
background: color-mix(in srgb, var(--card-bg) 92%, var(--border) 8%);
}
/* ===== Host Header Row (within table-ports) ===== */
.table-ports .header {
position: relative;
background: var(--header-bg, var(--card-bg));
color: var(--header-text, var(--text));
font-weight: 700;
font-size: 14px;
border-radius: 10px 10px 0 0;
padding: 12px 14px;
border-bottom: 2px solid var(--header-border, var(--border));
text-shadow: 0 1px 2px rgba(0,0,0,0.25);
box-shadow: 0 2px 8px var(--header-shadow, rgba(0,0,0,0.2));
letter-spacing: 0.2px;
}
/* Subtle left accent stripe (matches summary style) */
.table-ports .header::before {
content: "";
position: absolute;
top: 0; bottom: 0; left: 0;
width: 5px;
border-radius: 10px 0 0 0;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 85%, transparent) 0%,
color-mix(in srgb, var(--accent) 55%, transparent) 100%
);
}
</style>
</head>
<body>
<div class="container">
{% block body %}{% endblock %}
</div>
</body>
</html>

View File

@@ -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 */
}

View File

@@ -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 */
}

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body style="margin:0;padding:16px;background:#ffffff">
<div style="max-width:860px;margin:0 auto;font-family:Segoe UI,Arial,sans-serif">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 12px 0">
<tr>
<td style="padding:10px;background:#0f172a;border-radius:10px;color:#e2e8f0">
<div style="font-size:18px;font-weight:700;margin-bottom:4px">{{ title }}</div>
<div style="font-size:12px;color:#94a3b8">Generated: {{ generated }}</div>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 16px 0">
<tr>
<td style="padding:10px;border:1px solid #e5e7eb;border-radius:8px">
Total hosts: <strong>{{ total_hosts }}</strong>&nbsp;
Matching expected: <strong>{{ ok_hosts }}</strong>&nbsp;
With issues: <strong>{{ hosts_with_issues|length }}</strong>
</td>
</tr>
</table>
{% if not only_issues and ok_hosts == total_hosts and total_hosts > 0 %}
<div style="margin:6px 0 12px 0;font-size:12px;color:#64748b">
All hosts matched expected ports.
</div>
{% endif %}
{% if only_issues and hosts_with_issues|length == 0 %}
<div style="margin:8px 0;color:#64748b">
No hosts with issues found. ✅
</div>
{% 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 '') %}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="4" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
{{ header_title }} {% if has_issues %} {{ badge('ISSUES', '#ef4444') }} {% else %} {{ badge('OK', '#16a34a') }} {% endif %}
</td>
</tr>
{% if has_issues %}
<tr>
<td colspan="4" style="padding:10px 10px 6px 10px;font-size:13px">
{{ badge('ISSUES', '#ef4444') }}
</td>
</tr>
{% macro delta_row(label, ports) -%}
<tr>
<td colspan="4" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>{{ label }}:</strong> {{ fmt_ports(ports) }}
</td>
</tr>
{%- 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) }}
<tr>
<td colspan="4" style="padding:8px 10px 6px 10px;font-size:13px">
<div style="font-weight:600;margin:8px 0">Discovered Ports</div>
</td>
</tr>
<tr>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">Protocol</td>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">Port</td>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">State</td>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">Service</td>
</tr>
{% if hr and hr.ports %}
{% for p in hr.ports %}
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb">{{ pill_proto(p.protocol) }}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">{{ p.port }}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">{{ pill_state(p.state) }}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">{{ p.service or '-' }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="padding:8px 10px;border:1px solid #e5e7eb;color:#64748b">
No per-port details available for this host.
</td>
</tr>
{% endif %}
{% else %}
{# Host has no issues #}
<tr>
<td colspan="4" style="padding:10px 10px 8px 10px;font-size:13px">
{{ badge('OK', '#16a34a') }} &nbsp; Matches expected ports.
</td>
</tr>
{% endif %}
</table>
{% endfor %}
<div style="margin-top:18px;font-size:11px;color:#94a3b8">
Report generated by mass-scan-v2 • {{ generated }}
</div>

View File

@@ -0,0 +1,164 @@
{% extends "base_report.html.j2" %}
{% block body %}
{# ===== Title Card ===== #}
<table role="presentation" class="section table-clean">
<tr>
<td class="card">
<div class="title-xl">{{ title }}</div>
<div class="meta muted">Generated: {{ generated }}</div>
</td>
</tr>
</table>
{# ===== Summary Bar ===== #}
<table role="presentation" class="section table-clean">
<tr>
<td class="card summary-row">
Total hosts:
<strong class="text-strong">{{ total_hosts }}</strong>&nbsp;&nbsp;
Matching expected:
<strong class="text-ok">{{ ok_hosts }}</strong>&nbsp;&nbsp;
With issues:
<strong class="text-issue">{{ hosts_with_issues|length }}</strong>
</td>
</tr>
</table>
{% if not only_issues and ok_hosts == total_hosts and total_hosts > 0 %}
<div class="note muted">All hosts matched expected ports.</div>
{% endif %}
{% if only_issues and hosts_with_issues|length == 0 %}
<div class="note muted">No hosts with issues found. ✅</div>
{% endif %}
{# ===== Helpers ===== #}
{% macro delta_row(label, ports) -%}
<tr>
<td colspan="5" class="delta-row">
<strong>{{ label }}:</strong>
<span class="muted">{{ fmt_ports(ports) }}</span>
</td>
</tr>
{%- 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 '') %}
<table role="presentation" class="section table-clean table-ports">
<tr>
<td colspan="5" class="header">
{{ header_title }} <span class="badge issue">ISSUES</span>
</td>
</tr>
{{ 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) }}
<tr>
<td colspan="5" class="pad-s">
<div class="subhead">Discovered Ports</div>
</td>
</tr>
<tr>
<td class="th">Protocol</td>
<td class="th">Port</td>
<td class="th">State</td>
<td class="th">Service</td>
<td class="th">Expectation</td>
</tr>
{% 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 %}<span class="badge issue">Issue</span>{% endset %}
{% elif is_open %}
{% set expectation_badge %}<span class="badge ok">Expected</span>{% endset %}
{% else %}
{% set expectation_badge %}<span class="dash">—</span>{% endset %}
{% endif %}
{% if not skip_row %}
{% set ns.visible_rows = ns.visible_rows + 1 %}
<tr>
<td class="td proto">{{ pill_proto(p.protocol) | safe }}</td>
<td class="td num">{{ p.port }}</td>
<td class="td state">{{ pill_state(p.state) | safe }}</td>
<td class="td svc">{{ p.service or '-' }}</td>
<td class="td expect">{{ expectation_badge | safe }}</td>
</tr>
{% endif %}
{% endfor %}
{% if ns.visible_rows == 0 %}
<tr>
<td colspan="5" class="td muted">
No per-port details to display after filtering closed UDP results.
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="5" class="td muted">
No per-port details available for this host.
</td>
</tr>
{% endif %}
</table>
{% 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 '') %}
<table role="presentation" class="section table-clean">
<tr>
<td colspan="5" class="header">
{{ header_title }} <span class="badge ok">OK</span>
</td>
</tr>
<tr>
<td colspan="5" class="pad-s">
<span class="badge ok">OK</span>
&nbsp; <span class="muted">Matches expected ports.</span>
</td>
</tr>
</table>
{% endfor %}
{% endif %}
<div class="footer muted">
Report generated by mass-scan-v2 • {{ generated }}
</div>
{% endblock %}

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field, asdict
from typing import List, Optional from typing import Dict, List, Set, Optional, Any
from ipaddress import ip_address
@dataclass @dataclass
class PortFinding: class PortFinding:
@@ -16,7 +16,6 @@ class PortFinding:
state: str state: str
service: Optional[str] = None service: Optional[str] = None
@dataclass @dataclass
class HostResult: class HostResult:
""" """
@@ -28,3 +27,68 @@ class HostResult:
address: str address: str
host: Optional[str] = None host: Optional[str] = None
ports: List[PortFinding] = field(default_factory=list) 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,
}

View File

@@ -41,6 +41,7 @@ class Reporting:
report_name: str = "Scan Report" report_name: str = "Scan Report"
report_filename: str = "report.html" report_filename: str = "report.html"
full_details: bool = False full_details: bool = False
dark_mode: bool = True
email_to: List[str] = field(default_factory=list) email_to: List[str] = field(default_factory=list)
email_cc: List[str] = field(default_factory=list) email_cc: List[str] = field(default_factory=list)
@@ -161,6 +162,7 @@ class ScanConfigRepository:
report_name=str(rep_raw.get("report_name", "Scan Report")), report_name=str(rep_raw.get("report_name", "Scan Report")),
report_filename=str(rep_raw.get("report_filename", "report.html")), report_filename=str(rep_raw.get("report_filename", "report.html")),
full_details=bool(rep_raw.get("full_details", False)), full_details=bool(rep_raw.get("full_details", False)),
dark_mode = bool(rep_raw.get("dark_mode", False)),
email_to=self._as_str_list(rep_raw.get("email_to", []), "email_to"), email_to=self._as_str_list(rep_raw.get("email_to", []), "email_to"),
email_cc=self._as_str_list(rep_raw.get("email_cc", []), "email_cc"), email_cc=self._as_str_list(rep_raw.get("email_cc", []), "email_cc"),
) )
@@ -235,7 +237,6 @@ class ScanConfigRepository:
raise TypeError(f"'{field_name}' must be a string or a list of strings.") raise TypeError(f"'{field_name}' must be a string or a list of strings.")
# helpers # helpers
def list_configs(self) -> List[str]: def list_configs(self) -> List[str]:
""" """
Return names of loaded configs for UI selection. Return names of loaded configs for UI selection.

View File

@@ -268,7 +268,7 @@ class nmap_scanner:
Run a command and raise on non-zero exit with a readable message. Run a command and raise on non-zero exit with a readable message.
""" """
try: try:
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True,stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise RuntimeError(f"Command failed ({exc.returncode}): {' '.join(cmd)}") from exc raise RuntimeError(f"Command failed ({exc.returncode}): {' '.join(cmd)}") from exc

View File

@@ -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": []}
]

410
data/output/corp-wan.html Normal file
View File

@@ -0,0 +1,410 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<title>Corporate WAN Perimeter</title>
<style>
/* Reset-ish */
html, body { margin:0; padding:0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans",
"Liberation Sans", sans-serif, "Apple Color Emoji","Segoe UI Emoji";
background: var(--bg);
color: var(--text);
line-height: 1.45;
font-size: 14px;
}
table { border-collapse: collapse; width: 100%; }
th, td { vertical-align: top; }
/* Include palette */
: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 */
}
/* Layout & components */
.container { max-width: 900px; margin: 24px auto; padding: 0 12px; }
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
}
.header {
background: var(--card-bg);
border-radius: 10px;
padding: 12px;
font-weight: 600;
}
.muted { color: var(--muted); }
.dash { color: var(--muted); } /* for em-dash placeholder */
.badge {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.6;
border: 1px solid transparent;
margin-left: 6px;
}
.badge.ok { background: var(--ok); color: var(--ok-on); }
.badge.issue { background: var(--issue); color: var(--issue-on); }
.chip { display:inline-block; background: var(--chip-bg); padding: 2px 6px; border-radius: 6px; }
.section { margin: 0 0 18px 0; }
.table-clean { width:100%; }
.table-clean td { padding: 8px 10px; }
.host-title { font-size: 14px; }
.summary-row { padding: 10px; }
/* Utility helpers referenced by report_body.html.j2 */
.title-xl { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.meta { font-size: 12px; }
.note { margin: 6px 0 12px 0; font-size: 12px; }
.subhead { font-weight: 600; margin: 8px 0; }
.pad-s { padding: 10px; }
.footer { margin-top: 18px; font-size: 11px; }
/* ===== Summary Bar (prominent) ===== */
.summary-row {
position: relative;
background: var(--summary-bg, var(--card-bg));
border: 1px solid var(--summary-border, var(--border));
border-radius: 12px;
padding: 14px 16px;
font-size: 14px;
color: var(--text);
box-shadow:
0 0 0 1px var(--summary-border, var(--border)) inset,
0 8px 24px var(--summary-shadow, rgba(0,0,0,0.25));
}
/* left accent bar */
.summary-row::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 6px;
border-radius: 12px 0 0 12px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 90%, transparent) 0%,
color-mix(in srgb, var(--accent) 60%, transparent) 100%
);
opacity: 0.9;
}
/* tighten spacing between metrics */
.summary-row strong { margin-right: 8px; }
/* KPI “chips” for the numbers (no HTML changes needed) */
.text-strong,
.text-ok,
.text-issue {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-weight: 700;
line-height: 1.6;
border: 1px solid transparent;
font-variant-numeric: tabular-nums;
}
/* Neutral total */
.text-strong {
background: var(--accent-soft, color-mix(in srgb, var(--accent) 12%, transparent));
border-color: color-mix(in srgb, var(--accent) 45%, var(--summary-border, var(--border)));
}
/* OK / Issue map to your existing status colors */
.text-ok {
color: var(--text);
background: var(--ok-bg-soft, color-mix(in srgb, var(--ok) 18%, transparent));
border-color: color-mix(in srgb, var(--ok) 45%, var(--summary-border, var(--border)));
}
.text-issue {
color: var(--text);
background: var(--issue-bg-soft, color-mix(in srgb, var(--issue) 18%, transparent));
border-color: color-mix(in srgb, var(--issue) 45%, var(--summary-border, var(--border)));
}
/* ===== Port Table Card (bordered) ===== */
.table-ports {
position: relative;
margin-bottom: 24px;
border: 1px solid var(--card-border, var(--border));
border-radius: 12px;
background: var(--card-bg);
box-shadow:
0 0 0 1px var(--card-border, var(--border)) inset,
0 4px 12px rgba(0,0,0,0.25);
overflow: hidden;
}
.table-ports th,
.table-ports td {
border: 1px solid var(--border);
padding: 6px 10px;
}
.table-ports .th {
background: var(--bg);
color: var(--text);
font-weight: 600;
font-size: 13px;
border-bottom: 2px solid var(--border);
}
.table-ports .td {
font-size: 13px;
}
.table-ports .td.num { white-space: nowrap; }
.table-ports .td.proto,
.table-ports .td.state,
.table-ports .td.expect {
text-align: center;
}
/* optional: subtle row separation */
.table-ports tr:nth-child(even) {
background: color-mix(in srgb, var(--card-bg) 92%, var(--border) 8%);
}
/* ===== Host Header Row (within table-ports) ===== */
.table-ports .header {
position: relative;
background: var(--header-bg, var(--card-bg));
color: var(--header-text, var(--text));
font-weight: 700;
font-size: 14px;
border-radius: 10px 10px 0 0;
padding: 12px 14px;
border-bottom: 2px solid var(--header-border, var(--border));
text-shadow: 0 1px 2px rgba(0,0,0,0.25);
box-shadow: 0 2px 8px var(--header-shadow, rgba(0,0,0,0.2));
letter-spacing: 0.2px;
}
/* Subtle left accent stripe (matches summary style) */
.table-ports .header::before {
content: "";
position: absolute;
top: 0; bottom: 0; left: 0;
width: 5px;
border-radius: 10px 0 0 0;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 85%, transparent) 0%,
color-mix(in srgb, var(--accent) 55%, transparent) 100%
);
}
</style>
</head>
<body>
<div class="container">
<table role="presentation" class="section table-clean">
<tr>
<td class="card">
<div class="title-xl">Corporate WAN Perimeter</div>
<div class="meta muted">Generated: 2025-10-22 04:02:59</div>
</td>
</tr>
</table>
<table role="presentation" class="section table-clean">
<tr>
<td class="card summary-row">
Total hosts:
<strong class="text-strong">3</strong>&nbsp;&nbsp;
Matching expected:
<strong class="text-ok">2</strong>&nbsp;&nbsp;
With issues:
<strong class="text-issue">1</strong>
</td>
</tr>
</table>
<table role="presentation" class="section table-clean table-ports">
<tr>
<td colspan="5" class="header">
10.10.20.12 <span class="badge issue">ISSUES</span>
</td>
</tr>
<tr>
<td colspan="5" class="delta-row">
<strong>Unexpected TCP open ports:</strong>
<span class="muted">3128</span>
</td>
</tr>
<tr>
<td colspan="5" class="delta-row">
<strong>Expected TCP ports not seen:</strong>
<span class="muted">none</span>
</td>
</tr>
<tr>
<td colspan="5" class="delta-row">
<strong>Unexpected UDP open ports:</strong>
<span class="muted">none</span>
</td>
</tr>
<tr>
<td colspan="5" class="delta-row">
<strong>Expected UDP ports not seen:</strong>
<span class="muted">none</span>
</td>
</tr>
<tr>
<td colspan="5" class="pad-s">
<div class="subhead">Discovered Ports</div>
</td>
</tr>
<tr>
<td class="th">Protocol</td>
<td class="th">Port</td>
<td class="th">State</td>
<td class="th">Service</td>
<td class="th">Expectation</td>
</tr>
<tr>
<td class="td proto"><span class="chip">tcp</span></td>
<td class="td num">22</td>
<td class="td state"><span class="badge ok">open</span></td>
<td class="td svc">ssh</td>
<td class="td expect"><span class="badge ok">Expected</span></td>
</tr>
<tr>
<td class="td proto"><span class="chip">tcp</span></td>
<td class="td num">111</td>
<td class="td state"><span class="badge ok">open</span></td>
<td class="td svc">rpcbind</td>
<td class="td expect"><span class="badge ok">Expected</span></td>
</tr>
<tr>
<td class="td proto"><span class="chip">tcp</span></td>
<td class="td num">3128</td>
<td class="td state"><span class="badge ok">open</span></td>
<td class="td svc">squid-http</td>
<td class="td expect"><span class="badge issue">Issue</span></td>
</tr>
<tr>
<td class="td proto"><span class="chip">tcp</span></td>
<td class="td num">8006</td>
<td class="td state"><span class="badge ok">open</span></td>
<td class="td svc">wpl-analytics</td>
<td class="td expect"><span class="badge ok">Expected</span></td>
</tr>
<tr>
<td class="td proto"><span class="chip">udp</span></td>
<td class="td num">111</td>
<td class="td state"><span class="badge ok">open</span></td>
<td class="td svc">rpcbind</td>
<td class="td expect"><span class="badge ok">Expected</span></td>
</tr>
</table>
<table role="presentation" class="section table-clean">
<tr>
<td colspan="5" class="header">
10.10.20.4 <span class="badge ok">OK</span>
</td>
</tr>
<tr>
<td colspan="5" class="pad-s">
<span class="badge ok">OK</span>
&nbsp; <span class="muted">Matches expected ports.</span>
</td>
</tr>
</table>
<table role="presentation" class="section table-clean">
<tr>
<td colspan="5" class="header">
10.10.20.5 <span class="badge ok">OK</span>
</td>
</tr>
<tr>
<td colspan="5" class="pad-s">
<span class="badge ok">OK</span>
&nbsp; <span class="muted">Matches expected ports.</span>
</td>
</tr>
</table>
<div class="footer muted">
Report generated by mass-scan-v2 • 2025-10-22 04:02:59
</div>
</div>
</body>
</html>

View File

@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Compliance Report</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body style="margin:0;padding:16px;background:#ffffff">
<div style="max-width:860px;margin:0 auto;font-family:Segoe UI,Arial,sans-serif">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 12px 0">
<tr>
<td style="padding:10px;background:#0f172a;border-radius:10px;color:#e2e8f0">
<div style="font-size:18px;font-weight:700;margin-bottom:4px">Compliance Report</div>
<div style="font-size:12px;color:#94a3b8">Generated: 2025-10-17 21:42:08</div>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 16px 0">
<tr>
<td style="padding:10px;border:1px solid #e5e7eb;border-radius:8px">
Total hosts: <strong>2</strong>&nbsp;
Matching expected: <strong>2</strong>&nbsp;
With issues: <strong>0</strong>
</td>
</tr>
</table>
<div style="margin:8px 0;color:#64748b">
No hosts with issues found. ✅
</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="4" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
10.10.20.4 <span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#16a34a;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">OK</span> </td>
</tr>
<tr>
<td colspan="4" style="padding:10px 10px 8px 10px;font-size:13px">
<span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#16a34a;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">OK</span> &nbsp; Matches expected ports.
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="4" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
10.10.20.5 <span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#16a34a;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">OK</span> </td>
</tr>
<tr>
<td colspan="4" style="padding:10px 10px 8px 10px;font-size:13px">
<span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#16a34a;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">OK</span> &nbsp; Matches expected ports.
</td>
</tr>
</table>
<div style="margin-top:18px;font-size:11px;color:#94a3b8">
Report generated by mass-scan-v2 • 2025-10-17 21:42:08
</div>

View File

@@ -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": []}
]

View File

@@ -9,6 +9,7 @@ reporting:
report_name: Corporate WAN Perimeter report_name: Corporate WAN Perimeter
report_filename: corp-wan.html report_filename: corp-wan.html
full_details: true full_details: true
dark_mode: true
email_to: soc@example.com # single string is fine; or a list email_to: soc@example.com # single string is fine; or a list
email_cc: [] # explicitly none email_cc: [] # explicitly none
@@ -19,4 +20,8 @@ scan_targets:
- ip: 10.10.20.5 - ip: 10.10.20.5
expected_tcp: [22, 53, 80] expected_tcp: [22, 53, 80]
expected_udp: [53] expected_udp: [53]
- ip: 10.10.20.12
expected_tcp: [22, 111, 8006]
expected_udp: [111]

View File

@@ -8,6 +8,7 @@ reporting:
report_name: Corporate WAN Perimeter # Report Name report_name: Corporate WAN Perimeter # Report Name
report_filename: corp-wan.html # Report Filename report_filename: corp-wan.html # Report Filename
full_details: true # Show full details for ALL hosts (if nothing out of the ordinary is expected, still show ports) full_details: true # Show full details for ALL hosts (if nothing out of the ordinary is expected, still show ports)
dark_mode: true # Report generated will be in Dark colors or light colors theme
email_to: soc@example.com # single string is fine; or a list email_to: soc@example.com # single string is fine; or a list
email_cc: [] # explicitly none email_cc: [] # explicitly none

BIN
data/targets.xlsx Normal file

Binary file not shown.