Add masscan+nuclei mode, operator tuning controls, and vars file support to webrunner

- New scan mode: masscan+nuclei — masscan finds open ip:port pairs,
  nuclei runs operator-supplied CVE template against discovered hosts
- Per-country vulnerable host count in merge output via CIDR→country lookup
- Tuning args on every scan: --nmap-timing, --nmap-timeout, --nmap-workers,
  --nuclei-rate, --nuclei-concurrency, --nuclei-timeout, --masscan-rate
- Vars file support: operator provides scan_vars.yaml to pre-fill all
  tuning settings without interactive prompts
- Templates copied to nodes at provision time — no live fetches (OPSEC)
- run_scan.yml passes all tuning args with Ansible defaults as fallback
- configure_node.yml installs nuclei binary + uploads template on demand
- Updated deployment summary shows mode-specific tuning params
- countries-format.md and targets-format.md updated for new capabilities
- scan_vars.yaml.example documents all configurable settings with comments
This commit is contained in:
n0mad1k
2026-05-02 14:49:24 -04:00
parent c34f9f0401
commit acb32ab3c6
9 changed files with 406 additions and 13 deletions
@@ -15,6 +15,7 @@
- nmap
- python3
- python3-pip
- curl
state: present
retries: 3
delay: 10
@@ -76,3 +77,22 @@
mode: '0644'
when: scan_mode == 'geo-scout' and targets_file != ""
ignore_errors: true
- name: Install nuclei vulnerability scanner
shell: |
if ! command -v nuclei &>/dev/null; then
curl -sL "https://github.com/projectdiscovery/nuclei/releases/download/v3.3.9/nuclei_3.3.9_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin nuclei
chmod +x /usr/local/bin/nuclei
fi
args:
executable: /bin/bash
when: scan_mode == 'masscan+nuclei'
retries: 2
delay: 5
- name: Upload nuclei template
copy:
src: "{{ nuclei_template_local }}"
dest: /root/webrunner/nuclei_template.yaml
mode: '0644'
when: scan_mode == 'masscan+nuclei' and nuclei_template_local | default('') != ''
+51
View File
@@ -5,16 +5,42 @@ Run by Ansible on the controller after all nodes complete.
"""
import argparse
import ipaddress
import json
import sys
from pathlib import Path
def _build_cidr_map(cc_cidrs: dict) -> list[tuple]:
result = []
for cc, cidrs in cc_cidrs.items():
for cidr in cidrs:
try:
net = ipaddress.ip_network(cidr, strict=False)
result.append((net, cc.upper()))
except ValueError:
pass
result.sort(key=lambda x: x[0].prefixlen, reverse=True)
return result
def _lookup_country(ip: str, cidr_map: list[tuple]) -> str:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return ""
for net, cc in cidr_map:
if addr in net:
return cc
return ""
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--results-dir", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--deployment-id", required=True)
parser.add_argument("--cidr-map", default="", help="JSON file mapping country codes to CIDR lists")
args = parser.parse_args()
results_dir = Path(args.results_dir)
@@ -24,6 +50,14 @@ def main():
print(f"No result files found in {results_dir}", file=sys.stderr)
sys.exit(1)
cidr_map: list[tuple] = []
if args.cidr_map:
try:
cc_cidrs = json.loads(Path(args.cidr_map).read_text())
cidr_map = _build_cidr_map(cc_cidrs)
except (OSError, json.JSONDecodeError):
pass
merged: dict = {
"deployment_id": args.deployment_id,
"nodes": [],
@@ -51,16 +85,33 @@ def main():
key = f"{entry.get('ip')}:{entry.get('port')}"
if key not in seen:
seen.add(key)
if cidr_map:
entry["country"] = _lookup_country(entry.get("ip", ""), cidr_map)
merged["results"].append(entry)
merged["total_results"] = len(merged["results"])
if cidr_map:
country_counts: dict[str, int] = {}
for entry in merged["results"]:
cc = entry.get("country", "")
country_counts[cc] = country_counts.get(cc, 0) + 1
merged["country_summary"] = dict(
sorted(country_counts.items(), key=lambda x: x[1], reverse=True)
)
Path(args.output).write_text(json.dumps(merged, indent=2))
print(f"Merged {len(node_files)} node(s) — {merged['total_results']} unique results")
for n in merged["nodes"]:
print(f" {n['node_name']}: {n['result_count']} results ({n['scan_mode']})")
if cidr_map and merged.get("country_summary"):
print("\nVulnerable hosts by country:")
for cc, count in list(merged["country_summary"].items())[:20]:
label = cc if cc else "unknown"
print(f" {label:<6} {count}")
if __name__ == "__main__":
main()
+113 -3
View File
@@ -7,6 +7,7 @@ Reads cidrs.txt, runs scan pipeline, writes results.json.
import argparse
import json
import os
import re
import subprocess
import sys
import time
@@ -16,6 +17,8 @@ from pathlib import Path
WORKDIR = Path("/root/webrunner")
NMAP_WORKERS = int(os.environ.get("WEBRUNNER_NMAP_WORKERS", "10"))
NMAP_TIMING = int(os.environ.get("WEBRUNNER_NMAP_TIMING", "4"))
NMAP_TIMEOUT = int(os.environ.get("WEBRUNNER_NMAP_TIMEOUT", "60"))
def log(msg: str):
@@ -83,11 +86,11 @@ def run_nmap(ip: str, ports: list[int]) -> dict:
cmd = [
"nmap", "-sV", "--version-intensity", "5",
"-p", port_str, "-T4", "--open",
"-p", port_str, f"-T{NMAP_TIMING}", "--open",
"-oX", str(out_file), ip,
]
try:
subprocess.run(cmd, capture_output=True, timeout=60, check=False)
subprocess.run(cmd, capture_output=True, timeout=NMAP_TIMEOUT, check=False)
except (subprocess.TimeoutExpired, FileNotFoundError):
return {}
@@ -267,15 +270,116 @@ def scan_geo_scout(ports: str, rate: int, node_name: str) -> list[dict]:
return results
def run_nuclei(targets: list[str], template: str, rate: int, concurrency: int, timeout: int) -> list[dict]:
targets_file = WORKDIR / "nuclei_targets.txt"
targets_file.write_text("\n".join(targets) + "\n")
out_file = WORKDIR / "nuclei_results.jsonl"
out_file.unlink(missing_ok=True)
cmd = [
"nuclei",
"-t", template,
"-l", str(targets_file),
"-rl", str(rate),
"-c", str(concurrency),
"-timeout", str(timeout),
"-j",
"-o", str(out_file),
"-silent",
"-no-color",
"-disable-update-check",
]
log(f"nuclei starting: template={template} targets={len(targets)} rate={rate} concurrency={concurrency}")
try:
subprocess.run(cmd, timeout=43200, check=False)
except subprocess.TimeoutExpired:
log("nuclei timed out after 12h")
if not out_file.exists():
return []
results = []
for line in out_file.read_text(errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
host = entry.get("host", "")
ip = entry.get("ip", "")
if not ip and host:
raw = host.split("//")[-1].split("/")[0]
ip_part = raw.rsplit(":", 1)[0] if ":" in raw else raw
m = re.match(r"^(\d+\.\d+\.\d+\.\d+)$", ip_part)
ip = m.group(1) if m else ip_part
port = 0
matched_at = entry.get("matched-at", host)
raw_host = matched_at.split("//")[-1].split("/")[0]
if ":" in raw_host:
try:
port = int(raw_host.rsplit(":", 1)[1])
except (ValueError, IndexError):
pass
results.append({
"ip": ip,
"port": port,
"template_id": entry.get("template-id", ""),
"vuln_name": entry.get("info", {}).get("name", ""),
"severity": entry.get("info", {}).get("severity", ""),
"matched_at": entry.get("matched-at", ""),
"vuln": True,
})
log(f"nuclei found {len(results)} vulnerable hosts/services")
return results
def scan_masscan_nuclei(ports: str, rate: int, template: str, nuclei_rate: int, nuclei_concurrency: int, nuclei_timeout: int, node_name: str) -> list[dict]:
if not template:
log("ERROR: --template required for masscan+nuclei mode")
return []
hits = run_masscan(ports, rate)
if not hits:
return []
targets = [f"{h['ip']}:{h['port']}" for h in hits]
log(f"nuclei scanning {len(targets)} ip:port targets from masscan...")
return run_nuclei(targets, template, nuclei_rate, nuclei_concurrency, nuclei_timeout)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", required=True,
choices=["masscan-only", "nmap-only", "masscan+nmap", "geo-scout"])
choices=["masscan-only", "nmap-only", "masscan+nmap", "geo-scout", "masscan+nuclei"])
parser.add_argument("--ports", required=True)
parser.add_argument("--rate", type=int, default=3000)
parser.add_argument("--node-name", default="node")
parser.add_argument("--template", default="")
parser.add_argument("--nmap-timing", type=int, default=None, choices=[1, 2, 3, 4])
parser.add_argument("--nmap-timeout", type=int, default=None)
parser.add_argument("--nmap-workers", type=int, default=None)
parser.add_argument("--nuclei-rate", type=int, default=150)
parser.add_argument("--nuclei-concurrency", type=int, default=25)
parser.add_argument("--nuclei-timeout", type=int, default=10)
args = parser.parse_args()
global NMAP_WORKERS, NMAP_TIMING, NMAP_TIMEOUT
if args.nmap_workers is not None:
NMAP_WORKERS = args.nmap_workers
if args.nmap_timing is not None:
NMAP_TIMING = args.nmap_timing
if args.nmap_timeout is not None:
NMAP_TIMEOUT = args.nmap_timeout
log(f"WEBRUNNER node scanner starting — mode={args.mode} node={args.node_name}")
if args.mode == "masscan-only":
@@ -286,6 +390,12 @@ def main():
results = scan_masscan_nmap(args.ports, args.rate, args.node_name)
elif args.mode == "geo-scout":
results = scan_geo_scout(args.ports, args.rate, args.node_name)
elif args.mode == "masscan+nuclei":
results = scan_masscan_nuclei(
args.ports, args.rate, args.template,
args.nuclei_rate, args.nuclei_concurrency, args.nuclei_timeout,
args.node_name,
)
else:
log(f"Unknown mode: {args.mode}")
sys.exit(1)
+28
View File
@@ -22,6 +22,20 @@
- "{{ masscan_rate }}"
- --node-name
- "{{ node_name }}"
- --nmap-timing
- "{{ nmap_timing | default(4) }}"
- --nmap-timeout
- "{{ nmap_timeout | default(60) }}"
- --nmap-workers
- "{{ nmap_workers | default(10) }}"
- --nuclei-rate
- "{{ nuclei_rate | default(150) }}"
- --nuclei-concurrency
- "{{ nuclei_concurrency | default(25) }}"
- --nuclei-timeout
- "{{ nuclei_timeout | default(10) }}"
- --template
- "{{ nuclei_template_remote | default('') }}"
args:
chdir: /root/webrunner
register: scan_output
@@ -46,6 +60,20 @@
- "{{ masscan_rate }}"
- --node-name
- "{{ node_name }}"
- --nmap-timing
- "{{ nmap_timing | default(4) }}"
- --nmap-timeout
- "{{ nmap_timeout | default(60) }}"
- --nmap-workers
- "{{ nmap_workers | default(10) }}"
- --nuclei-rate
- "{{ nuclei_rate | default(150) }}"
- --nuclei-concurrency
- "{{ nuclei_concurrency | default(25) }}"
- --nuclei-timeout
- "{{ nuclei_timeout | default(10) }}"
- --template
- "{{ nuclei_template_remote | default('') }}"
args:
chdir: /root/webrunner
register: scan_output