Compare commits
7 Commits
583cbffeca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d307a37a5b | ||
| d5f10e4bfb | |||
| 4d3599149f | |||
| ed3b1de1cd | |||
| 4846af66c4 | |||
| 68aa25993d | |||
| f394e268da |
@@ -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
|
||||||
|
|
||||||
|
|||||||
142
app/main.py
142
app/main.py
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
220
app/templates/base_report.html.j2
Normal file
220
app/templates/base_report.html.j2
Normal 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>
|
||||||
30
app/templates/palettes/dark.css.j2
Normal file
30
app/templates/palettes/dark.css.j2
Normal 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 */
|
||||||
|
}
|
||||||
35
app/templates/palettes/light.css.j2
Normal file
35
app/templates/palettes/light.css.j2
Normal 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 */
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
Matching expected: <strong>{{ ok_hosts }}</strong>
|
|
||||||
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') }} 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>
|
|
||||||
164
app/templates/report_body.html.j2
Normal file
164
app/templates/report_body.html.j2
Normal 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>
|
||||||
|
Matching expected:
|
||||||
|
<strong class="text-ok">{{ ok_hosts }}</strong>
|
||||||
|
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>
|
||||||
|
<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 %}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
410
data/output/corp-wan.html
Normal 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>
|
||||||
|
Matching expected:
|
||||||
|
<strong class="text-ok">2</strong>
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
@@ -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>
|
|
||||||
Matching expected: <strong>2</strong>
|
|
||||||
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> 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> 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>
|
|
||||||
@@ -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": []}
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -20,3 +21,7 @@ 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]
|
||||||
@@ -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
BIN
data/targets.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user