Compare commits

..

2 Commits

Author SHA1 Message Date
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
13 changed files with 749 additions and 284 deletions

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]]]:
@@ -53,33 +49,29 @@ def results_to_open_sets(
def build_reports( def build_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
LIGHT_TEMPLATE = "report_light.html.j2"
DARK_TEMPLATE = "report_dark.html.j2"
template = LIGHT_TEMPLATE
if scan_config.reporting.dark_mode:
template = DARK_TEMPLATE
if scan_config.reporting.full_details:
show_only_issues = False
else:
show_only_issues = True
logger.info(f"Reporting Template Set to: {template}")
logger.info(f"Reporting 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) reports = build_reports(scan_config, discovered_sets)
write_html_report_jinja(reports=reports,host_results=scan_results,out_path=HTML_REPORT_FILE,title="Compliance Report",only_issues=True)
# build the HTML report
write_html_report_jinja(reports=reports,
host_results=scan_results,
out_path=file_out_path,
title=scan_config.reporting.report_name,
template_name=template,
only_issues=show_only_issues)
scanner.cleanup() scanner.cleanup()
def main(): def main():

View File

@@ -1,12 +1,14 @@
# utils/reporting_jinja.py
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from html import escape from html import escape
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Any, Dict, List, Set, Union
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 # <-- add HostReport, GroupedReports
from ipaddress import ip_address
def fmt_ports(ports: List[int]) -> str: def fmt_ports(ports: List[int]) -> str:
if not ports: if not ports:
@@ -45,8 +47,53 @@ def _env(templates_dir: Path) -> Environment:
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
# --- NEW: compatibility shim so renderer can accept either shape ---
ReportsArg = Union[GroupedReports, Dict[str, Dict[str, List[int]]]]
def _coerce_to_grouped(reports: ReportsArg) -> GroupedReports:
"""
Accept either:
- GroupedReports (new, preferred), or
- legacy dict: { ip: {"unexpected_tcp":[], "missing_tcp":[], "unexpected_udp":[], "missing_udp":[] } }
and return a GroupedReports instance.
"""
if isinstance(reports, GroupedReports):
return reports
# Legacy dict -> build HostReport objects
by_ip: Dict[str, HostReport] = {}
for ip, d in reports.items():
hr = HostReport(
ip=str(ip),
unexpected_tcp=list(d.get("unexpected_tcp", []) or []),
missing_tcp=list(d.get("missing_tcp", []) or []),
unexpected_udp=list(d.get("unexpected_udp", []) or []),
missing_udp=list(d.get("missing_udp", []) or []),
)
by_ip[ip] = hr
# Split & sort by IP
issues: List[HostReport] = []
expected: List[HostReport] = []
for hr in by_ip.values():
(issues if hr.has_issues() else expected).append(hr)
def _ip_key(hr: HostReport):
try:
return ip_address(hr.ip)
except ValueError:
return hr.ip
issues.sort(key=_ip_key)
expected.sort(key=_ip_key)
return GroupedReports(issues=issues, expected=expected, by_ip=by_ip)
def render_html_report_jinja( def render_html_report_jinja(
reports: Dict[str, Dict[str, List[int]]], reports: ReportsArg,
host_results: List[HostResult], host_results: List[HostResult],
templates_dir: Path, templates_dir: Path,
template_name: str = "report.html.j2", template_name: str = "report.html.j2",
@@ -56,31 +103,46 @@ def render_html_report_jinja(
env = _env(templates_dir) env = _env(templates_dir)
template = env.get_template(template_name) template = env.get_template(template_name)
total_hosts = len(reports) grouped = _coerce_to_grouped(reports)
hosts_with_issues = [ip for ip, r in reports.items() if any(r.values())]
ok_hosts = total_hosts - len(hosts_with_issues)
# No filtering — we'll show all, but only_issues changes template behavior. total_hosts = len(grouped.by_ip)
by_ip = {hr.address: hr for hr in host_results} ok_hosts = len(grouped.expected)
for hr in by_ip.values(): hosts_with_issues = [hr.ip for hr in grouped.issues]
# Build a mapping of IP -> HostResult and sort port rows for stable output
by_ip_results = {hr.address: hr for hr in host_results}
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)) hr.ports.sort(key=lambda p: (p.protocol, p.port))
html = template.render( html = template.render(
title=title, title=title,
generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# 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,
# New grouped context for simple “issues first, expected later” loops
issues=grouped.issues, # list[HostReport]
expected=grouped.expected, # list[HostReport]
by_ip=grouped.by_ip, # dict[str, HostReport]
# Legacy context (kept for compatibility if your template still uses it)
reports=reports, reports=reports,
host_results_by_ip=by_ip,
# Host scan details
host_results_by_ip=by_ip_results,
# Existing behavior switch
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]]], reports: ReportsArg,
host_results: List[HostResult], host_results: List[HostResult],
out_path: Path, out_path: Path,
templates_dir: Path = Path("templates"), templates_dir: Path = Path("templates"),

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,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
{# Dark mode variant of compliance report. Filename: report_dark.html.j2 #}
</head>
<body style="margin:0;padding:16px;background:#0b1020;color:#e5e7eb">
<div style="max-width:860px;margin:0 auto;font-family:Segoe UI,Arial,sans-serif">
{# ===== Title Card ===== #}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 12px 0">
<tr>
<td style="padding:14px 16px;background:#1e293b;border:1px solid #334155;border-radius:10px;color:#f1f5f9">
<div style="font-size:20px;font-weight:700;margin-bottom:4px">{{ title }}</div>
<div style="font-size:12px;color:#94a3b8">Generated: {{ generated }}</div>
</td>
</tr>
</table>
{# ===== Summary Bar ===== #}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 16px 0">
<tr>
<td style="
padding:12px 16px;
border:1px solid #334155;
border-radius:8px;
background:#1e293b;
color:#f1f5f9;
font-size:14px;
">
Total hosts:
<strong style="color:#f8fafc">{{ total_hosts }}</strong>&nbsp;&nbsp;
Matching expected:
<strong style="color:#4ade80">{{ ok_hosts }}</strong>&nbsp;&nbsp;
With issues:
<strong style="color:#f87171">{{ 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:#9ca3af">
All hosts matched expected ports.
</div>
{% endif %}
{% if only_issues and hosts_with_issues|length == 0 %}
<div style="margin:8px 0;color:#9ca3af">
No hosts with issues found. ✅
</div>
{% endif %}
{# ===== Host Sections (Issues first, then Expected) ===== #}
{# A small macro reused for delta lines #}
{% macro delta_row(label, ports) -%}
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong style="color:#e5e7eb">{{ label }}:</strong>
<span style="color:#cbd5e1">{{ 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 %} {# keep "r" alias so the rest of the block looks familiar #}
{% set has_issues = true %}
{% set host_row = host_results_by_ip.get(ip_key) %}
{% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0;border-collapse:separate;border-spacing:0">
<tr>
<td colspan="5" style="padding:12px 10px;background:#1e293b;color:#f1f5f9;font-weight:600;font-size:14px;border-radius:8px;border:1px solid #334155">
{{ header_title }} {{ badge('ISSUES', '#ef4444') }}
</td>
</tr>
<tr>
<td colspan="5" style="padding:10px 10px 6px 10px;font-size:13px">
{{ badge('ISSUES', '#ef4444') }}
</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" style="padding:8px 10px 6px 10px;font-size:13px">
<div style="font-weight:600;margin:8px 0;color:#e5e7eb">Discovered Ports</div>
</td>
</tr>
<tr>
<td style="padding:8px 10px;border:1px solid #1f2937;background:#111827;font-weight:600;color:#e5e7eb">Protocol</td>
<td style="padding:8px 10px;border:1px solid #1f2937;background:#111827;font-weight:600;color:#e5e7eb">Port</td>
<td style="padding:8px 10px;border:1px solid #1f2937;background:#111827;font-weight:600;color:#e5e7eb">State</td>
<td style="padding:8px 10px;border:1px solid #1f2937;background:#111827;font-weight:600;color:#e5e7eb">Service</td>
<td style="padding:8px 10px;border:1px solid #1f2937;background:#111827;font-weight:600;color:#e5e7eb">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) %}
{% set skip_row = (proto == 'udp' and ('closed' in state)) %}
{% set is_tcp = (proto == 'tcp') %}
{% set is_udp = (proto == 'udp') %}
{% set is_open = ('open' in state) %}
{% set is_issue = (is_open and (
(is_tcp and (p.port in (r.unexpected_tcp or []))) or
(is_udp and (p.port in (r.unexpected_udp or [])))
)) %}
{% set expectation_badge = (
badge('Issue', '#ef4444') if is_issue
else (badge('Expected', '#16a34a') if is_open else '<span style="color:#9ca3af">—</span>')
) %}
{% if not skip_row %}
{% set ns.visible_rows = ns.visible_rows + 1 %}
<tr>
<td style="padding:6px 10px;border:1px solid #1f2937;background:#0b1020">{{ pill_proto(p.protocol) }}</td>
<td style="padding:6px 10px;border:1px solid #1f2937;background:#0b1020;color:#e5e7eb">{{ p.port }}</td>
<td style="padding:6px 10px;border:1px solid #1f2937;background:#0b1020">{{ pill_state(p.state) }}</td>
<td style="padding:6px 10px;border:1px solid #1f2937;background:#0b1020;color:#cbd5e1">{{ p.service or '-' }}</td>
<td style="padding:6px 10px;border:1px solid #1f2937;background:#0b1020">{{ expectation_badge | safe }}</td>
</tr>
{% endif %}
{% endfor %}
{% if ns.visible_rows == 0 %}
<tr>
<td colspan="5" style="padding:8px 10px;border:1px solid #1f2937;background:#0b1020;color:#9ca3af">
No per-port details to display after filtering closed UDP results.
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="5" style="padding:8px 10px;border:1px solid #1f2937;background:#0b1020;color:#9ca3af">
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 has_issues = false %}
{% set host_row = host_results_by_ip.get(ip_key) %}
{% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0;border-collapse:separate;border-spacing:0">
<tr>
<td colspan="5" style="padding:12px 10px;background:#1e293b;color:#f1f5f9;font-weight:600;font-size:14px;border-radius:8px;border:1px solid #334155">
{{ header_title }} {{ badge('OK', '#16a34a') }}
</td>
</tr>
<tr>
<td colspan="5" style="padding:10px 10px 8px 10px;font-size:13px">
{{ badge('OK', '#16a34a') }} &nbsp; <span style="color:#cbd5e1">Matches expected ports.</span>
</td>
</tr>
</table>
{% endfor %}
{% endif %}
<div style="margin-top:18px;font-size:11px;color:#9ca3af">
Report generated by mass-scan-v2 • {{ generated }}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<!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 (LIGHT theme) ===== #}
{# Reusable delta row #}
{% macro delta_row(label, ports) -%}
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>{{ label }}:</strong> {{ fmt_ports(ports) }}
</td>
</tr>
{%- endmacro %}
{# ---------- 1) Issues first (already IP-sorted in Python) ---------- #}
{% for hr in issues %}
{% set ip_key = hr.ip %}
{% set r = hr %}
{% set host_row = host_results_by_ip.get(ip_key) %}
{% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="5" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
{{ header_title }} {{ badge('ISSUES', '#ef4444') }}
</td>
</tr>
<tr>
<td colspan="5" style="padding:10px 10px 6px 10px;font-size:13px">
{{ badge('ISSUES', '#ef4444') }}
</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" 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>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">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) %}
{% set skip_row = (proto == 'udp' and ('closed' in state)) %}
{% set is_tcp = (proto == 'tcp') %}
{% set is_udp = (proto == 'udp') %}
{% set is_open = ('open' in state) %}
{% set is_issue = (is_open and (
(is_tcp and (p.port in (r.unexpected_tcp or []))) or
(is_udp and (p.port in (r.unexpected_udp or [])))
)) %}
{% set expectation_badge = (
badge('Issue', '#ef4444') if is_issue
else (badge('Expected', '#16a34a') if is_open else '<span style="color:#64748b">—</span>')
) %}
{% if not skip_row %}
{% set ns.visible_rows = ns.visible_rows + 1 %}
<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>
<td style="padding:6px 10px;border:1px solid #e5e7eb">{{ expectation_badge | safe }}</td>
</tr>
{% endif %}
{% endfor %}
{% if ns.visible_rows == 0 %}
<tr>
<td colspan="5" style="padding:8px 10px;border:1px solid #e5e7eb;color:#64748b">
No per-port details to display after filtering closed UDP results.
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="5" style="padding:8px 10px;border:1px solid #e5e7eb;color:#64748b">
No per-port details available for this host.
</td>
</tr>
{% endif %}
</table>
{% endfor %}
{# ---------- 2) Expected / OK (only if not only_issues) ---------- #}
{% if not only_issues %}
{% for hr in expected %}
{% set ip_key = hr.ip %}
{% set host_row = host_results_by_ip.get(ip_key) %}
{% set header_title = ip_key ~ ((' (' ~ host_row.host ~ ')') if host_row and host_row.host else '') %}
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="5" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
{{ header_title }} {{ badge('OK', '#16a34a') }}
</td>
</tr>
<tr>
<td colspan="5" style="padding:10px 10px 8px 10px;font-size:13px">
{{ badge('OK', '#16a34a') }} &nbsp; Matches expected ports.
</td>
</tr>
</table>
{% endfor %}
{% endif %}
<div style="margin-top:18px;font-size:11px;color:#94a3b8">
Report generated by mass-scan-v2 • {{ generated }}
</div>

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

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

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Corporate WAN Perimeter</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">Corporate WAN Perimeter</div>
<div style="font-size:12px;color:#94a3b8">Generated: 2025-10-22 02:33:35</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>3</strong>&nbsp;
Matching expected: <strong>2</strong>&nbsp;
With issues: <strong>1</strong>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="5" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
10.10.20.12 <span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#ef4444;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">ISSUES</span>
</td>
</tr>
<tr>
<td colspan="5" style="padding:10px 10px 6px 10px;font-size:13px">
<span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#ef4444;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">ISSUES</span>
</td>
</tr>
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>Unexpected TCP open ports:</strong> 3128
</td>
</tr>
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>Expected TCP ports not seen:</strong> none
</td>
</tr>
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>Unexpected UDP open ports:</strong> none
</td>
</tr>
<tr>
<td colspan="5" style="padding:4px 10px 6px 10px;font-size:13px">
<strong>Expected UDP ports not seen:</strong> none
</td>
</tr>
<tr>
<td colspan="5" 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>
<td style="padding:8px 10px;border:1px solid #e5e7eb;background:#f8fafc;font-weight:600">Expectation</td>
</tr>
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#334155;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">tcp</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">22</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">open</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">ssh</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">Expected</span></td>
</tr>
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#334155;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">tcp</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">111</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">open</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">rpcbind</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">Expected</span></td>
</tr>
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#334155;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">tcp</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">3128</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">open</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">squid-http</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#ef4444;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">Issue</span></td>
</tr>
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#334155;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">tcp</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">8006</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">open</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">wpl-analytics</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">Expected</span></td>
</tr>
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><span style="display:inline-block;padding:2px 6px;border-radius:12px;font-size:12px;line-height:16px;background:#334155;color:#ffffff;font-family:Segoe UI,Arial,sans-serif">udp</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">111</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">open</span></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb">rpcbind</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb"><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">Expected</span></td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 18px 0">
<tr>
<td colspan="5" 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="5" 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="5" 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="5" 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-22 02:33:35
</div>

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: false
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]