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:
@@ -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('') != ''
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user