xwllz 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install
20
+ run: |
21
+ python -m pip install --upgrade pip
22
+ pip install -e .
23
+ - name: Test
24
+ run: python -m pytest -q
xwllz-0.1.0/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.db
11
+ .coverage
xwllz-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xwllz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
xwllz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: xwllz
3
+ Version: 0.1.0
4
+ Summary: Attack-surface management for blue teams — discover, monitor, and report on your external perimeter.
5
+ Project-URL: Homepage, https://xwllz.pages.dev
6
+ Project-URL: Repository, https://github.com/stalane/xwllz-cli
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: asm,attack-surface,blue-team,cli,recon,security
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: cryptography>=42.0
19
+ Requires-Dist: dnspython>=2.6
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: rich>=13.7
22
+ Requires-Dist: typer>=0.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # xwllz — Attack-surface management for blue teams
26
+
27
+ `xwllz` is a CLI for discovering, monitoring, and reporting on your **own external
28
+ attack surface**. It maps the domains, subdomains, IPs, services, certificates, and
29
+ exposure points an attacker can reach — so blue teams can see their perimeter before
30
+ attackers do.
31
+
32
+ ## Quickstart
33
+
34
+ ```bash
35
+ pip install xwllz
36
+
37
+ xwllz init acme --domains example.com,corp.example.com
38
+ xwllz discover acme
39
+ xwllz monitor acme # re-scan and show what changed
40
+ xwllz report acme --format md
41
+ ```
42
+
43
+ No system binaries are required. `xwllz` is pure Python by default; if you have
44
+ `nmap`, `masscan`, `rustscan`, `nuclei`, `httpx`, `dnsx`, or `dig` on your PATH,
45
+ it will detect and use them as accelerators automatically.
46
+
47
+ ## Data sources
48
+
49
+ **Keyless by default:** crt.sh (certificate transparency), DNS (A/NS/MX/SPF/DMARC/DKIM),
50
+ HTTP probing, TLS/cert inspection, socket port scans.
51
+
52
+ **Optional keyed enrichment** (set env vars to enable):
53
+
54
+ | Source | Env vars |
55
+ |---|---|
56
+ | Shodan | `SHODAN_API_KEY` |
57
+ | SecurityTrails | `SECURITYTRAILS_API_KEY` |
58
+ | Censys | `CENSYS_API_ID`, `CENSYS_API_SECRET` |
59
+ | urlscan.io | `URL_SCAN_API_KEY` |
60
+ | URLhaus | `URLHAUS_API_KEY` |
61
+ | VirusTotal | `VIRUSTOTAL_API_KEY` |
62
+
63
+ ## Commands
64
+
65
+ ```
66
+ xwllz init <org> --domains d1,d2 define the scope to monitor
67
+ xwllz discover <org> enumerate the attack surface
68
+ xwllz monitor <org> re-scan and report new/changed/removed
69
+ xwllz report <org> --format md|json|html
70
+ xwllz status <org> current surface dashboard
71
+ xwllz intel <indicator> URL/domain/IP lookup
72
+ xwllz whois <domain> whois lookup
73
+ xwllz cert <host> TLS certificate details
74
+ ```
75
+
76
+ Data is stored in SQLite at `~/.local/share/xwllz/xwllz.db`.
77
+
78
+ ## License
79
+
80
+ MIT
xwllz-0.1.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # xwllz — Attack-surface management for blue teams
2
+
3
+ `xwllz` is a CLI for discovering, monitoring, and reporting on your **own external
4
+ attack surface**. It maps the domains, subdomains, IPs, services, certificates, and
5
+ exposure points an attacker can reach — so blue teams can see their perimeter before
6
+ attackers do.
7
+
8
+ ## Quickstart
9
+
10
+ ```bash
11
+ pip install xwllz
12
+
13
+ xwllz init acme --domains example.com,corp.example.com
14
+ xwllz discover acme
15
+ xwllz monitor acme # re-scan and show what changed
16
+ xwllz report acme --format md
17
+ ```
18
+
19
+ No system binaries are required. `xwllz` is pure Python by default; if you have
20
+ `nmap`, `masscan`, `rustscan`, `nuclei`, `httpx`, `dnsx`, or `dig` on your PATH,
21
+ it will detect and use them as accelerators automatically.
22
+
23
+ ## Data sources
24
+
25
+ **Keyless by default:** crt.sh (certificate transparency), DNS (A/NS/MX/SPF/DMARC/DKIM),
26
+ HTTP probing, TLS/cert inspection, socket port scans.
27
+
28
+ **Optional keyed enrichment** (set env vars to enable):
29
+
30
+ | Source | Env vars |
31
+ |---|---|
32
+ | Shodan | `SHODAN_API_KEY` |
33
+ | SecurityTrails | `SECURITYTRAILS_API_KEY` |
34
+ | Censys | `CENSYS_API_ID`, `CENSYS_API_SECRET` |
35
+ | urlscan.io | `URL_SCAN_API_KEY` |
36
+ | URLhaus | `URLHAUS_API_KEY` |
37
+ | VirusTotal | `VIRUSTOTAL_API_KEY` |
38
+
39
+ ## Commands
40
+
41
+ ```
42
+ xwllz init <org> --domains d1,d2 define the scope to monitor
43
+ xwllz discover <org> enumerate the attack surface
44
+ xwllz monitor <org> re-scan and report new/changed/removed
45
+ xwllz report <org> --format md|json|html
46
+ xwllz status <org> current surface dashboard
47
+ xwllz intel <indicator> URL/domain/IP lookup
48
+ xwllz whois <domain> whois lookup
49
+ xwllz cert <host> TLS certificate details
50
+ ```
51
+
52
+ Data is stored in SQLite at `~/.local/share/xwllz/xwllz.db`.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,126 @@
1
+ # xwllz CLI — Design Spec
2
+
3
+ Date: 2026-08-23
4
+ Status: Approved (design approved by user; implementation complete)
5
+
6
+ ## Overview
7
+
8
+ A new open-source Python CLI, **`xwllz`**, for **blue-team attack-surface
9
+ management** (ASM): discover, monitor, and report on an organization's own
10
+ external perimeter — domains, subdomains, IPs, services, certificates, and
11
+ exposure points. It is a separate project/repo from the `xwllz` marketing site,
12
+ built to be distributable via PyPI with zero required system binaries.
13
+
14
+ ## Goals
15
+
16
+ - OSS-distributable ASM CLI: works on any machine with Python ≥3.10 only.
17
+ - Keyless data sources by default; optional keyed API enrichment when the user
18
+ supplies keys.
19
+ - Optional accelerators: detect installed binaries (nmap, masscan, rustscan,
20
+ nuclei, httpx, dnsx, dig) and use them when present, degrading gracefully to
21
+ pure-Python paths otherwise.
22
+ - Full ASM loop: discover → store → monitor (diff) → report.
23
+
24
+ ## Non-goals
25
+
26
+ - Real product copy/branding, docs site, multi-language support.
27
+ - Cloud deployment, SaaS, API server.
28
+ - Offensive/red-team functionality (this is a blue-team tool).
29
+
30
+ ## Architecture
31
+
32
+ Approach A chosen (from brainstorm): **pure-Python core with optional tool
33
+ accelerators and optional keyed APIs**.
34
+
35
+ Repo: `/home/david/Documents/xwllz-cli` (package name `xwllz`, PyPI-distributable).
36
+
37
+ ### Dependencies (runtime)
38
+ `typer` (CLI), `rich` (output), `httpx` (HTTP probing + API calls),
39
+ `dnspython` (DNS), `cryptography` (TLS/cert parsing). No system binaries required.
40
+
41
+ ### Layout
42
+ ```
43
+ src/xwllz/
44
+ cli.py Typer app, all subcommands, rich output
45
+ db.py SQLite schema + migrations + upsert helpers
46
+ config.py env-key loading + PATH tool detection
47
+ discovery.py ASM pipeline (gather→resolve→scan→probe→detect→store)
48
+ detectors.py passive blue-team findings
49
+ report.py markdown/JSON/HTML renderers
50
+ intel.py indicator lookup logic (URLhaus/VT/Shodan/urlscan)
51
+ sources/crt_sh.py keyless cert-transparency subdomain harvest
52
+ sources/keyed.py Shodan, SecurityTrails, Censys, URLhaus, urlscan, VT
53
+ engines/dns.py dnspython A/NS/MX/SPF/DMARC/DKIM
54
+ engines/ports.py async socket port scanner (pure Python)
55
+ engines/http.py httpx HTTP/HTTPS probing + tech detection
56
+ engines/tls.py ssl + cryptography cert inspection
57
+ integrations/ optional nmap/nuclei/dnsx wrappers (auto-detect)
58
+ tests/ mocked unit tests (no live calls)
59
+ ```
60
+
61
+ ### Storage
62
+ SQLite at `~/.local/share/xwllz/xwllz.db` (XDG data dir). Schema:
63
+ `orgs`, `assets`, `services`, `findings`, `snapshots`, `intel`.
64
+ Findings dedupe on org+kind+asset+service+title with NULLs coerced to 0 via a
65
+ partial unique index (SQLite NULL-distinct gotcha handled).
66
+
67
+ ## Data sources & tool selection (blue team)
68
+
69
+ | Phase | Pure-Python default | Optional accelerator | Keyed enrichment |
70
+ |---|---|---|---|
71
+ | Subdomain enum | crt.sh CT + DNS | dnsx brute | SecurityTrails, Shodan, Censys |
72
+ | DNS posture | dnspython (SPF/DMARC/DKIM) | dig | — |
73
+ | Port/service scan | async socket scan | nmap, masscan, rustscan | Shodan host data |
74
+ | HTTP fingerprint | httpx probe (title/headers/tech) | httpx bin, whatweb | — |
75
+ | TLS/cert | ssl + cryptography (expiry, SAN) | openssl | Censys |
76
+ | Exposure checks | passive rules | nuclei templates | — |
77
+ | Threat intel | — | — | URLhaus, urlscan, VirusTotal |
78
+
79
+ Blue-team differentiator: built-in **posture findings** — missing SPF/DMARC/DKIM,
80
+ expiring/expired certs, missing security headers (HSTS/CSP/nosniff/clickjack),
81
+ and exposed high-risk ports (RDP/SMB/FTP/DB listeners).
82
+
83
+ ## Command surface
84
+
85
+ ```
86
+ xwllz init <org> --domains d1,d2
87
+ xwllz discover <org> [--passive] [--ports N] [--no-nmap]
88
+ xwllz monitor <org> [--passive] # re-scan + snapshot diff
89
+ xwllz report <org> --format md|json|html
90
+ xwllz status <org> # surface dashboard
91
+ xwllz intel <indicator> # URL/domain/IP lookup
92
+ xwllz whois <domain>
93
+ xwllz cert <host> [--port N]
94
+ ```
95
+
96
+ ## Error handling / resilience
97
+
98
+ - Per-phase isolation: a failing phase never aborts the run (try/except +
99
+ `# noqa: BLE001, S112` documented).
100
+ - Missing tool → silent fallback to pure-Python path; missing API key → source
101
+ skipped.
102
+ - Network errors → caught, source returns None; DNS failures → empty result.
103
+ - `KeyboardInterrupt` handled in `discover`/`monitor`.
104
+
105
+ ## Testing
106
+
107
+ - `pytest` (29 tests): DB upsert/dedupe, detectors, report rendering, discovery
108
+ pipeline with all network functions mocked (no live calls), config/key/tool
109
+ detection.
110
+ - `ruff` lint clean.
111
+ - Live smoke test on a new keyless target (`example.com`, passive): crt.sh
112
+ harvest (800 subdomains), DNS resolution, SPF/DMARC findings — verified.
113
+ - CI: GitHub Actions on 3.10/3.12/3.14 (`pytest`).
114
+
115
+ ## Verified behavior
116
+
117
+ - `xwllz --help`, `init`, `status`, `report`, `discover --passive` all run.
118
+ - Engines verified against a local HTTP service (port scan → probe → detectors).
119
+ - Live passive discover on example.com produced SPF + DMARC findings.
120
+
121
+ ## Deferred
122
+
123
+ - GitHub Actions PyPI publish (v1 later).
124
+ - nuclei integration wiring into the findings pipeline (wrapper exists, not yet
125
+ invoked from `discover`).
126
+ - HTTPS-on-nonstandard-port TLS probing (only 443/8443 currently).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xwllz"
7
+ version = "0.1.0"
8
+ description = "Attack-surface management for blue teams — discover, monitor, and report on your external perimeter."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["security", "blue-team", "asm", "attack-surface", "recon", "cli"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Information Technology",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Security",
21
+ ]
22
+ dependencies = [
23
+ "typer>=0.12",
24
+ "rich>=13.7",
25
+ "httpx>=0.27",
26
+ "dnspython>=2.6",
27
+ "cryptography>=42.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ xwllz = "xwllz.cli:app"
32
+
33
+ [project.urls]
34
+ Homepage = "https://xwllz.pages.dev"
35
+ Repository = "https://github.com/stalane/xwllz-cli"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/xwllz"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """xwllz — attack-surface management for blue teams."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,252 @@
1
+ """xwllz CLI — attack-surface management for blue teams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import subprocess
7
+ import sys
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from xwllz import db, discovery
14
+ from xwllz import intel as intel_mod
15
+ from xwllz import report as report_mod
16
+ from xwllz.config import load_config
17
+
18
+ app = typer.Typer(add_completion=False, no_args_is_help=True, help="Attack-surface management for blue teams.")
19
+ console = Console()
20
+
21
+ DEFAULT_PORTS = "top-1000"
22
+
23
+
24
+ @app.command()
25
+ def init(
26
+ org: str = typer.Argument(..., help="Org/scope name"),
27
+ domains: str = typer.Option(..., "--domains", help="Comma-separated root domains"),
28
+ db_path: str = typer.Option(None, "--db", help="SQLite database path"),
29
+ ) -> None:
30
+ """Define a scope to monitor."""
31
+ conn = db.connect(db_path)
32
+ try:
33
+ conn.execute("INSERT INTO orgs (name, domains) VALUES (?, ?)", (org, domains))
34
+ conn.commit()
35
+ except Exception as exc: # noqa: BLE001
36
+ console.print(f"[red]error:[/red] {exc}")
37
+ raise typer.Exit(1)
38
+ console.print(f"[green]✓[/green] org [bold]{org}[/bold] set up for {domains}")
39
+ console.print(f" next: [bold]xwllz discover {org}[/bold]")
40
+
41
+
42
+ def _load_org(name: str, db_path: str | None):
43
+ conn = db.connect(db_path)
44
+ try:
45
+ org = discovery.load_org(conn, name)
46
+ except KeyError as exc:
47
+ console.print(f"[red]error:[/red] {exc}")
48
+ raise typer.Exit(1) from exc
49
+ return conn, org
50
+
51
+
52
+ @app.command()
53
+ def discover(
54
+ org: str = typer.Argument(...),
55
+ passive: bool = typer.Option(False, "--passive", help="Subdomains + DNS posture only, no port scanning"),
56
+ ports: str = typer.Option(DEFAULT_PORTS, "--ports", help="Port list for scanning (nmap format) or 'top-1000'"),
57
+ no_nmap: bool = typer.Option(False, "--no-nmap", help="Force pure-Python port scan"),
58
+ db_path: str = typer.Option(None, "--db"),
59
+ ) -> None:
60
+ """Enumerate an org's attack surface."""
61
+ conn, org_obj = _load_org(org, db_path)
62
+ cfg = load_config()
63
+ console.print(f"[bold]xwllz discover {org}[/bold] — passive={passive}")
64
+ port_list = None if ports == DEFAULT_PORTS else [int(p) for p in ports.split(",") if p]
65
+ try:
66
+ stats = asyncio.run(
67
+ discovery.run_discovery(
68
+ conn, org_obj, cfg, passive_only=passive, ports=port_list, use_nmap=not no_nmap
69
+ )
70
+ )
71
+ except KeyboardInterrupt:
72
+ console.print("\n[yellow]interrupted[/yellow]")
73
+ raise typer.Exit(130)
74
+ table = Table(title=f"{org} — discovery results")
75
+ table.add_column("Phase", style="cyan")
76
+ table.add_column("Count", justify="right")
77
+ table.add_row("subdomains", str(stats.subdomains))
78
+ table.add_row("hosts resolved", str(stats.hosts_resolved))
79
+ table.add_row("open ports", str(stats.open_ports))
80
+ table.add_row("services probed", str(stats.services_probed))
81
+ table.add_row("findings", str(stats.findings))
82
+ console.print(table)
83
+ console.print(f"next: [bold]xwllz monitor {org}[/bold] or [bold]xwllz report {org}[/bold]")
84
+
85
+
86
+ @app.command()
87
+ def monitor(
88
+ org: str = typer.Argument(...),
89
+ passive: bool = typer.Option(False, "--passive"),
90
+ ports: str = typer.Option(DEFAULT_PORTS, "--ports"),
91
+ db_path: str = typer.Option(None, "--db"),
92
+ ) -> None:
93
+ """Re-run discovery and show what changed since the last snapshot."""
94
+ conn, org_obj = _load_org(org, db_path)
95
+ cfg = load_config()
96
+ baseline = conn.execute(
97
+ "SELECT id FROM snapshots WHERE org_id = ? ORDER BY id DESC LIMIT 1",
98
+ (org_obj.id,),
99
+ ).fetchone()
100
+ console.print(f"[bold]xwllz monitor {org}[/bold]")
101
+ port_list = None if ports == DEFAULT_PORTS else [int(p) for p in ports.split(",") if p]
102
+ try:
103
+ stats = asyncio.run(discovery.run_discovery(conn, org_obj, cfg, passive_only=passive, ports=port_list))
104
+ except KeyboardInterrupt:
105
+ console.print("\n[yellow]interrupted[/yellow]")
106
+ raise typer.Exit(130)
107
+
108
+ if baseline is None:
109
+ console.print("[green]first snapshot taken[/green] — no baseline to diff against")
110
+ else:
111
+ diff = discovery.diff_snapshots(conn, org_obj, 0)
112
+ table = Table(title="delta vs previous snapshot")
113
+ table.add_column("Metric")
114
+ table.add_column("Before", justify="right")
115
+ table.add_column("After", justify="right")
116
+ table.add_column("Δ", justify="right")
117
+ for key, label in (("assets", "assets"), ("services", "services"), ("findings", "findings")):
118
+ prev_n = diff[key]["prev"]
119
+ new_n = diff[key]["new"]
120
+ delta = new_n - prev_n
121
+ color = "red" if delta > 0 else ("green" if delta < 0 else "white")
122
+ table.add_row(label, str(prev_n), str(new_n), f"[{color}]{delta:+d}[/{color}]")
123
+ console.print(table)
124
+ console.print(f"new findings recorded this run: [bold]{stats.findings}[/bold]")
125
+
126
+
127
+ @app.command()
128
+ def report(
129
+ org: str = typer.Argument(...),
130
+ format: str = typer.Option("md", "--format", "-f", help="md | json | html"),
131
+ output: str = typer.Option(None, "--output", "-o", help="Write to file instead of stdout"),
132
+ db_path: str = typer.Option(None, "--db"),
133
+ ) -> None:
134
+ """Render a report for an org."""
135
+ if format not in ("md", "json", "html"):
136
+ console.print(f"[red]unsupported format {format!r} (use md|json|html)[/red]")
137
+ raise typer.Exit(1)
138
+ conn = db.connect(db_path)
139
+ try:
140
+ body = report_mod.render(conn, org, format)
141
+ except KeyError as exc:
142
+ console.print(f"[red]error:[/red] {exc}")
143
+ raise typer.Exit(1) from exc
144
+ if output:
145
+ with open(output, "w") as fh:
146
+ fh.write(body)
147
+ console.print(f"[green]✓[/green] wrote {output}")
148
+ else:
149
+ console.print(body)
150
+
151
+
152
+ @app.command()
153
+ def status(
154
+ org: str = typer.Argument(...),
155
+ db_path: str = typer.Option(None, "--db"),
156
+ ) -> None:
157
+ """Show the current surface dashboard for an org."""
158
+ conn, org_obj = _load_org(org, db_path)
159
+ counts = conn.execute(
160
+ """SELECT
161
+ (SELECT COUNT(*) FROM assets WHERE org_id = ? AND status='active'),
162
+ (SELECT COUNT(*) FROM services s JOIN assets a ON s.asset_id=a.id
163
+ WHERE a.org_id=? AND a.status='active'),
164
+ (SELECT COUNT(*) FROM findings WHERE org_id = ?)""",
165
+ (org_obj.id, org_obj.id, org_obj.id),
166
+ ).fetchone()
167
+ table = Table(title=f"{org} — current surface")
168
+ table.add_column("Metric")
169
+ table.add_column("Value", justify="right")
170
+ table.add_row("active assets", str(counts[0]))
171
+ table.add_row("live services", str(counts[1]))
172
+ table.add_row("open findings", str(counts[2]))
173
+ console.print(table)
174
+
175
+ top = conn.execute(
176
+ """SELECT severity, COUNT(*) n FROM findings WHERE org_id=? GROUP BY severity
177
+ ORDER BY CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END""",
178
+ (org_obj.id,),
179
+ ).fetchall()
180
+ if top:
181
+ t = Table(title="findings by severity")
182
+ t.add_column("Severity")
183
+ t.add_column("Count", justify="right")
184
+ for row in top:
185
+ t.add_row(row["severity"], str(row["n"]))
186
+ console.print(t)
187
+
188
+
189
+ @app.command()
190
+ def intel(
191
+ indicator: str = typer.Argument(...),
192
+ db_path: str = typer.Option(None, "--db"),
193
+ ) -> None:
194
+ """Look up a URL/domain/IP across enabled intel sources."""
195
+ cfg = load_config()
196
+ conn = db.connect(db_path) if db_path else None
197
+ results = asyncio.run(intel_mod.run_lookup(indicator, cfg, conn))
198
+ if not results:
199
+ console.print("[yellow]no sources enabled — set SHODAN_API_KEY / VIRUSTOTAL_API_KEY / URLHAUS_API_KEY[/yellow]")
200
+ return
201
+ for source, payload in results.items():
202
+ console.print(f"[bold cyan]{source}:[/bold cyan]")
203
+ if source == "urlhaus":
204
+ status = (payload.get("data") or {}).get("query_status")
205
+ console.print(f" query_status: {status}")
206
+ for url in (payload.get("data") or {}).get("urls", [])[:5]:
207
+ console.print(f" - {url.get('url')} [{url.get('threat')}]")
208
+ elif source == "virustotal":
209
+ console.print(f" stats: {payload['stats']}")
210
+ elif source == "shodan":
211
+ console.print(f" ports: {payload['ports']} org: {payload['org']}")
212
+
213
+
214
+ @app.command()
215
+ def whois(
216
+ domain: str = typer.Argument(...),
217
+ ) -> None:
218
+ """Look up whois for a domain (requires 'whois')."""
219
+ try:
220
+ out = subprocess.run(["whois", domain], capture_output=True, text=True, timeout=30, check=False)
221
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
222
+ console.print(f"[red]whois unavailable:[/red] {exc}")
223
+ raise typer.Exit(1) from exc
224
+ console.print(out.stdout)
225
+
226
+
227
+ @app.command()
228
+ def cert(
229
+ host: str = typer.Argument(...),
230
+ port: int = typer.Option(443, "--port"),
231
+ ) -> None:
232
+ """Show TLS certificate details for a host."""
233
+ from xwllz.engines.tls import fetch_cert
234
+
235
+ result = asyncio.run(fetch_cert(host, port))
236
+ if result.error:
237
+ console.print(f"[red]error:[/red] {result.error}")
238
+ raise typer.Exit(1)
239
+ console.print(f"host: {result.host}")
240
+ console.print(f"subject: CN={result.cn}")
241
+ console.print(f"issuer: {result.issuer}")
242
+ console.print(f"expires: {result.not_after} ({result.days_left} days)")
243
+ if result.san:
244
+ console.print(f"sans: {', '.join(result.san[:8])}")
245
+
246
+
247
+ def main() -> None:
248
+ app()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,56 @@
1
+ """Configuration: API keys from env vars, optional tool detection on PATH."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from dataclasses import dataclass, field
8
+
9
+ ENV_KEYS: dict[str, str] = {
10
+ "shodan": "SHODAN_API_KEY",
11
+ "securitytrails": "SECURITYTRAILS_API_KEY",
12
+ "censys": "CENSYS_API_ID",
13
+ "censys_secret": "CENSYS_API_SECRET",
14
+ "urlscan": "URL_SCAN_API_KEY",
15
+ "urlhaus": "URLHAUS_API_KEY",
16
+ "virustotal": "VIRUSTOTAL_API_KEY",
17
+ }
18
+
19
+ # Optional binaries that accelerate phases when present.
20
+ TOOLS: list[str] = [
21
+ "nmap",
22
+ "masscan",
23
+ "rustscan",
24
+ "nuclei",
25
+ "httpx",
26
+ "dnsx",
27
+ "dig",
28
+ "whatweb",
29
+ "openssl",
30
+ ]
31
+
32
+
33
+ @dataclass
34
+ class Config:
35
+ """Resolved runtime configuration."""
36
+
37
+ keys: dict[str, str | None] = field(default_factory=dict)
38
+ tools: dict[str, str | None] = field(default_factory=dict)
39
+ http_timeout: float = 8.0
40
+ dns_timeout: float = 5.0
41
+ port_scan_concurrency: int = 256
42
+ max_workers: int = 32
43
+
44
+ def has(self, name: str) -> bool:
45
+ return bool(self.keys.get(name))
46
+
47
+ def tool(self, name: str) -> str | None:
48
+ """Path to an optional tool, or None if not installed."""
49
+ return self.tools.get(name)
50
+
51
+
52
+ def load_config() -> Config:
53
+ cfg = Config()
54
+ cfg.keys = {name: os.getenv(var) or None for name, var in ENV_KEYS.items()}
55
+ cfg.tools = {name: shutil.which(name) for name in TOOLS}
56
+ return cfg