Initial public portfolio release
Sanitized version of red team infrastructure automation platform. Operational content (implant pipelines, lures, credential capture) replaced with documented stubs. Architecture and infrastructure automation code intact.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Utils package for C2ingRed
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AWS provider utilities for C2ingRed deployment system
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from .common import COLORS, load_vars_file
|
||||
|
||||
def get_aws_credentials(provider_vars=None):
|
||||
"""Get AWS credentials — Infisical first, vars.yaml fallback, then prompt."""
|
||||
key = ''
|
||||
secret = ''
|
||||
|
||||
try:
|
||||
key = subprocess.check_output(
|
||||
[os.path.expanduser('~/.local/bin/creds'), 'get', 'AWS_ACCESS_KEY_ID', 'homelab'],
|
||||
text=True, stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
secret = subprocess.check_output(
|
||||
[os.path.expanduser('~/.local/bin/creds'), 'get', 'AWS_SECRET_ACCESS_KEY', 'homelab'],
|
||||
text=True, stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not key:
|
||||
if not provider_vars:
|
||||
provider_vars = load_vars_file('aws')
|
||||
key = provider_vars.get('aws_access_key', '')
|
||||
secret = provider_vars.get('aws_secret_key', '')
|
||||
if key and 'YOUR_AWS' in key:
|
||||
key = ''
|
||||
secret = ''
|
||||
|
||||
print(f"\n{COLORS['BLUE']}AWS Configuration{COLORS['RESET']}")
|
||||
if key:
|
||||
print(f" AWS credentials loaded")
|
||||
else:
|
||||
key = input("AWS Access Key: ").strip()
|
||||
secret = input("AWS Secret Key: ").strip()
|
||||
|
||||
return {'aws_access_key': key, 'aws_secret_key': secret}
|
||||
|
||||
def select_aws_regions(provider_vars=None) -> list[str]:
|
||||
"""Return region list — single entry if user specified one, all regions if blank (random per-node)."""
|
||||
if not provider_vars:
|
||||
provider_vars = load_vars_file('aws')
|
||||
|
||||
regions = provider_vars.get('aws_region_choices', ['us-east-1'])
|
||||
|
||||
print(f"\nAvailable AWS regions:")
|
||||
for i, region in enumerate(regions, 1):
|
||||
print(f" {i:2}. {region}")
|
||||
|
||||
region_input = input("\nSelect region (number or leave blank for random per-node): ").strip()
|
||||
|
||||
if not region_input:
|
||||
return regions
|
||||
|
||||
try:
|
||||
idx = int(region_input)
|
||||
if 1 <= idx <= len(regions):
|
||||
return [regions[idx - 1]]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print(f"{COLORS['RED']}Invalid input, using random per-node regions{COLORS['RESET']}")
|
||||
return regions
|
||||
|
||||
def gather_aws_config():
|
||||
"""Gather all AWS-specific configuration"""
|
||||
provider_vars = load_vars_file('aws')
|
||||
config = {}
|
||||
|
||||
aws_creds = get_aws_credentials(provider_vars)
|
||||
config.update(aws_creds)
|
||||
|
||||
aws_regions = select_aws_regions(provider_vars)
|
||||
config['aws_regions'] = aws_regions
|
||||
config['aws_region'] = aws_regions[0]
|
||||
|
||||
config['ami_map'] = provider_vars.get('ami_map', {})
|
||||
config['aws_instance_type'] = provider_vars.get('aws_instance_type', 't3.micro')
|
||||
config['aws_volume_size'] = provider_vars.get('aws_volume_size', 20)
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import ipaddress
|
||||
from utils.cidr_resolver import get_country_cidrs
|
||||
|
||||
|
||||
def ip_count(cidr: str) -> int:
|
||||
try:
|
||||
return ipaddress.ip_network(cidr, strict=False).num_addresses
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def chunk_cidrs(all_cidrs: list[str], chunk_size: int) -> list[dict]:
|
||||
chunks = []
|
||||
current_cidrs: list[str] = []
|
||||
current_count = 0
|
||||
|
||||
for cidr in all_cidrs:
|
||||
n = ip_count(cidr)
|
||||
if n == 0:
|
||||
continue
|
||||
if current_count > 0 and current_count + n > chunk_size * 1.5:
|
||||
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
|
||||
current_cidrs = []
|
||||
current_count = 0
|
||||
current_cidrs.append(cidr)
|
||||
current_count += n
|
||||
if current_count >= chunk_size:
|
||||
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
|
||||
current_cidrs = []
|
||||
current_count = 0
|
||||
|
||||
if current_cidrs:
|
||||
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
__all__ = ['get_country_cidrs', 'chunk_cidrs', 'ip_count']
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
import ipaddress
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
from urllib.error import URLError
|
||||
|
||||
CACHE_DIR = Path.home() / '.cache' / 'c2itall' / 'cidr'
|
||||
CACHE_TTL = 86400 # 24 hours
|
||||
|
||||
RIR_URLS = {
|
||||
'arin': 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
||||
'ripe': 'https://ftp.ripe.net/ripe/stats/delegated-ripencc-extended-latest',
|
||||
'apnic': 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
||||
'lacnic': 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
||||
'afrinic': 'https://ftp.afrinic.net/stats/delegated-afrinic-extended-latest',
|
||||
}
|
||||
|
||||
|
||||
def _cache_path(rir: str) -> Path:
|
||||
return CACHE_DIR / f'{rir}.txt'
|
||||
|
||||
|
||||
def _is_stale(path: Path) -> bool:
|
||||
if not path.exists():
|
||||
return True
|
||||
return (time.time() - path.stat().st_mtime) > CACHE_TTL
|
||||
|
||||
|
||||
def _fetch_rir(rir: str, url: str) -> Path:
|
||||
path = _cache_path(rir)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if _is_stale(path):
|
||||
try:
|
||||
urlretrieve(url, path)
|
||||
except (URLError, OSError):
|
||||
pass # use stale cache if download fails
|
||||
return path
|
||||
|
||||
|
||||
def _count_to_cidrs(start: str, count: int) -> list[str]:
|
||||
try:
|
||||
start_addr = ipaddress.IPv4Address(start)
|
||||
end_addr = ipaddress.IPv4Address(int(start_addr) + count - 1)
|
||||
return [str(n) for n in ipaddress.summarize_address_range(start_addr, end_addr)]
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _parse_rir_file(path: Path, target_ccs: set[str]) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {cc: [] for cc in target_ccs}
|
||||
try:
|
||||
with open(path, encoding='latin-1') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
parts = line.split('|')
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
_, cc, type_, start, count_str, _, status = parts[:7]
|
||||
if type_ != 'ipv4':
|
||||
continue
|
||||
if status not in ('allocated', 'assigned'):
|
||||
continue
|
||||
cc = cc.upper()
|
||||
if cc not in target_ccs:
|
||||
continue
|
||||
try:
|
||||
count = int(count_str)
|
||||
except ValueError:
|
||||
continue
|
||||
result[cc].extend(_count_to_cidrs(start, count))
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def get_country_cidrs(country_codes: list[str], exclude_map: dict[str, list] | None = None) -> dict[str, list[str]]:
|
||||
if exclude_map is None:
|
||||
exclude_map = {}
|
||||
target_ccs = {cc.upper() for cc in country_codes}
|
||||
combined: dict[str, list[str]] = {cc: [] for cc in target_ccs}
|
||||
|
||||
for rir, url in RIR_URLS.items():
|
||||
path = _fetch_rir(rir, url)
|
||||
partial = _parse_rir_file(path, target_ccs)
|
||||
for cc, cidrs in partial.items():
|
||||
combined[cc].extend(cidrs)
|
||||
|
||||
for cc, excludes in exclude_map.items():
|
||||
cc = cc.upper()
|
||||
if cc not in combined or not excludes:
|
||||
continue
|
||||
exclude_nets = []
|
||||
for ex in excludes:
|
||||
try:
|
||||
exclude_nets.append(ipaddress.ip_network(ex, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
if not exclude_nets:
|
||||
continue
|
||||
filtered = []
|
||||
for cidr in combined[cc]:
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
if not any(net.overlaps(ex) for ex in exclude_nets):
|
||||
filtered.append(cidr)
|
||||
except ValueError:
|
||||
filtered.append(cidr)
|
||||
combined[cc] = filtered
|
||||
|
||||
return combined
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cleanup and teardown engine for C2ingRed deployments
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import json
|
||||
import glob
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from utils.common import COLORS, PROVIDER_DIRS, confirm_action
|
||||
|
||||
def teardown_by_deployment_id(deployment_id):
|
||||
"""Teardown infrastructure by deployment ID"""
|
||||
print(f"\n{COLORS['BLUE']}Teardown Infrastructure by ID: {deployment_id}{COLORS['RESET']}")
|
||||
|
||||
# Look for deployment logs to determine provider and configuration
|
||||
log_files = glob.glob(f"logs/deployment_{deployment_id}*.log")
|
||||
|
||||
if not log_files:
|
||||
print(f"{COLORS['RED']}No deployment logs found for ID: {deployment_id}{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
# Try to find deployment info files
|
||||
info_files = glob.glob(f"logs/deployment_info_{deployment_id}*.txt")
|
||||
|
||||
if info_files:
|
||||
config = parse_deployment_info(info_files[0])
|
||||
if config:
|
||||
return execute_teardown(config)
|
||||
|
||||
print(f"{COLORS['YELLOW']}Could not determine deployment configuration from logs{COLORS['RESET']}")
|
||||
print(f"{COLORS['YELLOW']}Manual cleanup may be required{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
def teardown_all_infrastructure():
|
||||
"""Teardown all deployed infrastructure"""
|
||||
print(f"\n{COLORS['RED']}⚠️ WARNING: This will attempt to destroy ALL infrastructure!{COLORS['RESET']}")
|
||||
|
||||
if not confirm_action("Are you absolutely sure?", default=False):
|
||||
return False
|
||||
|
||||
# Find all deployment info files
|
||||
info_files = glob.glob("logs/deployment_info_*.txt")
|
||||
|
||||
if not info_files:
|
||||
print(f"{COLORS['GREEN']}No deployment info files found - nothing to teardown{COLORS['RESET']}")
|
||||
return True
|
||||
|
||||
print(f"Found {len(info_files)} deployments to teardown:")
|
||||
|
||||
success_count = 0
|
||||
for info_file in info_files:
|
||||
config = parse_deployment_info(info_file)
|
||||
if config:
|
||||
deployment_id = config.get('deployment_id', 'unknown')
|
||||
print(f"\nTearing down deployment: {deployment_id}")
|
||||
if execute_teardown(config):
|
||||
success_count += 1
|
||||
|
||||
print(f"\n{COLORS['GREEN']}Successfully tore down {success_count}/{len(info_files)} deployments{COLORS['RESET']}")
|
||||
return success_count == len(info_files)
|
||||
|
||||
def parse_deployment_info(info_file):
|
||||
"""Parse deployment info from file to extract configuration"""
|
||||
try:
|
||||
config = {}
|
||||
with open(info_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_config_section = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line == "Configuration:":
|
||||
in_config_section = True
|
||||
continue
|
||||
elif line.startswith("-" * 30):
|
||||
continue
|
||||
elif line.startswith("Access Information:"):
|
||||
break
|
||||
|
||||
if in_config_section and ": " in line:
|
||||
key, value = line.split(": ", 1)
|
||||
|
||||
# Convert string values back to appropriate types
|
||||
if value.lower() == 'true':
|
||||
value = True
|
||||
elif value.lower() == 'false':
|
||||
value = False
|
||||
elif value.lower() == 'none':
|
||||
value = None
|
||||
|
||||
config[key] = value
|
||||
|
||||
return config
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse deployment info from {info_file}: {e}")
|
||||
return None
|
||||
|
||||
def execute_teardown(config):
|
||||
"""Execute teardown based on configuration"""
|
||||
provider = config.get('provider')
|
||||
deployment_id = config.get('deployment_id')
|
||||
|
||||
if not provider or not deployment_id:
|
||||
print(f"{COLORS['RED']}Missing provider or deployment ID{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
print(f"{COLORS['BLUE']}Executing teardown for {provider} deployment {deployment_id}...{COLORS['RESET']}")
|
||||
|
||||
# Set up logging for teardown
|
||||
from utils.common import setup_logging
|
||||
setup_logging(deployment_id, "teardown")
|
||||
|
||||
try:
|
||||
# Set provider-specific environment variables
|
||||
set_provider_environment_for_teardown(config)
|
||||
|
||||
# Get correct provider directory
|
||||
provider_dir = PROVIDER_DIRS.get(provider, provider.capitalize())
|
||||
|
||||
# Execute teardown playbook
|
||||
playbook = f"providers/{provider_dir}/cleanup.yml"
|
||||
|
||||
if not os.path.exists(playbook):
|
||||
print(f"{COLORS['YELLOW']}Teardown playbook not found: {playbook}{COLORS['RESET']}")
|
||||
print(f"{COLORS['YELLOW']}Manual cleanup required{COLORS['RESET']}")
|
||||
return manual_cleanup_guidance(config)
|
||||
|
||||
success = run_teardown_playbook(playbook, config)
|
||||
|
||||
if success:
|
||||
# Clean up local SSH keys
|
||||
cleanup_ssh_keys_for_deployment(deployment_id)
|
||||
|
||||
# Move deployment logs to cleanup folder
|
||||
archive_deployment_logs(deployment_id)
|
||||
|
||||
print(f"{COLORS['GREEN']}Teardown completed successfully{COLORS['RESET']}")
|
||||
else:
|
||||
print(f"{COLORS['RED']}Teardown failed - manual cleanup may be required{COLORS['RESET']}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Teardown execution failed: {e}")
|
||||
print(f"{COLORS['RED']}Teardown execution failed: {e}{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
def set_provider_environment_for_teardown(config):
|
||||
"""Set provider-specific environment variables for teardown"""
|
||||
provider = config.get('provider')
|
||||
|
||||
if provider == "aws":
|
||||
if config.get('aws_access_key'):
|
||||
os.environ['AWS_ACCESS_KEY_ID'] = config['aws_access_key']
|
||||
if config.get('aws_secret_key'):
|
||||
os.environ['AWS_SECRET_ACCESS_KEY'] = config['aws_secret_key']
|
||||
elif provider == "linode":
|
||||
# Try to get token from config first, then from vars file
|
||||
if config.get('linode_token'):
|
||||
os.environ['LINODE_TOKEN'] = config['linode_token']
|
||||
else:
|
||||
# Load token from provider vars file
|
||||
try:
|
||||
from utils.common import load_vars_file, PROVIDER_DIRS
|
||||
vars_data = load_vars_file(provider)
|
||||
if vars_data and vars_data.get('linode_token'):
|
||||
os.environ['LINODE_TOKEN'] = vars_data['linode_token']
|
||||
config['linode_token'] = vars_data['linode_token'] # Add to config for later use
|
||||
else:
|
||||
logging.warning("Linode token not found in vars file - cleanup may fail")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load Linode token from vars file: {e}")
|
||||
|
||||
def run_teardown_playbook(playbook, config):
|
||||
"""Run the teardown Ansible playbook"""
|
||||
try:
|
||||
cmd = [
|
||||
'ansible-playbook',
|
||||
playbook,
|
||||
'--extra-vars', create_teardown_vars(config)
|
||||
]
|
||||
|
||||
if config.get('debug'):
|
||||
cmd.append('-vvv')
|
||||
|
||||
logging.info(f"Executing teardown: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logging.info("Teardown playbook executed successfully")
|
||||
return True
|
||||
else:
|
||||
logging.error(f"Teardown playbook failed with return code {result.returncode}")
|
||||
logging.error(f"STDOUT: {result.stdout}")
|
||||
logging.error(f"STDERR: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing teardown playbook: {e}")
|
||||
return False
|
||||
|
||||
def create_teardown_vars(config):
|
||||
"""Create Ansible extra vars for teardown"""
|
||||
teardown_vars = {
|
||||
'deployment_id': config.get('deployment_id'),
|
||||
'provider': config.get('provider'),
|
||||
'operation': 'teardown'
|
||||
}
|
||||
|
||||
# Add instance names for proper cleanup
|
||||
if config.get('redirector_name'):
|
||||
teardown_vars['redirector_name'] = config['redirector_name']
|
||||
if config.get('c2_name'):
|
||||
teardown_vars['c2_name'] = config['c2_name']
|
||||
if config.get('tracker_name'):
|
||||
teardown_vars['tracker_name'] = config['tracker_name']
|
||||
if config.get('attack_box_name'):
|
||||
teardown_vars['attack_box_name'] = config['attack_box_name']
|
||||
|
||||
# Add provider-specific vars if present
|
||||
if config.get('aws_access_key'):
|
||||
teardown_vars['aws_access_key'] = config['aws_access_key']
|
||||
if config.get('aws_secret_key'):
|
||||
teardown_vars['aws_secret_key'] = config['aws_secret_key']
|
||||
if config.get('aws_region'):
|
||||
teardown_vars['aws_region'] = config['aws_region']
|
||||
if config.get('linode_token'):
|
||||
teardown_vars['linode_token'] = config['linode_token']
|
||||
if config.get('linode_region'):
|
||||
teardown_vars['linode_region'] = config['linode_region']
|
||||
|
||||
return json.dumps(teardown_vars)
|
||||
|
||||
def cleanup_ssh_keys_for_deployment(deployment_id):
|
||||
"""Clean up SSH keys for a specific deployment"""
|
||||
ssh_dir = os.path.expanduser("~/.ssh")
|
||||
key_pattern = f"c2deploy_{deployment_id}*"
|
||||
|
||||
for key_file in glob.glob(os.path.join(ssh_dir, key_pattern)):
|
||||
try:
|
||||
os.remove(key_file)
|
||||
logging.info(f"Removed SSH key: {key_file}")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to remove SSH key {key_file}: {e}")
|
||||
|
||||
def archive_deployment_logs(deployment_id):
|
||||
"""Archive deployment logs after successful teardown"""
|
||||
try:
|
||||
# Create archive directory
|
||||
archive_dir = "logs/archive"
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
|
||||
# Move all files related to this deployment
|
||||
log_patterns = [
|
||||
f"logs/deployment_{deployment_id}*",
|
||||
f"logs/deployment_info_{deployment_id}*",
|
||||
f"logs/teardown_{deployment_id}*"
|
||||
]
|
||||
|
||||
for pattern in log_patterns:
|
||||
for file_path in glob.glob(pattern):
|
||||
archive_path = os.path.join(archive_dir, os.path.basename(file_path))
|
||||
os.rename(file_path, archive_path)
|
||||
logging.info(f"Archived: {file_path} -> {archive_path}")
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to archive logs for {deployment_id}: {e}")
|
||||
|
||||
def manual_cleanup_guidance(config):
|
||||
"""Provide manual cleanup guidance when automated teardown isn't available"""
|
||||
provider = config.get('provider')
|
||||
deployment_id = config.get('deployment_id')
|
||||
|
||||
print(f"\n{COLORS['YELLOW']}Manual Cleanup Required{COLORS['RESET']}")
|
||||
print(f"{COLORS['YELLOW']}========================{COLORS['RESET']}")
|
||||
print(f"Deployment ID: {deployment_id}")
|
||||
print(f"Provider: {provider}")
|
||||
|
||||
if provider == "aws":
|
||||
print(f"\nAWS Resources to check:")
|
||||
print(f"- EC2 instances with tags containing: {deployment_id}")
|
||||
print(f"- Security groups with names containing: {deployment_id}")
|
||||
print(f"- Key pairs with names containing: {deployment_id}")
|
||||
print(f"- EIPs associated with the deployment")
|
||||
|
||||
elif provider == "linode":
|
||||
print(f"\nLinode Resources to check:")
|
||||
print(f"- Linodes with labels containing: {deployment_id}")
|
||||
print(f"- NodeBalancers with labels containing: {deployment_id}")
|
||||
print(f"- Firewalls with labels containing: {deployment_id}")
|
||||
|
||||
elif provider == "flokinet":
|
||||
print(f"\nFlokiNET Resources to check:")
|
||||
print(f"- Check your FlokiNET control panel for resources created")
|
||||
print(f"- Look for servers with the deployment ID: {deployment_id}")
|
||||
|
||||
print(f"\nLocal cleanup:")
|
||||
print(f"- SSH keys: ~/.ssh/c2deploy_{deployment_id}*")
|
||||
print(f"- Log files: logs/*{deployment_id}*")
|
||||
|
||||
return False # Manual cleanup required
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Cleanup engine loaded")
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common utilities and constants for C2ingRed deployment system
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import string
|
||||
import logging
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# Constants for providers
|
||||
PROVIDERS = ["aws", "linode", "flokinet"]
|
||||
DEFAULT_SSH_USER = {
|
||||
"aws": "kali",
|
||||
"linode": "root",
|
||||
"flokinet": "root"
|
||||
}
|
||||
|
||||
# Directory names - maintain correct case for each provider
|
||||
PROVIDER_DIRS = {
|
||||
"aws": "AWS",
|
||||
"linode": "Linode",
|
||||
"flokinet": "FlokiNET"
|
||||
}
|
||||
|
||||
# Color codes for terminal output
|
||||
COLORS = {
|
||||
"RESET": "\033[0m",
|
||||
"RED": "\033[91m",
|
||||
"GREEN": "\033[92m",
|
||||
"YELLOW": "\033[93m",
|
||||
"BLUE": "\033[94m",
|
||||
"PURPLE": "\033[95m",
|
||||
"CYAN": "\033[96m",
|
||||
"WHITE": "\033[97m",
|
||||
"GRAY": "\033[90m"
|
||||
}
|
||||
|
||||
def clear_screen():
|
||||
"""Clear the terminal screen"""
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
def print_banner():
|
||||
"""Print the C2ingRed banner"""
|
||||
banner = f"""
|
||||
{COLORS['BLUE']}========================================================{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ██████╗██████╗ ██╗███╗ ██╗ ██████╗ ██████╗ ███████╗██████╗{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ██╔════╝╚════██╗██║████╗ ██║██╔════╝ ██╔══██╗██╔════╝██╔══██╗{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ██║ █████╔╝██║██╔██╗ ██║██║ ███╗██████╔╝█████╗ ██║ ██║{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ██║ ██╔═══╝ ██║██║╚██╗██║██║ ██║██╔══██╗██╔══╝ ██║ ██║{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ╚██████╗███████╗██║██║ ╚████║╚██████╔╝██║ ██║███████╗██████╔╝{COLORS['RESET']}
|
||||
{COLORS['BLUE']} ╚═════╝╚══════╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═════╝{COLORS['RESET']}
|
||||
{COLORS['BLUE']} {COLORS['RESET']}
|
||||
{COLORS['BLUE']} Red Team Infrastructure Deployment Tool {COLORS['RESET']}
|
||||
{COLORS['BLUE']}========================================================{COLORS['RESET']}
|
||||
"""
|
||||
print(banner)
|
||||
|
||||
def generate_random_string(length=8):
|
||||
"""Generate a random string of letters and digits."""
|
||||
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
|
||||
|
||||
def generate_deployment_id():
|
||||
"""Generate a consistent deployment ID for all resources in this deployment"""
|
||||
from utils.name_generator import generate_deployment_id as generate_verb_animal_id
|
||||
return generate_verb_animal_id()
|
||||
|
||||
def archive_old_logs(max_logs_to_keep=2):
|
||||
"""Archive old log files to keep logs directory clean - more aggressive archiving"""
|
||||
import glob
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
log_dir = "logs"
|
||||
archive_dir = os.path.join(log_dir, "archive")
|
||||
|
||||
if not os.path.exists(log_dir):
|
||||
return
|
||||
|
||||
# Create archive directory if it doesn't exist
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
|
||||
# Get all log files (excluding archive directory) - ANY existing logs should be archived
|
||||
log_files = []
|
||||
for pattern in ["deployment_*.log", "teardown_*.log", "20*.log"]:
|
||||
log_files.extend(glob.glob(os.path.join(log_dir, pattern)))
|
||||
|
||||
# Remove duplicates and filter out archive directory
|
||||
log_files = [f for f in set(log_files) if "archive" not in f]
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
log_files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
|
||||
|
||||
# Archive ALL deployment logs to keep directory clean for new deployments
|
||||
if len(log_files) > 0:
|
||||
print(f"Found {len(log_files)} log files to archive")
|
||||
|
||||
archived_count = 0
|
||||
for log_file in log_files:
|
||||
try:
|
||||
filename = os.path.basename(log_file)
|
||||
# Add timestamp to archived filename to prevent conflicts
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archived_filename = f"{timestamp}_{filename}"
|
||||
archive_path = os.path.join(archive_dir, archived_filename)
|
||||
|
||||
shutil.move(log_file, archive_path)
|
||||
archived_count += 1
|
||||
print(f"Archived: {filename} -> archive/{archived_filename}")
|
||||
except Exception as e:
|
||||
print(f"Failed to archive {log_file}: {e}")
|
||||
|
||||
if archived_count > 0:
|
||||
print(f"Archived {archived_count} log files to {archive_dir}")
|
||||
|
||||
# Also archive old deployment info files
|
||||
info_files = glob.glob(os.path.join(log_dir, "deployment_info_*.txt"))
|
||||
|
||||
if len(info_files) > 0:
|
||||
print(f"Found {len(info_files)} info files to archive")
|
||||
for info_file in info_files:
|
||||
try:
|
||||
filename = os.path.basename(info_file)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archived_filename = f"{timestamp}_{filename}"
|
||||
archive_path = os.path.join(archive_dir, archived_filename)
|
||||
|
||||
shutil.move(info_file, archive_path)
|
||||
print(f"Archived: {filename} -> archive/{archived_filename}")
|
||||
except Exception as e:
|
||||
print(f"Failed to archive {info_file}: {e}")
|
||||
|
||||
def setup_logging(deployment_id=None, operation_type="deployment"):
|
||||
"""Set up logging for the deployment or teardown"""
|
||||
log_dir = "logs"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Archive old logs before starting new deployment
|
||||
if operation_type == "deployment":
|
||||
archive_old_logs()
|
||||
|
||||
# Create distinct log files for deployment vs teardown operations
|
||||
if operation_type == "teardown":
|
||||
log_file = os.path.join(log_dir, f"teardown_{deployment_id}.log")
|
||||
else:
|
||||
log_file = os.path.join(log_dir, f"deployment_{deployment_id}.log")
|
||||
|
||||
# Clear any existing handlers
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Configure file handler to log DEBUG and above for full verbosity
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
# Add console handler for INFO level and above
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter('[%(levelname)s] %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
# Configure root logger
|
||||
root_logger.setLevel(logging.DEBUG) # Capture everything at root level
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
logging.info(f"{operation_type.capitalize()} operation started")
|
||||
logging.info(f"Deployment ID: {deployment_id}")
|
||||
logging.info(f"Full verbose output will be captured in: {log_file}")
|
||||
return log_file
|
||||
|
||||
def load_vars_file(provider):
|
||||
"""Load vars.yaml for the specified provider"""
|
||||
if provider not in PROVIDER_DIRS:
|
||||
return {}
|
||||
|
||||
# Use correct case for directory
|
||||
provider_dir = PROVIDER_DIRS[provider]
|
||||
vars_file = f"providers/{provider_dir}/vars.yaml"
|
||||
|
||||
if os.path.exists(vars_file):
|
||||
try:
|
||||
import yaml
|
||||
with open(vars_file, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load {vars_file}: {e}")
|
||||
return {}
|
||||
else:
|
||||
logging.warning(f"Vars file not found: {vars_file}")
|
||||
return {}
|
||||
|
||||
def validate_ip_address(ip):
|
||||
"""Validate IP address format"""
|
||||
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
|
||||
return re.match(pattern, ip) is not None
|
||||
|
||||
def get_public_ip():
|
||||
"""Get the user's public IP address"""
|
||||
try:
|
||||
import requests
|
||||
return requests.get('https://api.ipify.org', timeout=5).text.strip()
|
||||
except:
|
||||
return None
|
||||
|
||||
def confirm_action(message, default=False):
|
||||
"""Ask for user confirmation with a yes/no prompt"""
|
||||
prompt = f"{message} ({'Y/n' if default else 'y/N'}): "
|
||||
response = input(prompt).lower().strip()
|
||||
|
||||
if not response:
|
||||
return default
|
||||
|
||||
return response in ['y', 'yes']
|
||||
|
||||
def wait_for_input(message="Press Enter to continue..."):
|
||||
"""Wait for user input before continuing"""
|
||||
input(f"\n{message}")
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deployment engine for C2ingRed infrastructure deployments
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from utils.common import COLORS, PROVIDER_DIRS
|
||||
|
||||
_SECRET_KEYS = frozenset({
|
||||
'linode_token', 'aws_access_key', 'aws_secret_key', 'flokinet_api_key',
|
||||
'smtp_auth_pass', 'smtp_password', 'ftp_password', 'api_key',
|
||||
'password', 'secret', 'token',
|
||||
})
|
||||
|
||||
|
||||
def deploy_infrastructure(config):
|
||||
"""
|
||||
Deploy infrastructure based on the provided configuration.
|
||||
|
||||
Args:
|
||||
config: Dictionary containing deployment configuration
|
||||
|
||||
Returns:
|
||||
bool: True if deployment succeeded, False otherwise
|
||||
"""
|
||||
provider = config.get('provider')
|
||||
deployment_id = config.get('deployment_id')
|
||||
|
||||
if not provider or not deployment_id:
|
||||
logging.error("Missing provider or deployment ID in configuration")
|
||||
return False
|
||||
|
||||
# Determine which playbook to use
|
||||
playbook = determine_playbook(config)
|
||||
|
||||
if not playbook:
|
||||
logging.error("Could not determine appropriate playbook for deployment")
|
||||
return False
|
||||
|
||||
if not os.path.exists(playbook):
|
||||
logging.error(f"Playbook not found: {playbook}")
|
||||
print(f"{COLORS['RED']}Playbook not found: {playbook}{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
logging.info(f"Using playbook: {playbook}")
|
||||
|
||||
# Set provider-specific environment variables
|
||||
set_provider_environment(config)
|
||||
|
||||
# Execute the deployment
|
||||
return execute_playbook(playbook, config)
|
||||
|
||||
|
||||
def determine_playbook(config):
|
||||
"""
|
||||
Determine the correct playbook based on deployment configuration.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration dictionary
|
||||
|
||||
Returns:
|
||||
str: Path to the playbook file
|
||||
"""
|
||||
provider = config.get('provider')
|
||||
provider_dir = PROVIDER_DIRS.get(provider, provider.capitalize())
|
||||
base_path = f"providers/{provider_dir}"
|
||||
|
||||
# Attack box deployment
|
||||
if config.get('attack_box_deployment'):
|
||||
return f"{base_path}/attack_box.yml"
|
||||
|
||||
# Redirector-only deployment
|
||||
if config.get('redirector_only'):
|
||||
return f"{base_path}/redirector.yml"
|
||||
|
||||
# Tracker deployment
|
||||
if config.get('tracker_deployment'):
|
||||
return f"{base_path}/tracker.yml"
|
||||
|
||||
# Phishing infrastructure deployment
|
||||
if config.get('phishing_deployment'):
|
||||
# Check for provider-specific phishing playbook first
|
||||
provider_specific = f"{base_path}/{provider}_phishing.yml"
|
||||
if os.path.exists(provider_specific):
|
||||
return provider_specific
|
||||
return f"{base_path}/phishing.yml"
|
||||
|
||||
# Chat server deployment
|
||||
if config.get('chat_deployment'):
|
||||
return f"{base_path}/chat_server.yml"
|
||||
|
||||
# C2 deployment (default for C2 infrastructure)
|
||||
if config.get('c2_only') or config.get('deploy_c2') or config.get('c2_framework'):
|
||||
return f"{base_path}/c2.yml"
|
||||
|
||||
# Default to C2 playbook
|
||||
return f"{base_path}/c2.yml"
|
||||
|
||||
|
||||
def set_provider_environment(config):
|
||||
"""
|
||||
Set provider-specific environment variables for deployment.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration dictionary
|
||||
"""
|
||||
provider = config.get('provider')
|
||||
|
||||
if provider == "aws":
|
||||
if config.get('aws_access_key'):
|
||||
os.environ['AWS_ACCESS_KEY_ID'] = config['aws_access_key']
|
||||
if config.get('aws_secret_key'):
|
||||
os.environ['AWS_SECRET_ACCESS_KEY'] = config['aws_secret_key']
|
||||
if config.get('aws_region'):
|
||||
os.environ['AWS_DEFAULT_REGION'] = config['aws_region']
|
||||
|
||||
elif provider == "linode":
|
||||
if config.get('linode_token'):
|
||||
os.environ['LINODE_TOKEN'] = config['linode_token']
|
||||
else:
|
||||
# Pull from Infisical at runtime
|
||||
try:
|
||||
token = subprocess.check_output(
|
||||
[os.path.expanduser('~/.local/bin/creds'), 'get', 'LINODE_TOKEN', 'homelab'],
|
||||
text=True, stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
if token:
|
||||
os.environ['LINODE_TOKEN'] = token
|
||||
config['linode_token'] = token
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load Linode token from Infisical: {e}")
|
||||
|
||||
elif provider == "flokinet":
|
||||
if config.get('flokinet_api_key'):
|
||||
os.environ['FLOKINET_API_KEY'] = config['flokinet_api_key']
|
||||
|
||||
|
||||
def execute_playbook(playbook, config):
|
||||
"""
|
||||
Execute an Ansible playbook with the provided configuration.
|
||||
|
||||
Args:
|
||||
playbook: Path to the Ansible playbook
|
||||
config: Deployment configuration dictionary
|
||||
|
||||
Returns:
|
||||
bool: True if playbook executed successfully, False otherwise
|
||||
"""
|
||||
import tempfile
|
||||
secret_vars_file = None
|
||||
try:
|
||||
# Build the ansible-playbook command
|
||||
cmd = [
|
||||
'ansible-playbook',
|
||||
playbook,
|
||||
'--extra-vars', create_extra_vars(config)
|
||||
]
|
||||
|
||||
# Webrunner runs many nodes in parallel — increase fork count
|
||||
if 'webrunner' in playbook:
|
||||
cmd += ['-f', '50']
|
||||
|
||||
# Write sensitive vars to a temp file (0o600) so they reach Ansible as
|
||||
# variables without appearing in process listings or the main extra-vars JSON.
|
||||
# Read from environment (set by set_provider_environment) so this works even
|
||||
# when config was passed as a copy ({**config}) that didn't capture the token.
|
||||
secret_vars = {}
|
||||
if os.environ.get('LINODE_TOKEN'):
|
||||
secret_vars['linode_token'] = os.environ['LINODE_TOKEN']
|
||||
if os.environ.get('AWS_ACCESS_KEY_ID'):
|
||||
secret_vars['aws_access_key'] = os.environ['AWS_ACCESS_KEY_ID']
|
||||
if os.environ.get('AWS_SECRET_ACCESS_KEY'):
|
||||
secret_vars['aws_secret_key'] = os.environ['AWS_SECRET_ACCESS_KEY']
|
||||
if os.environ.get('FLOKINET_API_KEY'):
|
||||
secret_vars['flokinet_api_key'] = os.environ['FLOKINET_API_KEY']
|
||||
# Also pick up any remaining secret keys from config dict (e.g. smtp_auth_pass)
|
||||
for k, v in config.items():
|
||||
if v is not None and k not in secret_vars and any(s in k.lower() for s in _SECRET_KEYS):
|
||||
secret_vars[k] = v
|
||||
if secret_vars:
|
||||
fd, secret_vars_file = tempfile.mkstemp(suffix='.json', prefix='ansible_secret_')
|
||||
os.chmod(secret_vars_file, 0o600)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(secret_vars, f)
|
||||
cmd += ['--extra-vars', f'@{secret_vars_file}']
|
||||
|
||||
# Add verbosity if debug mode is enabled
|
||||
if config.get('debug'):
|
||||
cmd.append('-vvv')
|
||||
|
||||
logging.info(f"Executing: {' '.join(cmd[:3])} ...")
|
||||
|
||||
# Get log file path for output
|
||||
log_file = f"logs/deployment_{config['deployment_id']}.log"
|
||||
|
||||
# Execute the playbook — log file restricted to owner-only (contains Ansible output)
|
||||
log_fd = os.open(log_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
with open(log_fd, 'a') as log_output:
|
||||
# Write command to log
|
||||
log_output.write(f"\n{'='*60}\n")
|
||||
log_output.write(f"Executing playbook: {playbook}\n")
|
||||
log_output.write(f"{'='*60}\n\n")
|
||||
log_output.flush()
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
# Stream output to both console and log file
|
||||
for line in process.stdout:
|
||||
# Write to log file
|
||||
log_output.write(line)
|
||||
log_output.flush()
|
||||
|
||||
# Print select lines to console for user feedback
|
||||
if should_print_line(line):
|
||||
print(line.rstrip())
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
logging.info("Playbook executed successfully")
|
||||
save_deployment_info(config)
|
||||
return True
|
||||
else:
|
||||
logging.error(f"Playbook failed with return code {process.returncode}")
|
||||
print(f"\n{COLORS['RED']}Deployment failed. Check logs for details: {log_file}{COLORS['RESET']}")
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
logging.error("ansible-playbook command not found. Is Ansible installed?")
|
||||
print(f"{COLORS['RED']}Error: ansible-playbook not found. Please install Ansible.{COLORS['RESET']}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing playbook: {e}")
|
||||
print(f"{COLORS['RED']}Error executing playbook: {e}{COLORS['RESET']}")
|
||||
return False
|
||||
finally:
|
||||
if secret_vars_file and os.path.exists(secret_vars_file):
|
||||
os.unlink(secret_vars_file)
|
||||
|
||||
|
||||
def should_print_line(line):
|
||||
"""
|
||||
Determine if a line should be printed to console.
|
||||
Filters out verbose Ansible output to show only key information.
|
||||
|
||||
Args:
|
||||
line: Output line from Ansible
|
||||
|
||||
Returns:
|
||||
bool: True if line should be printed
|
||||
"""
|
||||
# Always show task names and important messages
|
||||
important_patterns = [
|
||||
'TASK [',
|
||||
'PLAY [',
|
||||
'ok:',
|
||||
'changed:',
|
||||
'failed:',
|
||||
'fatal:',
|
||||
'skipping:',
|
||||
'PLAY RECAP',
|
||||
'=> {',
|
||||
'msg:',
|
||||
'IP Address:',
|
||||
'Instance ID:',
|
||||
]
|
||||
|
||||
for pattern in important_patterns:
|
||||
if pattern in line:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_extra_vars(config):
|
||||
"""
|
||||
Create JSON string of extra variables for Ansible.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration dictionary
|
||||
|
||||
Returns:
|
||||
str: JSON string of variables
|
||||
"""
|
||||
# Credentials are passed via environment variables (set_provider_environment).
|
||||
# Strip them here so they never appear in --extra-vars or process listings.
|
||||
extra_vars = {}
|
||||
|
||||
for key, value in config.items():
|
||||
if value is None:
|
||||
continue
|
||||
if any(secret in key.lower() for secret in _SECRET_KEYS):
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
extra_vars[key] = value
|
||||
elif isinstance(value, (str, int, float, list, dict)):
|
||||
extra_vars[key] = value
|
||||
|
||||
return json.dumps(extra_vars)
|
||||
|
||||
|
||||
def save_deployment_info(config):
|
||||
"""
|
||||
Save deployment information to a file for later reference/cleanup.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration dictionary
|
||||
"""
|
||||
try:
|
||||
deployment_id = config.get('deployment_id')
|
||||
info_file = f"logs/deployment_info_{deployment_id}.txt"
|
||||
|
||||
with open(info_file, 'w') as f:
|
||||
f.write(f"Deployment Information\n")
|
||||
f.write(f"{'='*50}\n")
|
||||
f.write(f"Deployment ID: {deployment_id}\n")
|
||||
f.write(f"Provider: {config.get('provider')}\n")
|
||||
f.write(f"Deployment Type: {config.get('deployment_type', 'unknown')}\n")
|
||||
f.write(f"\n")
|
||||
f.write(f"Configuration:\n")
|
||||
f.write(f"{'-'*30}\n")
|
||||
|
||||
# Write key configuration items
|
||||
keys_to_save = [
|
||||
'provider', 'deployment_id', 'deployment_type',
|
||||
'attack_box_name', 'c2_name', 'redirector_name', 'tracker_name',
|
||||
'chat_server_name',
|
||||
'domain', 'c2_subdomain', 'redirector_subdomain',
|
||||
'c2_framework', 'redirector_type',
|
||||
'linode_region', 'aws_region',
|
||||
'setup_vpn', 'setup_tor',
|
||||
'ssh_key_path',
|
||||
'matrix_admin_user', 'enable_bot_support'
|
||||
]
|
||||
|
||||
for key in keys_to_save:
|
||||
if key in config and config[key] is not None:
|
||||
# Don't save sensitive data
|
||||
if 'password' in key.lower() or 'token' in key.lower() or 'secret' in key.lower():
|
||||
continue
|
||||
f.write(f"{key}: {config[key]}\n")
|
||||
|
||||
f.write(f"\n")
|
||||
f.write(f"Access Information:\n")
|
||||
f.write(f"{'-'*30}\n")
|
||||
f.write(f"(Instance IPs will be displayed in Ansible output above)\n")
|
||||
|
||||
logging.info(f"Deployment info saved to: {info_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to save deployment info: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Deployment engine loaded")
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FlokiNET provider utilities for C2ingRed deployment system
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .common import COLORS, load_vars_file, validate_ip_address
|
||||
|
||||
def get_flokinet_credentials(provider_vars=None):
|
||||
"""Get FlokiNET server IPs from user or vars file"""
|
||||
if not provider_vars:
|
||||
provider_vars = load_vars_file('flokinet')
|
||||
|
||||
default_redirector_ip = provider_vars.get('redirector_ip', '')
|
||||
default_c2_ip = provider_vars.get('c2_ip', '')
|
||||
|
||||
print(f"\n{COLORS['BLUE']}FlokiNET Configuration{COLORS['RESET']}")
|
||||
print(f"{COLORS['YELLOW']}Note: FlokiNET requires pre-provisioned servers{COLORS['RESET']}")
|
||||
|
||||
redirector_ip = input(f"FlokiNET Redirector IP Address [default: {default_redirector_ip}]: ") or default_redirector_ip
|
||||
c2_ip = input(f"FlokiNET C2 Server IP Address [default: {default_c2_ip}]: ") or default_c2_ip
|
||||
|
||||
# Validate IP addresses
|
||||
if redirector_ip and not validate_ip_address(redirector_ip):
|
||||
print(f"{COLORS['RED']}Invalid redirector IP address{COLORS['RESET']}")
|
||||
return None
|
||||
|
||||
if c2_ip and not validate_ip_address(c2_ip):
|
||||
print(f"{COLORS['RED']}Invalid C2 server IP address{COLORS['RESET']}")
|
||||
return None
|
||||
|
||||
return {
|
||||
'flokinet_redirector_ip': redirector_ip,
|
||||
'flokinet_c2_ip': c2_ip
|
||||
}
|
||||
|
||||
def gather_flokinet_config():
|
||||
"""Gather all FlokiNET-specific configuration"""
|
||||
provider_vars = load_vars_file('flokinet')
|
||||
config = {}
|
||||
|
||||
# Get server IPs
|
||||
flokinet_ips = get_flokinet_credentials(provider_vars)
|
||||
if not flokinet_ips:
|
||||
return None
|
||||
config.update(flokinet_ips)
|
||||
|
||||
# FlokiNET-specific settings
|
||||
config['ssh_user'] = 'root' # FlokiNET typically uses root
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Linode provider utilities for C2ingRed deployment system
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
from .common import COLORS, load_vars_file
|
||||
|
||||
def get_linode_credentials(provider_vars=None):
|
||||
"""Get Linode API token — Infisical first, vars.yaml fallback, then prompt."""
|
||||
default_token = ''
|
||||
|
||||
try:
|
||||
default_token = subprocess.check_output(
|
||||
[os.path.expanduser('~/.local/bin/creds'), 'get', 'LINODE_TOKEN', 'homelab'],
|
||||
text=True, stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not default_token:
|
||||
if not provider_vars:
|
||||
provider_vars = load_vars_file('linode')
|
||||
default_token = provider_vars.get('linode_token', '')
|
||||
|
||||
print(f"\n{COLORS['BLUE']}Linode Configuration{COLORS['RESET']}")
|
||||
token = input(f"Linode API Token [{'*****' if default_token else 'required'}]: ") or default_token
|
||||
|
||||
if not token:
|
||||
print(f"{COLORS['RED']}Linode API token is required{COLORS['RESET']}")
|
||||
return None
|
||||
|
||||
return {'linode_token': token}
|
||||
|
||||
def select_linode_regions(provider_vars=None) -> list[str]:
|
||||
"""Return region list — single entry if user specified one, all regions if blank (random per-node)."""
|
||||
if not provider_vars:
|
||||
provider_vars = load_vars_file('linode')
|
||||
|
||||
regions = provider_vars.get('region_choices', ['us-east'])
|
||||
|
||||
print(f"\nAvailable Linode regions:")
|
||||
for i, region in enumerate(regions, 1):
|
||||
print(f" {i:2}. {region}")
|
||||
|
||||
region_input = input("\nSelect region (number or leave blank for random per-node): ").strip()
|
||||
|
||||
if not region_input:
|
||||
return regions
|
||||
|
||||
try:
|
||||
idx = int(region_input)
|
||||
if 1 <= idx <= len(regions):
|
||||
return [regions[idx - 1]]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print(f"{COLORS['RED']}Invalid input, using random per-node regions{COLORS['RESET']}")
|
||||
return regions
|
||||
|
||||
def gather_linode_config():
|
||||
"""Gather all Linode-specific configuration"""
|
||||
provider_vars = load_vars_file('linode')
|
||||
config = {}
|
||||
|
||||
linode_creds = get_linode_credentials(provider_vars)
|
||||
if not linode_creds:
|
||||
return None
|
||||
config.update(linode_creds)
|
||||
|
||||
linode_regions = select_linode_regions(provider_vars)
|
||||
config['linode_regions'] = linode_regions
|
||||
config['linode_region'] = linode_regions[0]
|
||||
|
||||
config['linode_instance_type'] = provider_vars.get('linode_instance_type', 'g6-nanode-1')
|
||||
config['linode_image'] = provider_vars.get('linode_image', 'linode/debian12')
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Name generation utility for c2itall deployments
|
||||
Generates verb-animal names similar to FourEyes with shared deployment IDs
|
||||
"""
|
||||
|
||||
import random
|
||||
import os
|
||||
|
||||
def load_word_list(filename):
|
||||
"""Load words from a text file, one word per line"""
|
||||
try:
|
||||
# Check if we have FourEyes word lists
|
||||
foureyes_path = "/opt/redteam/FourEyes"
|
||||
if os.path.exists(foureyes_path):
|
||||
file_path = os.path.join(foureyes_path, filename)
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, 'r') as f:
|
||||
words = [line.strip().lower() for line in f if line.strip()]
|
||||
return [word for word in words if word] # Remove empty strings
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback word lists if FourEyes not available
|
||||
if 'verbs' in filename:
|
||||
return [
|
||||
"blazing", "soaring", "charging", "prowling", "hunting", "stalking",
|
||||
"striking", "rushing", "dashing", "racing", "flying", "diving",
|
||||
"leaping", "climbing", "sliding", "spinning", "rolling", "sneaking",
|
||||
"roaming", "wandering", "running", "jumping", "swimming", "crawling",
|
||||
"fighting", "defending", "attacking", "scanning", "searching", "finding"
|
||||
]
|
||||
else: # animals
|
||||
return [
|
||||
"wolf", "eagle", "tiger", "falcon", "bear", "lion", "shark", "hawk",
|
||||
"panther", "cobra", "viper", "rhino", "bull", "fox", "raven", "crow",
|
||||
"spider", "scorpion", "mantis", "dragon", "phoenix", "griffin",
|
||||
"badger", "wolverine", "lynx", "jaguar", "cheetah", "leopard"
|
||||
]
|
||||
|
||||
def _generate_verb_animal():
|
||||
"""Generate a verb-animal combination"""
|
||||
verbs = load_word_list('verbs.txt')
|
||||
animals = load_word_list('animals.txt')
|
||||
|
||||
verb = random.choice(verbs)
|
||||
animal = random.choice(animals)
|
||||
return f"{verb}{animal}"
|
||||
|
||||
def generate_deployment_id():
|
||||
"""Generate a deployment ID using verb-animal combination"""
|
||||
return _generate_verb_animal()
|
||||
|
||||
def generate_attack_box_name(deployment_id):
|
||||
"""Generate attack box name with a- prefix using shared deployment ID"""
|
||||
return f"a-{deployment_id}"
|
||||
|
||||
def generate_redirector_name(deployment_id):
|
||||
"""Generate redirector name with r- prefix using shared deployment ID"""
|
||||
return f"r-{deployment_id}"
|
||||
|
||||
def generate_c2_name(deployment_id):
|
||||
"""Generate C2 server name with s- prefix using shared deployment ID"""
|
||||
return f"s-{deployment_id}"
|
||||
|
||||
def generate_phishing_name(deployment_id):
|
||||
"""Generate phishing server name with p- prefix using shared deployment ID"""
|
||||
return f"p-{deployment_id}"
|
||||
|
||||
def generate_tracker_name(deployment_id):
|
||||
"""Generate tracker name with t- prefix using shared deployment ID"""
|
||||
return f"t-{deployment_id}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the name generation
|
||||
deployment_id = generate_deployment_id()
|
||||
print(f"Testing shared deployment ID: {deployment_id}")
|
||||
print(f"Attack Box: {generate_attack_box_name(deployment_id)}")
|
||||
print(f"Redirector: {generate_redirector_name(deployment_id)}")
|
||||
print(f"C2 Server: {generate_c2_name(deployment_id)}")
|
||||
print(f"Phishing: {generate_phishing_name(deployment_id)}")
|
||||
print(f"Tracker: {generate_tracker_name(deployment_id)}")
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common naming utilities for C2itall deployments
|
||||
Provides consistent naming options across all deployment types
|
||||
"""
|
||||
|
||||
import os
|
||||
import glob
|
||||
from utils.common import COLORS
|
||||
|
||||
def get_existing_deployments():
|
||||
"""Get list of existing deployments from deployment info files"""
|
||||
try:
|
||||
info_files = glob.glob("logs/deployment_info_*.txt")
|
||||
deployments = []
|
||||
|
||||
for info_file in info_files:
|
||||
try:
|
||||
deployment_info = {}
|
||||
with open(info_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if ": " in line and not line.startswith("-"):
|
||||
parts = line.split(": ", 1)
|
||||
if len(parts) == 2:
|
||||
key, value = parts
|
||||
deployment_info[key] = value
|
||||
|
||||
if deployment_info.get('deployment_id'):
|
||||
deployments.append(deployment_info)
|
||||
except Exception as e:
|
||||
continue # Skip problematic files
|
||||
|
||||
return deployments
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def select_deployment_for_naming(exclude_types=None, current_deployment_id=None):
|
||||
"""
|
||||
Allow user to select an existing deployment to base naming on
|
||||
|
||||
Args:
|
||||
exclude_types: List of deployment types to exclude (e.g., ['attack_box'])
|
||||
current_deployment_id: Current deployment ID to exclude from list
|
||||
"""
|
||||
deployments = get_existing_deployments()
|
||||
|
||||
if not deployments:
|
||||
print(f"{COLORS['YELLOW']}No existing deployments found{COLORS['RESET']}")
|
||||
return None
|
||||
|
||||
print(f"\n{COLORS['CYAN']}Existing Deployments:{COLORS['RESET']}")
|
||||
print(f"{COLORS['CYAN']}==================={COLORS['RESET']}")
|
||||
|
||||
# Filter deployments based on criteria
|
||||
filtered_deployments = []
|
||||
for deployment in deployments:
|
||||
deployment_id = deployment.get('deployment_id', 'unknown')
|
||||
|
||||
# Skip current deployment
|
||||
if current_deployment_id and deployment_id == current_deployment_id:
|
||||
continue
|
||||
|
||||
# Skip excluded types
|
||||
deployment_type = deployment.get('deployment_type', 'unknown')
|
||||
if exclude_types and deployment_type in exclude_types:
|
||||
continue
|
||||
|
||||
# Check if this is an attack box deployment (legacy check)
|
||||
is_attack_box = (
|
||||
deployment.get('deployment_type') == 'attack_box' or
|
||||
deployment.get('attack_box_deployment') == 'True' or
|
||||
deployment.get('attack_box_name')
|
||||
)
|
||||
|
||||
if exclude_types and 'attack_box' in exclude_types and is_attack_box:
|
||||
continue
|
||||
|
||||
filtered_deployments.append(deployment)
|
||||
|
||||
if not filtered_deployments:
|
||||
print(f"{COLORS['YELLOW']}No suitable deployments found for naming{COLORS['RESET']}")
|
||||
return None
|
||||
|
||||
# Display available deployments
|
||||
for i, deployment in enumerate(filtered_deployments, 1):
|
||||
deployment_id = deployment.get('deployment_id', 'unknown')
|
||||
provider = deployment.get('provider', 'unknown')
|
||||
domain = deployment.get('domain', 'N/A')
|
||||
deployment_type = deployment.get('deployment_type', 'unknown')
|
||||
|
||||
print(f"{i}. {deployment_id}")
|
||||
print(f" Type: {deployment_type}")
|
||||
print(f" Provider: {provider}")
|
||||
print(f" Domain: {domain}")
|
||||
|
||||
# Show instance names if available
|
||||
instances = []
|
||||
if deployment.get('redirector_name'):
|
||||
instances.append(f"Redirector: {deployment.get('redirector_name')}")
|
||||
if deployment.get('c2_name'):
|
||||
instances.append(f"C2: {deployment.get('c2_name')}")
|
||||
if deployment.get('tracker_name'):
|
||||
instances.append(f"Tracker: {deployment.get('tracker_name')}")
|
||||
if deployment.get('attack_box_name'):
|
||||
instances.append(f"Attack Box: {deployment.get('attack_box_name')}")
|
||||
|
||||
if instances:
|
||||
print(f" Instances: {', '.join(instances)}")
|
||||
print()
|
||||
|
||||
# Get user selection
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"Select deployment (1-{len(filtered_deployments)}) or 'c' to cancel: ").strip()
|
||||
|
||||
if choice.lower() == 'c':
|
||||
return None
|
||||
|
||||
choice_num = int(choice)
|
||||
if 1 <= choice_num <= len(filtered_deployments):
|
||||
selected = filtered_deployments[choice_num - 1]
|
||||
return selected.get('deployment_id')
|
||||
else:
|
||||
print(f"{COLORS['RED']}Invalid choice. Please try again.{COLORS['RESET']}")
|
||||
except ValueError:
|
||||
print(f"{COLORS['RED']}Invalid input. Please enter a number or 'c'.{COLORS['RESET']}")
|
||||
|
||||
def get_deployment_name_with_options(deployment_type, deployment_id, prefix="", existing_name=None):
|
||||
"""
|
||||
Get deployment name with multiple naming options
|
||||
|
||||
Args:
|
||||
deployment_type: Type of deployment (c2, redirector, tracker, attack_box, etc.)
|
||||
deployment_id: Current deployment ID
|
||||
prefix: Prefix for the name (e.g., 'r-', 'c-', 'a-', etc.)
|
||||
existing_name: Existing name if updating
|
||||
|
||||
Returns:
|
||||
Chosen name for the deployment
|
||||
"""
|
||||
|
||||
print(f"\n{COLORS['BLUE']}{deployment_type.title()} Naming Options:{COLORS['RESET']}")
|
||||
print(f"1) Auto-generate name ({prefix}{deployment_id})")
|
||||
print(f"2) Name after existing deployment")
|
||||
print(f"3) Custom name")
|
||||
|
||||
if existing_name:
|
||||
print(f"4) Keep current name ({existing_name})")
|
||||
default_choice = "4"
|
||||
else:
|
||||
default_choice = "1"
|
||||
|
||||
naming_choice = input(f"Select naming option [{default_choice}]: ").strip() or default_choice
|
||||
|
||||
if naming_choice == "1":
|
||||
# Auto-generate using deployment ID
|
||||
chosen_name = f"{prefix}{deployment_id}"
|
||||
print(f"Using auto-generated name: {COLORS['CYAN']}{chosen_name}{COLORS['RESET']}")
|
||||
|
||||
elif naming_choice == "2":
|
||||
# Name after existing deployment
|
||||
# Exclude attack boxes when naming other types, but allow other types when naming attack boxes
|
||||
exclude_types = ['attack_box'] if deployment_type != 'attack_box' else []
|
||||
selected_deployment_id = select_deployment_for_naming(
|
||||
exclude_types=exclude_types,
|
||||
current_deployment_id=deployment_id
|
||||
)
|
||||
|
||||
if selected_deployment_id:
|
||||
chosen_name = f"{prefix}{selected_deployment_id}"
|
||||
print(f"{deployment_type.title()} will be named: {COLORS['CYAN']}{chosen_name}{COLORS['RESET']}")
|
||||
print(f"This associates it with deployment: {COLORS['YELLOW']}{selected_deployment_id}{COLORS['RESET']}")
|
||||
else:
|
||||
print(f"{COLORS['YELLOW']}No deployment selected, using auto-generated name{COLORS['RESET']}")
|
||||
chosen_name = f"{prefix}{deployment_id}"
|
||||
|
||||
elif naming_choice == "3":
|
||||
# Custom name
|
||||
while True:
|
||||
custom_name = input(f"Enter custom {deployment_type} name: ").strip()
|
||||
if custom_name:
|
||||
# Ensure it starts with the correct prefix for consistency
|
||||
if prefix and not custom_name.startswith(prefix):
|
||||
chosen_name = f"{prefix}{custom_name}"
|
||||
print(f"Prefixed with '{prefix}': {COLORS['CYAN']}{chosen_name}{COLORS['RESET']}")
|
||||
else:
|
||||
chosen_name = custom_name
|
||||
break
|
||||
else:
|
||||
print(f"{COLORS['RED']}Name cannot be empty. Please try again.{COLORS['RESET']}")
|
||||
|
||||
elif naming_choice == "4" and existing_name:
|
||||
# Keep existing name
|
||||
chosen_name = existing_name
|
||||
print(f"Keeping current name: {COLORS['CYAN']}{chosen_name}{COLORS['RESET']}")
|
||||
|
||||
else:
|
||||
# Default fallback
|
||||
print(f"{COLORS['YELLOW']}Invalid choice, using auto-generated name{COLORS['RESET']}")
|
||||
chosen_name = f"{prefix}{deployment_id}"
|
||||
|
||||
return chosen_name
|
||||
|
||||
def show_naming_relationship(name, deployment_id, deployment_type):
|
||||
"""Show the relationship between the chosen name and deployment"""
|
||||
if not name:
|
||||
return
|
||||
|
||||
# Determine prefix based on deployment type
|
||||
prefix_map = {
|
||||
'redirector': 'r-',
|
||||
'c2': 's-', # s for server
|
||||
'tracker': 't-',
|
||||
'attack_box': 'a-',
|
||||
'payload': 'p-',
|
||||
'webrunner': 'wr-',
|
||||
}
|
||||
|
||||
expected_prefix = prefix_map.get(deployment_type, '')
|
||||
|
||||
if expected_prefix and name.startswith(expected_prefix):
|
||||
target_deployment = name[len(expected_prefix):] # Remove prefix
|
||||
if target_deployment != deployment_id:
|
||||
return {
|
||||
'target_deployment': target_deployment,
|
||||
'relationship_text': f"Named after deployment: {target_deployment}",
|
||||
'purpose_text': f"This {deployment_type} supports the {target_deployment} engagement"
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def get_deployment_type_prefix(deployment_type):
|
||||
"""Get the standard prefix for a deployment type"""
|
||||
prefix_map = {
|
||||
'redirector': 'r-',
|
||||
'c2': 's-', # s for server
|
||||
'tracker': 't-',
|
||||
'attack_box': 'a-',
|
||||
'payload': 'p-',
|
||||
'phishing': 'p-',
|
||||
'webrunner': 'wr-',
|
||||
}
|
||||
return prefix_map.get(deployment_type, '')
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
import math
|
||||
|
||||
INSTANCE_RATES = {
|
||||
'linode': {
|
||||
'g6-nanode-1': 0.0075,
|
||||
'g6-standard-2': 0.018,
|
||||
'g6-standard-4': 0.036,
|
||||
'g6-standard-8': 0.072,
|
||||
},
|
||||
'aws': {
|
||||
't3.micro': 0.0104,
|
||||
't3.small': 0.0208,
|
||||
't3.medium': 0.0416,
|
||||
't3.large': 0.0832,
|
||||
},
|
||||
'flokinet': {
|
||||
'vps-1': 0.0083,
|
||||
'vps-2': 0.0139,
|
||||
'vps-4': 0.0278,
|
||||
},
|
||||
}
|
||||
|
||||
SCAN_MODES = {
|
||||
'geo-scout': {'rate': 3000, 'desc': 'masscan + nmap + probe fingerprinting'},
|
||||
'masscan-only': {'rate': 10000, 'desc': 'masscan port discovery only'},
|
||||
'nmap-only': {'rate': 500, 'desc': 'nmap full fingerprint only'},
|
||||
'masscan+nmap': {'rate': 5000, 'desc': 'masscan + nmap (no probes)'},
|
||||
'masscan+nuclei': {'rate': 5000, 'desc': 'masscan discovery + nuclei CVE template'},
|
||||
}
|
||||
|
||||
PRESETS = {
|
||||
'sprint': {'chunk_size': 500_000, 'label': 'Sprint', 'desc': '~500K IPs/node'},
|
||||
'balanced': {'chunk_size': 2_000_000, 'label': 'Balanced', 'desc': '~2M IPs/node (recommended)'},
|
||||
'economy': {'chunk_size': 5_000_000, 'label': 'Economy', 'desc': '~5M IPs/node'},
|
||||
}
|
||||
|
||||
DEFAULT_INSTANCE = {
|
||||
'linode': 'g6-nanode-1',
|
||||
'aws': 't3.small',
|
||||
'flokinet': 'vps-2',
|
||||
}
|
||||
|
||||
BILLING_MINIMUM = {
|
||||
'linode': 1.0,
|
||||
'aws': 0.017, # billed per second, ~1 min minimum in practice
|
||||
'flokinet': 1.0,
|
||||
}
|
||||
|
||||
|
||||
# ── Empirical timing constants ────────────────────────────────────────────────
|
||||
# Per-host nmap time by timing template (seconds, -sV --version-intensity 5)
|
||||
NMAP_TIME_BY_TIMING = {
|
||||
1: 60.0, # T1 sneaky
|
||||
2: 30.0, # T2 polite
|
||||
3: 15.0, # T3 normal
|
||||
4: 10.0, # T4 aggressive (baseline)
|
||||
}
|
||||
|
||||
# Average per-target time for typical nuclei CVE template (1–3 HTTP requests).
|
||||
# Heavy templates with many requests/matchers will run 2–5× longer.
|
||||
NUCLEI_TIME_PER_TARGET_SEC = 5.0
|
||||
|
||||
# TCP banner grab time (geo-scout probe phase)
|
||||
PROBE_TIME_PER_HOST_SEC = 3.0
|
||||
|
||||
# Default fraction of scanned IPs with open ports on common ports.
|
||||
# Real-world range: 0.5%–5% depending on ports & geography.
|
||||
MASSCAN_HIT_RATE = 0.01
|
||||
|
||||
# Defaults — must match WEBRUNNER tuning defaults
|
||||
_NMAP_WORKERS = 10
|
||||
_NUCLEI_RATE = 150
|
||||
_NUCLEI_CONCURRENCY = 25
|
||||
|
||||
# Tor latency multiplier — applies ONLY to TCP probe phases (nmap/nuclei/probe).
|
||||
# Masscan uses raw sockets and bypasses proxychains entirely — Tor cannot
|
||||
# protect masscan SYN packets. The cloud node's IP is exposed to every
|
||||
# masscan target regardless of this setting.
|
||||
TOR_PHASE_MULTIPLIER = 5.0
|
||||
|
||||
# Per-node provisioning overhead (apt install, optional nuclei download).
|
||||
# Nodes provision in parallel so this is roughly constant.
|
||||
PROVISION_OVERHEAD_HOURS = 5 / 60
|
||||
|
||||
|
||||
def estimate_scan_hours(
|
||||
ip_count: int,
|
||||
n_ports: int,
|
||||
rate: int,
|
||||
scan_mode: str = 'masscan-only',
|
||||
*,
|
||||
nmap_timing: int = 4,
|
||||
nmap_workers: int = _NMAP_WORKERS,
|
||||
nuclei_rate: int = _NUCLEI_RATE,
|
||||
nuclei_concurrency: int = _NUCLEI_CONCURRENCY,
|
||||
hit_rate: float = MASSCAN_HIT_RATE,
|
||||
use_tor: bool = False,
|
||||
) -> float:
|
||||
"""Phase-decomposed scan time estimate. Returns hours per node."""
|
||||
if rate <= 0:
|
||||
return PROVISION_OVERHEAD_HOURS
|
||||
|
||||
nmap_workers = max(nmap_workers, 1)
|
||||
seconds = 0.0
|
||||
|
||||
# masscan phase: raw sockets, no Tor penalty (Tor cannot proxy raw sockets)
|
||||
if scan_mode in ('masscan-only', 'masscan+nmap', 'geo-scout', 'masscan+nuclei'):
|
||||
seconds += ip_count * n_ports / rate
|
||||
|
||||
# nmap-only phase: every IP gets full -sV fingerprint
|
||||
if scan_mode == 'nmap-only':
|
||||
per_host = NMAP_TIME_BY_TIMING.get(nmap_timing, 10.0)
|
||||
if use_tor:
|
||||
per_host *= TOR_PHASE_MULTIPLIER
|
||||
seconds += (ip_count * per_host) / nmap_workers
|
||||
|
||||
# nmap fingerprint phase: only on masscan hits
|
||||
if scan_mode in ('masscan+nmap', 'geo-scout'):
|
||||
nmap_hosts = ip_count * hit_rate
|
||||
per_host = NMAP_TIME_BY_TIMING.get(nmap_timing, 10.0)
|
||||
if use_tor:
|
||||
per_host *= TOR_PHASE_MULTIPLIER
|
||||
seconds += (nmap_hosts * per_host) / nmap_workers
|
||||
|
||||
# probe banner phase: geo-scout only, on masscan hits
|
||||
if scan_mode == 'geo-scout':
|
||||
probe_hosts = ip_count * hit_rate
|
||||
per_probe = PROBE_TIME_PER_HOST_SEC
|
||||
if use_tor:
|
||||
per_probe *= TOR_PHASE_MULTIPLIER
|
||||
seconds += (probe_hosts * per_probe) / nmap_workers
|
||||
|
||||
# nuclei phase: only on masscan-discovered ip:port pairs
|
||||
if scan_mode == 'masscan+nuclei':
|
||||
nuclei_targets = ip_count * hit_rate
|
||||
per_target = NUCLEI_TIME_PER_TARGET_SEC
|
||||
if use_tor:
|
||||
per_target *= TOR_PHASE_MULTIPLIER
|
||||
rate_throughput = float(nuclei_rate)
|
||||
parallel_throughput = nuclei_concurrency / per_target
|
||||
effective_rps = max(min(rate_throughput, parallel_throughput), 1.0)
|
||||
seconds += nuclei_targets / effective_rps
|
||||
|
||||
return seconds / 3600 + PROVISION_OVERHEAD_HOURS
|
||||
|
||||
|
||||
def node_cost(provider: str, instance_type: str, scan_hours: float) -> float:
|
||||
rate = INSTANCE_RATES.get(provider, {}).get(instance_type, 0.018)
|
||||
billed_hours = max(scan_hours, BILLING_MINIMUM.get(provider, 1.0))
|
||||
return rate * billed_hours
|
||||
|
||||
|
||||
def fmt_hours(h: float) -> str:
|
||||
total_mins = int(h * 60)
|
||||
hrs, mins = divmod(total_mins, 60)
|
||||
return f"{hrs}h {mins:02d}m" if hrs else f"{mins}m"
|
||||
|
||||
|
||||
def fmt_ip_count(n: int) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.0f}K"
|
||||
return str(n)
|
||||
|
||||
|
||||
def build_estimate_table(
|
||||
total_ips: int,
|
||||
n_ports: int,
|
||||
providers: list[str],
|
||||
scan_mode: str,
|
||||
use_tor: bool = False,
|
||||
tuning: dict | None = None,
|
||||
) -> list[dict]:
|
||||
tuning = tuning or {}
|
||||
masscan_rate = int(tuning.get('masscan_rate') or SCAN_MODES.get(scan_mode, {'rate': 3000})['rate'])
|
||||
|
||||
kwargs = dict(
|
||||
nmap_timing=int(tuning.get('nmap_timing', 4)),
|
||||
nmap_workers=int(tuning.get('nmap_workers', _NMAP_WORKERS)),
|
||||
nuclei_rate=int(tuning.get('nuclei_rate', _NUCLEI_RATE)),
|
||||
nuclei_concurrency=int(tuning.get('nuclei_concurrency', _NUCLEI_CONCURRENCY)),
|
||||
hit_rate=float(tuning.get('hit_rate', MASSCAN_HIT_RATE)),
|
||||
use_tor=use_tor,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for preset_key, preset in PRESETS.items():
|
||||
n_chunks = max(1, math.ceil(total_ips / preset['chunk_size']))
|
||||
typical_ips = min(preset['chunk_size'], total_ips)
|
||||
hours_per_node = estimate_scan_hours(typical_ips, n_ports, masscan_rate, scan_mode, **kwargs)
|
||||
total_cost = 0.0
|
||||
for i in range(n_chunks):
|
||||
chunk_ips = min(preset['chunk_size'], total_ips - i * preset['chunk_size'])
|
||||
chunk_hours = estimate_scan_hours(chunk_ips, n_ports, masscan_rate, scan_mode, **kwargs)
|
||||
provider = providers[i % len(providers)]
|
||||
instance = DEFAULT_INSTANCE.get(provider, 'g6-standard-2')
|
||||
total_cost += node_cost(provider, instance, chunk_hours)
|
||||
rows.append({
|
||||
'preset': preset_key,
|
||||
'label': preset['label'],
|
||||
'desc': preset['desc'],
|
||||
'n_nodes': n_chunks,
|
||||
'hours_per_node': hours_per_node,
|
||||
'total_cost_usd': total_cost,
|
||||
})
|
||||
return rows
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Provider selection and configuration utilities
|
||||
"""
|
||||
|
||||
from .common import COLORS, PROVIDERS
|
||||
from .aws_utils import gather_aws_config
|
||||
from .linode_utils import gather_linode_config
|
||||
from .flokinet_utils import gather_flokinet_config
|
||||
|
||||
def select_provider():
|
||||
"""Let the user select a cloud provider"""
|
||||
print(f"\n{COLORS['BLUE']}Available cloud providers:{COLORS['RESET']}")
|
||||
for i, provider in enumerate(PROVIDERS, 1):
|
||||
print(f" {i}. {provider.capitalize()}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
provider_choice = input(f"\nSelect a provider (1-{len(PROVIDERS)} or 99 to cancel): ")
|
||||
if provider_choice == "99":
|
||||
return None
|
||||
|
||||
provider_choice = int(provider_choice)
|
||||
if 1 <= provider_choice <= len(PROVIDERS):
|
||||
return PROVIDERS[provider_choice - 1]
|
||||
else:
|
||||
print(f"{COLORS['RED']}Please enter a number between 1 and {len(PROVIDERS)}{COLORS['RESET']}")
|
||||
except ValueError:
|
||||
print(f"{COLORS['RED']}Please enter a valid number{COLORS['RESET']}")
|
||||
|
||||
def gather_provider_config(provider):
|
||||
"""Gather configuration for the specified provider"""
|
||||
if provider == "aws":
|
||||
return gather_aws_config()
|
||||
elif provider == "linode":
|
||||
return gather_linode_config()
|
||||
elif provider == "flokinet":
|
||||
return gather_flokinet_config()
|
||||
else:
|
||||
print(f"{COLORS['RED']}Unknown provider: {provider}{COLORS['RESET']}")
|
||||
return None
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SSH utilities for C2ingRed deployment system
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import re
|
||||
import glob
|
||||
from .common import COLORS, generate_random_string
|
||||
|
||||
def generate_ssh_key(deployment_id=None):
|
||||
"""Generate an SSH key for deployment with proper tracking for cleanup"""
|
||||
# Use deployment_id if provided, otherwise generate random suffix
|
||||
if not deployment_id:
|
||||
deployment_id = generate_random_string(6)
|
||||
|
||||
ssh_key_path = os.path.expanduser(f"~/.ssh/c2deploy_{deployment_id}")
|
||||
ssh_key_pub_path = f"{ssh_key_path}.pub"
|
||||
|
||||
# Check if key already exists
|
||||
if os.path.exists(ssh_key_path):
|
||||
logging.info(f"SSH key already exists at {ssh_key_path}")
|
||||
return ssh_key_path
|
||||
|
||||
try:
|
||||
# Generate the SSH key
|
||||
subprocess.run([
|
||||
"ssh-keygen", "-t", "rsa", "-b", "4096",
|
||||
"-f", ssh_key_path, "-q", "-N", ""
|
||||
], check=True)
|
||||
|
||||
# Set proper permissions
|
||||
os.chmod(ssh_key_path, 0o600)
|
||||
|
||||
# Track generated keys for cleanup
|
||||
if not hasattr(generate_ssh_key, 'generated_keys'):
|
||||
generate_ssh_key.generated_keys = []
|
||||
generate_ssh_key.generated_keys.append(ssh_key_path)
|
||||
|
||||
logging.info(f"Generated SSH key: {ssh_key_path}")
|
||||
return ssh_key_path
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Failed to generate SSH key: {e}")
|
||||
return None
|
||||
|
||||
def get_ssh_public_key(private_key_path):
|
||||
"""Get the public key content from a private key file"""
|
||||
public_key_path = f"{private_key_path}.pub"
|
||||
|
||||
if not os.path.exists(public_key_path):
|
||||
logging.error(f"Public key file not found: {public_key_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(public_key_path, 'r') as f:
|
||||
return f.read().strip()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read public key: {e}")
|
||||
return None
|
||||
|
||||
def extract_attack_box_ip_from_logs(config):
|
||||
"""Extract attack box IP from deployment logs or Ansible output"""
|
||||
deployment_id = config.get('deployment_id', 'unknown')
|
||||
|
||||
# Check deployment log file first
|
||||
log_files_to_check = [
|
||||
f"logs/deployment_{deployment_id}.log",
|
||||
# Also check archived logs
|
||||
f"logs/archive/deployment_{deployment_id}.log"
|
||||
]
|
||||
|
||||
# Check for timestamped archived logs
|
||||
archive_pattern = f"logs/archive/*_deployment_{deployment_id}.log"
|
||||
archived_logs = glob.glob(archive_pattern)
|
||||
if archived_logs:
|
||||
# Get the most recent archived log
|
||||
log_files_to_check.append(max(archived_logs, key=os.path.getmtime))
|
||||
|
||||
for log_file in log_files_to_check:
|
||||
if os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'r') as f:
|
||||
content = f.read()
|
||||
# Look for various IP patterns in the log
|
||||
ip_patterns = [
|
||||
r'"attack_box_ip":\s*"([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})"',
|
||||
r'attack_box_ip.*?([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'"ipv4":\s*\["([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})"\]',
|
||||
r'ansible_host["\s]*=[\s]*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'instance_ip["\s]*:[\s]*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'Target: ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'IP["\s]*:[\s]*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
# Look for IP in Ansible task output patterns
|
||||
r'([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\s+:\s+ok=',
|
||||
r'PLAY RECAP.*?([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'changed: \[([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\]',
|
||||
r'ok: \[([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\]'
|
||||
]
|
||||
|
||||
for pattern in ip_patterns:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
ip = match.group(1)
|
||||
logging.info(f"Extracted attack box IP from logs: {ip}")
|
||||
return ip
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not read deployment log {log_file}: {e}")
|
||||
|
||||
# Check deployment info file
|
||||
info_file = f"logs/deployment_info_{deployment_id}.txt"
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r') as f:
|
||||
content = f.read()
|
||||
# Look for IP in SSH command or other contexts
|
||||
ip_patterns = [
|
||||
r'root@([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'Instance IP:\s*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})',
|
||||
r'IP["\s]*:[\s]*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})'
|
||||
]
|
||||
|
||||
for pattern in ip_patterns:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
ip = match.group(1)
|
||||
logging.info(f"Extracted attack box IP from deployment info: {ip}")
|
||||
return ip
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not read deployment info: {e}")
|
||||
|
||||
logging.warning("Could not extract attack box IP from logs")
|
||||
return None
|
||||
|
||||
def ssh_to_instance(config):
|
||||
"""SSH into an instance after deployment"""
|
||||
ssh_key_path = config.get('ssh_key_path', '').replace('.pub', '')
|
||||
|
||||
# Handle attack box deployments
|
||||
if config.get('attack_box_deployment'):
|
||||
instance_ip = config.get('attack_box_ip')
|
||||
instance_name = config.get('attack_box_name', 'attack box')
|
||||
|
||||
# If no IP stored in config, try to extract from deployment logs
|
||||
if not instance_ip:
|
||||
instance_ip = extract_attack_box_ip_from_logs(config)
|
||||
|
||||
# Determine which instance to connect to for C2 deployments
|
||||
elif config.get('c2_only') or (not config.get('redirector_only') and not config.get('deploy_tracker')):
|
||||
# Connect to C2 server
|
||||
instance_ip = config.get('c2_ip')
|
||||
instance_name = config.get('c2_name', 'C2 server')
|
||||
elif config.get('redirector_only'):
|
||||
# Connect to redirector
|
||||
instance_ip = config.get('redirector_ip')
|
||||
instance_name = config.get('redirector_name', 'redirector')
|
||||
elif config.get('deploy_tracker') and not config.get('integrated_tracker'):
|
||||
# Connect to tracker
|
||||
instance_ip = config.get('tracker_ip')
|
||||
instance_name = config.get('tracker_name', 'tracker')
|
||||
else:
|
||||
# Default to C2 server
|
||||
instance_ip = config.get('c2_ip')
|
||||
instance_name = config.get('c2_name', 'C2 server')
|
||||
|
||||
if not instance_ip:
|
||||
print(f"{COLORS['RED']}No instance IP found for SSH connection{COLORS['RESET']}")
|
||||
return
|
||||
|
||||
ssh_user = config.get('ssh_user', 'root')
|
||||
|
||||
print(f"{COLORS['GREEN']}Connecting to {instance_name} ({instance_ip})...{COLORS['RESET']}")
|
||||
|
||||
ssh_command = [
|
||||
"ssh",
|
||||
"-i", ssh_key_path,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
f"{ssh_user}@{instance_ip}"
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(ssh_command)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{COLORS['YELLOW']}SSH session ended{COLORS['RESET']}")
|
||||
except Exception as e:
|
||||
print(f"{COLORS['RED']}SSH connection failed: {e}{COLORS['RESET']}")
|
||||
|
||||
def cleanup_ssh_keys(deployment_id=None, keep_keys=False):
|
||||
"""Clean up generated SSH keys"""
|
||||
if keep_keys:
|
||||
return
|
||||
|
||||
if hasattr(generate_ssh_key, 'generated_keys'):
|
||||
for key_path in generate_ssh_key.generated_keys:
|
||||
# Only remove keys we generated for this deployment
|
||||
if deployment_id and f"_{deployment_id}" in key_path:
|
||||
try:
|
||||
if os.path.exists(key_path):
|
||||
os.remove(key_path)
|
||||
if os.path.exists(f"{key_path}.pub"):
|
||||
os.remove(f"{key_path}.pub")
|
||||
logging.info(f"Removed SSH key: {key_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to remove SSH key {key_path}: {e}")
|
||||
Reference in New Issue
Block a user