init commit
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Output files
|
||||||
|
output/*.json
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
#AI helpers
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install system dependencies and masscan
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
libpcap-dev \
|
||||||
|
nmap \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Build and install masscan from source
|
||||||
|
RUN git clone https://github.com/robertdavidgraham/masscan /tmp/masscan && \
|
||||||
|
cd /tmp/masscan && \
|
||||||
|
make && \
|
||||||
|
make install && \
|
||||||
|
cd / && \
|
||||||
|
rm -rf /tmp/masscan
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
RUN mkdir -p /app/output
|
||||||
|
|
||||||
|
# Make scanner executable
|
||||||
|
RUN chmod +x /app/src/scanner.py
|
||||||
|
|
||||||
|
# Force Python unbuffered output
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Set entry point with unbuffered Python
|
||||||
|
ENTRYPOINT ["python3", "-u", "/app/src/scanner.py"]
|
||||||
|
CMD ["--help"]
|
||||||
245
README.md
Normal file
245
README.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# SneakyScanner
|
||||||
|
|
||||||
|
A dockerized network scanning tool that uses masscan for fast port discovery and nmap for service detection to perform comprehensive infrastructure audits. SneakyScanner accepts YAML-based configuration files to define sites, IPs, and expected network behavior, then generates machine-readable JSON reports with detailed service information.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- YAML-based configuration for defining scan targets and expectations
|
||||||
|
- Comprehensive scanning using masscan:
|
||||||
|
- Ping/ICMP echo detection
|
||||||
|
- TCP port scanning (all 65535 ports)
|
||||||
|
- UDP port scanning (all 65535 ports)
|
||||||
|
- Service detection using nmap:
|
||||||
|
- Identifies services running on discovered TCP ports
|
||||||
|
- Extracts product names and versions
|
||||||
|
- Provides detailed service information
|
||||||
|
- HTTP/HTTPS analysis and SSL/TLS security assessment:
|
||||||
|
- Detects HTTP vs HTTPS on web services
|
||||||
|
- Extracts SSL certificate details (subject, issuer, expiration, SANs)
|
||||||
|
- Calculates days until certificate expiration
|
||||||
|
- Tests TLS version support (TLS 1.0, 1.1, 1.2, 1.3)
|
||||||
|
- Lists accepted cipher suites for each TLS version
|
||||||
|
- JSON output format for easy post-processing
|
||||||
|
- Dockerized for consistent execution environment and root privilege isolation
|
||||||
|
- Compare actual vs. expected network behavior
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose (optional, for easier usage)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
1. Create or modify a configuration file in `configs/`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
title: "My Infrastructure Scan"
|
||||||
|
sites:
|
||||||
|
- name: "Web Servers"
|
||||||
|
ips:
|
||||||
|
- address: "192.168.1.10"
|
||||||
|
expected:
|
||||||
|
ping: true
|
||||||
|
tcp_ports: [22, 80, 443]
|
||||||
|
udp_ports: []
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check results in the `output/` directory
|
||||||
|
|
||||||
|
## Scan Performance
|
||||||
|
|
||||||
|
SneakyScanner uses a five-phase approach for comprehensive scanning:
|
||||||
|
|
||||||
|
1. **Ping Scan** (masscan): ICMP echo detection - ~1-2 seconds
|
||||||
|
2. **TCP Port Discovery** (masscan): Scans all 65535 TCP ports at 10,000 packets/second - ~13 seconds per 2 IPs
|
||||||
|
3. **UDP Port Discovery** (masscan): Scans all 65535 UDP ports at 10,000 packets/second - ~13 seconds per 2 IPs
|
||||||
|
4. **Service Detection** (nmap): Identifies services on discovered TCP ports - ~20-60 seconds per IP with open ports
|
||||||
|
5. **HTTP/HTTPS Analysis** (SSL/TLS): Detects web protocols and analyzes certificates - ~5-10 seconds per web service
|
||||||
|
|
||||||
|
**Example**: Scanning 2 IPs with 10 open ports each (including 2-3 web services) typically takes 1.5-2.5 minutes total.
|
||||||
|
|
||||||
|
### Using Docker Directly
|
||||||
|
|
||||||
|
1. Build the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t sneakyscanner .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run a scan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --privileged --network host \
|
||||||
|
-v $(pwd)/configs:/app/configs:ro \
|
||||||
|
-v $(pwd)/output:/app/output \
|
||||||
|
sneakyscanner /app/configs/your-config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration File Format
|
||||||
|
|
||||||
|
The YAML configuration file defines the scan parameters:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
title: "Scan Title" # Required: Report title
|
||||||
|
sites: # Required: List of sites to scan
|
||||||
|
- name: "Site Name"
|
||||||
|
ips:
|
||||||
|
- address: "192.168.1.10"
|
||||||
|
expected:
|
||||||
|
ping: true # Expected ping response
|
||||||
|
tcp_ports: [22, 80] # Expected TCP ports
|
||||||
|
udp_ports: [53] # Expected UDP ports
|
||||||
|
```
|
||||||
|
|
||||||
|
See `configs/example-site.yaml` for a complete example.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Scan results are saved as JSON files in the `output/` directory with timestamps. The report includes the total scan duration (in seconds) covering all phases: ping scan, TCP/UDP port discovery, and service detection.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Sneaky Infra Scan",
|
||||||
|
"scan_time": "2024-01-15T10:30:00Z",
|
||||||
|
"scan_duration": 95.3,
|
||||||
|
"config_file": "/app/configs/example-site.yaml",
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"name": "Production Web Servers",
|
||||||
|
"ips": [
|
||||||
|
{
|
||||||
|
"address": "192.168.1.10",
|
||||||
|
"expected": {
|
||||||
|
"ping": true,
|
||||||
|
"tcp_ports": [22, 80, 443],
|
||||||
|
"udp_ports": [53]
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"ping": true,
|
||||||
|
"tcp_ports": [22, 80, 443, 3000],
|
||||||
|
"udp_ports": [53],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"port": 22,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"service": "ssh",
|
||||||
|
"product": "OpenSSH",
|
||||||
|
"version": "8.2p1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 80,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"service": "http",
|
||||||
|
"product": "nginx",
|
||||||
|
"version": "1.18.0",
|
||||||
|
"http_info": {
|
||||||
|
"protocol": "http"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"service": "https",
|
||||||
|
"product": "nginx",
|
||||||
|
"http_info": {
|
||||||
|
"protocol": "https",
|
||||||
|
"ssl_tls": {
|
||||||
|
"certificate": {
|
||||||
|
"subject": "CN=example.com",
|
||||||
|
"issuer": "CN=Let's Encrypt Authority X3,O=Let's Encrypt,C=US",
|
||||||
|
"serial_number": "123456789012345678901234567890",
|
||||||
|
"not_valid_before": "2025-01-01T00:00:00+00:00",
|
||||||
|
"not_valid_after": "2025-04-01T23:59:59+00:00",
|
||||||
|
"days_until_expiry": 89,
|
||||||
|
"sans": ["example.com", "www.example.com"]
|
||||||
|
},
|
||||||
|
"tls_versions": {
|
||||||
|
"TLS 1.0": {
|
||||||
|
"supported": false,
|
||||||
|
"cipher_suites": []
|
||||||
|
},
|
||||||
|
"TLS 1.1": {
|
||||||
|
"supported": false,
|
||||||
|
"cipher_suites": []
|
||||||
|
},
|
||||||
|
"TLS 1.2": {
|
||||||
|
"supported": true,
|
||||||
|
"cipher_suites": [
|
||||||
|
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
|
||||||
|
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TLS 1.3": {
|
||||||
|
"supported": true,
|
||||||
|
"cipher_suites": [
|
||||||
|
"TLS_AES_256_GCM_SHA384",
|
||||||
|
"TLS_AES_128_GCM_SHA256"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 3000,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"service": "http",
|
||||||
|
"product": "Node.js",
|
||||||
|
"http_info": {
|
||||||
|
"protocol": "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SneakyScanner/
|
||||||
|
├── src/
|
||||||
|
│ └── scanner.py # Main scanner application
|
||||||
|
├── configs/
|
||||||
|
│ └── example-site.yaml # Example configuration
|
||||||
|
├── output/ # Scan results (JSON files)
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notice
|
||||||
|
|
||||||
|
This tool requires:
|
||||||
|
- `--privileged` flag or `CAP_NET_RAW` capability for masscan and nmap raw socket access
|
||||||
|
- `--network host` for direct network access
|
||||||
|
|
||||||
|
Only use this tool on networks you own or have explicit authorization to scan. Unauthorized network scanning may be illegal in your jurisdiction.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Webpage Screenshots**: Capture screenshots of discovered web services for visual verification
|
||||||
|
- **HTML Report Generation**: Build comprehensive HTML reports from JSON output with:
|
||||||
|
- Service details and SSL/TLS information
|
||||||
|
- Visual comparison of expected vs. actual results
|
||||||
|
- Certificate expiration warnings
|
||||||
|
- TLS version compliance reports
|
||||||
|
- Embedded webpage screenshots
|
||||||
|
- **Comparison Reports**: Generate diff reports showing changes between scans
|
||||||
|
- **Email Notifications**: Alert on unexpected changes or certificate expirations
|
||||||
|
- **Scheduled Scanning**: Automated periodic scans with cron integration
|
||||||
|
- **Vulnerability Detection**: Integration with CVE databases for known vulnerabilities
|
||||||
16
configs/example-site.yaml
Normal file
16
configs/example-site.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
title: "Sneaky Infra Scan"
|
||||||
|
sites:
|
||||||
|
- name: "Production Web Servers"
|
||||||
|
ips:
|
||||||
|
- address: "10.10.20.4"
|
||||||
|
expected:
|
||||||
|
ping: true
|
||||||
|
tcp_ports: [22, 53, 80]
|
||||||
|
udp_ports: [53]
|
||||||
|
# Optional: specify expected services (detected automatically)
|
||||||
|
services: ["ssh", "domain", "http"]
|
||||||
|
- address: "10.10.20.11"
|
||||||
|
expected:
|
||||||
|
ping: true
|
||||||
|
tcp_ports: [22, 111, 3128, 8006]
|
||||||
|
udp_ports: []
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
scanner:
|
||||||
|
build: .
|
||||||
|
image: sneakyscanner:latest
|
||||||
|
container_name: sneakyscanner
|
||||||
|
privileged: true # Required for masscan raw socket access
|
||||||
|
network_mode: host # Required for network scanning
|
||||||
|
volumes:
|
||||||
|
- ./configs:/app/configs:ro
|
||||||
|
- ./output:/app/output
|
||||||
|
command: /app/configs/example-site.yaml
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PyYAML==6.0.1
|
||||||
|
python-libnmap==0.7.3
|
||||||
|
sslyze==6.0.0
|
||||||
709
src/scanner.py
Normal file
709
src/scanner.py
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SneakyScanner - Masscan-based network scanner with YAML configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from libnmap.process import NmapProcess
|
||||||
|
from libnmap.parser import NmapParser
|
||||||
|
|
||||||
|
# Force unbuffered output for Docker
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SneakyScanner:
|
||||||
|
"""Wrapper for masscan to perform network scans based on YAML config"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str, output_dir: str = "/app/output"):
|
||||||
|
self.config_path = Path(config_path)
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.config = self._load_config()
|
||||||
|
|
||||||
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
|
"""Load and validate YAML configuration"""
|
||||||
|
if not self.config_path.exists():
|
||||||
|
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||||
|
|
||||||
|
with open(self.config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if not config.get('title'):
|
||||||
|
raise ValueError("Config must include 'title' field")
|
||||||
|
if not config.get('sites'):
|
||||||
|
raise ValueError("Config must include 'sites' field")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Run masscan and return parsed results
|
||||||
|
|
||||||
|
Args:
|
||||||
|
targets: List of IP addresses to scan
|
||||||
|
ports: Port range string (e.g., "0-65535")
|
||||||
|
protocol: "tcp" or "udp"
|
||||||
|
"""
|
||||||
|
if not targets:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Create temporary file for targets
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
|
||||||
|
f.write('\n'.join(targets))
|
||||||
|
target_file = f.name
|
||||||
|
|
||||||
|
# Create temporary output file
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
|
||||||
|
output_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build command based on protocol
|
||||||
|
if protocol == 'tcp':
|
||||||
|
cmd = [
|
||||||
|
'masscan',
|
||||||
|
'-iL', target_file,
|
||||||
|
'-p', ports,
|
||||||
|
'--rate', '10000',
|
||||||
|
'-oJ', output_file,
|
||||||
|
'--wait', '0'
|
||||||
|
]
|
||||||
|
elif protocol == 'udp':
|
||||||
|
cmd = [
|
||||||
|
'masscan',
|
||||||
|
'-iL', target_file,
|
||||||
|
'--udp-ports', ports,
|
||||||
|
'--rate', '10000',
|
||||||
|
'-oJ', output_file,
|
||||||
|
'--wait', '0'
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid protocol: {protocol}")
|
||||||
|
|
||||||
|
print(f"Running: {' '.join(cmd)}", flush=True)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
print(f"Masscan {protocol.upper()} scan completed", flush=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Masscan stderr: {result.stderr}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Parse masscan JSON output
|
||||||
|
results = []
|
||||||
|
with open(output_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
try:
|
||||||
|
results.append(json.loads(line.rstrip(',')))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp files
|
||||||
|
Path(target_file).unlink(missing_ok=True)
|
||||||
|
Path(output_file).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _run_ping_scan(self, targets: List[str]) -> Dict[str, bool]:
|
||||||
|
"""
|
||||||
|
Run ping scan using masscan ICMP echo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping IP addresses to ping response status
|
||||||
|
"""
|
||||||
|
if not targets:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
|
||||||
|
f.write('\n'.join(targets))
|
||||||
|
target_file = f.name
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
|
||||||
|
output_file = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'masscan',
|
||||||
|
'-iL', target_file,
|
||||||
|
'--ping',
|
||||||
|
'--rate', '10000',
|
||||||
|
'-oJ', output_file,
|
||||||
|
'--wait', '0'
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Running: {' '.join(cmd)}", flush=True)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
print(f"Masscan PING scan completed", flush=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
responding_ips = set()
|
||||||
|
with open(output_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
try:
|
||||||
|
data = json.loads(line.rstrip(','))
|
||||||
|
if 'ip' in data:
|
||||||
|
responding_ips.add(data['ip'])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create result dict for all targets
|
||||||
|
return {ip: (ip in responding_ips) for ip in targets}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
Path(target_file).unlink(missing_ok=True)
|
||||||
|
Path(output_file).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _run_nmap_service_detection(self, ip_ports: Dict[str, List[int]]) -> Dict[str, List[Dict]]:
|
||||||
|
"""
|
||||||
|
Run nmap service detection on discovered ports
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_ports: Dict mapping IP addresses to list of TCP ports
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping IP addresses to list of service info dicts
|
||||||
|
"""
|
||||||
|
if not ip_ports:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
all_services = {}
|
||||||
|
|
||||||
|
for ip, ports in ip_ports.items():
|
||||||
|
if not ports:
|
||||||
|
all_services[ip] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build port list string
|
||||||
|
port_list = ','.join(map(str, sorted(ports)))
|
||||||
|
|
||||||
|
print(f" Scanning {ip} ports {port_list}...", flush=True)
|
||||||
|
|
||||||
|
# Create temporary output file for XML
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.xml') as f:
|
||||||
|
xml_output = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run nmap with service detection
|
||||||
|
cmd = [
|
||||||
|
'nmap',
|
||||||
|
'-sV', # Service version detection
|
||||||
|
'--version-intensity', '5', # Balanced speed/accuracy
|
||||||
|
'-p', port_list,
|
||||||
|
'-oX', xml_output, # XML output
|
||||||
|
'--host-timeout', '5m', # Timeout per host
|
||||||
|
ip
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
# Parse XML output
|
||||||
|
services = self._parse_nmap_xml(xml_output)
|
||||||
|
all_services[ip] = services
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(f" Nmap timeout for {ip}, skipping service detection", file=sys.stderr, flush=True)
|
||||||
|
all_services[ip] = []
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Nmap error for {ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
all_services[ip] = []
|
||||||
|
finally:
|
||||||
|
Path(xml_output).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return all_services
|
||||||
|
|
||||||
|
def _parse_nmap_xml(self, xml_file: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Parse nmap XML output to extract service information
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xml_file: Path to nmap XML output file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of service info dictionaries
|
||||||
|
"""
|
||||||
|
services = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(xml_file)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Find all ports
|
||||||
|
for port_elem in root.findall('.//port'):
|
||||||
|
port_id = port_elem.get('portid')
|
||||||
|
protocol = port_elem.get('protocol', 'tcp')
|
||||||
|
|
||||||
|
# Get state
|
||||||
|
state_elem = port_elem.find('state')
|
||||||
|
if state_elem is None or state_elem.get('state') != 'open':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get service info
|
||||||
|
service_elem = port_elem.find('service')
|
||||||
|
if service_elem is not None:
|
||||||
|
service_info = {
|
||||||
|
'port': int(port_id),
|
||||||
|
'protocol': protocol,
|
||||||
|
'service': service_elem.get('name', 'unknown'),
|
||||||
|
'product': service_elem.get('product', ''),
|
||||||
|
'version': service_elem.get('version', ''),
|
||||||
|
'extrainfo': service_elem.get('extrainfo', ''),
|
||||||
|
'ostype': service_elem.get('ostype', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up empty fields
|
||||||
|
service_info = {k: v for k, v in service_info.items() if v}
|
||||||
|
|
||||||
|
services.append(service_info)
|
||||||
|
else:
|
||||||
|
# Port is open but no service info
|
||||||
|
services.append({
|
||||||
|
'port': int(port_id),
|
||||||
|
'protocol': protocol,
|
||||||
|
'service': 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error parsing nmap XML: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
def _is_likely_web_service(self, service: Dict) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a service is likely HTTP/HTTPS based on nmap detection or common web ports
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: Service dictionary from nmap results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if service appears to be web-related
|
||||||
|
"""
|
||||||
|
# Check service name
|
||||||
|
web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt',
|
||||||
|
'http-alt', 'ssl/http', 'ssl/https']
|
||||||
|
service_name = service.get('service', '').lower()
|
||||||
|
|
||||||
|
if service_name in web_services:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check common non-standard web ports
|
||||||
|
web_ports = [80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443]
|
||||||
|
port = service.get('port')
|
||||||
|
|
||||||
|
return port in web_ports
|
||||||
|
|
||||||
|
def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str:
|
||||||
|
"""
|
||||||
|
Detect if a port is HTTP or HTTPS
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: IP address
|
||||||
|
port: Port number
|
||||||
|
timeout: Connection timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'http', 'https', or 'unknown'
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
import ssl as ssl_module
|
||||||
|
|
||||||
|
# Try HTTPS first
|
||||||
|
try:
|
||||||
|
context = ssl_module.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl_module.CERT_NONE
|
||||||
|
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||||
|
with context.wrap_socket(sock, server_hostname=ip) as ssock:
|
||||||
|
return 'https'
|
||||||
|
except ssl_module.SSLError:
|
||||||
|
# Not HTTPS, try HTTP
|
||||||
|
pass
|
||||||
|
except (socket.timeout, socket.error, ConnectionRefusedError):
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
# Try HTTP
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||||
|
sock.send(b'HEAD / HTTP/1.0\r\n\r\n')
|
||||||
|
response = sock.recv(1024)
|
||||||
|
if b'HTTP' in response:
|
||||||
|
return 'http'
|
||||||
|
except (socket.timeout, socket.error, ConnectionRefusedError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
def _analyze_ssl_tls(self, ip: str, port: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze SSL/TLS configuration including certificate and supported versions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: IP address
|
||||||
|
port: Port number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with certificate info and TLS version support
|
||||||
|
"""
|
||||||
|
from sslyze import (
|
||||||
|
Scanner,
|
||||||
|
ServerScanRequest,
|
||||||
|
ServerNetworkLocation,
|
||||||
|
ScanCommand,
|
||||||
|
ScanCommandAttemptStatusEnum,
|
||||||
|
ServerScanStatusEnum
|
||||||
|
)
|
||||||
|
from cryptography import x509
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'certificate': {},
|
||||||
|
'tls_versions': {},
|
||||||
|
'errors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create server location
|
||||||
|
server_location = ServerNetworkLocation(
|
||||||
|
hostname=ip,
|
||||||
|
port=port
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create scan request with all TLS version scans
|
||||||
|
scan_request = ServerScanRequest(
|
||||||
|
server_location=server_location,
|
||||||
|
scan_commands={
|
||||||
|
ScanCommand.CERTIFICATE_INFO,
|
||||||
|
ScanCommand.SSL_2_0_CIPHER_SUITES,
|
||||||
|
ScanCommand.SSL_3_0_CIPHER_SUITES,
|
||||||
|
ScanCommand.TLS_1_0_CIPHER_SUITES,
|
||||||
|
ScanCommand.TLS_1_1_CIPHER_SUITES,
|
||||||
|
ScanCommand.TLS_1_2_CIPHER_SUITES,
|
||||||
|
ScanCommand.TLS_1_3_CIPHER_SUITES,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run scan
|
||||||
|
scanner = Scanner()
|
||||||
|
scanner.queue_scans([scan_request])
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for scan_result in scanner.get_results():
|
||||||
|
if scan_result.scan_status != ServerScanStatusEnum.COMPLETED:
|
||||||
|
result['errors'].append('Connection failed')
|
||||||
|
return result
|
||||||
|
|
||||||
|
server_scan_result = scan_result.scan_result
|
||||||
|
|
||||||
|
# Extract certificate information
|
||||||
|
cert_attempt = getattr(server_scan_result, 'certificate_info', None)
|
||||||
|
if cert_attempt and cert_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED:
|
||||||
|
cert_result = cert_attempt.result
|
||||||
|
if cert_result.certificate_deployments:
|
||||||
|
deployment = cert_result.certificate_deployments[0]
|
||||||
|
leaf_cert = deployment.received_certificate_chain[0]
|
||||||
|
|
||||||
|
# Calculate days until expiry
|
||||||
|
not_after = leaf_cert.not_valid_after_utc
|
||||||
|
days_until_expiry = (not_after - datetime.now(not_after.tzinfo)).days
|
||||||
|
|
||||||
|
# Extract SANs
|
||||||
|
sans = []
|
||||||
|
try:
|
||||||
|
san_ext = leaf_cert.extensions.get_extension_for_class(
|
||||||
|
x509.SubjectAlternativeName
|
||||||
|
)
|
||||||
|
sans = [name.value for name in san_ext.value]
|
||||||
|
except x509.ExtensionNotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result['certificate'] = {
|
||||||
|
'subject': leaf_cert.subject.rfc4514_string(),
|
||||||
|
'issuer': leaf_cert.issuer.rfc4514_string(),
|
||||||
|
'serial_number': str(leaf_cert.serial_number),
|
||||||
|
'not_valid_before': leaf_cert.not_valid_before_utc.isoformat(),
|
||||||
|
'not_valid_after': leaf_cert.not_valid_after_utc.isoformat(),
|
||||||
|
'days_until_expiry': days_until_expiry,
|
||||||
|
'sans': sans
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test TLS versions
|
||||||
|
tls_attributes = {
|
||||||
|
'TLS 1.0': 'tls_1_0_cipher_suites',
|
||||||
|
'TLS 1.1': 'tls_1_1_cipher_suites',
|
||||||
|
'TLS 1.2': 'tls_1_2_cipher_suites',
|
||||||
|
'TLS 1.3': 'tls_1_3_cipher_suites'
|
||||||
|
}
|
||||||
|
|
||||||
|
for version_name, attr_name in tls_attributes.items():
|
||||||
|
tls_attempt = getattr(server_scan_result, attr_name, None)
|
||||||
|
if tls_attempt and tls_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED:
|
||||||
|
tls_result = tls_attempt.result
|
||||||
|
supported = len(tls_result.accepted_cipher_suites) > 0
|
||||||
|
cipher_suites = [
|
||||||
|
suite.cipher_suite.name
|
||||||
|
for suite in tls_result.accepted_cipher_suites
|
||||||
|
]
|
||||||
|
result['tls_versions'][version_name] = {
|
||||||
|
'supported': supported,
|
||||||
|
'cipher_suites': cipher_suites
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result['tls_versions'][version_name] = {
|
||||||
|
'supported': False,
|
||||||
|
'cipher_suites': []
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['errors'].append(str(e))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _run_http_analysis(self, ip_services: Dict[str, List[Dict]]) -> Dict[str, Dict[int, Dict]]:
|
||||||
|
"""
|
||||||
|
Analyze HTTP/HTTPS services and SSL/TLS configuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_services: Dict mapping IP addresses to their service lists
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping IPs to port-specific HTTP analysis results
|
||||||
|
"""
|
||||||
|
if not ip_services:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
|
||||||
|
for ip, services in ip_services.items():
|
||||||
|
ip_results = {}
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
if not self._is_likely_web_service(service):
|
||||||
|
continue
|
||||||
|
|
||||||
|
port = service['port']
|
||||||
|
print(f" Analyzing {ip}:{port}...", flush=True)
|
||||||
|
|
||||||
|
# Detect HTTP vs HTTPS
|
||||||
|
protocol = self._detect_http_https(ip, port, timeout=5)
|
||||||
|
|
||||||
|
if protocol == 'unknown':
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = {'protocol': protocol}
|
||||||
|
|
||||||
|
# If HTTPS, analyze SSL/TLS
|
||||||
|
if protocol == 'https':
|
||||||
|
try:
|
||||||
|
ssl_info = self._analyze_ssl_tls(ip, port)
|
||||||
|
# Only include ssl_tls if we got meaningful data
|
||||||
|
if ssl_info.get('certificate') or ssl_info.get('tls_versions'):
|
||||||
|
result['ssl_tls'] = ssl_info
|
||||||
|
elif ssl_info.get('errors'):
|
||||||
|
# Log errors even if we don't include ssl_tls in output
|
||||||
|
print(f" SSL/TLS analysis failed for {ip}:{port}: {ssl_info['errors']}",
|
||||||
|
file=sys.stderr, flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" SSL/TLS analysis error for {ip}:{port}: {e}",
|
||||||
|
file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
ip_results[port] = result
|
||||||
|
|
||||||
|
if ip_results:
|
||||||
|
all_results[ip] = ip_results
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
def scan(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Perform complete scan based on configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing scan results
|
||||||
|
"""
|
||||||
|
print(f"Starting scan: {self.config['title']}", flush=True)
|
||||||
|
print(f"Config: {self.config_path}", flush=True)
|
||||||
|
|
||||||
|
# Record start time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Collect all unique IPs
|
||||||
|
all_ips = set()
|
||||||
|
ip_to_site = {}
|
||||||
|
ip_expected = {}
|
||||||
|
|
||||||
|
for site in self.config['sites']:
|
||||||
|
site_name = site['name']
|
||||||
|
for ip_config in site['ips']:
|
||||||
|
ip = ip_config['address']
|
||||||
|
all_ips.add(ip)
|
||||||
|
ip_to_site[ip] = site_name
|
||||||
|
ip_expected[ip] = ip_config.get('expected', {})
|
||||||
|
|
||||||
|
all_ips = sorted(list(all_ips))
|
||||||
|
print(f"Total IPs to scan: {len(all_ips)}", flush=True)
|
||||||
|
|
||||||
|
# Perform ping scan
|
||||||
|
print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True)
|
||||||
|
ping_results = self._run_ping_scan(all_ips)
|
||||||
|
|
||||||
|
# Perform TCP scan (all ports)
|
||||||
|
print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
|
||||||
|
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
|
||||||
|
|
||||||
|
# Perform UDP scan (all ports)
|
||||||
|
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
|
||||||
|
udp_results = self._run_masscan(all_ips, '0-65535', 'udp')
|
||||||
|
|
||||||
|
# Organize results by IP
|
||||||
|
results_by_ip = {}
|
||||||
|
for ip in all_ips:
|
||||||
|
results_by_ip[ip] = {
|
||||||
|
'site': ip_to_site[ip],
|
||||||
|
'expected': ip_expected[ip],
|
||||||
|
'actual': {
|
||||||
|
'ping': ping_results.get(ip, False),
|
||||||
|
'tcp_ports': [],
|
||||||
|
'udp_ports': [],
|
||||||
|
'services': []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add TCP ports
|
||||||
|
for result in tcp_results:
|
||||||
|
ip = result.get('ip')
|
||||||
|
port = result.get('ports', [{}])[0].get('port')
|
||||||
|
if ip in results_by_ip and port:
|
||||||
|
results_by_ip[ip]['actual']['tcp_ports'].append(port)
|
||||||
|
|
||||||
|
# Add UDP ports
|
||||||
|
for result in udp_results:
|
||||||
|
ip = result.get('ip')
|
||||||
|
port = result.get('ports', [{}])[0].get('port')
|
||||||
|
if ip in results_by_ip and port:
|
||||||
|
results_by_ip[ip]['actual']['udp_ports'].append(port)
|
||||||
|
|
||||||
|
# Sort ports
|
||||||
|
for ip in results_by_ip:
|
||||||
|
results_by_ip[ip]['actual']['tcp_ports'].sort()
|
||||||
|
results_by_ip[ip]['actual']['udp_ports'].sort()
|
||||||
|
|
||||||
|
# Perform service detection on TCP ports
|
||||||
|
print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True)
|
||||||
|
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
|
||||||
|
service_results = self._run_nmap_service_detection(ip_ports)
|
||||||
|
|
||||||
|
# Add service information to results
|
||||||
|
for ip, services in service_results.items():
|
||||||
|
if ip in results_by_ip:
|
||||||
|
results_by_ip[ip]['actual']['services'] = services
|
||||||
|
|
||||||
|
# Perform HTTP/HTTPS analysis on web services
|
||||||
|
print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True)
|
||||||
|
http_results = self._run_http_analysis(service_results)
|
||||||
|
|
||||||
|
# Merge HTTP analysis into service results
|
||||||
|
for ip, port_results in http_results.items():
|
||||||
|
if ip in results_by_ip:
|
||||||
|
for service in results_by_ip[ip]['actual']['services']:
|
||||||
|
port = service['port']
|
||||||
|
if port in port_results:
|
||||||
|
service['http_info'] = port_results[port]
|
||||||
|
|
||||||
|
# Calculate scan duration
|
||||||
|
end_time = time.time()
|
||||||
|
scan_duration = round(end_time - start_time, 2)
|
||||||
|
|
||||||
|
# Build final report
|
||||||
|
report = {
|
||||||
|
'title': self.config['title'],
|
||||||
|
'scan_time': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
'scan_duration': scan_duration,
|
||||||
|
'config_file': str(self.config_path),
|
||||||
|
'sites': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for site in self.config['sites']:
|
||||||
|
site_result = {
|
||||||
|
'name': site['name'],
|
||||||
|
'ips': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for ip_config in site['ips']:
|
||||||
|
ip = ip_config['address']
|
||||||
|
site_result['ips'].append({
|
||||||
|
'address': ip,
|
||||||
|
'expected': ip_expected[ip],
|
||||||
|
'actual': results_by_ip[ip]['actual']
|
||||||
|
})
|
||||||
|
|
||||||
|
report['sites'].append(site_result)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def save_report(self, report: Dict[str, Any]) -> Path:
|
||||||
|
"""Save scan report to JSON file"""
|
||||||
|
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||||
|
output_file = self.output_dir / f"scan_report_{timestamp}.json"
|
||||||
|
|
||||||
|
with open(output_file, 'w') as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nReport saved to: {output_file}", flush=True)
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='SneakyScanner - Masscan-based network scanner'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'config',
|
||||||
|
help='Path to YAML configuration file'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-o', '--output-dir',
|
||||||
|
default='/app/output',
|
||||||
|
help='Output directory for scan results (default: /app/output)'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner = SneakyScanner(args.config, args.output_dir)
|
||||||
|
report = scanner.scan()
|
||||||
|
output_file = scanner.save_report(report)
|
||||||
|
|
||||||
|
print("\n" + "="*60, flush=True)
|
||||||
|
print("Scan completed successfully!", flush=True)
|
||||||
|
print(f"Results: {output_file}", flush=True)
|
||||||
|
print("="*60, flush=True)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr, flush=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user