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.
- xwllz-0.1.0/.github/workflows/ci.yml +24 -0
- xwllz-0.1.0/.gitignore +11 -0
- xwllz-0.1.0/LICENSE +21 -0
- xwllz-0.1.0/PKG-INFO +80 -0
- xwllz-0.1.0/README.md +56 -0
- xwllz-0.1.0/docs/superpowers/specs/2026-08-23-xwllz-cli-design.md +126 -0
- xwllz-0.1.0/pyproject.toml +41 -0
- xwllz-0.1.0/src/xwllz/__init__.py +3 -0
- xwllz-0.1.0/src/xwllz/cli.py +252 -0
- xwllz-0.1.0/src/xwllz/config.py +56 -0
- xwllz-0.1.0/src/xwllz/db.py +230 -0
- xwllz-0.1.0/src/xwllz/detectors.py +91 -0
- xwllz-0.1.0/src/xwllz/discovery.py +327 -0
- xwllz-0.1.0/src/xwllz/engines/dns.py +68 -0
- xwllz-0.1.0/src/xwllz/engines/http.py +95 -0
- xwllz-0.1.0/src/xwllz/engines/ports.py +64 -0
- xwllz-0.1.0/src/xwllz/engines/tls.py +98 -0
- xwllz-0.1.0/src/xwllz/integrations/__init__.py +124 -0
- xwllz-0.1.0/src/xwllz/intel.py +82 -0
- xwllz-0.1.0/src/xwllz/report.py +142 -0
- xwllz-0.1.0/src/xwllz/sources/crt_sh.py +34 -0
- xwllz-0.1.0/src/xwllz/sources/keyed.py +205 -0
- xwllz-0.1.0/tests/test_config.py +35 -0
- xwllz-0.1.0/tests/test_db.py +76 -0
- xwllz-0.1.0/tests/test_detectors.py +93 -0
- xwllz-0.1.0/tests/test_discovery.py +120 -0
- xwllz-0.1.0/tests/test_report.py +48 -0
|
@@ -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
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,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
|