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)