|
46 | 46 | } |
47 | 47 | ) |
48 | 48 |
|
| 49 | +# Ordered kill-chain: after running X, suggest Y (phase-agnostic sensible defaults) |
| 50 | +_KILL_CHAIN_NEXT: dict[str, list[str]] = { |
| 51 | + "ping": ["lazynmap", "arpscan", "hosts_discovery"], |
| 52 | + "lazynmap": ["gobuster", "ffuf", "enum4linux", "searchsploit"], |
| 53 | + "rustscan": ["gobuster", "ffuf", "enum4linux", "searchsploit"], |
| 54 | + "nmap": ["gobuster", "ffuf", "enum4linux", "searchsploit"], |
| 55 | + "gobuster": ["ffuf", "nikto", "whatweb", "feroxbuster"], |
| 56 | + "ffuf": ["nikto", "whatweb", "burpsuite", "sqlmap"], |
| 57 | + "enum4linux": ["crackmapexec", "secretsdump", "kerbrute"], |
| 58 | + "crackmapexec": ["secretsdump", "evil-winrm", "psexec"], |
| 59 | + "secretsdump": ["evil-winrm", "psexec", "hashcat"], |
| 60 | + "linpeas": ["pspy64", "find_suid", "sudo_privesc"], |
| 61 | + "winpeas": ["printspoofer", "juicypotato", "whoami_priv"], |
| 62 | + "searchsploit": ["lazynmap", "gobuster", "exploit_db"], |
| 63 | + "kerbrute": ["GetNPUsers", "GetUserSPNs", "crackmapexec"], |
| 64 | + "nikto": ["sqlmap", "burpsuite", "ffuf"], |
| 65 | + "whatweb": ["gobuster", "nikto", "burpsuite"], |
| 66 | + "feroxbuster": ["ffuf", "nikto", "whatweb"], |
| 67 | + "sqlmap": ["burpsuite", "ffuf", "wfuzz"], |
| 68 | + "hashcat": ["evil-winrm", "ssh", "crackmapexec"], |
| 69 | + "john": ["evil-winrm", "ssh", "crackmapexec"], |
| 70 | + "evil-winrm": ["winpeas", "secretsdump", "mimikatz"], |
| 71 | + "ssh": ["linpeas", "pspy64", "sudo_privesc"], |
| 72 | + "ftp": ["gobuster", "enum4linux", "searchsploit"], |
| 73 | + "smb": ["enum4linux", "crackmapexec", "secretsdump"], |
| 74 | + "responder": ["crackmapexec", "hashcat", "secretsdump"], |
| 75 | +} |
| 76 | + |
| 77 | +_PHASE_PRIORITY: dict[str, list[str]] = { |
| 78 | + "recon": ["ping", "lazynmap", "rustscan", "arpscan", "whois"], |
| 79 | + "enum": ["gobuster", "ffuf", "enum4linux", "nikto", "whatweb", "feroxbuster", "kerbrute"], |
| 80 | + "exploit": ["searchsploit", "crackmapexec", "sqlmap", "burpsuite", "evil-winrm"], |
| 81 | + "privesc": ["linpeas", "winpeas", "pspy64", "sudo_privesc", "printspoofer"], |
| 82 | + "lateral": ["crackmapexec", "evil-winrm", "chisel", "secretsdump", "psexec"], |
| 83 | + "cred": ["hashcat", "john", "responder", "kerbrute", "secretsdump"], |
| 84 | + "postexp": ["linpeas", "winpeas", "mimikatz", "secretsdump", "whoami_priv"], |
| 85 | + "exfil": ["download_c2", "nc", "curl", "scp", "rsync"], |
| 86 | +} |
| 87 | + |
49 | 88 | _MAX_LABEL_LEN: int = 24 |
50 | 89 | _HINT_CONSOLE: Console = Console(stderr=False, highlight=False, soft_wrap=True) |
51 | 90 |
|
@@ -118,4 +157,78 @@ def _render(labels: list[str]) -> None: |
118 | 157 | _HINT_CONSOLE.print(hint) |
119 | 158 |
|
120 | 159 |
|
121 | | -__all__ = ["SKIP_COMMANDS", "render_inline_hints"] |
| 160 | +def _read_run_commands(sessions_dir: str = "sessions") -> set[str]: |
| 161 | + """Return the set of command names already executed this session.""" |
| 162 | + import csv |
| 163 | + from pathlib import Path |
| 164 | + |
| 165 | + path = Path(sessions_dir) / "LazyOwn_session_report.csv" |
| 166 | + seen: set[str] = set() |
| 167 | + if not path.exists(): |
| 168 | + return seen |
| 169 | + try: |
| 170 | + with path.open("r", encoding="utf-8", errors="ignore") as fh: |
| 171 | + reader = csv.DictReader(fh) |
| 172 | + for row in reader: |
| 173 | + for col in ("tool", "command", "name"): |
| 174 | + val = (row.get(col) or "").strip().split()[0] |
| 175 | + if val: |
| 176 | + seen.add(val) |
| 177 | + break |
| 178 | + except Exception: |
| 179 | + pass |
| 180 | + return seen |
| 181 | + |
| 182 | + |
| 183 | +def render_command_hints( |
| 184 | + last_command: str, |
| 185 | + phase: str = "", |
| 186 | + sessions_dir: str = "sessions", |
| 187 | + limit: int = 3, |
| 188 | + enabled: bool = True, |
| 189 | +) -> None: |
| 190 | + """Print phase-aware, history-filtered command hints after each step. |
| 191 | +
|
| 192 | + Uses kill-chain adjacency (``_KILL_CHAIN_NEXT``) first, then falls back |
| 193 | + to phase priority (``_PHASE_PRIORITY``). Commands already in the session |
| 194 | + CSV are skipped so the hint is always forward-looking. |
| 195 | +
|
| 196 | + Args: |
| 197 | + last_command: The command that just ran (first token used). |
| 198 | + phase: Current engagement phase (from payload.json / world_model). |
| 199 | + sessions_dir: Path to sessions/ directory. |
| 200 | + limit: Maximum labels to display. |
| 201 | + enabled: When False this is a no-op. |
| 202 | +
|
| 203 | + Returns: |
| 204 | + None — prints at most one dim line. |
| 205 | + """ |
| 206 | + if not enabled: |
| 207 | + return |
| 208 | + cmd = _first_token(last_command) |
| 209 | + if not cmd or cmd in SKIP_COMMANDS: |
| 210 | + return |
| 211 | + |
| 212 | + already_run = _read_run_commands(sessions_dir) |
| 213 | + |
| 214 | + # 1. Kill-chain adjacency: known follow-up for this specific command |
| 215 | + candidates: list[str] = [ |
| 216 | + c for c in _KILL_CHAIN_NEXT.get(cmd, []) |
| 217 | + if c not in already_run |
| 218 | + ] |
| 219 | + |
| 220 | + # 2. Phase priority fallback |
| 221 | + if len(candidates) < limit: |
| 222 | + phase_key = phase.lower() if phase else "recon" |
| 223 | + for c in _PHASE_PRIORITY.get(phase_key, _PHASE_PRIORITY.get("recon", [])): |
| 224 | + if c not in already_run and c not in candidates and c != cmd: |
| 225 | + candidates.append(c) |
| 226 | + if len(candidates) >= limit * 2: |
| 227 | + break |
| 228 | + |
| 229 | + labels = [_truncate(c, _MAX_LABEL_LEN) for c in candidates[:limit]] |
| 230 | + if labels: |
| 231 | + _render(labels) |
| 232 | + |
| 233 | + |
| 234 | +__all__ = ["SKIP_COMMANDS", "render_inline_hints", "render_command_hints"] |
0 commit comments