init commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/venv/
|
||||||
|
.env
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Ubuntu slim base
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
VIRTUAL_ENV=/opt/venv \
|
||||||
|
PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# copy only requirements first so pip install can be cached
|
||||||
|
COPY app/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
# Minimal runtime: python3 + nmap (+ ca certs/tzdata), clean apt cache
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
python3 python3-venv python3-pip \
|
||||||
|
nmap ca-certificates tzdata && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- Create & activate venv ----
|
||||||
|
RUN python3 -m venv $VIRTUAL_ENV
|
||||||
|
|
||||||
|
# ---- Install Python deps into venv ----
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
# ---- App code ----
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app/ /app/
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
ENTRYPOINT ["python3", "/app/main.py"]
|
||||||
108
app/main.py
Normal file
108
app/main.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
port_checker.py
|
||||||
|
- expects `expected.json` in same dir (see format below)
|
||||||
|
- writes nmap XML to a temp file, parses, compares, prints a report
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
|
from utils.scanner import nmap_scanner
|
||||||
|
from utils.models import HostResult
|
||||||
|
from reporting_jinja import write_html_report_jinja
|
||||||
|
|
||||||
|
EXPECTED_FILE = Path() / "data" / "expected.json"
|
||||||
|
HTML_REPORT_FILE = Path() / "data" / "report.html"
|
||||||
|
|
||||||
|
def load_expected(path: Path) -> Dict[str, Dict[str, Set[int]]]:
|
||||||
|
with path.open() as fh:
|
||||||
|
arr = json.load(fh)
|
||||||
|
out = {}
|
||||||
|
for entry in arr:
|
||||||
|
ip = entry["ip"]
|
||||||
|
out[ip] = {
|
||||||
|
"expected_tcp": set(entry.get("expected_tcp", [])),
|
||||||
|
"expected_udp": set(entry.get("expected_udp", [])),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
# def write_targets(expected: Dict[str, Dict[str, Set[int]]], path: Path) -> None:
|
||||||
|
path.write_text("\n".join(sorted(expected.keys())) + "\n")
|
||||||
|
|
||||||
|
#
|
||||||
|
def results_to_open_sets(
|
||||||
|
results: List[HostResult],
|
||||||
|
count_as_open: Set[str] = frozenset({"open", "open|filtered"})) -> Dict[str, Dict[str, Set[int]]]:
|
||||||
|
"""
|
||||||
|
Convert HostResult list to:
|
||||||
|
{ ip: {"tcp": {open ports}, "udp": {open ports}} }
|
||||||
|
Only include ports whose state is in `count_as_open`.
|
||||||
|
"""
|
||||||
|
out: Dict[str, Dict[str, Set[int]]] = {}
|
||||||
|
for hr in results:
|
||||||
|
tcp = set()
|
||||||
|
udp = set()
|
||||||
|
for p in hr.ports:
|
||||||
|
if p.state.lower() in count_as_open:
|
||||||
|
(tcp if p.protocol == "tcp" else udp).add(p.port)
|
||||||
|
out[hr.address] = {"tcp": tcp, "udp": udp}
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Build the "reports" dict (what the HTML renderer expects)
|
||||||
|
def build_reports(
|
||||||
|
expected: Dict[str, Dict[str, Set[int]]],
|
||||||
|
discovered: Dict[str, Dict[str, Set[int]]],
|
||||||
|
) -> Dict[str, Dict[str, List[int]]]:
|
||||||
|
"""
|
||||||
|
Create the per-IP delta structure:
|
||||||
|
{
|
||||||
|
ip: {
|
||||||
|
"unexpected_tcp": [...],
|
||||||
|
"missing_tcp": [...],
|
||||||
|
"unexpected_udp": [...],
|
||||||
|
"missing_udp": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
reports: Dict[str, Dict[str, List[int]]] = {}
|
||||||
|
all_ips = set(expected.keys()) | set(discovered.keys())
|
||||||
|
|
||||||
|
for ip in sorted(all_ips):
|
||||||
|
exp_tcp = expected.get(ip, {}).get("expected_tcp", set())
|
||||||
|
exp_udp = expected.get(ip, {}).get("expected_udp", set())
|
||||||
|
disc_tcp = discovered.get(ip, {}).get("tcp", set())
|
||||||
|
disc_udp = discovered.get(ip, {}).get("udp", set())
|
||||||
|
|
||||||
|
reports[ip] = {
|
||||||
|
"unexpected_tcp": sorted(disc_tcp - exp_tcp),
|
||||||
|
"missing_tcp": sorted(exp_tcp - disc_tcp),
|
||||||
|
"unexpected_udp": sorted(disc_udp - exp_udp),
|
||||||
|
"missing_udp": sorted(exp_udp - disc_udp),
|
||||||
|
}
|
||||||
|
return reports
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
# repo = ScanConfigRepository()
|
||||||
|
|
||||||
|
if not EXPECTED_FILE.exists():
|
||||||
|
print("Expected File not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
expected = load_expected(EXPECTED_FILE)
|
||||||
|
targets = sorted(expected.keys())
|
||||||
|
scanner = nmap_scanner(targets)
|
||||||
|
scan_results = scanner.scan_targets()
|
||||||
|
discovered_sets = results_to_open_sets(scan_results, count_as_open={"open", "open|filtered"})
|
||||||
|
reports = build_reports(expected, discovered_sets)
|
||||||
|
write_html_report_jinja(reports=reports,host_results=scan_results,out_path=HTML_REPORT_FILE,title="Compliance Report",only_issues=True)
|
||||||
|
scanner.cleanup()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
95
app/reporting_jinja.py
Normal file
95
app/reporting_jinja.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
from utils.models import HostResult
|
||||||
|
|
||||||
|
def fmt_ports(ports: List[int]) -> str:
|
||||||
|
if not ports:
|
||||||
|
return "none"
|
||||||
|
return ", ".join(str(p) for p in sorted(set(int(x) for x in ports)))
|
||||||
|
|
||||||
|
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:
|
||||||
|
s = (state or "").lower()
|
||||||
|
if s == "open":
|
||||||
|
return badge("open", "#16a34a")
|
||||||
|
if s in ("open|filtered",):
|
||||||
|
return badge("open|filtered", "#0ea5e9")
|
||||||
|
if s == "filtered":
|
||||||
|
return badge("filtered", "#f59e0b", "#111111")
|
||||||
|
if s == "closed":
|
||||||
|
return badge("closed", "#ef4444")
|
||||||
|
return badge(s, "#6b7280")
|
||||||
|
|
||||||
|
def pill_proto(proto: str) -> str:
|
||||||
|
return badge((proto or "").lower(), "#334155")
|
||||||
|
|
||||||
|
def _env(templates_dir: Path) -> Environment:
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(templates_dir)),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
env.globals.update(badge=badge, pill_state=pill_state, pill_proto=pill_proto, fmt_ports=fmt_ports)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def render_html_report_jinja(
|
||||||
|
reports: Dict[str, Dict[str, List[int]]],
|
||||||
|
host_results: List[HostResult],
|
||||||
|
templates_dir: Path,
|
||||||
|
template_name: str = "report.html.j2",
|
||||||
|
title: str = "Port Compliance Report",
|
||||||
|
only_issues: bool = False,
|
||||||
|
) -> str:
|
||||||
|
env = _env(templates_dir)
|
||||||
|
template = env.get_template(template_name)
|
||||||
|
|
||||||
|
total_hosts = len(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.
|
||||||
|
by_ip = {hr.address: hr for hr in host_results}
|
||||||
|
for hr in by_ip.values():
|
||||||
|
if hr and hr.ports:
|
||||||
|
hr.ports.sort(key=lambda p: (p.protocol, p.port))
|
||||||
|
|
||||||
|
html = template.render(
|
||||||
|
title=title,
|
||||||
|
generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
total_hosts=total_hosts,
|
||||||
|
ok_hosts=ok_hosts,
|
||||||
|
hosts_with_issues=hosts_with_issues,
|
||||||
|
reports=reports,
|
||||||
|
host_results_by_ip=by_ip,
|
||||||
|
only_issues=only_issues,
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def write_html_report_jinja(
|
||||||
|
reports: Dict[str, Dict[str, List[int]]],
|
||||||
|
host_results: List[HostResult],
|
||||||
|
out_path: Path,
|
||||||
|
templates_dir: Path = Path("templates"),
|
||||||
|
template_name: str = "report.html.j2",
|
||||||
|
title: str = "Port Compliance Report",
|
||||||
|
only_issues: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
html = render_html_report_jinja(
|
||||||
|
reports, host_results, templates_dir, template_name, title, only_issues
|
||||||
|
)
|
||||||
|
out_path.write_text(html, encoding="utf-8")
|
||||||
|
return out_path
|
||||||
2
app/requirements.txt
Normal file
2
app/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
116
app/templates/report.html.j2
Normal file
116
app/templates/report.html.j2
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<!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>
|
||||||
30
app/utils/models.py
Normal file
30
app/utils/models.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortFinding:
|
||||||
|
"""
|
||||||
|
A single discovered port on a host.
|
||||||
|
protocol: 'tcp' or 'udp'
|
||||||
|
state: 'open', 'closed', 'filtered', 'open|filtered', etc.
|
||||||
|
service: optional nmap-reported service name (e.g., 'ssh', 'http')
|
||||||
|
"""
|
||||||
|
port: int
|
||||||
|
protocol: str
|
||||||
|
state: str
|
||||||
|
service: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostResult:
|
||||||
|
"""
|
||||||
|
Results for a single host.
|
||||||
|
address: IP address (e.g., '192.0.2.10')
|
||||||
|
host: primary hostname if reported by nmap (may be None)
|
||||||
|
ports: list of PortFinding instances for this host
|
||||||
|
"""
|
||||||
|
address: str
|
||||||
|
host: Optional[str] = None
|
||||||
|
ports: List[PortFinding] = field(default_factory=list)
|
||||||
210
app/utils/scan_config_loader.py
Normal file
210
app/utils/scan_config_loader.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanTarget:
|
||||||
|
"""
|
||||||
|
One IP and its expected ports.
|
||||||
|
"""
|
||||||
|
ip: str
|
||||||
|
expected_tcp: List[int] = field(default_factory=list)
|
||||||
|
expected_udp: List[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanOptions:
|
||||||
|
"""
|
||||||
|
Feature toggles that affect how scans are executed.
|
||||||
|
"""
|
||||||
|
udp_scan: bool = False
|
||||||
|
tls_security_scan: bool = True
|
||||||
|
tls_exp_check: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Reporting:
|
||||||
|
"""
|
||||||
|
Output/report preferences for this config file.
|
||||||
|
"""
|
||||||
|
report_name: str = "Scan Report"
|
||||||
|
report_filename: str = "report.html"
|
||||||
|
full_details: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanConfigFile:
|
||||||
|
"""
|
||||||
|
Full configuration for a single logical scan "set" (e.g., DMZ, WAN).
|
||||||
|
"""
|
||||||
|
name: str = "Unnamed"
|
||||||
|
scan_options: ScanOptions = field(default_factory=ScanOptions)
|
||||||
|
reporting: Reporting = field(default_factory=Reporting)
|
||||||
|
scan_targets: List[ScanTarget] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ScanConfigRepository:
|
||||||
|
"""
|
||||||
|
Loads and validates *.yaml scan configuration files from a directory.
|
||||||
|
|
||||||
|
Search order for the config directory:
|
||||||
|
1) Explicit path argument to load_all()
|
||||||
|
2) Environment variable SCAN_TARGETS_DIR
|
||||||
|
3) Default: /data/scan_targets
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPPORTED_EXT = (".yaml", ".yml")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._loaded: List[ScanConfigFile] = []
|
||||||
|
|
||||||
|
def load_all(self, directory: Optional[Path] = None) -> List[ScanConfigFile]:
|
||||||
|
"""
|
||||||
|
Load all YAML configs from the given directory and return them.
|
||||||
|
|
||||||
|
:param directory: Optional explicit directory path.
|
||||||
|
"""
|
||||||
|
root = self._resolve_directory(directory)
|
||||||
|
logger.info("Loading scan configs from: %s", root)
|
||||||
|
|
||||||
|
files = sorted([p for p in root.iterdir() if p.suffix.lower() in self.SUPPORTED_EXT])
|
||||||
|
logger.info("Found %d config file(s).", len(files))
|
||||||
|
|
||||||
|
configs: List[ScanConfigFile] = []
|
||||||
|
for fpath in files:
|
||||||
|
try:
|
||||||
|
data = self._read_yaml(fpath)
|
||||||
|
cfg = self._parse_config(data, default_name=fpath.stem)
|
||||||
|
self._validate_config(cfg, source=str(fpath))
|
||||||
|
configs.append(cfg)
|
||||||
|
logger.info("Loaded config: %s (%s targets)", cfg.name, len(cfg.scan_targets))
|
||||||
|
except Exception as exc:
|
||||||
|
# Fail-open vs fail-fast is up to you; here we log and continue.
|
||||||
|
logger.error("Failed to load %s: %s", fpath, exc)
|
||||||
|
|
||||||
|
self._loaded = configs
|
||||||
|
return configs
|
||||||
|
|
||||||
|
def _resolve_directory(self, directory: Optional[Path]) -> Path:
|
||||||
|
"""
|
||||||
|
Decide which directory to load from.
|
||||||
|
"""
|
||||||
|
if directory:
|
||||||
|
return directory
|
||||||
|
env = os.getenv("SCAN_TARGETS_DIR")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
return Path("/data/scan_targets")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_yaml(path: Path) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Safely read YAML file into a Python dict.
|
||||||
|
"""
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Top-level YAML must be a mapping (dict).")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_int_list(value: Any, field_name: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Coerce a sequence to a list of ints; raise if invalid.
|
||||||
|
"""
|
||||||
|
if value in (None, []):
|
||||||
|
return []
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
raise TypeError(f"'{field_name}' must be a list of integers.")
|
||||||
|
out: List[int] = []
|
||||||
|
for v in value:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
# Avoid True/False being treated as 1/0
|
||||||
|
raise TypeError(f"'{field_name}' must contain integers, not booleans.")
|
||||||
|
try:
|
||||||
|
out.append(int(v))
|
||||||
|
except Exception as exc:
|
||||||
|
raise TypeError(f"'{field_name}' contains a non-integer: {v!r}") from exc
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _parse_config(self, data: Dict[str, Any], default_name: str) -> ScanConfigFile:
|
||||||
|
"""
|
||||||
|
Convert a raw dict (from YAML) into a validated ScanConfigFile.
|
||||||
|
"""
|
||||||
|
name = str(data.get("name", default_name))
|
||||||
|
|
||||||
|
# Parse scan_options
|
||||||
|
so_raw = data.get("scan_options", {}) or {}
|
||||||
|
scan_options = ScanOptions(
|
||||||
|
udp_scan=bool(so_raw.get("udp_scan", False)),
|
||||||
|
tls_security_scan=bool(so_raw.get("tls_security_scan", True)),
|
||||||
|
tls_exp_check=bool(so_raw.get("tls_exp_check", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse reporting
|
||||||
|
rep_raw = data.get("reporting", {}) or {}
|
||||||
|
reporting = Reporting(
|
||||||
|
report_name=str(rep_raw.get("report_name", "Scan Report")),
|
||||||
|
report_filename=str(rep_raw.get("report_filename", "report.html")),
|
||||||
|
full_details=bool(rep_raw.get("full_details", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse targets
|
||||||
|
targets_raw = data.get("scan_targets", []) or []
|
||||||
|
if not isinstance(targets_raw, list):
|
||||||
|
raise TypeError("'scan_targets' must be a list.")
|
||||||
|
targets: List[ScanTarget] = []
|
||||||
|
for idx, item in enumerate(targets_raw, start=1):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise TypeError(f"scan_targets[{idx}] must be a mapping (dict).")
|
||||||
|
ip = item.get("ip")
|
||||||
|
if not ip or not isinstance(ip, str):
|
||||||
|
raise ValueError(f"scan_targets[{idx}].ip must be a non-empty string.")
|
||||||
|
expected_tcp = self._as_int_list(item.get("expected_tcp", []), "expected_tcp")
|
||||||
|
expected_udp = self._as_int_list(item.get("expected_udp", []), "expected_udp")
|
||||||
|
targets.append(ScanTarget(ip=ip, expected_tcp=expected_tcp, expected_udp=expected_udp))
|
||||||
|
|
||||||
|
return ScanConfigFile(
|
||||||
|
name=name,
|
||||||
|
scan_options=scan_options,
|
||||||
|
reporting=reporting,
|
||||||
|
scan_targets=targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_config(cfg: ScanConfigFile, source: str) -> None:
|
||||||
|
"""
|
||||||
|
Lightweight semantic checks.
|
||||||
|
"""
|
||||||
|
# Example: disallow duplicate IPs within a single file
|
||||||
|
seen: Dict[str, int] = {}
|
||||||
|
for t in cfg.scan_targets:
|
||||||
|
seen[t.ip] = seen.get(t.ip, 0) + 1
|
||||||
|
dups = [ip for ip, count in seen.items() if count > 1]
|
||||||
|
if dups:
|
||||||
|
raise ValueError(f"{source}: duplicate IP(s) in scan_targets: {dups}")
|
||||||
|
|
||||||
|
# Optional helpers
|
||||||
|
|
||||||
|
def list_configs(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Return names of loaded configs for UI selection.
|
||||||
|
"""
|
||||||
|
return [c.name for c in self._loaded]
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> Optional[ScanConfigFile]:
|
||||||
|
"""
|
||||||
|
Fetch a loaded config by its name.
|
||||||
|
"""
|
||||||
|
for c in self._loaded:
|
||||||
|
if c.name == name:
|
||||||
|
return c
|
||||||
|
return None
|
||||||
179
app/utils/scanner.py
Normal file
179
app/utils/scanner.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from utils.models import HostResult, PortFinding
|
||||||
|
|
||||||
|
|
||||||
|
class nmap_scanner:
|
||||||
|
|
||||||
|
TCP_REPORT_PATH = Path() / "data" / "nmap-tcp-results.xml"
|
||||||
|
UDP_REPORT_PATH = Path() / "data" / "nmap-udp-results.xml"
|
||||||
|
NMAP_RESULTS_PATH = Path() / "data" / "nmap-results.xml"
|
||||||
|
|
||||||
|
def __init__(self, targets:Iterable[str],scan_udp=False):
|
||||||
|
self.targets = list(targets)
|
||||||
|
self.scan_udp = scan_udp
|
||||||
|
pass
|
||||||
|
|
||||||
|
def scan_targets(self):
|
||||||
|
tcp_results = self.run_nmap_tcp_all()
|
||||||
|
|
||||||
|
if self.scan_udp:
|
||||||
|
udp_results = self.run_nmap_udp()
|
||||||
|
all_results = List[HostResult] = self.merge_host_results(tcp_results,udp_results)
|
||||||
|
else:
|
||||||
|
all_results = tcp_results
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
def run_nmap_tcp_all(self, min_rate: int = 1000, assume_up: bool = True) -> List[HostResult]:
|
||||||
|
"""
|
||||||
|
Run a TCP SYN scan across all ports (0-65535) for the given targets and parse results.
|
||||||
|
Returns a list of HostResult objects.
|
||||||
|
"""
|
||||||
|
targets_list = self.targets
|
||||||
|
if not targets_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"nmap",
|
||||||
|
"-sS", # TCP SYN scan
|
||||||
|
"-p-", # all TCP ports
|
||||||
|
"-T4",
|
||||||
|
"--min-rate", str(min_rate),
|
||||||
|
"-oX", str(self.TCP_REPORT_PATH),
|
||||||
|
]
|
||||||
|
if assume_up:
|
||||||
|
cmd.append("-Pn")
|
||||||
|
cmd.extend(targets_list)
|
||||||
|
|
||||||
|
self._run_nmap(cmd)
|
||||||
|
return self.parse_nmap_xml(self.TCP_REPORT_PATH)
|
||||||
|
|
||||||
|
def run_nmap_udp(self, ports: Optional[Iterable[int]] = None, min_rate: int = 500, assume_up: bool = True) -> List[HostResult]:
|
||||||
|
"""
|
||||||
|
Run a UDP scan for the provided ports (recommended to keep this list small).
|
||||||
|
If 'ports' is None, nmap defaults to its "top" UDP ports; full -p- UDP is very slow.
|
||||||
|
"""
|
||||||
|
targets_list = self.targets
|
||||||
|
if not targets_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"nmap",
|
||||||
|
"-sU", # UDP scan
|
||||||
|
"-T3", # less aggressive timing by default for UDP
|
||||||
|
"--min-rate", str(min_rate),
|
||||||
|
"-oX", str(self.UDP_REPORT_PATH),
|
||||||
|
]
|
||||||
|
if assume_up:
|
||||||
|
cmd.append("-Pn")
|
||||||
|
|
||||||
|
if ports:
|
||||||
|
# Explicit port set
|
||||||
|
port_list = sorted(set(int(p) for p in ports))
|
||||||
|
port_str = ",".join(str(p) for p in port_list)
|
||||||
|
cmd.extend(["-p", port_str])
|
||||||
|
|
||||||
|
cmd.extend(targets_list)
|
||||||
|
|
||||||
|
self._run_nmap(cmd)
|
||||||
|
return self.parse_nmap_xml(self.UDP_REPORT_PATH)
|
||||||
|
|
||||||
|
def merge_host_results(self, *result_sets: List[HostResult]) -> List[HostResult]:
|
||||||
|
"""
|
||||||
|
Merge multiple lists of HostResult (e.g., TCP set + UDP set) by address.
|
||||||
|
Ports are combined; hostnames preserved if found in any set.
|
||||||
|
"""
|
||||||
|
merged: Dict[str, HostResult] = {}
|
||||||
|
for results in result_sets:
|
||||||
|
for hr in results:
|
||||||
|
if hr.address not in merged:
|
||||||
|
merged[hr.address] = HostResult(address=hr.address, host=hr.host, ports=list(hr.ports))
|
||||||
|
else:
|
||||||
|
existing = merged[hr.address]
|
||||||
|
# prefer a hostname if we didn't have one yet
|
||||||
|
if not existing.host and hr.host:
|
||||||
|
existing.host = hr.host
|
||||||
|
# merge ports (avoid dupes)
|
||||||
|
existing_ports_key = {(p.protocol, p.port, p.state, p.service) for p in existing.ports}
|
||||||
|
for p in hr.ports:
|
||||||
|
key = (p.protocol, p.port, p.state, p.service)
|
||||||
|
if key not in existing_ports_key:
|
||||||
|
existing.ports.append(p)
|
||||||
|
existing_ports_key.add(key)
|
||||||
|
# Sort ports in each host for stable output
|
||||||
|
for hr in merged.values():
|
||||||
|
hr.ports.sort(key=lambda p: (p.protocol, p.port))
|
||||||
|
return sorted(merged.values(), key=lambda h: h.address)
|
||||||
|
|
||||||
|
def parse_nmap_xml(self, xml_path: Path) -> List[HostResult]:
|
||||||
|
"""
|
||||||
|
Parse an Nmap XML file into a list of HostResult objects.
|
||||||
|
Captures per-port protocol, state, and optional service name.
|
||||||
|
"""
|
||||||
|
tree = ET.parse(str(xml_path))
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
results: List[HostResult] = []
|
||||||
|
|
||||||
|
for host_el in root.findall("host"):
|
||||||
|
# Address
|
||||||
|
addr_el = host_el.find("address")
|
||||||
|
if addr_el is None:
|
||||||
|
continue
|
||||||
|
address = addr_el.get("addr", "")
|
||||||
|
|
||||||
|
# Hostname (if present)
|
||||||
|
hostname: Optional[str] = None
|
||||||
|
hostnames_el = host_el.find("hostnames")
|
||||||
|
if hostnames_el is not None:
|
||||||
|
hn_el = hostnames_el.find("hostname")
|
||||||
|
if hn_el is not None:
|
||||||
|
hostname = hn_el.get("name")
|
||||||
|
|
||||||
|
findings: List[PortFinding] = []
|
||||||
|
ports_el = host_el.find("ports")
|
||||||
|
if ports_el is not None:
|
||||||
|
for port_el in ports_el.findall("port"):
|
||||||
|
protocol = (port_el.get("protocol") or "").lower() # 'tcp' or 'udp'
|
||||||
|
portid_str = port_el.get("portid", "0")
|
||||||
|
try:
|
||||||
|
portid = int(portid_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
state_el = port_el.find("state")
|
||||||
|
state = (state_el.get("state") if state_el is not None else "unknown").lower()
|
||||||
|
|
||||||
|
# optional service info
|
||||||
|
service_el = port_el.find("service")
|
||||||
|
service_name = service_el.get("name") if service_el is not None else None
|
||||||
|
|
||||||
|
findings.append(PortFinding(port=portid, protocol=protocol, state=state, service=service_name))
|
||||||
|
|
||||||
|
results.append(HostResult(address=address, host=hostname, ports=findings))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _run_nmap(self, cmd: List[str]) -> None:
|
||||||
|
"""
|
||||||
|
Run a command and raise on non-zero exit with a readable message.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise RuntimeError(f"Command failed ({exc.returncode}): {' '.join(cmd)}") from exc
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
if self.TCP_REPORT_PATH.exists():
|
||||||
|
self.TCP_REPORT_PATH.unlink()
|
||||||
|
if self.UDP_REPORT_PATH.exists():
|
||||||
|
self.UDP_REPORT_PATH.unlink()
|
||||||
|
if self.NMAP_RESULTS_PATH.exists:
|
||||||
|
self.NMAP_RESULTS_PATH.unlink()
|
||||||
|
|
||||||
127
app/utils/settings.py
Normal file
127
app/utils/settings.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#
|
||||||
|
# Note the settings file is hardcoded in this class at the top after imports.
|
||||||
|
#
|
||||||
|
# To make a new settings section, just add the setting dict to your yaml
|
||||||
|
# and then define the data class below in the config data classes area.
|
||||||
|
#
|
||||||
|
# Example use from anywhere - this will always return the same singleton
|
||||||
|
# from settings import get_settings
|
||||||
|
# def main():
|
||||||
|
# settings = get_settings()
|
||||||
|
# print(settings.database.host) # Autocomplete works
|
||||||
|
# print(settings.logging.level)
|
||||||
|
|
||||||
|
# if __name__ == "__main__":
|
||||||
|
# main()
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, TypeVar
|
||||||
|
from dataclasses import dataclass, fields, is_dataclass, field, MISSING
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logger = logging.getLogger(__file__)
|
||||||
|
msg = (
|
||||||
|
"Required modules are not installed. "
|
||||||
|
"Can not continue with module / application loading.\n"
|
||||||
|
"Install it with: pip install -r requirements"
|
||||||
|
)
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
logger.error(msg)
|
||||||
|
exit()
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS_FILE = Path.cwd() / "config" /"settings.yaml"
|
||||||
|
|
||||||
|
# ---------- CONFIG DATA CLASSES ----------
|
||||||
|
@dataclass
|
||||||
|
class DatabaseConfig:
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
username: str = "root"
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
name: str = "MyApp"
|
||||||
|
version_major: int = 1
|
||||||
|
version_minor: int = 0
|
||||||
|
production: bool = False
|
||||||
|
enabled: bool = True
|
||||||
|
token_expiry: int = 3600
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||||
|
app: AppConfig = field(default_factory=AppConfig)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, path: str | Path) -> "Settings":
|
||||||
|
"""Load settings from YAML file into a Settings object."""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
init_kwargs = {}
|
||||||
|
for f_def in fields(cls):
|
||||||
|
yaml_value = raw.get(f_def.name, None)
|
||||||
|
|
||||||
|
# Determine default value from default_factory or default
|
||||||
|
if f_def.default_factory is not MISSING:
|
||||||
|
default_value = f_def.default_factory()
|
||||||
|
elif f_def.default is not MISSING:
|
||||||
|
default_value = f_def.default
|
||||||
|
else:
|
||||||
|
default_value = None
|
||||||
|
|
||||||
|
# Handle nested dataclasses
|
||||||
|
if is_dataclass(f_def.type):
|
||||||
|
if isinstance(yaml_value, dict):
|
||||||
|
# Merge YAML values with defaults
|
||||||
|
merged_data = {fld.name: getattr(default_value, fld.name) for fld in fields(f_def.type)}
|
||||||
|
merged_data.update(yaml_value)
|
||||||
|
init_kwargs[f_def.name] = f_def.type(**merged_data)
|
||||||
|
else:
|
||||||
|
init_kwargs[f_def.name] = default_value
|
||||||
|
else:
|
||||||
|
init_kwargs[f_def.name] = yaml_value if yaml_value is not None else default_value
|
||||||
|
|
||||||
|
return cls(**init_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- SINGLETON DECORATOR ----------
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
def singleton_loader(func: Callable[..., T]) -> Callable[..., T]:
|
||||||
|
"""Ensure the function only runs once, returning the cached value."""
|
||||||
|
cache: dict[str, T] = {}
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs) -> T:
|
||||||
|
if func.__name__ not in cache:
|
||||||
|
cache[func.__name__] = func(*args, **kwargs)
|
||||||
|
return cache[func.__name__]
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@singleton_loader
|
||||||
|
def get_settings(config_path: str | Path | None = None) -> Settings:
|
||||||
|
"""
|
||||||
|
Returns the singleton Settings instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Optional path to the YAML config file. If not provided,
|
||||||
|
defaults to 'config/settings.yaml' in the current working directory.
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
config_path = DEFAULT_SETTINGS_FILE
|
||||||
|
else:
|
||||||
|
config_path = Path(config_path)
|
||||||
|
|
||||||
|
return Settings.from_yaml(config_path)
|
||||||
5
data/expected.json
Normal file
5
data/expected.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{"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": []}
|
||||||
|
]
|
||||||
121
data/report.html
Normal file
121
data/report.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!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 17:19:25</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>
|
||||||
|
Matching expected: <strong>2</strong>
|
||||||
|
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="4" style="padding:12px 10px;background:#0f172a;color:#e2e8f0;font-weight:600;font-size:14px;border-radius:8px">
|
||||||
|
10.10.99.10 (git.sneakygeek.net) <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.99.2 <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="4" 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="4" style="padding:4px 10px 6px 10px;font-size:13px">
|
||||||
|
<strong>Unexpected TCP open ports:</strong> 80
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding:4px 10px 6px 10px;font-size:13px">
|
||||||
|
<strong>Expected TCP ports not seen:</strong> 222, 3000
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding:4px 10px 6px 10px;font-size:13px">
|
||||||
|
<strong>Unexpected UDP open ports:</strong> none
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding:4px 10px 6px 10px;font-size:13px">
|
||||||
|
<strong>Expected UDP ports not seen:</strong> none
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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">80</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">http</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.99.6 <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 17:19:25
|
||||||
|
</div>
|
||||||
34
data/rw-expected.json
Normal file
34
data/rw-expected.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{"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": []}
|
||||||
|
]
|
||||||
15
data/scan_targets/corp-wan.yaml
Normal file
15
data/scan_targets/corp-wan.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: Corp WAN
|
||||||
|
scan_options:
|
||||||
|
udp_scan: true
|
||||||
|
tls_security_scan: false
|
||||||
|
tls_exp_check: false
|
||||||
|
|
||||||
|
reporting:
|
||||||
|
report_name: Corporate WAN Perimeter
|
||||||
|
report_filename: corp-wan.html
|
||||||
|
full_details: true
|
||||||
|
|
||||||
|
scan_targets:
|
||||||
|
- ip: 10.10.20.5
|
||||||
|
expected_tcp: [22, 80]
|
||||||
|
expected_udp: [53]
|
||||||
23
data/scan_targets/dmz.yaml
Normal file
23
data/scan_targets/dmz.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: DMZ
|
||||||
|
scan_options:
|
||||||
|
udp_scan: false
|
||||||
|
tls_security_scan: true
|
||||||
|
tls_exp_check: true
|
||||||
|
|
||||||
|
reporting:
|
||||||
|
report_name: Sneaky Geek Labs DMZ Report
|
||||||
|
report_filename: dmz-report.html
|
||||||
|
full_details: false
|
||||||
|
|
||||||
|
scan_targets:
|
||||||
|
- 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: []
|
||||||
11
data/settings.yaml
Normal file
11
data/settings.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
app:
|
||||||
|
|
||||||
|
scan_options:
|
||||||
|
targets_filename: expected.json
|
||||||
|
tcp_scan_type: all
|
||||||
|
udp_scan: false
|
||||||
|
|
||||||
|
reporting:
|
||||||
|
report_name: Compliance Report
|
||||||
|
report_filename: report.html
|
||||||
|
full_details: false
|
||||||
16
docker-compose.yaml
Normal file
16
docker-compose.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
port-checker:
|
||||||
|
build: .
|
||||||
|
image: sneaky/port-checker:ubuntu-slim
|
||||||
|
# Host networking is recommended for scanning accuracy/speed
|
||||||
|
network_mode: host
|
||||||
|
# Raw sockets for SYN scans (-sS). NET_ADMIN helps some environments.
|
||||||
|
cap_add:
|
||||||
|
- NET_RAW
|
||||||
|
- NET_ADMIN
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:false
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
Reference in New Issue
Block a user