web-snapshot-cli 0.2.0__tar.gz → 1.0.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.
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/PKG-INFO +15 -2
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/README.md +13 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/pyproject.toml +6 -2
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/__init__.py +1 -1
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/cli.py +41 -2
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/downloader.py +69 -1
- web_snapshot_cli-1.0.0/src/snapshot/wordlist.py +171 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/__init__.py +0 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/common.txt +132 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/large.txt +18637 -0
- web_snapshot_cli-1.0.0/tests/test_wordlist.py +32 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.github/workflows/ci.yml +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.github/workflows/publish.yml +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.gitignore +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/LICENSE +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/__main__.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/crawl_policy.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/manifest.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/restore.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/utils.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_crawl_policy.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_crawler.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_features.py +0 -0
- {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-snapshot-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Take offline snapshots of any website and restore them locally
|
|
5
5
|
Project-URL: Homepage, https://github.com/codingsushi79/Snapshot
|
|
6
6
|
Project-URL: Repository, https://github.com/codingsushi79/Snapshot
|
|
@@ -8,7 +8,7 @@ Author: snapshot contributors
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Keywords: cli,crawler,offline,snapshot,web-archive
|
|
11
|
-
Classifier: Development Status ::
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -85,6 +85,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
|
|
|
85
85
|
snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### Gobuster-style path discovery
|
|
89
|
+
|
|
90
|
+
Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
snapshot --crawl --gobuster https://example.com ./mirror
|
|
94
|
+
snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
|
|
95
|
+
snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
|
|
96
|
+
```
|
|
97
|
+
|
|
88
98
|
### Resume an interrupted snapshot
|
|
89
99
|
|
|
90
100
|
```bash
|
|
@@ -137,6 +147,9 @@ snapshot -restore ./mirror --port 3000 --no-open
|
|
|
137
147
|
| `--robots` / `--no-robots` | Respect robots.txt (default: on) |
|
|
138
148
|
| `--crawl-delay` | Seconds to wait after each request (default: 0) |
|
|
139
149
|
| `--sitemap` | Seed crawl from sitemap.xml |
|
|
150
|
+
| `--gobuster`, `-g` | Discover paths with bundled wordlists |
|
|
151
|
+
| `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
|
|
152
|
+
| `--wordlist-ext` | Extensions to append per word (`html,php`) |
|
|
140
153
|
| `--resume` | Skip pages/assets already saved |
|
|
141
154
|
| `--verbose`, `-v` | Detailed log output |
|
|
142
155
|
| `--dry-run` | Fetch without writing files |
|
|
@@ -56,6 +56,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
|
|
|
56
56
|
snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
### Gobuster-style path discovery
|
|
60
|
+
|
|
61
|
+
Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
snapshot --crawl --gobuster https://example.com ./mirror
|
|
65
|
+
snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
|
|
66
|
+
snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
|
|
67
|
+
```
|
|
68
|
+
|
|
59
69
|
### Resume an interrupted snapshot
|
|
60
70
|
|
|
61
71
|
```bash
|
|
@@ -108,6 +118,9 @@ snapshot -restore ./mirror --port 3000 --no-open
|
|
|
108
118
|
| `--robots` / `--no-robots` | Respect robots.txt (default: on) |
|
|
109
119
|
| `--crawl-delay` | Seconds to wait after each request (default: 0) |
|
|
110
120
|
| `--sitemap` | Seed crawl from sitemap.xml |
|
|
121
|
+
| `--gobuster`, `-g` | Discover paths with bundled wordlists |
|
|
122
|
+
| `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
|
|
123
|
+
| `--wordlist-ext` | Extensions to append per word (`html,php`) |
|
|
111
124
|
| `--resume` | Skip pages/assets already saved |
|
|
112
125
|
| `--verbose`, `-v` | Detailed log output |
|
|
113
126
|
| `--dry-run` | Fetch without writing files |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "web-snapshot-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "1.0.0"
|
|
8
8
|
description = "Take offline snapshots of any website and restore them locally"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -12,7 +12,7 @@ requires-python = ">=3.10"
|
|
|
12
12
|
authors = [{ name = "snapshot contributors" }]
|
|
13
13
|
keywords = ["cli", "web-archive", "snapshot", "offline", "crawler"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
16
|
"Environment :: Console",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"License :: OSI Approved :: MIT License",
|
|
@@ -41,6 +41,10 @@ Repository = "https://github.com/codingsushi79/Snapshot"
|
|
|
41
41
|
[tool.hatch.build.targets.wheel]
|
|
42
42
|
packages = ["src/snapshot"]
|
|
43
43
|
|
|
44
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
45
|
+
"src/snapshot/wordlists/common.txt" = "snapshot/wordlists/common.txt"
|
|
46
|
+
"src/snapshot/wordlists/large.txt" = "snapshot/wordlists/large.txt"
|
|
47
|
+
|
|
44
48
|
[tool.pytest.ini_options]
|
|
45
49
|
asyncio_mode = "auto"
|
|
46
50
|
testpaths = ["tests"]
|
|
@@ -68,13 +68,20 @@ def _build_options(
|
|
|
68
68
|
respect_robots: bool,
|
|
69
69
|
crawl_delay: float,
|
|
70
70
|
use_sitemap: bool,
|
|
71
|
+
gobuster: bool,
|
|
72
|
+
wordlists: tuple[str, ...],
|
|
73
|
+
wordlist_ext: str,
|
|
71
74
|
resume: bool,
|
|
72
75
|
verbose: bool,
|
|
73
76
|
dry_run: bool,
|
|
74
77
|
) -> SnapshotOptions:
|
|
75
78
|
default_ua = SnapshotOptions().user_agent
|
|
79
|
+
use_gobuster = gobuster or bool(extras.get("gobuster")) or bool(
|
|
80
|
+
wordlists or extras.get("wordlist")
|
|
81
|
+
)
|
|
82
|
+
crawl_enabled = crawl or bool(extras.get("crawl")) or use_gobuster
|
|
76
83
|
return SnapshotOptions(
|
|
77
|
-
crawl=
|
|
84
|
+
crawl=crawl_enabled,
|
|
78
85
|
max_pages=int(extras.get("max_pages", max_pages)),
|
|
79
86
|
max_depth=int(extras.get("depth", depth)),
|
|
80
87
|
lang=str(extras.get("lang", lang)),
|
|
@@ -90,6 +97,9 @@ def _build_options(
|
|
|
90
97
|
respect_robots=bool(extras.get("respect_robots", respect_robots)),
|
|
91
98
|
crawl_delay=float(extras.get("crawl_delay", crawl_delay)),
|
|
92
99
|
use_sitemap=use_sitemap or bool(extras.get("sitemap")),
|
|
100
|
+
gobuster=gobuster or bool(extras.get("gobuster")),
|
|
101
|
+
wordlists=list(wordlists) + _parse_list_value(extras.get("wordlist")),
|
|
102
|
+
wordlist_extensions=_parse_list_value(extras.get("wordlist_ext") or wordlist_ext),
|
|
93
103
|
resume=resume or bool(extras.get("resume")),
|
|
94
104
|
verbose=verbose or bool(extras.get("verbose")),
|
|
95
105
|
dry_run=dry_run or bool(extras.get("dry_run")),
|
|
@@ -184,6 +194,23 @@ def _build_options(
|
|
|
184
194
|
is_flag=True,
|
|
185
195
|
help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
|
|
186
196
|
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--gobuster",
|
|
199
|
+
"-g",
|
|
200
|
+
is_flag=True,
|
|
201
|
+
help="Brute-force discover paths using bundled wordlists (common + large).",
|
|
202
|
+
)
|
|
203
|
+
@click.option(
|
|
204
|
+
"--wordlist",
|
|
205
|
+
"-w",
|
|
206
|
+
multiple=True,
|
|
207
|
+
help="Wordlist file or builtin name: common, large (repeatable). Enables path scanning.",
|
|
208
|
+
)
|
|
209
|
+
@click.option(
|
|
210
|
+
"--wordlist-ext",
|
|
211
|
+
default="",
|
|
212
|
+
help="Comma-separated extensions to try with each word (e.g. html,php,asp).",
|
|
213
|
+
)
|
|
187
214
|
@click.option(
|
|
188
215
|
"--resume",
|
|
189
216
|
is_flag=True,
|
|
@@ -222,6 +249,9 @@ def main(
|
|
|
222
249
|
robots: bool,
|
|
223
250
|
crawl_delay: float,
|
|
224
251
|
use_sitemap: bool,
|
|
252
|
+
gobuster: bool,
|
|
253
|
+
wordlist: tuple[str, ...],
|
|
254
|
+
wordlist_ext: str,
|
|
225
255
|
resume: bool,
|
|
226
256
|
verbose: bool,
|
|
227
257
|
dry_run: bool,
|
|
@@ -235,7 +265,8 @@ def main(
|
|
|
235
265
|
Examples:
|
|
236
266
|
snapshot https://example.com ./mirror
|
|
237
267
|
snapshot --crawl https://docs.example.com ./docs --max-pages=200
|
|
238
|
-
snapshot --crawl --
|
|
268
|
+
snapshot --crawl --gobuster --sitemap https://example.com ./mirror
|
|
269
|
+
snapshot --crawl -w common -w /path/to/SecLists.txt URL ./out
|
|
239
270
|
snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
|
|
240
271
|
snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
|
|
241
272
|
snapshot --resume --crawl URL ./mirror
|
|
@@ -270,6 +301,9 @@ def main(
|
|
|
270
301
|
respect_robots=robots,
|
|
271
302
|
crawl_delay=crawl_delay,
|
|
272
303
|
use_sitemap=use_sitemap,
|
|
304
|
+
gobuster=gobuster,
|
|
305
|
+
wordlists=wordlist,
|
|
306
|
+
wordlist_ext=wordlist_ext,
|
|
273
307
|
resume=resume,
|
|
274
308
|
verbose=verbose,
|
|
275
309
|
dry_run=dry_run,
|
|
@@ -283,6 +317,9 @@ def main(
|
|
|
283
317
|
console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
|
|
284
318
|
if options.use_sitemap:
|
|
285
319
|
console.print(" sitemap: enabled")
|
|
320
|
+
if options.gobuster or options.wordlists:
|
|
321
|
+
sources = options.wordlists or ["common", "large"]
|
|
322
|
+
console.print(f" wordlist: {', '.join(sources)}")
|
|
286
323
|
if options.resume:
|
|
287
324
|
console.print(" resume: enabled")
|
|
288
325
|
console.print(f" format: {options.lang}")
|
|
@@ -314,6 +351,8 @@ def main(
|
|
|
314
351
|
console.print(
|
|
315
352
|
f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
|
|
316
353
|
)
|
|
354
|
+
if result.wordlist_hits:
|
|
355
|
+
console.print(f" wordlist: {result.wordlist_hits} paths discovered")
|
|
317
356
|
if result.errors:
|
|
318
357
|
console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
|
|
319
358
|
for err in result.errors[:10]:
|
|
@@ -13,6 +13,7 @@ from markdownify import markdownify
|
|
|
13
13
|
from snapshot.crawl_policy import (
|
|
14
14
|
RobotsChecker,
|
|
15
15
|
discover_sitemap_urls,
|
|
16
|
+
parse_sitemap_xml,
|
|
16
17
|
url_matches_filters,
|
|
17
18
|
)
|
|
18
19
|
from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
|
|
@@ -27,6 +28,7 @@ from snapshot.utils import (
|
|
|
27
28
|
same_origin,
|
|
28
29
|
url_to_local_path,
|
|
29
30
|
)
|
|
31
|
+
from snapshot.wordlist import load_words, resolve_wordlist_sources, scan_wordlists
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
@dataclass
|
|
@@ -39,7 +41,7 @@ class SnapshotOptions:
|
|
|
39
41
|
timeout: float = 15.0
|
|
40
42
|
concurrency: int = 16
|
|
41
43
|
same_origin_only: bool = True
|
|
42
|
-
user_agent: str = "web-snapshot-cli/0
|
|
44
|
+
user_agent: str = "web-snapshot-cli/1.0 (+https://github.com/codingsushi79/Snapshot)"
|
|
43
45
|
cookies: list[str] = field(default_factory=list)
|
|
44
46
|
headers: list[str] = field(default_factory=list)
|
|
45
47
|
include_patterns: list[str] = field(default_factory=list)
|
|
@@ -47,6 +49,9 @@ class SnapshotOptions:
|
|
|
47
49
|
respect_robots: bool = True
|
|
48
50
|
crawl_delay: float = 0.0
|
|
49
51
|
use_sitemap: bool = False
|
|
52
|
+
gobuster: bool = False
|
|
53
|
+
wordlists: list[str] = field(default_factory=list)
|
|
54
|
+
wordlist_extensions: list[str] = field(default_factory=list)
|
|
50
55
|
resume: bool = False
|
|
51
56
|
verbose: bool = False
|
|
52
57
|
dry_run: bool = False
|
|
@@ -58,6 +63,7 @@ class SnapshotResult:
|
|
|
58
63
|
assets_saved: int = 0
|
|
59
64
|
pages_skipped: int = 0
|
|
60
65
|
assets_skipped: int = 0
|
|
66
|
+
wordlist_hits: int = 0
|
|
61
67
|
errors: list[str] = field(default_factory=list)
|
|
62
68
|
|
|
63
69
|
|
|
@@ -75,6 +81,7 @@ class SnapshotEngine:
|
|
|
75
81
|
self._robots = RobotsChecker(options.user_agent, options.respect_robots)
|
|
76
82
|
self._pages_skipped = 0
|
|
77
83
|
self._assets_skipped = 0
|
|
84
|
+
self._wordlist_hits = 0
|
|
78
85
|
self._log_fn: Callable[[str], None] | None = None
|
|
79
86
|
|
|
80
87
|
async def run(
|
|
@@ -119,6 +126,7 @@ class SnapshotEngine:
|
|
|
119
126
|
assets_saved=len(self._seen_assets),
|
|
120
127
|
pages_skipped=self._pages_skipped,
|
|
121
128
|
assets_skipped=self._assets_skipped,
|
|
129
|
+
wordlist_hits=self._wordlist_hits,
|
|
122
130
|
errors=self._errors,
|
|
123
131
|
)
|
|
124
132
|
|
|
@@ -133,6 +141,8 @@ class SnapshotEngine:
|
|
|
133
141
|
"respect_robots": self.options.respect_robots,
|
|
134
142
|
"crawl_delay": self.options.crawl_delay,
|
|
135
143
|
"use_sitemap": self.options.use_sitemap,
|
|
144
|
+
"gobuster": self.options.gobuster,
|
|
145
|
+
"wordlists": self.options.wordlists,
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
def _build_headers(self) -> dict[str, str]:
|
|
@@ -198,6 +208,35 @@ class SnapshotEngine:
|
|
|
198
208
|
self._log(f"sitemap: discovered {len(sitemap_urls)} URLs")
|
|
199
209
|
seed_urls.extend(sitemap_urls)
|
|
200
210
|
|
|
211
|
+
wordlist_sources = resolve_wordlist_sources(
|
|
212
|
+
self.options.gobuster,
|
|
213
|
+
self.options.wordlists,
|
|
214
|
+
)
|
|
215
|
+
if wordlist_sources:
|
|
216
|
+
if progress:
|
|
217
|
+
progress("loading wordlists…")
|
|
218
|
+
words = load_words(wordlist_sources)
|
|
219
|
+
extensions = tuple(self.options.wordlist_extensions)
|
|
220
|
+
found = await scan_wordlists(
|
|
221
|
+
self._client,
|
|
222
|
+
self.root_url,
|
|
223
|
+
words,
|
|
224
|
+
extensions=extensions,
|
|
225
|
+
concurrency=self.options.concurrency,
|
|
226
|
+
crawl_delay=self.options.crawl_delay,
|
|
227
|
+
robots=self._robots,
|
|
228
|
+
url_allowed=self._url_allowed,
|
|
229
|
+
log=self._log,
|
|
230
|
+
progress=progress,
|
|
231
|
+
)
|
|
232
|
+
self._wordlist_hits = len(found)
|
|
233
|
+
extra = await self._expand_metadata_urls(found)
|
|
234
|
+
seed_urls.extend(found)
|
|
235
|
+
seed_urls.extend(extra)
|
|
236
|
+
self._log(
|
|
237
|
+
f"wordlist: {len(found)} paths, {len(extra)} extra URLs from robots/sitemaps"
|
|
238
|
+
)
|
|
239
|
+
|
|
201
240
|
for url in seed_urls:
|
|
202
241
|
normalized = normalize_url(url)
|
|
203
242
|
if not normalized or not self._url_allowed(normalized):
|
|
@@ -216,6 +255,35 @@ class SnapshotEngine:
|
|
|
216
255
|
worker.cancel()
|
|
217
256
|
await asyncio.gather(*workers, return_exceptions=True)
|
|
218
257
|
|
|
258
|
+
async def _expand_metadata_urls(self, discovered: list[str]) -> list[str]:
|
|
259
|
+
"""Parse robots.txt and sitemap XML found during wordlist scanning."""
|
|
260
|
+
from snapshot.crawl_policy import parse_robots_sitemaps
|
|
261
|
+
|
|
262
|
+
extra: list[str] = []
|
|
263
|
+
seen: set[str] = set(discovered)
|
|
264
|
+
for url in discovered:
|
|
265
|
+
lower = url.lower()
|
|
266
|
+
try:
|
|
267
|
+
if lower.endswith("/robots.txt") or lower.endswith("robots.txt"):
|
|
268
|
+
response = await self._client.get(url)
|
|
269
|
+
if response.status_code == 200:
|
|
270
|
+
for sitemap_url in parse_robots_sitemaps(response.text):
|
|
271
|
+
if sitemap_url not in seen:
|
|
272
|
+
seen.add(sitemap_url)
|
|
273
|
+
extra.append(sitemap_url)
|
|
274
|
+
if "sitemap" in lower and (
|
|
275
|
+
lower.endswith(".xml") or lower.endswith(".xml.gz") or lower.endswith(".txt")
|
|
276
|
+
):
|
|
277
|
+
response = await self._client.get(url)
|
|
278
|
+
if response.status_code == 200:
|
|
279
|
+
for page_url in parse_sitemap_xml(response.text):
|
|
280
|
+
if page_url not in seen:
|
|
281
|
+
seen.add(page_url)
|
|
282
|
+
extra.append(page_url)
|
|
283
|
+
except Exception as exc: # noqa: BLE001
|
|
284
|
+
self._errors.append(f"{url}: {exc}")
|
|
285
|
+
return extra
|
|
286
|
+
|
|
219
287
|
async def _crawl_worker(
|
|
220
288
|
self,
|
|
221
289
|
queue: asyncio.Queue[tuple[str, int]],
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from snapshot.crawl_policy import RobotsChecker
|
|
12
|
+
|
|
13
|
+
BUILTIN_WORDLISTS = ("common", "large")
|
|
14
|
+
HIT_STATUS = {200, 201, 204, 301, 302, 307, 308, 401, 403}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_wordlist_sources(
|
|
18
|
+
gobuster: bool,
|
|
19
|
+
wordlist_args: list[str],
|
|
20
|
+
) -> list[Path | str]:
|
|
21
|
+
"""Return paths and/or builtin names to load."""
|
|
22
|
+
if wordlist_args:
|
|
23
|
+
return list(wordlist_args)
|
|
24
|
+
if gobuster:
|
|
25
|
+
return list(BUILTIN_WORDLISTS)
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_words(sources: list[Path | str]) -> list[str]:
|
|
30
|
+
words: list[str] = []
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
for source in sources:
|
|
33
|
+
for word in _load_source(source):
|
|
34
|
+
if word not in seen:
|
|
35
|
+
seen.add(word)
|
|
36
|
+
words.append(word)
|
|
37
|
+
return words
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_source(source: Path | str) -> list[str]:
|
|
41
|
+
name = str(source).strip()
|
|
42
|
+
if name in BUILTIN_WORDLISTS:
|
|
43
|
+
return _load_builtin(name)
|
|
44
|
+
path = Path(name)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
msg = f"wordlist not found: {name} (use common, large, or a file path)"
|
|
47
|
+
raise FileNotFoundError(msg)
|
|
48
|
+
return _parse_wordlist_text(path.read_text(encoding="utf-8", errors="replace"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_builtin(name: str) -> list[str]:
|
|
52
|
+
text = resources.files("snapshot.wordlists").joinpath(f"{name}.txt").read_text(
|
|
53
|
+
encoding="utf-8"
|
|
54
|
+
)
|
|
55
|
+
return _parse_wordlist_text(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_wordlist_text(text: str) -> list[str]:
|
|
59
|
+
words: list[str] = []
|
|
60
|
+
for line in text.splitlines():
|
|
61
|
+
stripped = line.strip()
|
|
62
|
+
if not stripped or stripped.startswith("#"):
|
|
63
|
+
continue
|
|
64
|
+
words.append(stripped.lstrip("/"))
|
|
65
|
+
return words
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def paths_for_word(word: str, extensions: tuple[str, ...]) -> list[str]:
|
|
69
|
+
"""Build URL path candidates for a single wordlist entry."""
|
|
70
|
+
cleaned = word.strip().strip("/")
|
|
71
|
+
if not cleaned:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
basename = cleaned.split("/")[-1]
|
|
75
|
+
paths = [f"/{cleaned}"]
|
|
76
|
+
|
|
77
|
+
if "." not in basename:
|
|
78
|
+
paths.append(f"/{cleaned}/")
|
|
79
|
+
for ext in extensions:
|
|
80
|
+
suffix = ext if ext.startswith(".") else f".{ext}"
|
|
81
|
+
paths.append(f"/{cleaned}{suffix}")
|
|
82
|
+
|
|
83
|
+
return list(dict.fromkeys(paths))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def scan_wordlists(
|
|
87
|
+
client: httpx.AsyncClient,
|
|
88
|
+
root_url: str,
|
|
89
|
+
words: list[str],
|
|
90
|
+
*,
|
|
91
|
+
extensions: tuple[str, ...],
|
|
92
|
+
concurrency: int,
|
|
93
|
+
crawl_delay: float,
|
|
94
|
+
robots: RobotsChecker,
|
|
95
|
+
url_allowed: Callable[[str], bool],
|
|
96
|
+
log: Callable[[str], None] | None = None,
|
|
97
|
+
progress: Callable[[str], None] | None = None,
|
|
98
|
+
) -> list[str]:
|
|
99
|
+
"""Probe wordlist paths and return URLs that appear to exist."""
|
|
100
|
+
parsed_root = urlparse(root_url)
|
|
101
|
+
base = f"{parsed_root.scheme}://{parsed_root.netloc}"
|
|
102
|
+
|
|
103
|
+
candidates: list[str] = []
|
|
104
|
+
seen_candidates: set[str] = set()
|
|
105
|
+
for word in words:
|
|
106
|
+
for path in paths_for_word(word, extensions):
|
|
107
|
+
absolute = normalize_probe_url(base, path)
|
|
108
|
+
if absolute is None or absolute in seen_candidates:
|
|
109
|
+
continue
|
|
110
|
+
if not url_allowed(absolute):
|
|
111
|
+
continue
|
|
112
|
+
seen_candidates.add(absolute)
|
|
113
|
+
candidates.append(absolute)
|
|
114
|
+
|
|
115
|
+
if not candidates:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
if progress:
|
|
119
|
+
progress(f"wordlist: probing {len(candidates)} paths…")
|
|
120
|
+
if log:
|
|
121
|
+
log(f"wordlist: probing {len(candidates)} paths from {len(words)} words")
|
|
122
|
+
|
|
123
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
124
|
+
found: list[str] = []
|
|
125
|
+
found_lock = asyncio.Lock()
|
|
126
|
+
probed = 0
|
|
127
|
+
|
|
128
|
+
async def probe(target: str) -> None:
|
|
129
|
+
nonlocal probed
|
|
130
|
+
if robots.enabled and not await robots.can_fetch(client, target):
|
|
131
|
+
return
|
|
132
|
+
async with sem:
|
|
133
|
+
hit = await _probe_url(client, target)
|
|
134
|
+
if crawl_delay > 0:
|
|
135
|
+
await asyncio.sleep(crawl_delay)
|
|
136
|
+
probed += 1
|
|
137
|
+
if probed % 250 == 0 and progress:
|
|
138
|
+
progress(f"wordlist: {probed}/{len(candidates)} probes, {len(found)} hits")
|
|
139
|
+
if hit:
|
|
140
|
+
async with found_lock:
|
|
141
|
+
found.append(target)
|
|
142
|
+
if log:
|
|
143
|
+
log(f"wordlist hit: {target}")
|
|
144
|
+
|
|
145
|
+
await asyncio.gather(*(probe(url) for url in candidates))
|
|
146
|
+
|
|
147
|
+
if log:
|
|
148
|
+
log(f"wordlist: {len(found)} paths found")
|
|
149
|
+
return sorted(found)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def normalize_probe_url(base: str, path: str) -> str | None:
|
|
153
|
+
joined = urljoin(f"{base}/", path.lstrip("/"))
|
|
154
|
+
parsed = urlparse(joined)
|
|
155
|
+
if not parsed.scheme or not parsed.netloc:
|
|
156
|
+
return None
|
|
157
|
+
return parsed._replace(fragment="").geturl()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _probe_url(client: httpx.AsyncClient, url: str) -> bool:
|
|
161
|
+
for method in ("HEAD", "GET"):
|
|
162
|
+
try:
|
|
163
|
+
response = await client.request(method, url, follow_redirects=False)
|
|
164
|
+
except httpx.RequestError:
|
|
165
|
+
return False
|
|
166
|
+
if response.status_code in HIT_STATUS:
|
|
167
|
+
return True
|
|
168
|
+
if response.status_code == 405 and method == "HEAD":
|
|
169
|
+
continue
|
|
170
|
+
return False
|
|
171
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Essential discovery paths (robots, sitemaps, well-known, admin)
|
|
2
|
+
robots.txt
|
|
3
|
+
sitemap.xml
|
|
4
|
+
sitemap_index.xml
|
|
5
|
+
sitemap-index.xml
|
|
6
|
+
sitemap1.xml
|
|
7
|
+
sitemap.txt
|
|
8
|
+
sitemap.gz
|
|
9
|
+
sitemap.xml.gz
|
|
10
|
+
sitemap_index.xml.gz
|
|
11
|
+
atom.xml
|
|
12
|
+
feed
|
|
13
|
+
rss
|
|
14
|
+
rss.xml
|
|
15
|
+
feed.xml
|
|
16
|
+
index.xml
|
|
17
|
+
crossdomain.xml
|
|
18
|
+
clientaccesspolicy.xml
|
|
19
|
+
favicon.ico
|
|
20
|
+
apple-touch-icon.png
|
|
21
|
+
.well-known/security.txt
|
|
22
|
+
.well-known/change-password
|
|
23
|
+
.well-known/assetlinks.json
|
|
24
|
+
.well-known/apple-app-site-association
|
|
25
|
+
.well-known/ai-plugin.json
|
|
26
|
+
.well-known/openid-configuration
|
|
27
|
+
.well-known/jwks.json
|
|
28
|
+
humans.txt
|
|
29
|
+
ads.txt
|
|
30
|
+
app-ads.txt
|
|
31
|
+
security.txt
|
|
32
|
+
browserconfig.xml
|
|
33
|
+
manifest.json
|
|
34
|
+
site.webmanifest
|
|
35
|
+
opensearch.xml
|
|
36
|
+
health
|
|
37
|
+
healthz
|
|
38
|
+
healthcheck
|
|
39
|
+
status
|
|
40
|
+
ping
|
|
41
|
+
version
|
|
42
|
+
api
|
|
43
|
+
api/v1
|
|
44
|
+
api/v2
|
|
45
|
+
graphql
|
|
46
|
+
swagger
|
|
47
|
+
swagger.json
|
|
48
|
+
swagger-ui
|
|
49
|
+
swagger-ui.html
|
|
50
|
+
openapi.json
|
|
51
|
+
docs
|
|
52
|
+
documentation
|
|
53
|
+
redoc
|
|
54
|
+
admin
|
|
55
|
+
administrator
|
|
56
|
+
login
|
|
57
|
+
logout
|
|
58
|
+
signin
|
|
59
|
+
signup
|
|
60
|
+
register
|
|
61
|
+
auth
|
|
62
|
+
oauth
|
|
63
|
+
dashboard
|
|
64
|
+
portal
|
|
65
|
+
console
|
|
66
|
+
panel
|
|
67
|
+
cpanel
|
|
68
|
+
webmail
|
|
69
|
+
phpmyadmin
|
|
70
|
+
wp-admin
|
|
71
|
+
wp-login.php
|
|
72
|
+
wp-json
|
|
73
|
+
xmlrpc.php
|
|
74
|
+
.git
|
|
75
|
+
.git/config
|
|
76
|
+
.git/HEAD
|
|
77
|
+
.env
|
|
78
|
+
.env.local
|
|
79
|
+
.env.production
|
|
80
|
+
backup
|
|
81
|
+
backups
|
|
82
|
+
old
|
|
83
|
+
test
|
|
84
|
+
dev
|
|
85
|
+
staging
|
|
86
|
+
tmp
|
|
87
|
+
temp
|
|
88
|
+
upload
|
|
89
|
+
uploads
|
|
90
|
+
files
|
|
91
|
+
assets
|
|
92
|
+
static
|
|
93
|
+
public
|
|
94
|
+
media
|
|
95
|
+
images
|
|
96
|
+
img
|
|
97
|
+
css
|
|
98
|
+
js
|
|
99
|
+
javascript
|
|
100
|
+
dist
|
|
101
|
+
build
|
|
102
|
+
cache
|
|
103
|
+
config
|
|
104
|
+
settings
|
|
105
|
+
search
|
|
106
|
+
archive
|
|
107
|
+
archives
|
|
108
|
+
blog
|
|
109
|
+
news
|
|
110
|
+
press
|
|
111
|
+
about
|
|
112
|
+
about-us
|
|
113
|
+
contact
|
|
114
|
+
contact-us
|
|
115
|
+
help
|
|
116
|
+
support
|
|
117
|
+
faq
|
|
118
|
+
privacy
|
|
119
|
+
terms
|
|
120
|
+
legal
|
|
121
|
+
sitemap
|
|
122
|
+
sitemaps
|
|
123
|
+
sitemap-index
|
|
124
|
+
sitemap_index
|
|
125
|
+
sitemapindex
|
|
126
|
+
sitemap.php
|
|
127
|
+
sitemap.html
|
|
128
|
+
sitemap.asp
|
|
129
|
+
sitemap.aspx
|
|
130
|
+
robots
|
|
131
|
+
robots.php
|
|
132
|
+
robots.html
|