daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

dorkforge.py (78540B)


      1 #!/usr/bin/env -S uv run --script
      2 # /// script
      3 # requires-python = ">=3.11"
      4 # dependencies = [
      5 #     "rich>=13.7",
      6 #     "questionary>=2.0",
      7 # ]
      8 # ///
      9 """dorkforge - DAEMON//SEC search-operator workbench.
     10 
     11 An interactive coach for search-engine dorking. Pick what you are hunting
     12 for, answer a couple of scope questions, and dorkforge builds the query,
     13 explains every operator it used, translates it for the engine you picked,
     14 and hands it to your clipboard, your browser, or an engagement log.
     15 
     16     uv run dorkforge.py
     17 
     18 Everything it produces is a *search query*. It sends no traffic to any
     19 target and needs no API key. The searching still happens in your browser,
     20 under your account, from your IP - which is exactly why the OPSEC notes
     21 matter.
     22 
     23 Full cheatsheet: https://cheatsheet.daemon-sec.xyz/sheets/enumeration/google-dorking
     24 """
     25 
     26 from __future__ import annotations
     27 
     28 import argparse
     29 import json
     30 import os
     31 import platform
     32 import re
     33 import shutil
     34 import subprocess
     35 import sys
     36 import textwrap
     37 import urllib.parse
     38 import webbrowser
     39 from dataclasses import dataclass, field
     40 from datetime import datetime, timezone
     41 from pathlib import Path
     42 
     43 try:
     44     import questionary
     45     from questionary import Choice, Style as QStyle
     46     from rich import box
     47     from rich.console import Console, Group
     48     from rich.panel import Panel
     49     from rich.rule import Rule
     50     from rich.table import Table
     51     from rich.text import Text
     52     from rich.theme import Theme
     53 except ModuleNotFoundError:  # pragma: no cover - only hit when run bare
     54     sys.exit(
     55         "dorkforge needs rich + questionary.\n"
     56         "Run it through uv so they are resolved for you:\n"
     57         "    uv run dorkforge.py\n"
     58         "or install them yourself: pip install rich questionary"
     59     )
     60 
     61 VERSION = "1.0.0"
     62 SHEET_URL = "https://cheatsheet.daemon-sec.xyz/sheets/enumeration/google-dorking"
     63 
     64 # ---------------------------------------------------------------------------
     65 # Rose Pine (night) - the contrast-tuned DAEMON//SEC values, not stock upstream.
     66 # `pine` here is #3e8fb0, lifted from the published #31748f, which sits at
     67 # 3.3:1 on this background and is a fill colour, never ink.
     68 # ---------------------------------------------------------------------------
     69 BASE = "#191724"
     70 SURFACE = "#1f1d2e"
     71 OVERLAY = "#26233a"
     72 MUTED = "#8b87a3"
     73 SUBTLE = "#908caa"
     74 TEXT = "#e0def4"
     75 LOVE = "#eb6f92"
     76 GOLD = "#f6c177"
     77 ROSE = "#ebbcba"
     78 PINE = "#3e8fb0"
     79 FOAM = "#9ccfd8"
     80 IRIS = "#c4a7e7"
     81 HL_MED = "#403d52"
     82 
     83 THEME = Theme(
     84     {
     85         "df.text": TEXT,
     86         "df.dim": SUBTLE,
     87         "df.faint": MUTED,
     88         "df.rule": HL_MED,
     89         "df.love": LOVE,
     90         "df.gold": GOLD,
     91         "df.rose": ROSE,
     92         "df.pine": PINE,
     93         "df.foam": FOAM,
     94         "df.iris": IRIS,
     95         "df.op": f"bold {FOAM}",
     96         "df.val": GOLD,
     97         "df.kw": IRIS,
     98         "df.warn": f"bold {GOLD}",
     99         "df.bad": f"bold {LOVE}",
    100         "df.ok": f"bold {FOAM}",
    101         "df.eyebrow": f"bold {MUTED}",
    102         "df.head": f"bold {TEXT}",
    103     }
    104 )
    105 
    106 QUESTIONARY_STYLE = QStyle(
    107     [
    108         ("qmark", f"fg:{FOAM} bold"),
    109         ("question", f"fg:{TEXT} bold"),
    110         ("answer", f"fg:{GOLD} bold"),
    111         ("pointer", f"fg:{LOVE} bold"),
    112         ("highlighted", f"fg:{IRIS} bold"),
    113         ("selected", f"fg:{FOAM}"),
    114         ("separator", f"fg:{HL_MED}"),
    115         ("instruction", f"fg:{MUTED}"),
    116         ("text", f"fg:{TEXT}"),
    117         ("disabled", f"fg:{MUTED} italic"),
    118     ]
    119 )
    120 
    121 # --- Nerd Font glyphs, with an --ascii fallback ----------------------------
    122 GLYPHS = {
    123     "search": "",
    124     "file": "",
    125     "lock": "",
    126     "cloud": "",
    127     "db": "",
    128     "bug": "",
    129     "camera": "",
    130     "code": "",
    131     "users": "",
    132     "folder": "",
    133     "globe": "",
    134     "book": "",
    135     "bolt": "",
    136     "check": "",
    137     "cross": "",
    138     "info": "",
    139     "warn": "",
    140     "copy": "",
    141     "save": "",
    142     "key": "",
    143     "shield": "",
    144     "arrow": "",
    145     "dot": "▪",
    146 }
    147 ASCII_GLYPHS = {
    148     "search": ">", "file": "f", "lock": "#", "cloud": "^", "db": "=",
    149     "bug": "!", "camera": "o", "code": "<", "users": "&", "folder": "/",
    150     "globe": "@", "book": "b", "bolt": "*", "check": "+", "cross": "x",
    151     "info": "i", "warn": "!", "copy": "c", "save": "s", "key": "k",
    152     "shield": "%", "arrow": "->", "dot": "-",
    153 }
    154 _G = dict(GLYPHS)
    155 
    156 
    157 def g(name: str) -> str:
    158     return _G.get(name, "")
    159 
    160 
    161 # ---------------------------------------------------------------------------
    162 # Operator reference
    163 # ---------------------------------------------------------------------------
    164 @dataclass(frozen=True)
    165 class Operator:
    166     key: str            # canonical token, e.g. "site:"
    167     label: str          # human name
    168     summary: str        # one line, shown in the explain table
    169     detail: str         # the paragraph shown by `--explain`
    170     example: str
    171     status: str = "live"        # live | flaky | retired
    172     caveat: str | None = None
    173 
    174 
    175 OPERATORS: dict[str, Operator] = {
    176     "site": Operator(
    177         "site:", "Site / domain restriction",
    178         "Only return pages whose host matches.",
    179         "The load-bearing operator of every recon dork. Matches on the host "
    180         "portion of the URL as a suffix, so site:example.com also covers "
    181         "www.example.com and dev.example.com. Pair it with a negative "
    182         "-site:www.example.com to surface the subdomains DNS enumeration "
    183         "missed, since Google only indexes hosts it can reach and link to.",
    184         "site:example.com",
    185     ),
    186     "filetype": Operator(
    187         "filetype:", "File type",
    188         "Restrict to one file extension.",
    189         "Matches the extension Google recorded for the document, not the "
    190         "Content-Type header. One extension per operator - stack them with "
    191         "OR to cover a family. Google indexes a fixed list of types (pdf, "
    192         "doc(x), xls(x), ppt(x), rtf, ps, swf, kml, txt and a handful more); "
    193         "a filetype:env dork works only because those files got served as "
    194         "plain text and indexed as such, which is also why it is such a "
    195         "reliable finding.",
    196         "filetype:pdf",
    197     ),
    198     "ext": Operator(
    199         "ext:", "File extension (alias)",
    200         "Synonym for filetype: on Google and Bing.",
    201         "Functionally identical to filetype: on Google, Bing and Brave. "
    202         "Shorter, so it reads better in long stacked dorks. Yandex does not "
    203         "accept it - use mime: there.",
    204         "ext:sql",
    205     ),
    206     "inurl": Operator(
    207         "inurl:", "Term in URL",
    208         "The term appears anywhere in the URL.",
    209         "Substring match against the whole URL including query string, which "
    210         "makes it the operator for finding parameter names worth testing "
    211         "(inurl:id=, inurl:redirect=, inurl:file=). It matches one term; for "
    212         "several terms all of which must appear, use allinurl:.",
    213         "inurl:admin",
    214     ),
    215     "allinurl": Operator(
    216         "allinurl:", "All terms in URL",
    217         "Every following term must appear in the URL.",
    218         "Applies to everything after it, which is why it does not mix with "
    219         "other operators - Google quietly ignores the rest of the query. "
    220         "Prefer stacking several inurl: operators instead; the result is the "
    221         "same and the dork stays composable.",
    222         "allinurl:admin login",
    223         status="flaky",
    224         caveat="Do not combine with other operators - the rest of the query is ignored.",
    225     ),
    226     "intitle": Operator(
    227         "intitle:", "Term in title",
    228         "The term appears in the page <title>.",
    229         "The title is the highest-signal field for fingerprinting an "
    230         "application, because vendors ship a default. intitle:\"index of\" is "
    231         "the classic directory-listing dork; intitle:\"phpMyAdmin\" finds "
    232         "exposed database consoles by their stock title.",
    233         'intitle:"index of"',
    234     ),
    235     "allintitle": Operator(
    236         "allintitle:", "All terms in title",
    237         "Every following term must appear in the title.",
    238         "Same swallowing behaviour as allinurl: - it consumes the rest of the "
    239         "query and disables other operators. Stack intitle: instead.",
    240         "allintitle:index of backup",
    241         status="flaky",
    242         caveat="Do not combine with other operators.",
    243     ),
    244     "intext": Operator(
    245         "intext:", "Term in body text",
    246         "The term appears in the page body.",
    247         "Forces the term into the body rather than letting Google match it "
    248         "in the title, URL or inbound anchor text. Useful for pinning a "
    249         "string that would otherwise match the navigation of every page on a "
    250         "site, and for secret patterns such as intext:\"BEGIN RSA PRIVATE KEY\".",
    251         'intext:"password"',
    252     ),
    253     "allintext": Operator(
    254         "allintext:", "All terms in body",
    255         "Every following term must appear in the body.",
    256         "Consumes the rest of the query like the other allin* operators.",
    257         "allintext:username password",
    258         status="flaky",
    259         caveat="Do not combine with other operators.",
    260     ),
    261     "inanchor": Operator(
    262         "inanchor:", "Term in inbound anchor text",
    263         "Matches the text other pages use to link here.",
    264         "Searches the anchor text of inbound links rather than the page "
    265         "itself. Thin and unreliable now that Google has de-emphasised link "
    266         "signals, but occasionally surfaces a page whose own content never "
    267         "names what it is.",
    268         "inanchor:login",
    269         status="flaky",
    270     ),
    271     "phrase": Operator(
    272         '"..."', "Exact phrase",
    273         "Match the quoted string verbatim, in order.",
    274         "Disables stemming, synonym expansion and word reordering. Any dork "
    275         "that hunts for a literal artefact - a banner, an error string, a key "
    276         "header - belongs in quotes, or Google will helpfully find you "
    277         "something adjacent instead of the thing.",
    278         '"index of /backup"',
    279     ),
    280     "or": Operator(
    281         "OR", "Disjunction",
    282         "Match either side. Must be uppercase.",
    283         "Written OR or | . Lowercase 'or' is treated as a stop word and "
    284         "silently ignored, which is the single most common reason a dork "
    285         "returns the wrong thing. Group alternatives in parentheses when you "
    286         "mix them with AND-ed terms: site:x.com (ext:sql | ext:bak).",
    287         "ext:sql OR ext:bak",
    288     ),
    289     "exclude": Operator(
    290         "-term", "Exclusion",
    291         "Drop results containing the term.",
    292         "Prefix any term or operator with a hyphen and no space. The "
    293         "workhorse for cutting noise: -site:www to leave only subdomains, "
    294         "-inurl:wp-content to drop theme assets, -\"404\" to lose soft error "
    295         "pages.",
    296         "-site:www.example.com",
    297     ),
    298     "wildcard": Operator(
    299         "*", "Single-token wildcard",
    300         "Stands in for one or more whole words.",
    301         "Only meaningful inside a quoted phrase, where it matches whole "
    302         "tokens rather than characters - \"admin * panel\" matches 'admin "
    303         "control panel'. It is not a substring wildcard: site:*.example.com "
    304         "works, but adm*n does not.",
    305         '"confidential * report"',
    306     ),
    307     "around": Operator(
    308         "AROUND(n)", "Proximity",
    309         "Two terms within n words of each other.",
    310         "Uppercase, no space before the bracket. Tighter than an AND and "
    311         "looser than a phrase, which makes it the right tool when you know "
    312         "two things co-occur but not in what order - password AROUND(3) "
    313         "database finds the sentence without guessing its shape.",
    314         "password AROUND(5) database",
    315     ),
    316     "before": Operator(
    317         "before:", "Upper date bound",
    318         "Only documents Google dates before YYYY-MM-DD.",
    319         "Filters on Google's estimate of the document date, which is often "
    320         "wrong for pages without explicit markup. Good for narrowing a noisy "
    321         "result set, never good enough to prove when something was published.",
    322         "before:2020-01-01",
    323     ),
    324     "after": Operator(
    325         "after:", "Lower date bound",
    326         "Only documents Google dates after YYYY-MM-DD.",
    327         "Same caveat as before:. Pair the two to bracket a window. The most "
    328         "useful recon framing is after: a known breach or migration date, to "
    329         "see what got published since.",
    330         "after:2024-01-01",
    331     ),
    332     "numrange": Operator(
    333         "n..m", "Numeric range",
    334         "Match any number in the range.",
    335         "Two dots between two integers. Works in plain terms and inside "
    336         "other operators, so inurl:id=1..100 is legal. Legacy numrange:1-100 "
    337         "syntax still parses but the dotted form is the documented one.",
    338         "1000..2000",
    339     ),
    340     "related": Operator(
    341         "related:", "Similar sites",
    342         "Sites Google considers similar.",
    343         "Was a genuine competitor-discovery tool; now returns nothing for "
    344         "most inputs and is officially unsupported in Search. Occasionally "
    345         "still answers for very large domains. Treat any result as a bonus.",
    346         "related:example.com",
    347         status="flaky",
    348         caveat="Officially unsupported since 2023; usually returns nothing.",
    349     ),
    350     "cache": Operator(
    351         "cache:", "Cached copy",
    352         "RETIRED - removed by Google in 2024.",
    353         "Google removed the cache: operator and the cached-page links in "
    354         "September 2024. For a historical copy use the Wayback Machine "
    355         "(web.archive.org/web/*/example.com/*), archive.today, or Bing's own "
    356         "cached link where it still appears.",
    357         "cache:example.com",
    358         status="retired",
    359         caveat="Use web.archive.org or archive.today instead.",
    360     ),
    361     "link": Operator(
    362         "link:", "Inbound links",
    363         "RETIRED - dropped in 2017.",
    364         "Returned pages linking to a URL. Deprecated in 2017 and now a no-op "
    365         "that silently degrades to a plain keyword search. Backlink data "
    366         "lives in commercial SEO tools or Search Console for sites you own.",
    367         "link:example.com",
    368         status="retired",
    369         caveat="No-op. Use Search Console or a backlink tool.",
    370     ),
    371     "info": Operator(
    372         "info:", "Page info",
    373         "RETIRED - folded into normal search.",
    374         "Used to show Google's summary card for a URL. Retired; a bare URL "
    375         "search gets you the same thing.",
    376         "info:example.com",
    377         status="retired",
    378     ),
    379     "define": Operator(
    380         "define:", "Definition",
    381         "Dictionary entry for a term.",
    382         "Not a recon operator, but worth knowing it exists so you do not "
    383         "confuse it with a filter - it short-circuits the whole query into a "
    384         "dictionary card.",
    385         "define:kerberoasting",
    386     ),
    387     "source": Operator(
    388         "source:", "News source",
    389         "Restrict Google News to one publication.",
    390         "Only meaningful on news.google.com. Occasionally useful for "
    391         "mapping an org's public announcements, acquisitions and outage "
    392         "history during a passive-recon pass.",
    393         "source:reuters",
    394     ),
    395     "imagesize": Operator(
    396         "imagesize:", "Exact image dimensions",
    397         "Images at exactly WxH pixels.",
    398         "Images vertical only. Niche, but it is the fastest way to pull "
    399         "every copy of a specific badge, logo or screenshot from an org.",
    400         "imagesize:1920x1080",
    401     ),
    402     # --- non-Google natives, reachable via translation -------------------
    403     "ip": Operator(
    404         "ip:", "Hosts on an IP (Bing)",
    405         "Bing-only: every indexed site on an address.",
    406         "Bing's single best recon operator and it has no Google equivalent. "
    407         "ip:203.0.113.10 lists the virtual hosts Bing saw on that address - a "
    408         "free reverse-IP lookup that often reveals the rest of a shared "
    409         "estate behind one origin.",
    410         "ip:203.0.113.10",
    411     ),
    412     "contains": Operator(
    413         "contains:", "Links to a file type (Bing)",
    414         "Bing-only: pages linking to that extension.",
    415         "Different from filetype:. contains:sql returns pages that *link to* "
    416         "a .sql file rather than the file itself, which catches the index "
    417         "page of a backup directory that was never itself indexed.",
    418         "contains:sql",
    419     ),
    420     "host": Operator(
    421         "host:", "Exact host (Yandex)",
    422         "Yandex-only: exact host match.",
    423         "Yandex distinguishes host: (this exact host) from rhost: (reversed "
    424         "domain, so rhost:com.example.* covers the whole tree). Both are "
    425         "stricter and more predictable than Google's suffix matching.",
    426         "host:dev.example.com",
    427     ),
    428     "mime": Operator(
    429         "mime:", "MIME type (Yandex)",
    430         "Yandex-only: filter by document type.",
    431         "Yandex's answer to filetype:, keyed on the recorded MIME type.",
    432         "mime:pdf",
    433     ),
    434     "org": Operator(
    435         "org:", "GitHub organisation",
    436         "Code search scoped to one org.",
    437         "GitHub code search. Combine with path:, language: and a literal "
    438         "string to hunt committed secrets in an org's public repositories. "
    439         "Requires being signed in.",
    440         "org:example-inc",
    441     ),
    442     "path": Operator(
    443         "path:", "GitHub file path",
    444         "Code search scoped to a path or filename.",
    445         "Replaced the old filename: operator. path:.env matches any file at "
    446         "that path segment; path:**/config/*.yml globs.",
    447         "path:.env",
    448     ),
    449     "language": Operator(
    450         "language:", "GitHub language",
    451         "Code search scoped to one language.",
    452         "Narrows a noisy content search to the language the secret would "
    453         "plausibly live in.",
    454         "language:python",
    455     ),
    456 }
    457 
    458 RETIRED = {k for k, v in OPERATORS.items() if v.status == "retired"}
    459 
    460 
    461 # ---------------------------------------------------------------------------
    462 # Engines
    463 # ---------------------------------------------------------------------------
    464 @dataclass(frozen=True)
    465 class Engine:
    466     key: str
    467     label: str
    468     url: str                       # printf-style with {q}
    469     supports: frozenset[str]
    470     rewrites: dict[str, str] = field(default_factory=dict)
    471     notes: str = ""
    472     login_warning: bool = False
    473 
    474 
    475 _CORE = {"site", "filetype", "ext", "inurl", "intitle", "intext", "phrase",
    476          "or", "exclude", "wildcard"}
    477 
    478 ENGINES: dict[str, Engine] = {
    479     "google": Engine(
    480         "google", "Google",
    481         "https://www.google.com/search?q={q}",
    482         frozenset(_CORE | {"allinurl", "allintitle", "allintext", "inanchor",
    483                            "around", "before", "after", "numrange", "related",
    484                            "define", "source", "imagesize"}),
    485         notes="The reference dialect. Everything in this tool is written in it.",
    486         login_warning=True,
    487     ),
    488     "bing": Engine(
    489         "bing", "Bing",
    490         "https://www.bing.com/search?q={q}",
    491         frozenset(_CORE | {"ip", "contains"}),
    492         rewrites={"intext:": "inbody:"},
    493         notes="Adds ip: (reverse-IP) and contains: (links to a filetype). "
    494               "intext: becomes inbody:. No AROUND(), no before:/after:.",
    495     ),
    496     "ddg": Engine(
    497         "ddg", "DuckDuckGo",
    498         "https://duckduckgo.com/?q={q}",
    499         frozenset(_CORE - {"wildcard"}),
    500         notes="Relays Bing's index with its own ranking. Operator support is "
    501               "real but shallow: no AROUND(), no date bounds, wildcards are "
    502               "inert. Its bangs (!gh, !so) are shortcuts, not operators.",
    503     ),
    504     "yandex": Engine(
    505         "yandex", "Yandex",
    506         "https://yandex.com/search/?text={q}",
    507         frozenset({"site", "phrase", "or", "exclude", "host", "mime"}),
    508         rewrites={"filetype:": "mime:", "ext:": "mime:", "intitle:": "title:"},
    509         notes="Often indexes hosts Google never crawled, which makes it the "
    510               "second engine worth running any subdomain dork through. Its "
    511               "native spellings differ: title: not intitle:, mime: not "
    512               "filetype:, url: not inurl:, plus host:/rhost:.",
    513     ),
    514     "brave": Engine(
    515         "brave", "Brave Search",
    516         "https://search.brave.com/search?q={q}",
    517         frozenset(_CORE),
    518         notes="Independent index, no account required, no personalisation - "
    519               "the lowest-friction engine to dork from a clean browser.",
    520     ),
    521     "github": Engine(
    522         "github", "GitHub code search",
    523         "https://github.com/search?type=code&q={q}",
    524         frozenset({"org", "path", "language", "phrase", "or", "exclude"}),
    525         notes="A different dialect entirely: org:, repo:, path:, language:, "
    526               "symbol:. Requires a signed-in account - use a throwaway, never "
    527               "your real one.",
    528         login_warning=True,
    529     ),
    530     "shodan": Engine(
    531         "shodan", "Shodan",
    532         "https://www.shodan.io/search?query={q}",
    533         frozenset({"phrase", "exclude"}),
    534         notes="Indexes service banners, not web pages. Its own keys "
    535               "(ssl.cert.subject.cn:, http.title:, http.html:, org:, net:, "
    536               "port:) replace the web operators outright.",
    537         login_warning=True,
    538     ),
    539 }
    540 
    541 
    542 # ---------------------------------------------------------------------------
    543 # The dork library
    544 # ---------------------------------------------------------------------------
    545 @dataclass(frozen=True)
    546 class Recipe:
    547     template: str
    548     why: str
    549     noise: str = "low"          # low | medium | high - how junky the results are
    550 
    551 
    552 @dataclass(frozen=True)
    553 class Objective:
    554     key: str
    555     label: str
    556     glyph: str
    557     blurb: str
    558     needs: tuple[str, ...]       # ordered subset of domain/org/keyword/ext
    559     recipes: tuple[Recipe, ...]
    560     caution: str | None = None
    561 
    562 
    563 OBJECTIVES: tuple[Objective, ...] = (
    564     Objective(
    565         "files", "Exposed files & configs", "file",
    566         "Config, environment, log and key files that were never meant to be "
    567         "served - the highest-yield category in the whole practice.",
    568         ("domain",),
    569         (
    570             Recipe(
    571                 'site:{domain} ext:env OR ext:cfg OR ext:conf OR ext:ini',
    572                 "Application config. A served .env is credentials in plaintext - "
    573                 "database URI, mail password, cloud keys, app secret.",
    574             ),
    575             Recipe(
    576                 'site:{domain} ext:log OR ext:txt inurl:log',
    577                 "Application and error logs. Usually leak internal paths, "
    578                 "usernames, session tokens and stack traces.",
    579                 noise="medium",
    580             ),
    581             Recipe(
    582                 'site:{domain} ext:bak OR ext:old OR ext:backup OR ext:swp OR ext:save',
    583                 "Editor and deploy leftovers. index.php.bak is served as text "
    584                 "instead of executed, which hands over the source.",
    585             ),
    586             Recipe(
    587                 'site:{domain} ext:yml OR ext:yaml OR ext:toml OR ext:json inurl:config',
    588                 "Modern config formats - docker-compose, CI definitions, "
    589                 "appsettings.json, secrets baked into a values file.",
    590                 noise="medium",
    591             ),
    592             Recipe(
    593                 'site:{domain} ext:pem OR ext:key OR ext:ppk OR ext:p12 OR ext:pfx',
    594                 "Private keys and certificate bundles. Any hit here is a "
    595                 "critical finding on its own.",
    596             ),
    597             Recipe(
    598                 'site:{domain} inurl:.git OR inurl:.svn OR inurl:.hg',
    599                 "Exposed VCS metadata. A readable .git/ directory means the "
    600                 "whole repository and its history can be reconstructed.",
    601             ),
    602             Recipe(
    603                 'site:{domain} inurl:wp-config OR inurl:configuration.php OR inurl:settings.py',
    604                 "Framework config by its canonical filename - WordPress, "
    605                 "Joomla, Django.",
    606             ),
    607             Recipe(
    608                 'site:{domain} ext:sql OR ext:dbf OR ext:mdb OR ext:sqlite',
    609                 "Database files served straight off the webroot.",
    610             ),
    611         ),
    612         caution="A file being indexed does not make it yours. Read the result "
    613                 "snippet; do not download what is out of scope.",
    614     ),
    615     Objective(
    616         "panels", "Login & admin panels", "lock",
    617         "Authentication surfaces, management consoles and anything that "
    618         "should have been behind the VPN.",
    619         ("domain",),
    620         (
    621             Recipe(
    622                 'site:{domain} inurl:admin OR inurl:administrator OR inurl:adminpanel',
    623                 "The obvious path names. Still hits constantly.",
    624                 noise="medium",
    625             ),
    626             Recipe(
    627                 'site:{domain} intitle:"login" OR intitle:"sign in" OR intitle:"log in"',
    628                 "Title-based, so it catches panels on paths you would never "
    629                 "guess.",
    630                 noise="medium",
    631             ),
    632             Recipe(
    633                 'site:{domain} inurl:phpmyadmin OR inurl:adminer OR inurl:pma',
    634                 "Exposed database consoles. Frequently unauthenticated or on "
    635                 "vendor defaults.",
    636             ),
    637             Recipe(
    638                 'site:{domain} intitle:"Dashboard" (inurl:jenkins OR inurl:grafana OR inurl:kibana)',
    639                 "CI and observability dashboards - build secrets, internal "
    640                 "hostnames, query access to production telemetry.",
    641             ),
    642             Recipe(
    643                 'site:{domain} inurl:/manager/html OR inurl:/axis2 OR inurl:/jmx-console',
    644                 "Java application-server managers. Tomcat's manager with "
    645                 "default creds is a WAR deploy away from RCE.",
    646             ),
    647             Recipe(
    648                 'site:{domain} inurl:owa OR inurl:/rdweb OR inurl:citrix OR inurl:vpn',
    649                 "Remote-access front doors - the surface password spraying "
    650                 "actually targets.",
    651             ),
    652             Recipe(
    653                 'site:{domain} intitle:"index of" inurl:admin',
    654                 "A browsable admin directory: the panel and its source at "
    655                 "once.",
    656             ),
    657         ),
    658         caution="Finding a panel is recon. Logging in - even with default "
    659                 "credentials - is access, and needs it in writing.",
    660     ),
    661     Objective(
    662         "listing", "Open directory listings", "folder",
    663         "Webservers with autoindex on, handing out a file browser.",
    664         ("domain",),
    665         (
    666             Recipe(
    667                 'site:{domain} intitle:"index of"',
    668                 "The canonical dork. Apache, nginx and IIS all emit a title "
    669                 "that starts this way.",
    670                 noise="medium",
    671             ),
    672             Recipe(
    673                 'site:{domain} intitle:"index of" (backup OR bak OR old OR archive)',
    674                 "Listings that name themselves as a dumping ground.",
    675             ),
    676             Recipe(
    677                 'site:{domain} intitle:"index of" "parent directory" (.sql OR .zip OR .tar.gz)',
    678                 "Listings with an archive or dump sitting in them.",
    679             ),
    680             Recipe(
    681                 'site:{domain} intitle:"index of" (uploads OR files OR documents)',
    682                 "User-upload directories - often unfiltered, sometimes "
    683                 "writable.",
    684                 noise="medium",
    685             ),
    686             Recipe(
    687                 'site:{domain} "Directory Listing For" OR "Index of /" -inurl:html',
    688                 "Tomcat and other servers whose listing wording differs from "
    689                 "Apache's.",
    690             ),
    691         ),
    692     ),
    693     Objective(
    694         "cloud", "Cloud storage buckets", "cloud",
    695         "Public object storage referenced by, or belonging to, the target.",
    696         ("domain", "org"),
    697         (
    698             Recipe(
    699                 'site:s3.amazonaws.com "{org}"',
    700                 "S3 buckets whose listing mentions the organisation.",
    701             ),
    702             Recipe(
    703                 'site:{domain} inurl:s3.amazonaws.com OR inurl:s3-external',
    704                 "Pages on the target that link into S3 - the fastest way to "
    705                 "learn the bucket naming convention.",
    706             ),
    707             Recipe(
    708                 'inurl:blob.core.windows.net "{org}"',
    709                 "Azure Blob containers.",
    710             ),
    711             Recipe(
    712                 'inurl:storage.googleapis.com "{org}"',
    713                 "Google Cloud Storage objects.",
    714             ),
    715             Recipe(
    716                 'site:{domain} inurl:digitaloceanspaces.com OR inurl:r2.dev OR inurl:backblazeb2.com',
    717                 "The smaller providers, which get less scrutiny and are "
    718                 "misconfigured more often.",
    719             ),
    720             Recipe(
    721                 '"{org}" (site:trello.com OR site:notion.site OR site:docs.google.com)',
    722                 "Public boards and documents. Trello boards in particular "
    723                 "leak credentials and architecture diagrams at a remarkable "
    724                 "rate.",
    725                 noise="medium",
    726             ),
    727         ),
    728         caution="Listing a public bucket is passive. Enumerating or "
    729                 "downloading its contents is not, and may be unauthorised "
    730                 "access even when the bucket is world-readable.",
    731     ),
    732     Objective(
    733         "secrets", "Credentials, keys & tokens", "key",
    734         "Live secret material indexed in pages, files or code.",
    735         ("domain", "org"),
    736         (
    737             Recipe(
    738                 'site:{domain} intext:"BEGIN RSA PRIVATE KEY" OR intext:"BEGIN OPENSSH PRIVATE KEY"',
    739                 "Private key headers in page text - pasted into a wiki, a "
    740                 "ticket, a README.",
    741             ),
    742             Recipe(
    743                 'site:{domain} intext:"api_key" OR intext:"apikey" OR intext:"client_secret"',
    744                 "Generic secret variable names in served content.",
    745                 noise="high",
    746             ),
    747             Recipe(
    748                 'site:{domain} (ext:env OR ext:yml) intext:PASSWORD',
    749                 "Config files with a password assignment in them.",
    750             ),
    751             Recipe(
    752                 'site:{domain} intext:"AKIA" ',
    753                 "AWS access key IDs all begin AKIA - a distinctive literal "
    754                 "that rarely false-positives.",
    755             ),
    756             Recipe(
    757                 '"{org}" ("xoxb-" OR "xoxp-" OR "ghp_" OR "sk_live_")',
    758                 "Slack, GitHub and Stripe token prefixes. Each is "
    759                 "structurally unique, so a hit is almost never noise.",
    760             ),
    761             Recipe(
    762                 'site:pastebin.com OR site:ghostbin.co OR site:justpaste.it "{domain}"',
    763                 "Paste sites - where dumps, configs and credential lists get "
    764                 "parked.",
    765                 noise="medium",
    766             ),
    767             Recipe(
    768                 'site:{domain} inurl:/.aws/credentials OR inurl:/.ssh/id_rsa OR inurl:/.npmrc',
    769                 "Dotfiles that made it into a webroot.",
    770             ),
    771         ),
    772         caution="A live key is a reportable finding, not a login. Do not "
    773                 "authenticate with anything you find; report it immediately, "
    774                 "and treat the search itself as sensitive.",
    775     ),
    776     Objective(
    777         "docs", "Documents & metadata", "book",
    778         "Published files whose contents - or EXIF/author metadata - give up "
    779         "names, software versions and internal paths.",
    780         ("domain", "org"),
    781         (
    782             Recipe(
    783                 'site:{domain} ext:pdf OR ext:docx OR ext:xlsx OR ext:pptx',
    784                 "The corpus. Pull it, then run exiftool over the lot for "
    785                 "Author, Creator and internal UNC paths.",
    786                 noise="medium",
    787             ),
    788             Recipe(
    789                 'site:{domain} ext:pdf (confidential OR internal OR "not for distribution")',
    790                 "Documents labelled as restricted that are nonetheless "
    791                 "public.",
    792             ),
    793             Recipe(
    794                 'site:{domain} ext:xlsx (employee OR salary OR roster OR contact)',
    795                 "Spreadsheets - the format people paste staff lists into.",
    796             ),
    797             Recipe(
    798                 'site:{domain} ext:vsd OR ext:vsdx OR ext:drawio OR ext:mmd',
    799                 "Network and architecture diagrams.",
    800             ),
    801             Recipe(
    802                 '"{org}" filetype:pdf intext:"@{domain}"',
    803                 "Documents from anywhere that contain the target's email "
    804                 "addresses - harvests the address format.",
    805                 noise="medium",
    806             ),
    807         ),
    808     ),
    809     Objective(
    810         "errors", "Error messages & stack traces", "bug",
    811         "Verbose failures: the cheapest source of stack depth, framework "
    812         "versions and absolute paths.",
    813         ("domain",),
    814         (
    815             Recipe(
    816                 'site:{domain} intext:"Warning: mysql_connect()" OR intext:"You have an error in your SQL syntax"',
    817                 "Database errors. The second string is the literal MySQL "
    818                 "parser message that confirms injectable input.",
    819             ),
    820             Recipe(
    821                 'site:{domain} intext:"Fatal error" OR intext:"Uncaught exception"',
    822                 "PHP and Java fatals - usually carry the absolute filesystem "
    823                 "path.",
    824                 noise="medium",
    825             ),
    826             Recipe(
    827                 'site:{domain} intext:"Traceback (most recent call last)"',
    828                 "Python tracebacks. Django debug pages additionally dump "
    829                 "settings and environment.",
    830             ),
    831             Recipe(
    832                 'site:{domain} intitle:"Whoops, looks like something went wrong"',
    833                 "Laravel's debug page, which shows environment variables "
    834                 "verbatim.",
    835             ),
    836             Recipe(
    837                 'site:{domain} intext:"Server Error in" "Application" intext:"Stack Trace"',
    838                 "ASP.NET yellow-screen-of-death with the trace enabled.",
    839             ),
    840             Recipe(
    841                 'site:{domain} intext:"phpinfo()" OR intitle:"phpinfo()"',
    842                 "A left-behind phpinfo page - full build config, loaded "
    843                 "modules, absolute paths, sometimes environment secrets.",
    844             ),
    845         ),
    846     ),
    847     Objective(
    848         "db", "Databases, backups & dumps", "db",
    849         "Whole datasets, in whatever format they escaped as.",
    850         ("domain", "org"),
    851         (
    852             Recipe(
    853                 'site:{domain} ext:sql intext:"INSERT INTO"',
    854                 "SQL dumps, confirmed by their own statement syntax rather "
    855                 "than just an extension.",
    856             ),
    857             Recipe(
    858                 'site:{domain} ext:sql intext:"CREATE TABLE" intext:password',
    859                 "Dumps that include a credentials table.",
    860             ),
    861             Recipe(
    862                 'site:{domain} (ext:zip OR ext:tar OR ext:gz OR ext:7z) (backup OR dump OR export)',
    863                 "Archived backups sitting in the webroot.",
    864                 noise="medium",
    865             ),
    866             Recipe(
    867                 'site:{domain} inurl:backup OR inurl:dump OR inurl:export',
    868                 "Path-based, catches the directory even when the file itself "
    869                 "was not indexed.",
    870                 noise="medium",
    871             ),
    872             Recipe(
    873                 '"{org}" (site:pastebin.com OR site:anonfiles.com) (dump OR leak OR combo)',
    874                 "Third-party hosting of a leak that names the org.",
    875                 noise="medium",
    876             ),
    877         ),
    878         caution="Downloading a customer database is not passive recon under "
    879                 "any framework. Note the URL, report it, stop.",
    880     ),
    881     Objective(
    882         "params", "Injectable parameters", "bolt",
    883         "URLs carrying the parameter shapes that map to a vulnerability "
    884         "class - a target list for later, authorised testing.",
    885         ("domain",),
    886         (
    887             Recipe(
    888                 'site:{domain} inurl:id= OR inurl:pid= OR inurl:cat= OR inurl:item=',
    889                 "Numeric identifiers - the classic SQL injection and IDOR "
    890                 "surface.",
    891                 noise="medium",
    892             ),
    893             Recipe(
    894                 'site:{domain} inurl:file= OR inurl:page= OR inurl:path= OR inurl:template=',
    895                 "Path-ish parameters - local file inclusion and traversal.",
    896                 noise="medium",
    897             ),
    898             Recipe(
    899                 'site:{domain} inurl:url= OR inurl:redirect= OR inurl:next= OR inurl:return=',
    900                 "Destination parameters - open redirect, and the front half "
    901                 "of many SSRF chains.",
    902                 noise="medium",
    903             ),
    904             Recipe(
    905                 'site:{domain} inurl:cmd= OR inurl:exec= OR inurl:query= OR inurl:search=',
    906                 "Command and query parameters - injection and reflected XSS.",
    907                 noise="medium",
    908             ),
    909             Recipe(
    910                 'site:{domain} inurl:debug=true OR inurl:test= OR inurl:admin=1',
    911                 "Flags that flip an application into a more verbose or more "
    912                 "privileged mode.",
    913             ),
    914             Recipe(
    915                 'site:{domain} ext:php inurl:? ',
    916                 "Every indexed PHP endpoint that takes a query string - the "
    917                 "raw list to feed a parameter miner.",
    918                 noise="high",
    919             ),
    920         ),
    921         caution="This builds a target list, nothing more. Testing any of it "
    922                 "requires the engagement to cover it.",
    923     ),
    924     Objective(
    925         "devices", "Cameras, printers & ICS", "camera",
    926         "Embedded web interfaces indexed by their stock titles and banners.",
    927         ("domain", "keyword"),
    928         (
    929             Recipe(
    930                 'intitle:"webcamXP" OR intitle:"Live View / - AXIS" OR inurl:/view.shtml',
    931                 "IP camera interfaces by vendor default title.",
    932             ),
    933             Recipe(
    934                 'intitle:"HP LaserJet" OR intitle:"Brother" inurl:printer',
    935                 "Networked printers - address books, stored jobs, sometimes "
    936                 "LDAP credentials in the config page.",
    937             ),
    938             Recipe(
    939                 'intitle:"index of" inurl:ftp "{keyword}"',
    940                 "Anonymous FTP roots reachable over HTTP.",
    941             ),
    942             Recipe(
    943                 'inurl:/level/15/exec OR intitle:"Cisco" inurl:/cgi-bin',
    944                 "Network-device management CGIs.",
    945             ),
    946             Recipe(
    947                 'site:{domain} intitle:"Router" OR intitle:"Gateway" inurl:status',
    948                 "Edge devices on the target's own domain.",
    949             ),
    950         ),
    951         caution="Most device dorks are untargeted by nature - they return "
    952                 "hardware belonging to strangers. Scope them with site: or "
    953                 "do not run them. Never interact with a device you do not "
    954                 "have authorisation for.",
    955     ),
    956     Objective(
    957         "people", "Employees, emails & usernames", "users",
    958         "The identity layer: names, address format, and the username "
    959         "convention that feeds spraying lists.",
    960         ("domain", "org"),
    961         (
    962             Recipe(
    963                 'site:linkedin.com/in "{org}"',
    964                 "Staff profiles. Names plus role, which is what you need to "
    965                 "derive the username scheme.",
    966                 noise="medium",
    967             ),
    968             Recipe(
    969                 'site:{domain} intext:"@{domain}"',
    970                 "The organisation's own pages leaking its address format "
    971                 "(first.last@, finitial.last@, flast@).",
    972                 noise="medium",
    973             ),
    974             Recipe(
    975                 '"{org}" (site:github.com OR site:gitlab.com) intext:"@{domain}"',
    976                 "Developers using a work address in commits or profiles.",
    977             ),
    978             Recipe(
    979                 '"{org}" (site:stackoverflow.com OR site:serverfault.com)',
    980                 "Engineers asking about their own stack - frequently pasting "
    981                 "real config with real hostnames.",
    982                 noise="medium",
    983             ),
    984             Recipe(
    985                 '"{org}" ("we use" OR "powered by" OR "built with") -site:{domain}',
    986                 "Third-party mentions of the tech stack: job ads, case "
    987                 "studies, conference talks.",
    988                 noise="high",
    989             ),
    990             Recipe(
    991                 'site:{domain} (inurl:team OR inurl:staff OR inurl:about) intext:"@"',
    992                 "The org's own directory page.",
    993             ),
    994         ),
    995         caution="Personal data is in scope for GDPR and equivalents whether "
    996                 "or not it is in scope for the test. Collect the minimum, "
    997                 "store it with the engagement, delete it after.",
    998     ),
    999     Objective(
   1000         "code", "Code, repos & CI", "code",
   1001         "Source and pipeline configuration, mostly through GitHub's own "
   1002         "dialect.",
   1003         ("domain", "org"),
   1004         (
   1005             Recipe(
   1006                 'org:{org} path:.env',
   1007                 "Committed environment files, scoped to the organisation.",
   1008             ),
   1009             Recipe(
   1010                 'org:{org} "BEGIN RSA PRIVATE KEY"',
   1011                 "Keys in the org's public code.",
   1012             ),
   1013             Recipe(
   1014                 '"{domain}" "password" language:yaml',
   1015                 "Credentials in YAML anywhere on GitHub that reference the "
   1016                 "target's domain.",
   1017                 noise="medium",
   1018             ),
   1019             Recipe(
   1020                 'org:{org} path:.github/workflows',
   1021                 "CI definitions - runner labels, deploy targets, secret "
   1022                 "names, and occasionally the secrets themselves.",
   1023             ),
   1024             Recipe(
   1025                 '"{domain}" ("192.168." OR "10.0." OR ".local")',
   1026                 "Internal addressing leaked into public code - a free "
   1027                 "network map.",
   1028                 noise="medium",
   1029             ),
   1030             Recipe(
   1031                 'org:{org} path:Dockerfile',
   1032                 "Base images, build args and package versions.",
   1033             ),
   1034         ),
   1035         caution="Public repositories only. Cloning a private repo you can "
   1036                 "somehow reach is unauthorised access, full stop.",
   1037     ),
   1038     Objective(
   1039         "subdomains", "Subdomains & forgotten hosts", "globe",
   1040         "Hosts the index knows about that DNS enumeration did not return.",
   1041         ("domain",),
   1042         (
   1043             Recipe(
   1044                 'site:*.{domain} -site:www.{domain}',
   1045                 "The core subdomain dork: everything under the apex except "
   1046                 "the main site.",
   1047             ),
   1048             Recipe(
   1049                 'site:*.{domain} (dev OR staging OR test OR uat OR qa OR beta)',
   1050                 "Non-production environments - weaker credentials, debug on, "
   1051                 "real data.",
   1052             ),
   1053             Recipe(
   1054                 'site:*.{domain} (vpn OR remote OR portal OR mail OR intranet)',
   1055                 "Access infrastructure and internal-facing hosts.",
   1056             ),
   1057             Recipe(
   1058                 'site:*.{domain} -site:www.{domain} -site:blog.{domain} -site:shop.{domain}',
   1059                 "Iteratively subtract the hosts you already know to force "
   1060                 "new ones to the surface.",
   1061             ),
   1062             Recipe(
   1063                 '"{domain}" -site:{domain}',
   1064                 "Third-party pages that reference the domain - partners, "
   1065                 "status pages, monitoring, archived copies.",
   1066                 noise="high",
   1067             ),
   1068         ),
   1069     ),
   1070     Objective(
   1071         "archive", "Archived & removed pages", "book",
   1072         "Content that was taken down but still exists somewhere.",
   1073         ("domain", "keyword"),
   1074         (
   1075             Recipe(
   1076                 'site:web.archive.org "{domain}"',
   1077                 "Wayback captures indexed by Google. For the full history "
   1078                 "query the CDX API directly instead.",
   1079                 noise="medium",
   1080             ),
   1081             Recipe(
   1082                 'site:{domain} before:2022-01-01 "{keyword}"',
   1083                 "Older material on the live site, bounded by Google's date "
   1084                 "estimate.",
   1085             ),
   1086             Recipe(
   1087                 'site:archive.today "{domain}" OR site:archive.ph "{domain}"',
   1088                 "The other archive, which captures pages Wayback refuses.",
   1089             ),
   1090             Recipe(
   1091                 'cache:{domain}',
   1092                 "RETIRED - kept here so the tool can show you what replaced "
   1093                 "it. Use the Wayback Machine.",
   1094             ),
   1095         ),
   1096     ),
   1097 )
   1098 
   1099 OBJECTIVE_BY_KEY = {o.key: o for o in OBJECTIVES}
   1100 
   1101 FIELD_PROMPTS = {
   1102     "domain": ("Target domain", "example.com", "apex domain, no scheme, no path"),
   1103     "org": ("Organisation name", "Example Inc", "as it appears in profiles and docs"),
   1104     "keyword": ("Keyword", "invoice", "a term specific to this hunt"),
   1105     "ext": ("File extension", "pdf", "without the dot"),
   1106 }
   1107 
   1108 
   1109 # ---------------------------------------------------------------------------
   1110 # Query analysis
   1111 # ---------------------------------------------------------------------------
   1112 _OP_TOKEN = re.compile(r"(?<![\w.])-?([a-zA-Z_]+):")
   1113 _ALIASES = {"inbody": "intext", "rhost": "host", "filename": "path"}
   1114 
   1115 
   1116 def used_operators(query: str) -> list[str]:
   1117     """Operator keys present in a query, in order of first appearance."""
   1118     seen: list[str] = []
   1119 
   1120     def add(key: str) -> None:
   1121         if key in OPERATORS and key not in seen:
   1122             seen.append(key)
   1123 
   1124     for m in _OP_TOKEN.finditer(query):
   1125         name = m.group(1).lower()
   1126         add(_ALIASES.get(name, name))
   1127     if '"' in query:
   1128         add("phrase")
   1129     if re.search(r"\bOR\b|\|", query):
   1130         add("or")
   1131     if re.search(r"(?:^|\s)-\S", query):
   1132         add("exclude")
   1133     if "*" in query:
   1134         add("wildcard")
   1135     if re.search(r"AROUND\(\d+\)", query):
   1136         add("around")
   1137     if re.search(r"\d+\.\.\d+", query):
   1138         add("numrange")
   1139     return seen
   1140 
   1141 
   1142 # Where an operator has no home on an engine, say what to do instead.
   1143 _FALLBACK: dict[tuple[str, str], str] = {
   1144     ("yandex", "intext"): "drop it - Yandex searches the body by default",
   1145     ("yandex", "inurl"): "not Yandex's spelling - use url:example.com/* instead",
   1146     ("ddg", "wildcard"): "inert here; tighten with a quoted phrase",
   1147     ("github", "site"): "use org: or repo: instead",
   1148     ("github", "filetype"): "use path: instead",
   1149     ("github", "ext"): "use path: instead",
   1150     ("shodan", "site"): "use hostname: or ssl.cert.subject.cn: instead",
   1151     ("shodan", "intitle"): "use http.title: instead",
   1152     ("shodan", "intext"): "use http.html: instead",
   1153 }
   1154 _GENERIC_FALLBACK = {
   1155     "around": "no proximity operator - use a quoted phrase",
   1156     "before": "no date operator - use the engine's date filter UI",
   1157     "after": "no date operator - use the engine's date filter UI",
   1158     "numrange": "no range syntax - enumerate the values",
   1159     "inanchor": "no anchor-text operator",
   1160     "related": "no similarity operator",
   1161 }
   1162 
   1163 
   1164 def translate(query: str, engine: Engine) -> tuple[str, list[tuple[str, str]]]:
   1165     """Rewrite a Google-dialect query for `engine`.
   1166 
   1167     Returns the rewritten query and a list of (operator, note) pairs for
   1168     everything that did not survive the trip intact.
   1169     """
   1170     out = query
   1171     notes: list[tuple[str, str]] = []
   1172 
   1173     for src, dst in engine.rewrites.items():
   1174         pattern = re.compile(re.escape(src), re.IGNORECASE)
   1175         if pattern.search(out):
   1176             out = pattern.sub(dst, out)
   1177             notes.append((src, f"rewritten to {dst} for {engine.label}"))
   1178 
   1179     for key in used_operators(out):
   1180         if key in engine.supports:
   1181             continue
   1182         op = OPERATORS[key]
   1183         advice = _FALLBACK.get((engine.key, key)) or _GENERIC_FALLBACK.get(key)
   1184         if advice is None:
   1185             advice = f"not supported on {engine.label} - the terms still search, unfiltered"
   1186         notes.append((op.key, advice))
   1187     return out, notes
   1188 
   1189 
   1190 def highlight(query: str) -> Text:
   1191     t = Text(query, style="df.text")
   1192     for m in re.finditer(r'"[^"]*"', query):
   1193         t.stylize("df.val", m.start(), m.end())
   1194     for m in _OP_TOKEN.finditer(query):
   1195         t.stylize("df.op", m.start(), m.end())
   1196     for m in re.finditer(r"\bOR\b|\|", query):
   1197         t.stylize("df.kw", m.start(), m.end())
   1198     for m in re.finditer(r"AROUND\(\d+\)", query):
   1199         t.stylize("df.kw", m.start(), m.end())
   1200     for m in re.finditer(r"(?:^|\s)(-)[\w\"]", query):
   1201         t.stylize("df.love", m.start(1), m.end(1))
   1202     for m in re.finditer(r"\*", query):
   1203         t.stylize("df.rose", m.start(), m.end())
   1204     return t
   1205 
   1206 
   1207 def search_url(engine: Engine, query: str) -> str:
   1208     return engine.url.format(q=urllib.parse.quote_plus(query))
   1209 
   1210 
   1211 # ---------------------------------------------------------------------------
   1212 # Host integration: clipboard, browser, session log, state
   1213 # ---------------------------------------------------------------------------
   1214 _CLIPBOARD = (
   1215     ("pbcopy", ["pbcopy"]),
   1216     ("wl-copy", ["wl-copy"]),
   1217     ("xclip", ["xclip", "-selection", "clipboard"]),
   1218     ("xsel", ["xsel", "--clipboard", "--input"]),
   1219     ("clip.exe", ["clip.exe"]),
   1220 )
   1221 
   1222 
   1223 def clipboard_tool() -> tuple[str, list[str]] | None:
   1224     for name, cmd in _CLIPBOARD:
   1225         if shutil.which(cmd[0]):
   1226             return name, cmd
   1227     return None
   1228 
   1229 
   1230 def copy_to_clipboard(text: str) -> tuple[bool, str]:
   1231     found = clipboard_tool()
   1232     if found is None:
   1233         return False, "no clipboard helper found (pbcopy/wl-copy/xclip/xsel/clip.exe)"
   1234     name, cmd = found
   1235     try:
   1236         subprocess.run(cmd, input=text.encode(), check=True)
   1237     except (OSError, subprocess.CalledProcessError) as exc:
   1238         return False, f"{name} failed: {exc}"
   1239     return True, name
   1240 
   1241 
   1242 def state_dir() -> Path:
   1243     root = os.environ.get("XDG_STATE_HOME")
   1244     base = Path(root) if root else Path.home() / ".local" / "state"
   1245     return base / "dorkforge"
   1246 
   1247 
   1248 def ack_path() -> Path:
   1249     return state_dir() / "ack.json"
   1250 
   1251 
   1252 def load_ack() -> dict | None:
   1253     try:
   1254         return json.loads(ack_path().read_text(encoding="utf-8"))
   1255     except (OSError, ValueError):
   1256         return None
   1257 
   1258 
   1259 def save_ack(scope: str) -> None:
   1260     path = ack_path()
   1261     try:
   1262         path.parent.mkdir(parents=True, exist_ok=True)
   1263         path.write_text(
   1264             json.dumps(
   1265                 {
   1266                     "acknowledged": True,
   1267                     "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
   1268                     "scope_note": scope,
   1269                     "version": VERSION,
   1270                 },
   1271                 indent=2,
   1272             ),
   1273             encoding="utf-8",
   1274         )
   1275     except OSError:
   1276         pass  # a read-only home should not stop the tool working
   1277 
   1278 
   1279 class SessionLog:
   1280     """Append-only markdown log of everything built this run."""
   1281 
   1282     def __init__(self, path: Path, scope: str):
   1283         self.path = path
   1284         self.scope = scope
   1285         self.count = 0
   1286         self._started = path.exists()
   1287 
   1288     def _header(self) -> str:
   1289         return (
   1290             f"# dorkforge session\n\n"
   1291             f"- **Started:** {datetime.now().astimezone():%Y-%m-%d %H:%M %Z}\n"
   1292             f"- **Authorised scope:** {self.scope or 'not recorded'}\n"
   1293             f"- **Tool:** dorkforge {VERSION}\n\n"
   1294             f"---\n\n"
   1295         )
   1296 
   1297     def add(self, engine: Engine, query: str, why: str, notes: list[tuple[str, str]]) -> None:
   1298         chunk = []
   1299         if not self._started:
   1300             chunk.append(self._header())
   1301             self._started = True
   1302         self.count += 1
   1303         chunk.append(f"## {self.count}. {engine.label}\n\n")
   1304         if why:
   1305             chunk.append(f"{why}\n\n")
   1306         chunk.append(f"```text\n{query}\n```\n\n")
   1307         chunk.append(f"<{search_url(engine, query)}>\n\n")
   1308         if notes:
   1309             chunk.append("Translation notes:\n\n")
   1310             for op, note in notes:
   1311                 chunk.append(f"- `{op}` - {note}\n")
   1312             chunk.append("\n")
   1313         chunk.append(f"_Built {datetime.now().astimezone():%Y-%m-%d %H:%M}._\n\n")
   1314         with self.path.open("a", encoding="utf-8") as fh:
   1315             fh.write("".join(chunk))
   1316 
   1317 
   1318 # ---------------------------------------------------------------------------
   1319 # Rendering
   1320 # ---------------------------------------------------------------------------
   1321 PLACEHOLDERS = {"domain": "$DOMAIN", "org": "$ORG", "keyword": "$KEYWORD", "ext": "$EXT"}
   1322 
   1323 
   1324 def as_template(text: str) -> str:
   1325     return text.format(**PLACEHOLDERS)
   1326 
   1327 
   1328 def banner(console: Console) -> None:
   1329     lock = Text()
   1330     lock.append("  ", style="df.love")
   1331     lock.append(g("dot") + " ", style="df.love")
   1332     lock.append("DÆMON", style="df.head")
   1333     lock.append("//", style="df.love")
   1334     lock.append("SEC", style="df.head")
   1335     sub = Text()
   1336     sub.append("  dorkforge ", style="df.foam")
   1337     sub.append(f"v{VERSION}", style="df.faint")
   1338     sub.append("   " + g("search") + "  ", style="df.iris")
   1339     sub.append("search-operator workbench", style="df.dim")
   1340     console.print()
   1341     console.print(lock)
   1342     console.print(sub)
   1343     console.print(Rule(style="df.rule"))
   1344 
   1345 
   1346 def gate(console: Console, force: bool = False) -> str:
   1347     """Banner + one-time authorisation acknowledgement. Returns a scope note."""
   1348     prior = load_ack()
   1349     if prior and not force:
   1350         note = prior.get("scope_note") or ""
   1351         line = Text("  " + g("shield") + "  authorisation acknowledged ", style="df.foam")
   1352         line.append(prior.get("at", "")[:10], style="df.faint")
   1353         if note:
   1354             line.append(f"  {g('dot')} {note}", style="df.faint")
   1355         console.print(line)
   1356         console.print()
   1357         return note
   1358 
   1359     body = Text()
   1360     body.append("dorkforge builds search queries. It sends nothing to any target "
   1361                 "and needs no API key.\n\n", style="df.dim")
   1362     body.append("What it cannot do is make a search legal. ", style="df.text")
   1363     body.append(
   1364         "Indexed does not mean authorised: retrieving a config file, "
   1365         "enumerating a bucket, or logging into a panel you found is access, "
   1366         "and access needs written permission. Personal data you collect is "
   1367         "regulated whether or not the engagement covers it.\n\n",
   1368         style="df.dim",
   1369     )
   1370     body.append("Use this against your own estate, a scope you hold in writing, "
   1371                 "or a lab you are entitled to.", style="df.text")
   1372     console.print(
   1373         Panel(body, title=Text(f" {g('warn')}  BEFORE YOU START ", style="df.warn"),
   1374               title_align="left", border_style="df.gold", box=box.SQUARE, padding=(1, 2))
   1375     )
   1376     console.print()
   1377 
   1378     ok = questionary.confirm(
   1379         "I have authorisation for what I am about to look for.",
   1380         default=False, style=QUESTIONARY_STYLE, auto_enter=False,
   1381     ).ask()
   1382     if not ok:
   1383         console.print(Text(f"  {g('cross')}  Not acknowledged - nothing to do.", style="df.bad"))
   1384         raise SystemExit(1)
   1385 
   1386     # No `default=` here: questionary pre-fills it and parks the cursor at the
   1387     # end, so anything typed lands *after* the default instead of replacing it.
   1388     scope = questionary.text(
   1389         "Scope reference - recorded in every session log:",
   1390         instruction="(engagement ID, ticket, or leave blank for 'personal lab') ",
   1391         style=QUESTIONARY_STYLE, qmark=g("shield"),
   1392     ).ask() or ""
   1393     scope = scope.strip() or "personal lab"
   1394     save_ack(scope)
   1395     console.print(Text(f"  {g('check')}  Acknowledged as '{scope}'. Not asking again "
   1396                        f"(reset with --reset-ack).", style="df.ok"))
   1397     console.print()
   1398     return scope
   1399 
   1400 
   1401 def show_dork(console: Console, engine: Engine, query: str, why: str,
   1402               notes: list[tuple[str, str]]) -> None:
   1403     inner = [highlight(query)]
   1404     if why:
   1405         inner += [Text(""), Text(why, style="df.dim")]
   1406     console.print()
   1407     console.print(
   1408         Panel(
   1409             Group(*inner),
   1410             title=Text(f" {g('search')}  {engine.label} ", style="df.foam"),
   1411             title_align="left", border_style="df.rule", box=box.SQUARE,
   1412             padding=(1, 2),
   1413         )
   1414     )
   1415     if notes:
   1416         console.print(Text(f"  {g('warn')}  dialect notes", style="df.warn"))
   1417         for op, note in notes:
   1418             line = Text("     " + g("dot") + " ", style="df.faint")
   1419             line.append(op, style="df.op")
   1420             line.append("  " + note, style="df.dim")
   1421             console.print(line)
   1422 
   1423 
   1424 def explain(console: Console, query: str, engine: Engine) -> None:
   1425     keys = used_operators(query)
   1426     if not keys:
   1427         console.print(Text("  no operators in this query - it is a plain keyword search.",
   1428                            style="df.faint"))
   1429         return
   1430     table = Table(box=box.SIMPLE_HEAD, border_style="df.rule", pad_edge=False,
   1431                   header_style="df.eyebrow", expand=False)
   1432     table.add_column("OPERATOR", style="df.op", no_wrap=True)
   1433     table.add_column("WHAT IT DOES", style="df.text")
   1434     table.add_column(engine.label.upper(), justify="center", no_wrap=True)
   1435     for key in keys:
   1436         op = OPERATORS[key]
   1437         if op.status == "retired":
   1438             mark = Text(g("cross"), style="df.bad")
   1439         elif key not in engine.supports:
   1440             mark = Text(g("cross"), style="df.love")
   1441         elif op.status == "flaky":
   1442             mark = Text("~", style="df.gold")
   1443         else:
   1444             mark = Text(g("check"), style="df.ok")
   1445         summary = op.summary
   1446         if op.caveat:
   1447             summary += f"\n{g('warn')} {op.caveat}"
   1448         table.add_row(op.key, summary, mark)
   1449     console.print()
   1450     console.print(table)
   1451     legend = Text("     ", style="df.faint")
   1452     legend.append(g("check") + " supported   ", style="df.ok")
   1453     legend.append("~ unreliable   ", style="df.gold")
   1454     legend.append(g("cross") + " unsupported or retired", style="df.love")
   1455     console.print(legend)
   1456 
   1457 
   1458 def operator_reference(console: Console, only: str | None = None) -> None:
   1459     if only:
   1460         key = only.strip().rstrip(":").lower()
   1461         key = _ALIASES.get(key, key)
   1462         op = OPERATORS.get(key)
   1463         if op is None:
   1464             console.print(Text(f"  unknown operator '{only}'. Try --operators for the list.",
   1465                                style="df.bad"))
   1466             raise SystemExit(2)
   1467         head = Text("  " + op.key + "  ", style="df.op")
   1468         head.append(op.label, style="df.head")
   1469         if op.status != "live":
   1470             head.append(f"   [{op.status}]", style="df.warn" if op.status == "flaky" else "df.bad")
   1471         console.print()
   1472         console.print(head)
   1473         console.print(Rule(style="df.rule"))
   1474         for line in textwrap.wrap(op.detail, width=min(88, console.width - 4)):
   1475             console.print(Text("  " + line, style="df.dim"))
   1476         console.print()
   1477         ex = Text("  example  ", style="df.eyebrow")
   1478         ex.append_text(highlight(op.example))
   1479         console.print(ex)
   1480         support = [e.label for e in ENGINES.values() if key in e.supports]
   1481         console.print(Text("  engines  " + (", ".join(support) or "none"), style="df.faint"))
   1482         console.print()
   1483         return
   1484 
   1485     table = Table(box=box.SIMPLE_HEAD, border_style="df.rule", header_style="df.eyebrow",
   1486                   pad_edge=False)
   1487     table.add_column("OPERATOR", style="df.op", no_wrap=True)
   1488     table.add_column("DOES", style="df.text")
   1489     table.add_column("EXAMPLE", style="df.val")
   1490     table.add_column("STATUS", no_wrap=True)
   1491     for op in OPERATORS.values():
   1492         status = {
   1493             "live": Text("live", style="df.ok"),
   1494             "flaky": Text("unreliable", style="df.gold"),
   1495             "retired": Text("retired", style="df.bad"),
   1496         }[op.status]
   1497         table.add_row(op.key, op.summary, op.example, status)
   1498     console.print()
   1499     console.print(table)
   1500     console.print(Text(f"\n  {g('info')}  dorkforge --explain site:   for the long form on any one "
   1501                        f"of these.\n", style="df.faint"))
   1502 
   1503 
   1504 def library_dump(console: Console) -> None:
   1505     console.print()
   1506     for obj in OBJECTIVES:
   1507         head = Text("  " + g(obj.glyph) + "  ", style="df.foam")
   1508         head.append(obj.label, style="df.head")
   1509         head.append(f"   ({obj.key})", style="df.faint")
   1510         console.print(head)
   1511         console.print(Text("  " + obj.blurb, style="df.faint"))
   1512         console.print()
   1513         for r in obj.recipes:
   1514             line = Text("    ")
   1515             line.append_text(highlight(as_template(r.template)))
   1516             console.print(line)
   1517             for wrapped in textwrap.wrap(r.why, width=min(84, console.width - 8)):
   1518                 console.print(Text("      " + wrapped, style="df.dim"))
   1519             console.print()
   1520         if obj.caution:
   1521             console.print(Text("    " + g("warn") + "  " + obj.caution, style="df.warn"))
   1522             console.print()
   1523         console.print(Rule(style="df.rule"))
   1524     console.print(Text(f"  {len(OBJECTIVES)} objectives, "
   1525                        f"{sum(len(o.recipes) for o in OBJECTIVES)} recipes, "
   1526                        f"{len(OPERATORS)} operators.\n", style="df.faint"))
   1527 
   1528 
   1529 def doctor(console: Console) -> int:
   1530     banner(console)
   1531     # ok=None means informational: reported, but never a failing check.
   1532     rows: list[tuple[str, bool | None, str]] = []
   1533     clip = clipboard_tool()
   1534     rows.append(("clipboard", clip is not None,
   1535                  clip[0] if clip else "install pbcopy / wl-copy / xclip / xsel"))
   1536     try:
   1537         browser = webbrowser.get()
   1538         rows.append(("browser", True, getattr(browser, "name", type(browser).__name__)))
   1539     except webbrowser.Error:
   1540         rows.append(("browser", False, "no browser registered; --open will fail"))
   1541     rows.append(("colour", True if console.color_system else None,
   1542                  console.color_system or "none - output is piped, or NO_COLOR is set"))
   1543     sd = state_dir()
   1544     writable = True
   1545     try:
   1546         sd.mkdir(parents=True, exist_ok=True)
   1547         probe = sd / ".probe"
   1548         probe.write_text("", encoding="utf-8")
   1549         probe.unlink()
   1550     except OSError:
   1551         writable = False
   1552     rows.append(("state dir", writable, str(sd)))
   1553     ack = load_ack()
   1554     rows.append(("authorisation", True if ack else None,
   1555                  f"acknowledged {ack['at'][:10]}" if ack
   1556                  else "not yet acknowledged - you will be asked on first run"))
   1557     rows.append(("python", True, platform.python_version()))
   1558     rows.append(("platform", True, f"{platform.system()} {platform.machine()}"))
   1559 
   1560     table = Table(box=box.SIMPLE_HEAD, border_style="df.rule", header_style="df.eyebrow",
   1561                   pad_edge=False)
   1562     table.add_column("CHECK", style="df.text", no_wrap=True)
   1563     table.add_column("", justify="center", no_wrap=True)
   1564     table.add_column("DETAIL", style="df.dim")
   1565     for name, ok, detail in rows:
   1566         if ok is None:
   1567             mark = Text(g("info"), style="df.gold")
   1568         elif ok:
   1569             mark = Text(g("check"), style="df.ok")
   1570         else:
   1571             mark = Text(g("cross"), style="df.love")
   1572         table.add_row(name, mark, detail)
   1573     console.print(table)
   1574     console.print()
   1575     return 0 if all(ok is not False for _, ok, _ in rows) else 1
   1576 
   1577 
   1578 # ---------------------------------------------------------------------------
   1579 # Interactive flow
   1580 # ---------------------------------------------------------------------------
   1581 def _ask(prompt):
   1582     """Run a questionary prompt, treating Ctrl-C as 'go back'."""
   1583     try:
   1584         return prompt.ask()
   1585     except KeyboardInterrupt:
   1586         return None
   1587 
   1588 
   1589 def pick_objective() -> str | None:
   1590     choices = [Choice(title=f"{g(o.glyph)}  {o.label}", value=o.key) for o in OBJECTIVES]
   1591     choices += [
   1592         questionary.Separator("  " + "-" * 30),
   1593         Choice(title=f"{g('code')}  Custom / freeform dork", value="__custom__"),
   1594         Choice(title=f"{g('book')}  Operator reference", value="__ops__"),
   1595         Choice(title=f"{g('cross')}  Quit", value="__quit__"),
   1596     ]
   1597     return _ask(questionary.select(
   1598         "What are you hunting for?", choices=choices, style=QUESTIONARY_STYLE,
   1599         qmark=g("search"), instruction=" ",
   1600     ))
   1601 
   1602 
   1603 def pick_engine(default: str = "google") -> Engine | None:
   1604     choices = [
   1605         Choice(title=f"{e.label:<22}{e.notes.split('.')[0][:46]}", value=k)
   1606         for k, e in ENGINES.items()
   1607     ]
   1608     key = _ask(questionary.select(
   1609         "Which engine?", choices=choices, default=default, style=QUESTIONARY_STYLE,
   1610         qmark=g("globe"), instruction=" ",
   1611     ))
   1612     return ENGINES[key] if key else None
   1613 
   1614 
   1615 def ask_fields(needs: tuple[str, ...], preset: dict[str, str]) -> dict[str, str] | None:
   1616     vals = dict(PLACEHOLDERS)
   1617     for name in needs:
   1618         if preset.get(name):
   1619             vals[name] = preset[name]
   1620             continue
   1621         label, example, hint = FIELD_PROMPTS[name]
   1622         ans = _ask(questionary.text(
   1623             f"{label}:", style=QUESTIONARY_STYLE, qmark=g("arrow"),
   1624             instruction=f"({hint}, e.g. {example}) ",
   1625             validate=lambda t: True if t.strip() else "required",
   1626         ))
   1627         if ans is None:
   1628             return None
   1629         vals[name] = ans.strip()
   1630     return vals
   1631 
   1632 
   1633 def pick_recipe(console: Console, obj: Objective, vals: dict[str, str]) -> Recipe | str | None:
   1634     width = max(40, console.width - 14)
   1635     choices = []
   1636     for i, r in enumerate(obj.recipes):
   1637         q = r.template.format(**vals).strip()
   1638         label = q if len(q) <= width else q[: width - 1] + "…"
   1639         choices.append(Choice(title=label, value=i))
   1640     choices += [
   1641         questionary.Separator("  " + "-" * 30),
   1642         Choice(title=f"{g('bolt')}  Build all {len(obj.recipes)}", value="__all__"),
   1643         Choice(title=f"{g('arrow')}  Back", value="__back__"),
   1644     ]
   1645     picked = _ask(questionary.select(
   1646         "Which dork?", choices=choices, style=QUESTIONARY_STYLE, qmark=g(obj.glyph),
   1647         instruction=" ",
   1648     ))
   1649     if picked is None or picked == "__back__":
   1650         return None
   1651     if picked == "__all__":
   1652         return "__all__"
   1653     return obj.recipes[picked]
   1654 
   1655 
   1656 def deliver(console: Console, engine: Engine, query: str, why: str,
   1657             session: SessionLog | None) -> str | None:
   1658     """Show a dork, explain it, then offer the action menu. Returns a signal."""
   1659     translated, notes = translate(query, engine)
   1660     show_dork(console, engine, translated, why, notes)
   1661     explain(console, translated, engine)
   1662 
   1663     while True:
   1664         choices = [
   1665             Choice(title=f"{g('copy')}  Copy to clipboard", value="copy"),
   1666             Choice(title=f"{g('globe')}  Open in browser", value="open"),
   1667             Choice(title=f"{g('save')}  Save to session log", value="save"),
   1668             Choice(title=f"{g('book')}  Explain an operator", value="explain"),
   1669             Choice(title=f"{g('bolt')}  Refine this query", value="refine"),
   1670             questionary.Separator("  " + "-" * 30),
   1671             Choice(title=f"{g('arrow')}  Another dork", value="back"),
   1672             Choice(title=f"{g('search')}  New objective", value="objective"),
   1673             Choice(title=f"{g('cross')}  Quit", value="quit"),
   1674         ]
   1675         action = _ask(questionary.select(
   1676             "Now what?", choices=choices, style=QUESTIONARY_STYLE, qmark=g("arrow"),
   1677             instruction=" ",
   1678         ))
   1679         if action in (None, "back"):
   1680             return "back"
   1681         if action in ("objective", "quit"):
   1682             return action
   1683 
   1684         if action == "copy":
   1685             ok, detail = copy_to_clipboard(translated)
   1686             icon, style = (g("check"), "df.ok") if ok else (g("cross"), "df.love")
   1687             console.print(Text(f"  {icon}  "
   1688                                + (f"copied ({detail})" if ok else detail), style=style))
   1689         elif action == "open":
   1690             url = search_url(engine, translated)
   1691             opened = webbrowser.open(url)
   1692             if opened:
   1693                 console.print(Text(f"  {g('check')}  opened in your browser", style="df.ok"))
   1694                 console.print(Text(f"     {url}", style="df.faint"))
   1695             else:
   1696                 console.print(Text(f"  {g('cross')}  could not open a browser. URL:",
   1697                                    style="df.love"))
   1698                 console.print(Text(f"     {url}", style="df.faint"))
   1699         elif action == "save":
   1700             if session is None:
   1701                 console.print(Text(f"  {g('cross')}  no session log - start with "
   1702                                    f"--session FILE", style="df.love"))
   1703             else:
   1704                 session.add(engine, translated, why, notes)
   1705                 console.print(Text(f"  {g('check')}  appended to {session.path} "
   1706                                    f"(#{session.count})", style="df.ok"))
   1707         elif action == "explain":
   1708             name = _ask(questionary.text(
   1709                 "Operator:", style=QUESTIONARY_STYLE, qmark=g("book"),
   1710                 instruction="(e.g. site:, intitle:, AROUND) ",
   1711             ))
   1712             if name:
   1713                 try:
   1714                     operator_reference(console, name)
   1715                 except SystemExit:
   1716                     pass
   1717         elif action == "refine":
   1718             edited = _ask(questionary.text(
   1719                 "Query:", default=translated, style=QUESTIONARY_STYLE, qmark=g("bolt"),
   1720             ))
   1721             if edited and edited.strip():
   1722                 translated = edited.strip()
   1723                 _, notes = translate(translated, engine)
   1724                 show_dork(console, engine, translated, why, notes)
   1725                 explain(console, translated, engine)
   1726 
   1727 
   1728 def interactive(console: Console, args: argparse.Namespace, scope: str) -> int:
   1729     session = SessionLog(Path(args.session).expanduser(), scope) if args.session else None
   1730     if session:
   1731         console.print(Text(f"  {g('save')}  session log: {session.path}", style="df.faint"))
   1732         console.print()
   1733     preset = {"domain": args.domain or "", "org": args.org or "",
   1734               "keyword": args.keyword or "", "ext": args.ext or ""}
   1735     engine = ENGINES[args.engine]
   1736 
   1737     while True:
   1738         key = pick_objective()
   1739         if key in (None, "__quit__"):
   1740             break
   1741         if key == "__ops__":
   1742             operator_reference(console)
   1743             continue
   1744         if key == "__custom__":
   1745             chosen = pick_engine(engine.key)
   1746             if chosen is None:
   1747                 continue
   1748             engine = chosen
   1749             raw = _ask(questionary.text(
   1750                 "Your dork:", style=QUESTIONARY_STYLE, qmark=g("code"),
   1751                 instruction="(Google dialect - it gets translated) ",
   1752             ))
   1753             if not raw or not raw.strip():
   1754                 continue
   1755             signal = deliver(console, engine, raw.strip(), "", session)
   1756             if signal == "quit":
   1757                 break
   1758             continue
   1759 
   1760         obj = OBJECTIVE_BY_KEY[key]
   1761         console.print()
   1762         head = Text("  " + g(obj.glyph) + "  ", style="df.foam")
   1763         head.append(obj.label, style="df.head")
   1764         console.print(head)
   1765         for line in textwrap.wrap(obj.blurb, width=min(86, console.width - 4)):
   1766             console.print(Text("  " + line, style="df.faint"))
   1767         if obj.caution:
   1768             console.print()
   1769             console.print(Text(f"  {g('warn')}  {obj.caution}", style="df.warn"))
   1770         console.print()
   1771 
   1772         vals = ask_fields(obj.needs, preset)
   1773         if vals is None:
   1774             continue
   1775         for f in obj.needs:
   1776             preset[f] = vals[f]
   1777 
   1778         chosen = pick_engine(engine.key)
   1779         if chosen is None:
   1780             continue
   1781         engine = chosen
   1782 
   1783         quit_now = False
   1784         while True:
   1785             recipe = pick_recipe(console, obj, vals)
   1786             if recipe is None:
   1787                 break
   1788             if recipe == "__all__":
   1789                 for r in obj.recipes:
   1790                     q, notes = translate(r.template.format(**vals).strip(), engine)
   1791                     show_dork(console, engine, q, r.why, notes)
   1792                     if session:
   1793                         session.add(engine, q, r.why, notes)
   1794                 if session:
   1795                     console.print()
   1796                     console.print(Text(f"  {g('check')}  {len(obj.recipes)} dorks appended "
   1797                                        f"to {session.path}", style="df.ok"))
   1798                 continue
   1799             signal = deliver(console, engine, recipe.template.format(**vals).strip(),
   1800                              recipe.why, session)
   1801             if signal == "quit":
   1802                 quit_now = True
   1803                 break
   1804             if signal == "objective":
   1805                 break
   1806         if quit_now:
   1807             break
   1808 
   1809     console.print()
   1810     if session and session.count:
   1811         console.print(Text(f"  {g('save')}  {session.count} dork(s) written to {session.path}",
   1812                            style="df.ok"))
   1813     console.print(Text(f"  {g('dot')} cheatsheet: {SHEET_URL}", style="df.faint"))
   1814     console.print()
   1815     return 0
   1816 
   1817 
   1818 def oneshot(console: Console, args: argparse.Namespace, scope: str) -> int:
   1819     obj = OBJECTIVE_BY_KEY.get(args.objective)
   1820     if obj is None:
   1821         console.print(Text(f"  unknown objective '{args.objective}'. Known: "
   1822                            + ", ".join(OBJECTIVE_BY_KEY), style="df.bad"))
   1823         return 2
   1824     vals = dict(PLACEHOLDERS)
   1825     for name in obj.needs:
   1826         supplied = getattr(args, name, None)
   1827         if not supplied:
   1828             console.print(Text(f"  {g('warn')}  --{name} not given; leaving {PLACEHOLDERS[name]} "
   1829                                f"in the query", style="df.warn"))
   1830         else:
   1831             vals[name] = supplied
   1832     engine = ENGINES[args.engine]
   1833     session = SessionLog(Path(args.session).expanduser(), scope) if args.session else None
   1834     for r in obj.recipes:
   1835         q, notes = translate(r.template.format(**vals).strip(), engine)
   1836         show_dork(console, engine, q, r.why, notes)
   1837         if session:
   1838             session.add(engine, q, r.why, notes)
   1839     console.print()
   1840     if session:
   1841         console.print(Text(f"  {g('check')}  {session.count} dork(s) written to {session.path}",
   1842                            style="df.ok"))
   1843     return 0
   1844 
   1845 
   1846 # ---------------------------------------------------------------------------
   1847 # CLI
   1848 # ---------------------------------------------------------------------------
   1849 def build_parser() -> argparse.ArgumentParser:
   1850     p = argparse.ArgumentParser(
   1851         prog="dorkforge",
   1852         description="DAEMON//SEC search-operator workbench - build, understand "
   1853                     "and translate search-engine dorks.",
   1854         epilog=f"cheatsheet: {SHEET_URL}",
   1855         formatter_class=argparse.RawDescriptionHelpFormatter,
   1856     )
   1857     p.add_argument("--objective", metavar="KEY",
   1858                    help="run non-interactively for one objective (see --list)")
   1859     p.add_argument("--domain", metavar="D", help="target domain, e.g. example.com")
   1860     p.add_argument("--org", metavar="O", help="organisation name")
   1861     p.add_argument("--keyword", metavar="K", help="hunt-specific keyword")
   1862     p.add_argument("--ext", metavar="E", help="file extension, without the dot")
   1863     p.add_argument("--engine", default="google", choices=sorted(ENGINES),
   1864                    help="engine dialect to emit (default: google)")
   1865     p.add_argument("--session", metavar="FILE",
   1866                    help="append every dork to this markdown log")
   1867     p.add_argument("--list", action="store_true", help="print the whole dork library and exit")
   1868     p.add_argument("--operators", action="store_true",
   1869                    help="print the operator reference table and exit")
   1870     p.add_argument("--explain", metavar="OP",
   1871                    help="explain one operator in full, e.g. --explain site:")
   1872     p.add_argument("--doctor", action="store_true",
   1873                    help="check clipboard, browser, colour and state directory")
   1874     p.add_argument("--reset-ack", action="store_true",
   1875                    help="forget the authorisation acknowledgement and ask again")
   1876     p.add_argument("--ascii", action="store_true", help="ASCII markers instead of Nerd Font glyphs")
   1877     p.add_argument("--no-color", action="store_true", help="disable colour output")
   1878     p.add_argument("--version", action="version", version=f"dorkforge {VERSION}")
   1879     return p
   1880 
   1881 
   1882 def main(argv: list[str] | None = None) -> int:
   1883     args = build_parser().parse_args(argv)
   1884     if args.ascii:
   1885         _G.clear()
   1886         _G.update(ASCII_GLYPHS)
   1887     console = Console(theme=THEME, no_color=args.no_color, highlight=False, soft_wrap=False)
   1888 
   1889     if args.doctor:
   1890         return doctor(console)
   1891     if args.reset_ack:
   1892         try:
   1893             ack_path().unlink()
   1894             console.print(Text(f"  {g('check')}  acknowledgement cleared", style="df.ok"))
   1895         except FileNotFoundError:
   1896             console.print(Text(f"  {g('info')}  nothing to clear", style="df.faint"))
   1897         except OSError as exc:
   1898             console.print(Text(f"  {g('cross')}  {exc}", style="df.love"))
   1899             return 1
   1900         if not (args.objective or args.list or args.operators or args.explain):
   1901             return 0
   1902     if args.explain:
   1903         banner(console)
   1904         operator_reference(console, args.explain)
   1905         return 0
   1906     if args.operators:
   1907         banner(console)
   1908         operator_reference(console)
   1909         return 0
   1910     if args.list:
   1911         banner(console)
   1912         library_dump(console)
   1913         return 0
   1914 
   1915     banner(console)
   1916 
   1917     # Everything past this point wants a keyboard. --list/--operators/--explain
   1918     # and an already-acknowledged --objective run are the non-interactive paths.
   1919     needs_prompt = load_ack() is None or not args.objective
   1920     if needs_prompt and not sys.stdin.isatty():
   1921         console.print(Text(f"  {g('cross')}  dorkforge is interactive and stdin is not a "
   1922                            f"terminal.", style="df.bad"))
   1923         console.print(Text("     Run it in a terminal, or use the non-interactive paths:",
   1924                            style="df.dim"))
   1925         console.print(Text("       dorkforge --list            the whole dork library",
   1926                            style="df.faint"))
   1927         console.print(Text("       dorkforge --operators       the operator reference",
   1928                            style="df.faint"))
   1929         console.print(Text("       dorkforge --objective files --domain example.com",
   1930                            style="df.faint"))
   1931         console.print()
   1932         return 2
   1933 
   1934     scope = gate(console)
   1935     if args.objective:
   1936         return oneshot(console, args, scope)
   1937     return interactive(console, args, scope)
   1938 
   1939 
   1940 if __name__ == "__main__":
   1941     try:
   1942         raise SystemExit(main())
   1943     except KeyboardInterrupt:
   1944         print()
   1945         raise SystemExit(130)