mem safe & format
This commit is contained in:
1 parent
2cc8e1d788
commit
8f92e64ffd
13 files changed
+813
-656
No files matched your search
@@ -25,7 +25,13 @@ pip install -e .
|
|||||||
|
|
||||||
Every command streams input and writes incrementally. Peak memory does not grow
|
Every command streams input and writes incrementally. Peak memory does not grow
|
||||||
with file size: a single 100 GB+ file is parsed with a small constant footprint.
|
with file size: a single 100 GB+ file is parsed with a small constant footprint.
|
||||||
`sort` and `clean` shell out to the OS `sort -u`, which spills to disk.
|
`sort` shells out to the OS `sort -u`, which spills to disk.
|
||||||
|
|
||||||
|
- `crack` builds the hash index once and shares it with the workers by forking
|
||||||
|
(copy-on-write) — one copy, not one per worker (verified: ~450 MB total for a
|
||||||
|
1M-hash index across 4 workers).
|
||||||
|
- `query` streams wildcard matches line by line from ripgrep, so a broad pattern
|
||||||
|
over a huge dataset stays bounded.
|
||||||
|
|
||||||
Measured on real Collections 2-5 data (19.94 GB, three files): **160 MB peak RSS**
|
Measured on real Collections 2-5 data (19.94 GB, three files): **160 MB peak RSS**
|
||||||
for the whole process tree, versus **15.4 GB** for the original parser. Min RAM
|
for the whole process tree, versus **15.4 GB** for the original parser. Min RAM
|
||||||
@@ -155,13 +161,8 @@ Run `collectionparser --help` or `collectionparser <command> --help`.
|
|||||||
| `crack` | Dictionary-attack collected hashes with a wordlist. |
|
| `crack` | Dictionary-attack collected hashes with a wordlist. |
|
||||||
| `parse` | Parse an unstructured folder into `success`/`failure`/`hashes` files. |
|
| `parse` | Parse an unstructured folder into `success`/`failure`/`hashes` files. |
|
||||||
| `split` | Convert a large sorted file into a query-friendly `a/b/c` folder. |
|
| `split` | Convert a large sorted file into a query-friendly `a/b/c` folder. |
|
||||||
| `chunk` | Split large TXT files into smaller ones. |
|
|
||||||
| `sort` | Sort and deduplicate every file in a query-friendly folder. |
|
| `sort` | Sort and deduplicate every file in a query-friendly folder. |
|
||||||
| `clean` | Move misplaced records to the right bucket and sort. |
|
| `merge` | Merge one query-friendly folder into another, then sort/dedup. |
|
||||||
| `merge` | Merge one query-friendly folder into another. |
|
|
||||||
| `test` | Look up passwords for the emails in a file. |
|
|
||||||
| `evaluate` | Measure hit rate of one dataset against another. |
|
|
||||||
| `filter-lines` | Keep lines whose length is within a range. |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -57,11 +57,8 @@ sort -T tmp -u -S 90% --parallel=12 success.txt > success_sorted.txt
|
|||||||
# 3. Sorted unique file -> query-friendly a/b/c tree.
|
# 3. Sorted unique file -> query-friendly a/b/c tree.
|
||||||
collectionparser split --file success_sorted.txt --out data
|
collectionparser split --file success_sorted.txt --out data
|
||||||
|
|
||||||
# 4. Optional repair pass (moves records to the right bucket, re-sorts).
|
# 4. Query.
|
||||||
collectionparser clean --path data
|
collectionparser query --dataset data --pattern '[email protected]'
|
||||||
|
|
||||||
# 5. Query.
|
|
||||||
collectionparser test --file emails.txt --dataset data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To add another dataset to an existing index:
|
To add another dataset to an existing index:
|
||||||
@@ -79,15 +76,11 @@ collectionparser merge --src other_data --dest big_dataset
|
|||||||
| `query` | Exact or wildcard (`*`, `?`) search over a dataset. | `--dataset`, `--pattern` |
|
| `query` | Exact or wildcard (`*`, `?`) search over a dataset. | `--dataset`, `--pattern` |
|
||||||
| `export` | Export a dataset as JSON Lines (OpenSearch). | `--dataset`, `--out` |
|
| `export` | Export a dataset as JSON Lines (OpenSearch). | `--dataset`, `--out` |
|
||||||
| `crack` | Dictionary-attack collected MD5/SHA1/SHA256 hashes. | `--hashes`, `--wordlist`, `--out`, `--workers` |
|
| `crack` | Dictionary-attack collected MD5/SHA1/SHA256 hashes. | `--hashes`, `--wordlist`, `--out`, `--workers` |
|
||||||
| `parse` | Folder of raw files -> `success`/`failure` files. Streams. | `--path`, `--success_file`, `--failure_file` |
|
| `parse` | Folder of raw files -> `success`/`failure`/`hashes` files. Streams. | `--path`, `--success_file`, `--failure_file`, `--hashes_file` |
|
||||||
| `split` | One sorted file -> `a/b/c` tree. | `--file`, `--out`, `--restart_from N` |
|
| `split` | One sorted file -> `a/b/c` tree. | `--file`, `--out`, `--restart_from N` |
|
||||||
| `chunk` | Split oversized TXT files. | `--path`, `--size MB` (default 50) |
|
|
||||||
| `sort` | `sort -u` every file in place. | `--path` |
|
| `sort` | `sort -u` every file in place. | `--path` |
|
||||||
| `clean` | Move misplaced records, then sort each file. | `--path` |
|
| `merge` | Merge `--src` into `--dest` (destructive), then sort/dedup. | `--src`, `--dest` |
|
||||||
| `merge` | Merge `--src` into `--dest` (destructive). Runs `clean` after. | `--src`, `--dest` |
|
| `status` | JSON build/dataset progress. | `--scratch`, `--pretty` |
|
||||||
| `test` | Print passwords found for the emails in a file. | `--file`, `--dataset` |
|
|
||||||
| `evaluate` | Hit-rate of one index against another. | `--old`, `--new` |
|
|
||||||
| `filter-lines` | Keep lines within a length range. | `--file`, `--out`, `--less_than`, `--more_than` |
|
|
||||||
|
|
||||||
## Rules an agent must follow
|
## Rules an agent must follow
|
||||||
|
|
||||||
@@ -106,8 +99,8 @@ collectionparser merge --src other_data --dest big_dataset
|
|||||||
- **`merge` is destructive**: it appends into `--dest` and deletes each `--src`
|
- **`merge` is destructive**: it appends into `--dest` and deletes each `--src`
|
||||||
file. Back up `--dest` before running.
|
file. Back up `--dest` before running.
|
||||||
- **`--dataset` is a path** to a query-friendly folder built by `split`.
|
- **`--dataset` is a path** to a query-friendly folder built by `split`.
|
||||||
- **`query`/`test`/`evaluate` need `rg` on `PATH`.** If `rg` is missing, queries
|
- **`query` needs `rg` on `PATH`.** If `rg` is missing, queries return nothing;
|
||||||
return nothing; install ripgrep instead of working around it.
|
install ripgrep instead of working around it.
|
||||||
- **`query` wildcards are anchored to the email field**: `*`/`?` never cross the
|
- **`query` wildcards are anchored to the email field**: `*`/`?` never cross the
|
||||||
`:` separator (`*@gmail.com` works, matching into the password does not).
|
`:` separator (`*@gmail.com` works, matching into the password does not).
|
||||||
- **`parse` restarts safely.** Completed inputs are recorded in `progress.txt`
|
- **`parse` restarts safely.** Completed inputs are recorded in `progress.txt`
|
||||||
@@ -126,7 +119,8 @@ collectionparser merge --src other_data --dest big_dataset
|
|||||||
collectionparser --help
|
collectionparser --help
|
||||||
# round trip on toy data (~1s)
|
# round trip on toy data (~1s)
|
||||||
printf '[email protected]:pw\[email protected]:pw\n' > /tmp/raw.txt
|
printf '[email protected]:pw\[email protected]:pw\n' > /tmp/raw.txt
|
||||||
collectionparser filter-lines --file /tmp/raw.txt --out /tmp/o.txt --less_than 1 --more_than 50
|
collectionparser split --file /tmp/raw.txt --out /tmp/tree
|
||||||
|
test -f /tmp/tree/a/symbols && echo OK
|
||||||
```
|
```
|
||||||
|
|
||||||
If that works, the package and its entry point are installed correctly.
|
If that works, the package and its entry point are installed correctly.
|
||||||
+37
-122
@@ -1,17 +1,14 @@
|
|||||||
"""Implementations behind the CLI commands: parse, split, sort, merge, query."""
|
"""Implementations behind the CLI commands: parse, split, sort, merge, export."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from collectionparser.query import QueryAPI
|
from collectionparser.utils import create_dir_for_file, find_files
|
||||||
from collectionparser.splitter import get_output_file, write_line_to_file
|
|
||||||
from collectionparser.utils import create_dir_for_file, filter_printable, shuffle
|
|
||||||
|
|
||||||
# ponytail: buckets under this size sort byte-exactly in RAM; larger buckets
|
# ponytail: buckets under this size sort byte-exactly in RAM; larger buckets
|
||||||
# shell out to `sort -u`, which spills to disk.
|
# shell out to `sort -u`, which spills to disk.
|
||||||
@@ -21,137 +18,51 @@ _IN_MEMORY_SORT_LIMIT = 8 * 1024 * 1024
|
|||||||
class API:
|
class API:
|
||||||
"""Command implementations shared by the CLI."""
|
"""Command implementations shared by the CLI."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def evaluate(old: str, new: str) -> None:
|
|
||||||
"""Report the hit rate of ``new`` against a sample of ``old``."""
|
|
||||||
hit_count = total_count = 0
|
|
||||||
files = [p for p in Path(old).expanduser().glob('**/*') if p.is_file()]
|
|
||||||
for _ in range(1000):
|
|
||||||
# reservoir sample 10 lines so arbitrarily large files stay O(1) in RAM.
|
|
||||||
random_lines: list[str] = []
|
|
||||||
with open(random.choice(files), encoding='utf8', errors='ignore') as reader:
|
|
||||||
for count, line in enumerate(reader):
|
|
||||||
if count < 10:
|
|
||||||
random_lines.append(line.rstrip('\n'))
|
|
||||||
elif (j := random.randint(0, count)) < 10:
|
|
||||||
random_lines[j] = line.rstrip('\n')
|
|
||||||
for random_line in random_lines:
|
|
||||||
password_list = QueryAPI.query(new, random_line.partition(':')[0])
|
|
||||||
total_count += 1
|
|
||||||
hit = 'hit' if password_list else 'miss'
|
|
||||||
hit_count += bool(password_list)
|
|
||||||
print(f'OLD: {random_line}, NEW: {hit}, HIT_RATE = {hit_count / total_count:.3f}.')
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def merge(src: str, dest: str) -> None:
|
def merge(src: str, dest: str) -> None:
|
||||||
"""Merge the bucket tree ``src`` into ``dest`` (destructive)."""
|
"""Merge the bucket tree ``src`` into ``dest`` (destructive)."""
|
||||||
src_root = Path(src).expanduser()
|
src_root = Path(src).expanduser()
|
||||||
dest_root = Path(dest).expanduser()
|
dest_root = Path(dest).expanduser()
|
||||||
dest_files = {str(f).replace(str(dest_root), ''): f for f in find_files(dest_root)}
|
dest_files = {
|
||||||
|
str(f).replace(str(dest_root), ""): f for f in find_files(dest_root)
|
||||||
|
}
|
||||||
for src_file in find_files(src_root):
|
for src_file in find_files(src_root):
|
||||||
src_index = str(src_file).replace(str(src_root), '')
|
src_index = str(src_file).replace(str(src_root), "")
|
||||||
if src_index in dest_files:
|
if src_index in dest_files:
|
||||||
append_file(src_file, dest_files[src_index])
|
append_file(src_file, dest_files[src_index])
|
||||||
else:
|
else:
|
||||||
dst = dest_root / src_index.lstrip('/')
|
dst = dest_root / src_index.lstrip("/")
|
||||||
create_dir_for_file(str(dst))
|
create_dir_for_file(str(dst))
|
||||||
move_file(src_file, dst)
|
move_file(src_file, dst)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sort(path: str) -> None:
|
def sort(path: str) -> None:
|
||||||
"""Sort and de-duplicate every file under ``path`` in place."""
|
"""Sort and de-duplicate every file under ``path`` in place."""
|
||||||
files = [p for p in Path(path).expanduser().glob('**/*')
|
files = [p for p in find_files(path) if p.suffix != ".sorted"]
|
||||||
if p.is_file() and p.suffix != '.sorted']
|
for file in tqdm(files, desc="Unique > Sorting > Replacing existing files"):
|
||||||
for file in tqdm(files, desc='Unique > Sorting > Replacing existing files'):
|
output_path = file.with_suffix(".sorted")
|
||||||
output_path = file.with_suffix('.sorted')
|
|
||||||
sort_unique(file, output_path)
|
sort_unique(file, output_path)
|
||||||
shutil.move(output_path, file)
|
shutil.move(output_path, file)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def clean(path: str) -> None:
|
|
||||||
"""Move misplaced records to their correct bucket and sort each file."""
|
|
||||||
files = find_files(Path(path).expanduser())
|
|
||||||
shuffle(files)
|
|
||||||
num_files_moved = 0
|
|
||||||
handles: dict[str, object] = {}
|
|
||||||
with tqdm(files, desc='clean') as bar:
|
|
||||||
for file in bar:
|
|
||||||
key = str(file).replace(path, '').removeprefix('/')
|
|
||||||
unique_file = str(file) + '.unique'
|
|
||||||
# stream: moved lines go to their target file, kept lines to a
|
|
||||||
# temp file. Nothing is held in RAM.
|
|
||||||
with open(str(file), encoding='utf8', errors='ignore') as reader, \
|
|
||||||
open(unique_file, 'w', encoding='utf8') as writer:
|
|
||||||
for line in reader:
|
|
||||||
email = line.partition(':')[0]
|
|
||||||
if get_output_file(email) != key:
|
|
||||||
write_line_to_file(handles, line, Path(path) / get_output_file(email),
|
|
||||||
buffer_scale=10)
|
|
||||||
num_files_moved += 1
|
|
||||||
if num_files_moved % 100_000 == 0:
|
|
||||||
bar.set_description(f'clean. {num_files_moved:,} moved.')
|
|
||||||
else:
|
|
||||||
writer.write(line)
|
|
||||||
sort_unique(unique_file, file)
|
|
||||||
os.remove(unique_file)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def test(file: str, dataset: str) -> None:
|
|
||||||
"""Look up every email in ``file`` and print the cracked rate."""
|
|
||||||
cracked = total = 0
|
|
||||||
with open(file, encoding='utf8', errors='ignore') as reader:
|
|
||||||
for line in reader:
|
|
||||||
results = QueryAPI.query(dataset, line.strip())
|
|
||||||
if results:
|
|
||||||
passwords = [p.partition(':')[2] for p in results]
|
|
||||||
print('FOUND:', line, ':'.join(passwords))
|
|
||||||
cracked += 1
|
|
||||||
total += 1
|
|
||||||
print(f'Cracked rate: {cracked / total if total else 0.0}')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def chunk_files(path: str, max_size: int) -> None:
|
|
||||||
"""Split files larger than ``max_size`` MB and delete the originals."""
|
|
||||||
files = find_files(Path(path).expanduser())
|
|
||||||
for file in tqdm(files, desc='chunk large files'):
|
|
||||||
if file.stat().st_size / 1e6 > max_size:
|
|
||||||
# -d (numeric suffixes) is not compatible with MacOS.
|
|
||||||
cmd = ['split', '-b', str(int(max_size * 1e6)), str(file), str(file) + '_']
|
|
||||||
print(f'File size: {file.stat().st_size / 1e6:.2f} Mb. ' + ' '.join(cmd))
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
file.unlink()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def export_jsonl(dataset: str, out: str) -> None:
|
def export_jsonl(dataset: str, out: str) -> None:
|
||||||
"""Export the dataset as JSON Lines (`{"email":..., "password":...}`)."""
|
"""Export the dataset as JSON Lines (`{"email":..., "password":...}`)."""
|
||||||
create_dir_for_file(out)
|
create_dir_for_file(out)
|
||||||
total = 0
|
total = 0
|
||||||
with open(out, 'w', encoding='utf8') as writer:
|
with open(out, "w", encoding="utf8") as writer:
|
||||||
for path in tqdm(find_files(Path(dataset).expanduser()), desc='export jsonl'):
|
for path in tqdm(find_files(dataset), desc="export jsonl"):
|
||||||
with open(path, encoding='utf8', errors='ignore') as reader:
|
with open(path, encoding="utf8", errors="ignore") as reader:
|
||||||
for line in reader:
|
for line in reader:
|
||||||
email, _, password = line.rstrip('\n').partition(':')
|
email, _, password = line.rstrip("\n").partition(":")
|
||||||
writer.write(json.dumps({'email': email, 'password': password},
|
writer.write(
|
||||||
ensure_ascii=False) + '\n')
|
json.dumps(
|
||||||
|
{"email": email, "password": password},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
total += 1
|
total += 1
|
||||||
print(f'Exported {total:,} records to {out}.')
|
print(f"Exported {total:,} records to {out}.")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def filter_lines(file: str, out: str, less_than: int, more_than: int) -> None:
|
|
||||||
"""Keep lines whose printable length is within ``[less_than, more_than]``."""
|
|
||||||
create_dir_for_file(out)
|
|
||||||
with open(out, 'w', encoding='utf8') as writer, \
|
|
||||||
open(file, encoding='utf8', errors='ignore') as reader:
|
|
||||||
for line in reader:
|
|
||||||
printable = filter_printable(line)
|
|
||||||
if less_than <= len(printable.strip()) <= more_than:
|
|
||||||
writer.write(printable)
|
|
||||||
|
|
||||||
|
|
||||||
def find_files(path) -> list[Path]:
|
|
||||||
"""Return every regular file under ``path`` (recursively)."""
|
|
||||||
root = Path(path).expanduser()
|
|
||||||
return [p for p in root.glob('**/*') if p.is_file()]
|
|
||||||
|
|
||||||
|
|
||||||
def sort_unique(src, dst) -> None:
|
def sort_unique(src, dst) -> None:
|
||||||
@@ -162,28 +73,32 @@ def sort_unique(src, dst) -> None:
|
|||||||
"""
|
"""
|
||||||
src = Path(src)
|
src = Path(src)
|
||||||
if src.stat().st_size <= _IN_MEMORY_SORT_LIMIT:
|
if src.stat().st_size <= _IN_MEMORY_SORT_LIMIT:
|
||||||
lines = src.read_bytes().split(b'\n')
|
lines = src.read_bytes().split(b"\n")
|
||||||
if lines and lines[-1] == b'':
|
if lines and lines[-1] == b"":
|
||||||
lines.pop()
|
lines.pop()
|
||||||
lines = sorted(set(lines))
|
lines = sorted(set(lines))
|
||||||
Path(dst).write_bytes(b'\n'.join(lines) + b'\n' if lines else b'')
|
Path(dst).write_bytes(b"\n".join(lines) + b"\n" if lines else b"")
|
||||||
else:
|
else:
|
||||||
tmp = f'{dst}.tmp'
|
tmp = f"{dst}.tmp"
|
||||||
with open(tmp, 'wb') as writer:
|
with open(tmp, "wb") as writer:
|
||||||
subprocess.run(['sort', '-u', str(src)], stdout=writer, check=True,
|
subprocess.run(
|
||||||
env={**os.environ, 'LC_ALL': 'C'})
|
["sort", "-u", str(src)],
|
||||||
|
stdout=writer,
|
||||||
|
check=True,
|
||||||
|
env={**os.environ, "LC_ALL": "C"},
|
||||||
|
)
|
||||||
os.replace(tmp, dst)
|
os.replace(tmp, dst)
|
||||||
|
|
||||||
|
|
||||||
def append_file(src, dst) -> None:
|
def append_file(src, dst) -> None:
|
||||||
"""Append ``src`` to ``dst`` and delete ``src`` (streams, constant memory)."""
|
"""Append ``src`` to ``dst`` and delete ``src`` (streams, constant memory)."""
|
||||||
print(f'merge {src} {dst}')
|
print(f"merge {src} {dst}")
|
||||||
with open(src, 'rb') as reader, open(dst, 'ab') as writer:
|
with open(src, "rb") as reader, open(dst, "ab") as writer:
|
||||||
shutil.copyfileobj(reader, writer, length=1024 * 1024)
|
shutil.copyfileobj(reader, writer, length=1024 * 1024)
|
||||||
print(f'del {src}')
|
print(f"del {src}")
|
||||||
os.remove(src)
|
os.remove(src)
|
||||||
|
|
||||||
|
|
||||||
def move_file(src, dst) -> None:
|
def move_file(src, dst) -> None:
|
||||||
print(f'mv {src} {dst}')
|
print(f"mv {src} {dst}")
|
||||||
shutil.move(src, dst)
|
shutil.move(src, dst)
|
||||||
+201
-127
@@ -31,9 +31,22 @@ from collectionparser.api import API, find_files, sort_unique
|
|||||||
from collectionparser.parser import parse_files
|
from collectionparser.parser import parse_files
|
||||||
from collectionparser.splitter import split_file
|
from collectionparser.splitter import split_file
|
||||||
|
|
||||||
DEFAULT_BATCH_BYTES = 120 * 1024 ** 3
|
DEFAULT_BATCH_BYTES = 120 * 1024**3
|
||||||
ARCHIVE_SUFFIXES = ('.rar', '.zip', '.7z', '.tar', '.tar.gz', '.tgz', '.tar.bz2',
|
ARCHIVE_SUFFIXES = (
|
||||||
'.tbz2', '.tar.xz', '.txz', '.gz', '.bz2', '.xz')
|
".rar",
|
||||||
|
".zip",
|
||||||
|
".7z",
|
||||||
|
".tar",
|
||||||
|
".tar.gz",
|
||||||
|
".tgz",
|
||||||
|
".tar.bz2",
|
||||||
|
".tbz2",
|
||||||
|
".tar.xz",
|
||||||
|
".txz",
|
||||||
|
".gz",
|
||||||
|
".bz2",
|
||||||
|
".xz",
|
||||||
|
)
|
||||||
DEVNULL = subprocess.DEVNULL
|
DEVNULL = subprocess.DEVNULL
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +66,7 @@ def _which(*names: str) -> str | None:
|
|||||||
def _safe_target(dest: Path, name: str) -> Path:
|
def _safe_target(dest: Path, name: str) -> Path:
|
||||||
target = (dest / name).resolve()
|
target = (dest / name).resolve()
|
||||||
if not str(target).startswith(str(dest.resolve()) + os.sep):
|
if not str(target).startswith(str(dest.resolve()) + os.sep):
|
||||||
raise ValueError(f'unsafe archive member: {name}')
|
raise ValueError(f"unsafe archive member: {name}")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
@@ -64,28 +77,32 @@ def _extract_compressed(path: Path, dest: Path) -> None:
|
|||||||
mislabeled archives in the wild. Try both, first rc in (0, 1) wins.
|
mislabeled archives in the wild. Try both, first rc in (0, 1) wins.
|
||||||
ponytail: 7z may follow archive paths outside dest; trusted local dumps only.
|
ponytail: 7z may follow archive paths outside dest; trusted local dumps only.
|
||||||
"""
|
"""
|
||||||
seven = _which('7z', '7za')
|
seven = _which("7z", "7za")
|
||||||
unrar = _which('unrar')
|
unrar = _which("unrar")
|
||||||
candidates = [unrar, seven] if path.name.lower().endswith('.rar') else [seven, unrar]
|
candidates = (
|
||||||
|
[unrar, seven] if path.name.lower().endswith(".rar") else [seven, unrar]
|
||||||
|
)
|
||||||
last = None
|
last = None
|
||||||
for tool in (c for c in candidates if c):
|
for tool in (c for c in candidates if c):
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
if 'unrar' in tool:
|
if "unrar" in tool:
|
||||||
cmd = [tool, 'x', '-o+', '-idq', str(path), str(dest) + '/']
|
cmd = [tool, "x", "-o+", "-idq", str(path), str(dest) + "/"]
|
||||||
else:
|
else:
|
||||||
cmd = [tool, 'x', '-y', f'-o{dest}', str(path)]
|
cmd = [tool, "x", "-y", f"-o{dest}", str(path)]
|
||||||
last = subprocess.run(cmd, check=False, stdout=DEVNULL, stderr=DEVNULL).returncode
|
last = subprocess.run(
|
||||||
|
cmd, check=False, stdout=DEVNULL, stderr=DEVNULL
|
||||||
|
).returncode
|
||||||
if last in (0, 1):
|
if last in (0, 1):
|
||||||
return
|
return
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
raise RuntimeError(f'extractors failed (last rc={last})')
|
raise RuntimeError(f"extractors failed (last rc={last})")
|
||||||
|
|
||||||
|
|
||||||
def _extract_tar(path: Path, dest: Path) -> None:
|
def _extract_tar(path: Path, dest: Path) -> None:
|
||||||
with tarfile.open(path) as t:
|
with tarfile.open(path) as t:
|
||||||
try:
|
try:
|
||||||
t.extractall(dest, filter='data') # py3.12+
|
t.extractall(dest, filter="data") # py3.12+
|
||||||
except TypeError: # py3.11
|
except TypeError: # py3.11
|
||||||
t.extractall(dest)
|
t.extractall(dest)
|
||||||
|
|
||||||
@@ -93,25 +110,36 @@ def _extract_tar(path: Path, dest: Path) -> None:
|
|||||||
def _extract_archive(path: Path, dest: Path) -> None:
|
def _extract_archive(path: Path, dest: Path) -> None:
|
||||||
name = path.name.lower()
|
name = path.name.lower()
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
if name.endswith('.zip'):
|
if name.endswith(".zip"):
|
||||||
with zipfile.ZipFile(path) as z:
|
with zipfile.ZipFile(path) as z:
|
||||||
for member in z.namelist():
|
for member in z.namelist():
|
||||||
_safe_target(dest, member)
|
_safe_target(dest, member)
|
||||||
z.extractall(dest)
|
z.extractall(dest)
|
||||||
elif name.endswith(('.rar', '.7z')):
|
elif name.endswith((".rar", ".7z")):
|
||||||
_extract_compressed(path, dest)
|
_extract_compressed(path, dest)
|
||||||
elif name.endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
|
elif name.endswith(
|
||||||
|
(".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")
|
||||||
|
):
|
||||||
_extract_tar(path, dest)
|
_extract_tar(path, dest)
|
||||||
else: # single-file .gz/.bz2/.xz
|
else: # single-file .gz/.bz2/.xz
|
||||||
opener = gzip.open if name.endswith('.gz') else bz2.open if name.endswith('.bz2') else lzma.open
|
opener = (
|
||||||
with opener(path, 'rb') as r, open(dest / path.stem, 'wb') as w:
|
gzip.open
|
||||||
|
if name.endswith(".gz")
|
||||||
|
else bz2.open
|
||||||
|
if name.endswith(".bz2")
|
||||||
|
else lzma.open
|
||||||
|
)
|
||||||
|
with opener(path, "rb") as r, open(dest / path.stem, "wb") as w:
|
||||||
shutil.copyfileobj(r, w, length=1 << 20)
|
shutil.copyfileobj(r, w, length=1 << 20)
|
||||||
|
|
||||||
|
|
||||||
def _discover(source: str) -> list[Path]:
|
def _discover(source: str) -> list[Path]:
|
||||||
files = find_files(source)
|
files = find_files(source)
|
||||||
files.sort(key=lambda p: p.stat().st_size) # small files first
|
files.sort(key=lambda p: p.stat().st_size) # small files first
|
||||||
print(f'{len(files)} files, {sum(1 for p in files if _is_archive(p))} archives.', flush=True)
|
print(
|
||||||
|
f"{len(files)} files, {sum(1 for p in files if _is_archive(p))} archives.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -131,47 +159,50 @@ def _batches(files: list[Path], batch_bytes: int):
|
|||||||
|
|
||||||
def _count_lines(path: str | Path) -> int:
|
def _count_lines(path: str | Path) -> int:
|
||||||
total = 0
|
total = 0
|
||||||
with open(path, 'rb') as r:
|
with open(path, "rb") as r:
|
||||||
for chunk in iter(lambda: r.read(1 << 20), b''):
|
for chunk in iter(lambda: r.read(1 << 20), b""):
|
||||||
total += chunk.count(b'\n')
|
total += chunk.count(b"\n")
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def _totals(stats: list[dict]) -> dict:
|
def _totals(stats: list[dict]) -> dict:
|
||||||
return {
|
return {
|
||||||
'input_bytes': sum(s['input_bytes'] for s in stats),
|
"input_bytes": sum(s["input_bytes"] for s in stats),
|
||||||
'files': sum(s['files'] for s in stats),
|
"files": sum(s["files"] for s in stats),
|
||||||
'pairs': sum(s['pairs'] for s in stats),
|
"pairs": sum(s["pairs"] for s in stats),
|
||||||
'rejects': sum(s['rejects'] for s in stats),
|
"rejects": sum(s["rejects"] for s in stats),
|
||||||
'seconds': sum(s['total_s'] for s in stats),
|
"seconds": sum(s["total_s"] for s in stats),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _stats_markdown(state: dict) -> str:
|
def _stats_markdown(state: dict) -> str:
|
||||||
stats = state['stats']
|
stats = state["stats"]
|
||||||
totals = _totals(stats)
|
totals = _totals(stats)
|
||||||
lines = [
|
lines = [
|
||||||
'# collectionparser build stats',
|
"# collectionparser build stats",
|
||||||
'',
|
"",
|
||||||
f'- input: {totals["input_bytes"] / 1e9:.1f} GB across {totals["files"]:,} files',
|
f"- input: {totals['input_bytes'] / 1e9:.1f} GB across {totals['files']:,} files",
|
||||||
f'- valid pairs: {totals["pairs"]:,}',
|
f"- valid pairs: {totals['pairs']:,}",
|
||||||
f'- rejects: {totals["rejects"]:,}',
|
f"- rejects: {totals['rejects']:,}",
|
||||||
f'- batches: {len(stats)}',
|
f"- batches: {len(stats)}",
|
||||||
f'- build seconds: {totals["seconds"]:,.0f}',
|
f"- build seconds: {totals['seconds']:,.0f}",
|
||||||
]
|
]
|
||||||
if totals['seconds']:
|
if totals["seconds"]:
|
||||||
lines.append(f'- aggregate throughput: {totals["input_bytes"] / 1e6 / totals["seconds"]:.1f} MB/s')
|
lines.append(
|
||||||
if state.get('hashes') is not None:
|
f"- aggregate throughput: {totals['input_bytes'] / 1e6 / totals['seconds']:.1f} MB/s"
|
||||||
lines.append(f'- hashed passwords collected: {state["hashes"]:,}')
|
)
|
||||||
if state.get('dedup_s') is not None:
|
if state.get("hashes") is not None:
|
||||||
lines.append(f'- dedup seconds: {state["dedup_s"]:,.0f}')
|
lines.append(f"- hashed passwords collected: {state['hashes']:,}")
|
||||||
if state.get('unique_records') is not None:
|
if state.get("dedup_s") is not None:
|
||||||
lines.append(f'- unique records: {state["unique_records"]:,}')
|
lines.append(f"- dedup seconds: {state['dedup_s']:,.0f}")
|
||||||
return '\n'.join(lines) + '\n'
|
if state.get("unique_records") is not None:
|
||||||
|
lines.append(f"- unique records: {state['unique_records']:,}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _extract_batch(archives: list[Path], extract_dir: Path,
|
def _extract_batch(
|
||||||
index: int) -> tuple[list[Path], list[str]]:
|
archives: list[Path], extract_dir: Path, index: int
|
||||||
|
) -> tuple[list[Path], list[str]]:
|
||||||
"""Extract archives; return ``(files, failures)``.
|
"""Extract archives; return ``(files, failures)``.
|
||||||
|
|
||||||
Failures are logged, never silently dropped, so they can be recovered later.
|
Failures are logged, never silently dropped, so they can be recovered later.
|
||||||
@@ -182,19 +213,28 @@ def _extract_batch(archives: list[Path], extract_dir: Path,
|
|||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
for i, archive in enumerate(archives):
|
for i, archive in enumerate(archives):
|
||||||
try:
|
try:
|
||||||
_extract_archive(archive, extract_dir / f'{i:05d}_{archive.name}')
|
_extract_archive(archive, extract_dir / f"{i:05d}_{archive.name}")
|
||||||
except Exception as exc: # one bad archive must not stop the build
|
except Exception as exc: # one bad archive must not stop the build
|
||||||
failures.append(f'{archive}\t{exc}')
|
failures.append(f"{archive}\t{exc}")
|
||||||
files = [p for p in extract_dir.glob('**/*') if p.is_file()]
|
files = [p for p in extract_dir.glob("**/*") if p.is_file()]
|
||||||
print(f'batch {index}: {len(archives)} archives -> {len(files)} files, '
|
print(
|
||||||
f'{len(failures)} failed ({time.time() - t0:.0f}s)', flush=True)
|
f"batch {index}: {len(archives)} archives -> {len(files)} files, "
|
||||||
|
f"{len(failures)} failed ({time.time() - t0:.0f}s)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
for failure in failures[:5]:
|
for failure in failures[:5]:
|
||||||
print(f'WARN: {failure}', flush=True)
|
print(f"WARN: {failure}", flush=True)
|
||||||
return files, failures
|
return files, failures
|
||||||
|
|
||||||
|
|
||||||
def build(source: str, dataset: str, scratch: str, batch_bytes: int = DEFAULT_BATCH_BYTES,
|
def build(
|
||||||
dedup: bool = True, extract: bool = True) -> None:
|
source: str,
|
||||||
|
dataset: str,
|
||||||
|
scratch: str,
|
||||||
|
batch_bytes: int = DEFAULT_BATCH_BYTES,
|
||||||
|
dedup: bool = True,
|
||||||
|
extract: bool = True,
|
||||||
|
) -> None:
|
||||||
"""Build the deduped query dataset from ``source`` into ``dataset``.
|
"""Build the deduped query dataset from ``source`` into ``dataset``.
|
||||||
|
|
||||||
``scratch`` (a fast disk) holds per-batch files, sort temp and build state.
|
``scratch`` (a fast disk) holds per-batch files, sort temp and build state.
|
||||||
@@ -205,63 +245,85 @@ def build(source: str, dataset: str, scratch: str, batch_bytes: int = DEFAULT_BA
|
|||||||
scratch = Path(scratch).expanduser()
|
scratch = Path(scratch).expanduser()
|
||||||
dataset.mkdir(parents=True, exist_ok=True)
|
dataset.mkdir(parents=True, exist_ok=True)
|
||||||
scratch.mkdir(parents=True, exist_ok=True)
|
scratch.mkdir(parents=True, exist_ok=True)
|
||||||
os.environ['TMPDIR'] = str(scratch) # `sort -u` spills here, not into the dataset
|
os.environ["TMPDIR"] = str(scratch) # `sort -u` spills here, not into the dataset
|
||||||
for stale in scratch.glob('extract_*'):
|
for stale in scratch.glob("extract_*"):
|
||||||
shutil.rmtree(stale, ignore_errors=True)
|
shutil.rmtree(stale, ignore_errors=True)
|
||||||
|
|
||||||
if extract and not _which('7z', '7za', 'unrar'):
|
if extract and not _which("7z", "7za", "unrar"):
|
||||||
print('WARN: no 7z/7za/unrar found; .rar/.7z archives will be skipped '
|
print(
|
||||||
'(.zip/.tar/.gz still work).', flush=True)
|
"WARN: no 7z/7za/unrar found; .rar/.7z archives will be skipped "
|
||||||
|
"(.zip/.tar/.gz still work).",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
state_path = scratch / 'build_state.json'
|
state_path = scratch / "build_state.json"
|
||||||
state = json.loads(state_path.read_text()) if state_path.exists() else {'done': [], 'stats': []}
|
state = (
|
||||||
|
json.loads(state_path.read_text())
|
||||||
|
if state_path.exists()
|
||||||
|
else {"done": [], "stats": []}
|
||||||
|
)
|
||||||
|
|
||||||
def save():
|
def save():
|
||||||
state_path.write_text(json.dumps(state, indent=2))
|
state_path.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
done = set(state['done'])
|
done = set(state["done"])
|
||||||
files = _discover(source)
|
files = _discover(source)
|
||||||
batches = list(_batches(files, batch_bytes))
|
batches = list(_batches(files, batch_bytes))
|
||||||
state.update({'batches_total': len(batches), 'batch_files': [len(b) for b in batches],
|
state.update(
|
||||||
'dataset': str(dataset), 'complete': False})
|
{
|
||||||
|
"batches_total": len(batches),
|
||||||
|
"batch_files": [len(b) for b in batches],
|
||||||
|
"dataset": str(dataset),
|
||||||
|
"complete": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
save()
|
save()
|
||||||
print(f'{len(files)} files, {len(batches)} batches, {len(done)} already done.', flush=True)
|
print(
|
||||||
|
f"{len(files)} files, {len(batches)} batches, {len(done)} already done.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
failures = scratch / 'failures.txt'
|
failures = scratch / "failures.txt"
|
||||||
extract_failures = scratch / 'extract_failures.txt'
|
extract_failures = scratch / "extract_failures.txt"
|
||||||
hashes = scratch / 'hashes.txt'
|
hashes = scratch / "hashes.txt"
|
||||||
for index, batch in enumerate(batches):
|
for index, batch in enumerate(batches):
|
||||||
if index in done:
|
if index in done:
|
||||||
continue
|
continue
|
||||||
state['phase'] = f'batch {index}'
|
state["phase"] = f"batch {index}"
|
||||||
save()
|
save()
|
||||||
success = scratch / f'success_{index:05d}.txt'
|
success = scratch / f"success_{index:05d}.txt"
|
||||||
failure = scratch / f'failure_{index:05d}.txt'
|
failure = scratch / f"failure_{index:05d}.txt"
|
||||||
extract_dir = scratch / f'extract_{index:05d}'
|
extract_dir = scratch / f"extract_{index:05d}"
|
||||||
progress_path = scratch / f'progress_{index:05d}.txt'
|
progress_path = scratch / f"progress_{index:05d}.txt"
|
||||||
input_bytes = sum(f.stat().st_size for f in batch)
|
input_bytes = sum(f.stat().st_size for f in batch)
|
||||||
|
|
||||||
if not success.exists():
|
if not success.exists():
|
||||||
progress_path.unlink(missing_ok=True)
|
progress_path.unlink(missing_ok=True)
|
||||||
parsed = set()
|
parsed = set()
|
||||||
if success.exists() and progress_path.exists():
|
if success.exists() and progress_path.exists():
|
||||||
with open(progress_path, encoding='utf8', errors='ignore') as r:
|
with open(progress_path, encoding="utf8", errors="ignore") as r:
|
||||||
parsed = {line.rstrip('\n') for line in r}
|
parsed = {line.rstrip("\n") for line in r}
|
||||||
|
|
||||||
inputs = [f for f in batch if not _is_archive(f)]
|
inputs = [f for f in batch if not _is_archive(f)]
|
||||||
if extract:
|
if extract:
|
||||||
extracted, failures_ = _extract_batch(
|
extracted, failures_ = _extract_batch(
|
||||||
[f for f in batch if _is_archive(f)], extract_dir, index)
|
[f for f in batch if _is_archive(f)], extract_dir, index
|
||||||
|
)
|
||||||
inputs.extend(extracted)
|
inputs.extend(extracted)
|
||||||
if failures_:
|
if failures_:
|
||||||
with open(extract_failures, 'a', encoding='utf8') as w:
|
with open(extract_failures, "a", encoding="utf8") as w:
|
||||||
w.write('\n'.join(failures_) + '\n')
|
w.write("\n".join(failures_) + "\n")
|
||||||
pending = [f for f in inputs if str(f) not in parsed]
|
pending = [f for f in inputs if str(f) not in parsed]
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
if pending:
|
if pending:
|
||||||
parse_files(pending, str(success), str(failure),
|
parse_files(
|
||||||
progress_filename=str(progress_path), hashes_filename=str(hashes))
|
pending,
|
||||||
|
str(success),
|
||||||
|
str(failure),
|
||||||
|
progress_filename=str(progress_path),
|
||||||
|
hashes_filename=str(hashes),
|
||||||
|
)
|
||||||
if success.exists():
|
if success.exists():
|
||||||
pairs = _count_lines(success)
|
pairs = _count_lines(success)
|
||||||
rejects = _count_lines(failure) if failure.exists() else 0
|
rejects = _count_lines(failure) if failure.exists() else 0
|
||||||
@@ -269,7 +331,7 @@ def build(source: str, dataset: str, scratch: str, batch_bytes: int = DEFAULT_BA
|
|||||||
split_file(str(success), str(dataset))
|
split_file(str(success), str(dataset))
|
||||||
success.unlink(missing_ok=True)
|
success.unlink(missing_ok=True)
|
||||||
if failure.exists():
|
if failure.exists():
|
||||||
with open(failures, 'ab') as w, open(failure, 'rb') as r:
|
with open(failures, "ab") as w, open(failure, "rb") as r:
|
||||||
shutil.copyfileobj(r, w, length=1024 * 1024)
|
shutil.copyfileobj(r, w, length=1024 * 1024)
|
||||||
failure.unlink(missing_ok=True)
|
failure.unlink(missing_ok=True)
|
||||||
progress_path.unlink(missing_ok=True)
|
progress_path.unlink(missing_ok=True)
|
||||||
@@ -278,79 +340,91 @@ def build(source: str, dataset: str, scratch: str, batch_bytes: int = DEFAULT_BA
|
|||||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
stat = {'batch': index, 'files': len(batch), 'input_bytes': input_bytes,
|
stat = {
|
||||||
'pairs': pairs, 'rejects': rejects, 'total_s': round(elapsed, 1),
|
"batch": index,
|
||||||
'mb_per_s': round(input_bytes / 1e6 / elapsed, 2) if elapsed else 0.0}
|
"files": len(batch),
|
||||||
state['stats'].append(stat)
|
"input_bytes": input_bytes,
|
||||||
|
"pairs": pairs,
|
||||||
|
"rejects": rejects,
|
||||||
|
"total_s": round(elapsed, 1),
|
||||||
|
"mb_per_s": round(input_bytes / 1e6 / elapsed, 2) if elapsed else 0.0,
|
||||||
|
}
|
||||||
|
state["stats"].append(stat)
|
||||||
done.add(index)
|
done.add(index)
|
||||||
state['done'] = sorted(done)
|
state["done"] = sorted(done)
|
||||||
save()
|
save()
|
||||||
(scratch / 'stats.md').write_text(_stats_markdown(state))
|
(scratch / "stats.md").write_text(_stats_markdown(state))
|
||||||
print('BATCH ' + json.dumps(stat), flush=True)
|
print("BATCH " + json.dumps(stat), flush=True)
|
||||||
|
|
||||||
if hashes.exists():
|
if hashes.exists():
|
||||||
sort_unique(hashes, hashes) # de-duplicate collected hashed passwords
|
sort_unique(hashes, hashes) # de-duplicate collected hashed passwords
|
||||||
state['hashes'] = _count_lines(hashes)
|
state["hashes"] = _count_lines(hashes)
|
||||||
save()
|
save()
|
||||||
|
|
||||||
if dedup and not state.get('deduped'):
|
if dedup and not state.get("deduped"):
|
||||||
state['phase'] = 'deduping'
|
state["phase"] = "deduping"
|
||||||
save()
|
save()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
API.sort(str(dataset)) # per-bucket sort -u dedups across batches
|
API.sort(str(dataset)) # per-bucket sort -u dedups across batches
|
||||||
state['dedup_s'] = round(time.time() - t0, 1)
|
state["dedup_s"] = round(time.time() - t0, 1)
|
||||||
state['deduped'] = True
|
state["deduped"] = True
|
||||||
save()
|
save()
|
||||||
|
|
||||||
if state.get('unique_records') is None:
|
if state.get("unique_records") is None:
|
||||||
state['unique_records'] = sum(_count_lines(p) for p in dataset.glob('**/*') if p.is_file())
|
state["unique_records"] = sum(
|
||||||
|
_count_lines(p) for p in dataset.glob("**/*") if p.is_file()
|
||||||
|
)
|
||||||
save()
|
save()
|
||||||
|
|
||||||
state['phase'] = 'complete'
|
state["phase"] = "complete"
|
||||||
state['complete'] = True
|
state["complete"] = True
|
||||||
save()
|
save()
|
||||||
(scratch / 'stats.md').write_text(_stats_markdown(state))
|
(scratch / "stats.md").write_text(_stats_markdown(state))
|
||||||
print('BUILD COMPLETE', flush=True)
|
print("BUILD COMPLETE", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def build_status(scratch: str) -> dict:
|
def build_status(scratch: str) -> dict:
|
||||||
"""Return a compact, machine-readable snapshot of a build under ``scratch``."""
|
"""Return a compact, machine-readable snapshot of a build under ``scratch``."""
|
||||||
scratch = Path(scratch).expanduser()
|
scratch = Path(scratch).expanduser()
|
||||||
state_path = scratch / 'build_state.json'
|
state_path = scratch / "build_state.json"
|
||||||
if not state_path.exists():
|
if not state_path.exists():
|
||||||
return {'error': f'no build_state.json in {scratch}'}
|
return {"error": f"no build_state.json in {scratch}"}
|
||||||
state = json.loads(state_path.read_text())
|
state = json.loads(state_path.read_text())
|
||||||
done = state.get('done', [])
|
done = state.get("done", [])
|
||||||
stats = state.get('stats', [])
|
stats = state.get("stats", [])
|
||||||
total = state.get('batches_total')
|
total = state.get("batches_total")
|
||||||
totals = _totals(stats)
|
totals = _totals(stats)
|
||||||
result = {
|
result = {
|
||||||
'phase': state.get('phase', 'unknown'),
|
"phase": state.get("phase", "unknown"),
|
||||||
'complete': state.get('complete', False),
|
"complete": state.get("complete", False),
|
||||||
'batches_done': len(done),
|
"batches_done": len(done),
|
||||||
'batches_total': total,
|
"batches_total": total,
|
||||||
'deduped': state.get('deduped', False),
|
"deduped": state.get("deduped", False),
|
||||||
'input_gb': round(totals['input_bytes'] / 1e9, 2),
|
"input_gb": round(totals["input_bytes"] / 1e9, 2),
|
||||||
'pairs': totals['pairs'],
|
"pairs": totals["pairs"],
|
||||||
'rejects': totals['rejects'],
|
"rejects": totals["rejects"],
|
||||||
'hashes': state.get('hashes'),
|
"hashes": state.get("hashes"),
|
||||||
'unique_records': state.get('unique_records'),
|
"unique_records": state.get("unique_records"),
|
||||||
}
|
}
|
||||||
current = next((i for i in range(total or 0) if i not in done), None)
|
current = next((i for i in range(total or 0) if i not in done), None)
|
||||||
if current is not None:
|
if current is not None:
|
||||||
progress = scratch / f'progress_{current:05d}.txt'
|
progress = scratch / f"progress_{current:05d}.txt"
|
||||||
result['current_batch'] = current
|
result["current_batch"] = current
|
||||||
result['current_files_done'] = sum(1 for _ in progress.open()) if progress.exists() else 0
|
result["current_files_done"] = (
|
||||||
batch_files = state.get('batch_files') or []
|
sum(1 for _ in progress.open()) if progress.exists() else 0
|
||||||
|
)
|
||||||
|
batch_files = state.get("batch_files") or []
|
||||||
if current < len(batch_files):
|
if current < len(batch_files):
|
||||||
result['current_files_total'] = batch_files[current]
|
result["current_files_total"] = batch_files[current]
|
||||||
for prefix in ('success', 'failure'):
|
for prefix in ("success", "failure"):
|
||||||
result[f'{prefix}_bytes'] = sum(p.stat().st_size for p in scratch.glob(f'{prefix}_*.txt'))
|
result[f"{prefix}_bytes"] = sum(
|
||||||
hashes_file = scratch / 'hashes.txt'
|
p.stat().st_size for p in scratch.glob(f"{prefix}_*.txt")
|
||||||
result['hashes_bytes'] = hashes_file.stat().st_size if hashes_file.exists() else 0
|
)
|
||||||
dataset = state.get('dataset')
|
hashes_file = scratch / "hashes.txt"
|
||||||
|
result["hashes_bytes"] = hashes_file.stat().st_size if hashes_file.exists() else 0
|
||||||
|
dataset = state.get("dataset")
|
||||||
if dataset and Path(dataset).exists():
|
if dataset and Path(dataset).exists():
|
||||||
files = [p for p in Path(dataset).glob('**/*') if p.is_file()]
|
files = [p for p in Path(dataset).glob("**/*") if p.is_file()]
|
||||||
result['dataset_files'] = len(files)
|
result["dataset_files"] = len(files)
|
||||||
result['dataset_bytes'] = sum(p.stat().st_size for p in files)
|
result["dataset_bytes"] = sum(p.stat().st_size for p in files)
|
||||||
return result
|
return result
|
||||||
+110
-94
@@ -16,133 +16,149 @@ def cli():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Converts a large FILE to a query friendly folder OUT (e.g. a/b/c). '
|
@cli.command(
|
||||||
'Use RESTART_FROM to resume from the i-th line.')
|
short_help="Converts a large FILE to a query friendly folder OUT (e.g. a/b/c). "
|
||||||
@click.option('--file', required=True, type=input_file())
|
"Use RESTART_FROM to resume from the i-th line."
|
||||||
@click.option('--out', required=True, type=output_dir())
|
)
|
||||||
@click.option('--restart_from', default=0, type=int, show_default=True)
|
@click.option("--file", required=True, type=input_file())
|
||||||
|
@click.option("--out", required=True, type=output_dir())
|
||||||
|
@click.option("--restart_from", default=0, type=int, show_default=True)
|
||||||
def split(file, out, restart_from):
|
def split(file, out, restart_from):
|
||||||
split_file(file, out, restart_from)
|
split_file(file, out, restart_from)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='chunk large TXT files into smaller files.')
|
@cli.command(
|
||||||
@click.option('--path', required=True, type=input_dir())
|
short_help="Sorts and de-duplicates every file in a query friendly folder PATH."
|
||||||
@click.option('--size', default=50, type=int, show_default=True) # MB.
|
)
|
||||||
def chunk(path, size):
|
@click.option("--path", required=True, type=input_dir())
|
||||||
API.chunk_files(path, size)
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Sorts a query friendly folder PATH. Target is itself.')
|
|
||||||
@click.option('--path', required=True, type=input_dir())
|
|
||||||
def sort(path):
|
def sort(path):
|
||||||
API.sort(path)
|
API.sort(path)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Cleans a query friendly folder PATH. Move incorrect records and sort the files.')
|
@cli.command(
|
||||||
@click.option('--path', required=True, type=input_dir())
|
short_help="Parses an unstructured folder PATH into SUCCESS_FILE, FAILURE_FILE "
|
||||||
def clean(path):
|
"and (optionally) HASHES_FILE."
|
||||||
API.clean(path)
|
)
|
||||||
|
@click.option("--path", required=True, type=input_dir())
|
||||||
|
@click.option("--success_file", required=True, type=output_file())
|
||||||
@cli.command(short_help='Infers passwords of a list of emails defined in FILE with a query friendly folder DATASET.')
|
@click.option("--failure_file", required=True, type=output_file())
|
||||||
@click.option('--file', required=True, type=input_file())
|
@click.option(
|
||||||
@click.option('--dataset', required=True)
|
"--hashes_file",
|
||||||
def test(file: str, dataset: str):
|
required=False,
|
||||||
API.test(file, dataset)
|
type=output_file(),
|
||||||
|
help="Collect hashed passwords here for later `crack`.",
|
||||||
|
)
|
||||||
@cli.command(short_help='Parses an unstructured folder PATH of many files and generates two files: '
|
|
||||||
'SUCCESS_FILE and FAILURE_FILE. All valid email:password will go to SUCCESS_FILE.')
|
|
||||||
@click.option('--path', required=True, type=input_dir())
|
|
||||||
@click.option('--success_file', required=True, type=output_file())
|
|
||||||
@click.option('--failure_file', required=True, type=output_file())
|
|
||||||
@click.option('--hashes_file', required=False, type=output_file(),
|
|
||||||
help='Collect hashed passwords here for later `crack`.')
|
|
||||||
def parse(path, success_file, failure_file, hashes_file):
|
def parse(path, success_file, failure_file, hashes_file):
|
||||||
parse_to_files(path, success_file, failure_file, hashes_file)
|
parse_to_files(path, success_file, failure_file, hashes_file)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Merges dataset SRC into dataset DEST. Duplicates are removed in the second pass.')
|
@cli.command(short_help="Merges dataset SRC into dataset DEST, then sorts/dedups DEST.")
|
||||||
@click.option('--src', required=True, type=input_dir())
|
@click.option("--src", required=True, type=input_dir())
|
||||||
@click.option('--dest', required=True, type=input_dir())
|
@click.option("--dest", required=True, type=input_dir())
|
||||||
def merge(src: str, dest: str):
|
def merge(src: str, dest: str):
|
||||||
API.merge(src, dest)
|
API.merge(src, dest)
|
||||||
API.clean(dest)
|
API.sort(dest)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Evaluates some metrics such as precision/recall (e.g. is OLD into NEW).')
|
@cli.command(
|
||||||
@click.option('--old', required=True, type=input_dir())
|
short_help="Searches a dataset by exact email or wildcard pattern "
|
||||||
@click.option('--new', required=True, type=input_dir())
|
'(e.g. "*@gmail.com", "john*", "jo?n@*").'
|
||||||
def evaluate(old: str, new: str):
|
)
|
||||||
API.evaluate(old, new)
|
@click.option("--dataset", required=True, type=input_dir())
|
||||||
|
@click.option("--pattern", required=True)
|
||||||
|
|
||||||
@cli.command(short_help='Filters lines less than N and more than M.')
|
|
||||||
@click.option('--file', required=True, type=input_file())
|
|
||||||
@click.option('--out', required=True, type=output_file())
|
|
||||||
@click.option('--less_than', required=True, type=int)
|
|
||||||
@click.option('--more_than', required=True, type=int)
|
|
||||||
def filter_lines(file: str, out: str, less_than: int, more_than: int):
|
|
||||||
API.filter_lines(file, out, less_than, more_than)
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Searches a dataset by exact email or wildcard pattern '
|
|
||||||
'(e.g. "*@gmail.com", "john*", "jo?n@*").')
|
|
||||||
@click.option('--dataset', required=True, type=input_dir())
|
|
||||||
@click.option('--pattern', required=True)
|
|
||||||
def query(dataset: str, pattern: str):
|
def query(dataset: str, pattern: str):
|
||||||
if '*' in pattern or '?' in pattern:
|
if "*" in pattern or "?" in pattern:
|
||||||
results = QueryAPI.wildcard(dataset, pattern)
|
for line in QueryAPI.wildcard(dataset, pattern):
|
||||||
|
click.echo(line)
|
||||||
else:
|
else:
|
||||||
results = QueryAPI.query(dataset, pattern)
|
results = QueryAPI.query(dataset, pattern)
|
||||||
if results:
|
if results:
|
||||||
click.echo('\n'.join(results))
|
click.echo("\n".join(results))
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Exports a dataset as JSON Lines for OpenSearch/bulk ingest.')
|
@cli.command(short_help="Exports a dataset as JSON Lines for OpenSearch/bulk ingest.")
|
||||||
@click.option('--dataset', required=True, type=input_dir())
|
@click.option("--dataset", required=True, type=input_dir())
|
||||||
@click.option('--out', required=True, type=output_file())
|
@click.option("--out", required=True, type=output_file())
|
||||||
def export(dataset: str, out: str):
|
def export(dataset: str, out: str):
|
||||||
API.export_jsonl(dataset, out)
|
API.export_jsonl(dataset, out)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Dictionary-attacks collected MD5/SHA1/SHA256 hashes.')
|
@cli.command(short_help="Dictionary-attacks collected MD5/SHA1/SHA256 hashes.")
|
||||||
@click.option('--hashes', 'hashes_path', required=True, type=input_file())
|
@click.option("--hashes", "hashes_path", required=True, type=input_file())
|
||||||
@click.option('--wordlist', multiple=True, type=click.Path(),
|
@click.option(
|
||||||
help='Plain wordlist(s); missing files are downloaded from rockyou.')
|
"--wordlist",
|
||||||
@click.option('--passwords-from', multiple=True, type=click.Path(),
|
multiple=True,
|
||||||
help='Dataset file/folder whose plaintext passwords join the wordlist.')
|
type=click.Path(),
|
||||||
@click.option('--out', required=True, type=output_file())
|
help="Plain wordlist(s); missing files are downloaded from rockyou.",
|
||||||
@click.option('--workers', default=0, type=int, show_default=True,
|
)
|
||||||
help='0 = all CPUs.')
|
@click.option(
|
||||||
def crack(hashes_path: str, wordlist: tuple, passwords_from: tuple, out: str, workers: int):
|
"--passwords-from",
|
||||||
|
multiple=True,
|
||||||
|
type=click.Path(),
|
||||||
|
help="Dataset file/folder whose plaintext passwords join the wordlist.",
|
||||||
|
)
|
||||||
|
@click.option("--out", required=True, type=output_file())
|
||||||
|
@click.option("--workers", default=0, type=int, show_default=True, help="0 = all CPUs.")
|
||||||
|
def crack(
|
||||||
|
hashes_path: str, wordlist: tuple, passwords_from: tuple, out: str, workers: int
|
||||||
|
):
|
||||||
from collectionparser.crack import crack as run_crack
|
from collectionparser.crack import crack as run_crack
|
||||||
run_crack(hashes_path, out, wordlists=wordlist, passwords_from=passwords_from,
|
|
||||||
workers=workers or None)
|
run_crack(
|
||||||
|
hashes_path,
|
||||||
|
out,
|
||||||
|
wordlists=wordlist,
|
||||||
|
passwords_from=passwords_from,
|
||||||
|
workers=workers or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Print build/dataset progress as JSON (for scripts and agents).')
|
@cli.command(
|
||||||
@click.option('--scratch', required=True, type=click.Path(exists=True, file_okay=False, dir_okay=True))
|
short_help="Print build/dataset progress as JSON (for scripts and agents)."
|
||||||
@click.option('--pretty', is_flag=True, help='Indent the JSON.')
|
)
|
||||||
|
@click.option(
|
||||||
|
"--scratch",
|
||||||
|
required=True,
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||||
|
)
|
||||||
|
@click.option("--pretty", is_flag=True, help="Indent the JSON.")
|
||||||
def status(scratch: str, pretty: bool):
|
def status(scratch: str, pretty: bool):
|
||||||
from collectionparser.build import build_status
|
from collectionparser.build import build_status
|
||||||
click.echo(json.dumps(build_status(scratch), indent=2 if pretty else None, default=str))
|
|
||||||
|
click.echo(
|
||||||
|
json.dumps(build_status(scratch), indent=2 if pretty else None, default=str)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help='Builds the deduped query dataset from a raw source folder in batches. '
|
@cli.command(
|
||||||
'Stop and rerun to resume.')
|
short_help="Builds the deduped query dataset from a raw source folder in batches. "
|
||||||
@click.option('--source', required=True, type=input_dir())
|
"Stop and rerun to resume."
|
||||||
@click.option('--dataset', required=True, type=output_dir())
|
)
|
||||||
@click.option('--scratch', required=True, type=output_dir())
|
@click.option("--source", required=True, type=input_dir())
|
||||||
@click.option('--batch_gb', default=120, type=int, show_default=True)
|
@click.option("--dataset", required=True, type=output_dir())
|
||||||
@click.option('--dedup/--no-dedup', default=True)
|
@click.option("--scratch", required=True, type=output_dir())
|
||||||
@click.option('--extract/--no-extract', default=True, show_default=True,
|
@click.option("--batch_gb", default=120, type=int, show_default=True)
|
||||||
help='Extract archives (.rar/.zip/.7z/.tar.*/.gz) found in the source.')
|
@click.option("--dedup/--no-dedup", default=True)
|
||||||
def build(source: str, dataset: str, scratch: str, batch_gb: int, dedup: bool, extract: bool):
|
@click.option(
|
||||||
|
"--extract/--no-extract",
|
||||||
|
default=True,
|
||||||
|
show_default=True,
|
||||||
|
help="Extract archives (.rar/.zip/.7z/.tar.*/.gz) found in the source.",
|
||||||
|
)
|
||||||
|
def build(
|
||||||
|
source: str, dataset: str, scratch: str, batch_gb: int, dedup: bool, extract: bool
|
||||||
|
):
|
||||||
from collectionparser.build import build as run_build
|
from collectionparser.build import build as run_build
|
||||||
run_build(source, dataset, scratch, batch_bytes=batch_gb * 1024 ** 3,
|
|
||||||
dedup=dedup, extract=extract)
|
run_build(
|
||||||
|
source,
|
||||||
|
dataset,
|
||||||
|
scratch,
|
||||||
|
batch_bytes=batch_gb * 1024**3,
|
||||||
|
dedup=dedup,
|
||||||
|
extract=extract,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
+77
-43
@@ -6,12 +6,16 @@ through ``sort -u`` so memory stays bounded for arbitrarily large inputs.
|
|||||||
|
|
||||||
Only unsalted MD5/SHA1/SHA256 are attackable; bcrypt is intentionally not
|
Only unsalted MD5/SHA1/SHA256 are attackable; bcrypt is intentionally not
|
||||||
supported (a wordlist attack on it is infeasible).
|
supported (a wordlist attack on it is infeasible).
|
||||||
ponytail: the target index is rebuilt per worker (RAM x workers); a shared mmap
|
|
||||||
index is the upgrade path if the hash file is enormous.
|
Memory: the target index is built once and shared with the workers by forking
|
||||||
|
(copy-on-write), so it costs one copy, not one per worker. ponytail: platforms
|
||||||
|
without ``fork`` fall back to rebuilding it in each worker (RAM x workers); a
|
||||||
|
shared mmap index is the upgrade path for enormous hash files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import gzip
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -21,11 +25,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from collectionparser.api import find_files
|
from collectionparser.api import find_files
|
||||||
|
|
||||||
DEFAULT_WORDLIST_URL = 'https://weakpass.com/download/90/rockyou.txt.gz'
|
DEFAULT_WORDLIST_URL = "https://weakpass.com/download/90/rockyou.txt.gz"
|
||||||
|
|
||||||
# hex digest length -> hashlib constructor
|
# hex digest length -> hashlib constructor
|
||||||
_ALGOS = {32: hashlib.md5, 40: hashlib.sha1, 64: hashlib.sha256}
|
_ALGOS = {32: hashlib.md5, 40: hashlib.sha1, 64: hashlib.sha256}
|
||||||
_HEX_DIGITS = frozenset(b'0123456789abcdefABCDEF')
|
_HEX_DIGITS = frozenset(b"0123456789abcdefABCDEF")
|
||||||
|
|
||||||
|
# Filled once in `crack`; forked workers inherit it instead of rebuilding it.
|
||||||
|
_TARGETS: dict[int, dict[bytes, list[bytes]]] | None = None
|
||||||
|
|
||||||
|
|
||||||
def download_rockyou(dest: Path) -> Path:
|
def download_rockyou(dest: Path) -> Path:
|
||||||
@@ -34,58 +41,68 @@ def download_rockyou(dest: Path) -> Path:
|
|||||||
if dest.exists() and dest.stat().st_size:
|
if dest.exists() and dest.stat().st_size:
|
||||||
return dest
|
return dest
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
print(f'Downloading wordlist {DEFAULT_WORDLIST_URL} ...', flush=True)
|
print(f"Downloading wordlist {DEFAULT_WORDLIST_URL} ...", flush=True)
|
||||||
with urllib.request.urlopen(DEFAULT_WORDLIST_URL) as response, \
|
with (
|
||||||
gzip.GzipFile(fileobj=response) as gz, open(dest, 'wb') as out:
|
urllib.request.urlopen(DEFAULT_WORDLIST_URL) as response,
|
||||||
|
gzip.GzipFile(fileobj=response) as gz,
|
||||||
|
open(dest, "wb") as out,
|
||||||
|
):
|
||||||
shutil.copyfileobj(gz, out, length=1 << 20)
|
shutil.copyfileobj(gz, out, length=1 << 20)
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _write_passwords(source: Path, out) -> None:
|
def _write_passwords(source: Path, out) -> None:
|
||||||
"""Append the password column of an ``email:password`` file to ``out``."""
|
"""Append the password column of an ``email:password`` file to ``out``."""
|
||||||
with open(source, encoding='utf8', errors='ignore') as reader:
|
with open(source, encoding="utf8", errors="ignore") as reader:
|
||||||
for line in reader:
|
for line in reader:
|
||||||
out.write(line.partition(':')[2].encode('utf8'))
|
out.write(line.partition(":")[2].encode("utf8"))
|
||||||
|
|
||||||
|
|
||||||
def prepare_candidates(wordlists, passwords_from, work_dir: Path) -> Path:
|
def prepare_candidates(wordlists, passwords_from, work_dir: Path) -> Path:
|
||||||
"""Return a single, de-duplicated wordlist built from all candidate sources."""
|
"""Return a single, de-duplicated wordlist built from all candidate sources."""
|
||||||
work_dir = Path(work_dir)
|
work_dir = Path(work_dir)
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
wordlists = [Path(w) for w in wordlists] or [work_dir / 'rockyou.txt']
|
wordlists = [Path(w) for w in wordlists] or [work_dir / "rockyou.txt"]
|
||||||
wordlists = [w if w.exists() else download_rockyou(w) for w in wordlists]
|
wordlists = [w if w.exists() else download_rockyou(w) for w in wordlists]
|
||||||
if len(wordlists) == 1 and not passwords_from:
|
if len(wordlists) == 1 and not passwords_from:
|
||||||
return wordlists[0]
|
return wordlists[0]
|
||||||
|
|
||||||
unique = work_dir / 'candidates.unique.txt'
|
unique = work_dir / "candidates.unique.txt"
|
||||||
env = {**os.environ, 'LC_ALL': 'C', 'TMPDIR': str(work_dir)}
|
env = {**os.environ, "LC_ALL": "C", "TMPDIR": str(work_dir)}
|
||||||
process = subprocess.Popen(['sort', '-u'], stdin=subprocess.PIPE,
|
with (
|
||||||
stdout=open(unique, 'wb'), env=env) # noqa: SIM115
|
open(unique, "wb") as sink_file,
|
||||||
with process.stdin as sink:
|
subprocess.Popen(
|
||||||
for wordlist in wordlists:
|
["sort", "-u"], stdin=subprocess.PIPE, stdout=sink_file, env=env
|
||||||
with open(wordlist, 'rb') as reader:
|
) as process,
|
||||||
shutil.copyfileobj(reader, sink, length=1 << 20)
|
):
|
||||||
for source in passwords_from:
|
with process.stdin as sink:
|
||||||
root = Path(source).expanduser()
|
for wordlist in wordlists:
|
||||||
for path in ([root] if root.is_file() else find_files(root)):
|
with open(wordlist, "rb") as reader:
|
||||||
_write_passwords(path, sink)
|
shutil.copyfileobj(reader, sink, length=1 << 20)
|
||||||
process.wait()
|
for source in passwords_from:
|
||||||
if process.returncode != 0:
|
root = Path(source).expanduser()
|
||||||
raise RuntimeError('failed to build the candidate wordlist')
|
for path in [root] if root.is_file() else find_files(root):
|
||||||
|
_write_passwords(path, sink)
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise RuntimeError("failed to build the candidate wordlist")
|
||||||
return unique
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def load_targets(hashes_path: str | Path) -> dict[int, dict[bytes, list[bytes]]]:
|
def load_targets(hashes_path: str | Path) -> dict[int, dict[bytes, list[bytes]]]:
|
||||||
"""Return ``{hex_length: {digest_bytes: [email, ...]}}`` from a hashes file."""
|
"""Return ``{hex_length: {digest_bytes: [email, ...]}}`` from a hashes file."""
|
||||||
targets: dict[int, dict[bytes, list[bytes]]] = {}
|
targets: dict[int, dict[bytes, list[bytes]]] = {}
|
||||||
with open(hashes_path, 'rb') as reader:
|
with open(hashes_path, "rb") as reader:
|
||||||
for raw in reader:
|
for raw in reader:
|
||||||
line = raw.rstrip(b'\r\n')
|
line = raw.rstrip(b"\r\n")
|
||||||
email, separator, digest = line.rpartition(b':')
|
email, separator, digest = line.rpartition(b":")
|
||||||
if not separator or len(digest) not in _ALGOS or not _HEX_DIGITS.issuperset(digest):
|
if (
|
||||||
|
not separator
|
||||||
|
or len(digest) not in _ALGOS
|
||||||
|
or not _HEX_DIGITS.issuperset(digest)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
key = bytes.fromhex(digest.decode('ascii'))
|
key = bytes.fromhex(digest.decode("ascii"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
targets.setdefault(len(digest), {}).setdefault(key, []).append(email)
|
targets.setdefault(len(digest), {}).setdefault(key, []).append(email)
|
||||||
@@ -95,44 +112,61 @@ def load_targets(hashes_path: str | Path) -> dict[int, dict[bytes, list[bytes]]]
|
|||||||
def _scan_stripe(args: tuple) -> list[bytes]:
|
def _scan_stripe(args: tuple) -> list[bytes]:
|
||||||
"""Attack one residue class of the wordlist (``index`` of every ``total``)."""
|
"""Attack one residue class of the wordlist (``index`` of every ``total``)."""
|
||||||
hashes_path, wordlist_path, index, total = args
|
hashes_path, wordlist_path, index, total = args
|
||||||
targets = load_targets(hashes_path)
|
targets = _TARGETS if _TARGETS is not None else load_targets(hashes_path)
|
||||||
checks = [(targets[length], _ALGOS[length]) for length in targets]
|
checks = [(targets[length], _ALGOS[length]) for length in targets]
|
||||||
matches: list[bytes] = []
|
matches: list[bytes] = []
|
||||||
with open(wordlist_path, 'rb') as reader:
|
with open(wordlist_path, "rb") as reader:
|
||||||
for n, raw in enumerate(reader):
|
for n, raw in enumerate(reader):
|
||||||
if n % total != index:
|
if n % total != index:
|
||||||
continue
|
continue
|
||||||
word = raw.rstrip(b'\r\n')
|
word = raw.rstrip(b"\r\n")
|
||||||
if not word:
|
if not word:
|
||||||
continue
|
continue
|
||||||
for target, constructor in checks:
|
for target, constructor in checks:
|
||||||
emails = target.get(constructor(word).digest())
|
emails = target.get(constructor(word).digest())
|
||||||
if emails:
|
if emails:
|
||||||
matches.extend(email + b':' + word + b'\n' for email in emails)
|
matches.extend(email + b":" + word + b"\n" for email in emails)
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
def crack(hashes_path: str, out_path: str, wordlists=(), passwords_from=(),
|
def crack(
|
||||||
workers: int | None = None, work_dir: str | None = None) -> int:
|
hashes_path: str,
|
||||||
|
out_path: str,
|
||||||
|
wordlists=(),
|
||||||
|
passwords_from=(),
|
||||||
|
workers: int | None = None,
|
||||||
|
work_dir: str | None = None,
|
||||||
|
) -> int:
|
||||||
"""Attack the hashes with the candidate wordlists; write ``email:password``."""
|
"""Attack the hashes with the candidate wordlists; write ``email:password``."""
|
||||||
targets = load_targets(hashes_path)
|
global _TARGETS
|
||||||
|
_TARGETS = load_targets(hashes_path)
|
||||||
|
targets = _TARGETS
|
||||||
if not targets:
|
if not targets:
|
||||||
print('No crackable MD5/SHA1/SHA256 hashes found.')
|
print("No crackable MD5/SHA1/SHA256 hashes found.")
|
||||||
return 0
|
return 0
|
||||||
work_dir = Path(work_dir) if work_dir else Path(out_path).parent
|
work_dir = Path(work_dir) if work_dir else Path(out_path).parent
|
||||||
wordlist = prepare_candidates(list(wordlists), list(passwords_from), work_dir)
|
wordlist = prepare_candidates(list(wordlists), list(passwords_from), work_dir)
|
||||||
|
|
||||||
workers = max(1, workers or os.cpu_count() or 1)
|
workers = max(1, workers or os.cpu_count() or 1)
|
||||||
n_hashes = sum(len(emails) for by_digest in targets.values() for emails in by_digest.values())
|
n_hashes = sum(
|
||||||
print(f'Attacking {n_hashes:,} hashes with {wordlist.name} on {workers} worker(s).', flush=True)
|
len(emails) for by_digest in targets.values() for emails in by_digest.values()
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Attacking {n_hashes:,} hashes with {wordlist.name} on {workers} worker(s).",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
jobs = [(str(hashes_path), str(wordlist), i, workers) for i in range(workers)]
|
jobs = [(str(hashes_path), str(wordlist), i, workers) for i in range(workers)]
|
||||||
total = 0
|
total = 0
|
||||||
with open(out_path, 'wb') as writer:
|
with open(out_path, "wb") as writer:
|
||||||
if workers == 1:
|
if workers == 1:
|
||||||
results = map(_scan_stripe, jobs)
|
results = map(_scan_stripe, jobs)
|
||||||
else:
|
else:
|
||||||
executor = ProcessPoolExecutor(max_workers=workers)
|
# fork shares the parent's _TARGETS by copy-on-write.
|
||||||
|
context = (
|
||||||
|
multiprocessing.get_context("fork") if hasattr(os, "fork") else None
|
||||||
|
)
|
||||||
|
executor = ProcessPoolExecutor(max_workers=workers, mp_context=context)
|
||||||
results = executor.map(_scan_stripe, jobs)
|
results = executor.map(_scan_stripe, jobs)
|
||||||
try:
|
try:
|
||||||
for matches in results:
|
for matches in results:
|
||||||
@@ -142,5 +176,5 @@ def crack(hashes_path: str, out_path: str, wordlists=(), passwords_from=(),
|
|||||||
finally:
|
finally:
|
||||||
if workers != 1:
|
if workers != 1:
|
||||||
executor.shutdown()
|
executor.shutdown()
|
||||||
print(f'Cracked {total:,} hashes -> {out_path}.')
|
print(f"Cracked {total:,} hashes -> {out_path}.")
|
||||||
return total
|
return total
|
||||||
+96
-64
@@ -11,30 +11,31 @@ from time import time
|
|||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from collectionparser.utils import PasswordType, filter_printable, shuffle
|
from collectionparser.utils import PasswordType, filter_printable, find_files, shuffle
|
||||||
|
|
||||||
# Separators tried in order; the duplicate ',' of the original is dropped.
|
# Separators tried in order; the duplicate ',' of the original is dropped.
|
||||||
VALID_SEPARATORS = (':', ';', ' ', '\t', ' ', ' ', ', ', ',', ';;', '::')
|
VALID_SEPARATORS = (":", ";", " ", "\t", " ", " ", ", ", ",", ";;", "::")
|
||||||
|
|
||||||
# Same shape as the old `validate_email` dependency: rejects '<', '/', '>' etc.
|
# Same shape as the old `validate_email` dependency: rejects '<', '/', '>' etc.
|
||||||
_EMAIL_RE = re.compile(
|
_EMAIL_RE = re.compile(
|
||||||
r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
|
r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
|
||||||
r"@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$",
|
r"@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE,
|
||||||
_IP_RE = re.compile(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}')
|
)
|
||||||
|
_IP_RE = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
|
||||||
_HEX = frozenset(string.hexdigits)
|
_HEX = frozenset(string.hexdigits)
|
||||||
_HASH_TYPES = {32: PasswordType.MD5, 40: PasswordType.SHA1, 64: PasswordType.SHA256}
|
_HASH_TYPES = {32: PasswordType.MD5, 40: PasswordType.SHA1, 64: PasswordType.SHA256}
|
||||||
|
|
||||||
|
|
||||||
def valid_email(candidate: str) -> bool:
|
def valid_email(candidate: str) -> bool:
|
||||||
return candidate.count('@') == 1 and _EMAIL_RE.match(candidate) is not None
|
return candidate.count("@") == 1 and _EMAIL_RE.match(candidate) is not None
|
||||||
|
|
||||||
|
|
||||||
def password_type(password: str) -> PasswordType:
|
def password_type(password: str) -> PasswordType:
|
||||||
hash_type = _HASH_TYPES.get(len(password))
|
hash_type = _HASH_TYPES.get(len(password))
|
||||||
if hash_type is not None and _HEX.issuperset(password):
|
if hash_type is not None and _HEX.issuperset(password):
|
||||||
return hash_type
|
return hash_type
|
||||||
if 50 < len(password) < 70 and password[:4] in ('$2a$', '$2b$', '$2y$'):
|
if 50 < len(password) < 70 and password[:4] in ("$2a$", "$2b$", "$2y$"):
|
||||||
return PasswordType.B_CRYPT
|
return PasswordType.B_CRYPT
|
||||||
return PasswordType.DECRYPTED
|
return PasswordType.DECRYPTED
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ def parse_line(row: str, separator: str, flip: bool = False):
|
|||||||
"""Try to read one ``email<sep>password`` record; ``None`` if implausible."""
|
"""Try to read one ``email<sep>password`` record; ``None`` if implausible."""
|
||||||
if separator not in row:
|
if separator not in row:
|
||||||
return None
|
return None
|
||||||
if separator != ' ':
|
if separator != " ":
|
||||||
parts = row.split(separator)
|
parts = row.split(separator)
|
||||||
if len(parts) > 2:
|
if len(parts) > 2:
|
||||||
# [email protected]:kiyan1365:16 -> keep the last two fields.
|
# [email protected]:kiyan1365:16 -> keep the last two fields.
|
||||||
@@ -51,40 +52,40 @@ def parse_line(row: str, separator: str, flip: bool = False):
|
|||||||
mail, password = row.split(separator, 1)
|
mail, password = row.split(separator, 1)
|
||||||
if flip:
|
if flip:
|
||||||
mail, password = password, mail
|
mail, password = password, mail
|
||||||
password = password.replace(' ', '')
|
password = password.replace(" ", "")
|
||||||
if not password or '\n\n' in password:
|
if not password or "\n\n" in password:
|
||||||
return None
|
return None
|
||||||
kind = password_type(password)
|
kind = password_type(password)
|
||||||
# Plaintext beyond 30 chars is junk; hashes are longer by nature and are kept
|
# Plaintext beyond 30 chars is junk; hashes are longer by nature and are kept
|
||||||
# so they can be attacked later with `crack`.
|
# so they can be attacked later with `crack`.
|
||||||
if kind == PasswordType.DECRYPTED and len(password) > 30:
|
if kind == PasswordType.DECRYPTED and len(password) > 30:
|
||||||
return None
|
return None
|
||||||
if set(password) == {'?'}: # [email protected]:?????? ??? ?????
|
if set(password) == {"?"}: # [email protected]:?????? ??? ?????
|
||||||
return None
|
return None
|
||||||
if password.count('.') >= 3 and _IP_RE.search(password):
|
if password.count(".") >= 3 and _IP_RE.search(password):
|
||||||
return None
|
return None
|
||||||
mail = mail.replace(' ', '')
|
mail = mail.replace(" ", "")
|
||||||
if '@' not in mail: # https://grox.net/utils/encoding.html
|
if "@" not in mail: # https://grox.net/utils/encoding.html
|
||||||
mail = mail.replace('%40', '@')
|
mail = mail.replace("%40", "@")
|
||||||
if not mail:
|
if not mail:
|
||||||
return None
|
return None
|
||||||
last_char, first_char = mail[-1], mail[0]
|
last_char, first_char = mail[-1], mail[0]
|
||||||
# [email protected]\ ilovebois3 / [email protected] atohiyamamot
|
# [email protected]\ ilovebois3 / [email protected] atohiyamamot
|
||||||
if last_char in '<.\\>_':
|
if last_char in "<.\\>_":
|
||||||
mail = mail[:-1]
|
mail = mail[:-1]
|
||||||
if first_char in '.<':
|
if first_char in ".<":
|
||||||
mail = mail[1:]
|
mail = mail[1:]
|
||||||
if mail.endswith('@com'): # iemongozalu@gmail@com mizo1210
|
if mail.endswith("@com"): # iemongozalu@gmail@com mizo1210
|
||||||
mail = mail.replace('@com', '.com')
|
mail = mail.replace("@com", ".com")
|
||||||
if not valid_email(mail):
|
if not valid_email(mail):
|
||||||
# some people replace neanta,[email protected],jp
|
# some people replace neanta,[email protected],jp
|
||||||
mail = mail.replace(',', '.')
|
mail = mail.replace(",", ".")
|
||||||
parts = mail.split('@')
|
parts = mail.split("@")
|
||||||
if len(parts) > 2:
|
if len(parts) > 2:
|
||||||
mail = '@'.join(parts[:2])
|
mail = "@".join(parts[:2])
|
||||||
# TODO: do it for most common domain names.
|
# TODO: do it for most common domain names.
|
||||||
if mail.count('@hotmail.com') == 2: # [email protected]@hotmail.com:8765432f
|
if mail.count("@hotmail.com") == 2: # [email protected]@hotmail.com:8765432f
|
||||||
mail = mail.replace('@hotmail.com', '', 1)
|
mail = mail.replace("@hotmail.com", "", 1)
|
||||||
if valid_email(mail) and not valid_email(password):
|
if valid_email(mail) and not valid_email(password):
|
||||||
return mail, password, kind
|
return mail, password, kind
|
||||||
return None
|
return None
|
||||||
@@ -93,17 +94,17 @@ def parse_line(row: str, separator: str, flip: bool = False):
|
|||||||
def parseline(s: str):
|
def parseline(s: str):
|
||||||
"""Clean one raw line and return ``(email, password, type)`` or ``None``."""
|
"""Clean one raw line and return ``(email, password, type)`` or ``None``."""
|
||||||
b = filter_printable(s.strip())
|
b = filter_printable(s.strip())
|
||||||
while '..' in b:
|
while ".." in b:
|
||||||
b = b.replace('..', '')
|
b = b.replace("..", "")
|
||||||
b = b.removeprefix('mailto:').removeprefix('E-mail:')
|
b = b.removeprefix("mailto:").removeprefix("E-mail:")
|
||||||
b = b.removeprefix(':').removesuffix(':')
|
b = b.removeprefix(":").removesuffix(":")
|
||||||
# TODO: could be moved just for the email, but we don't need 100% accuracy.
|
# TODO: could be moved just for the email, but we don't need 100% accuracy.
|
||||||
b = b.replace('.@', '@').replace('@.', '@').replace(' @ ', '@').replace('@@', '@')
|
b = b.replace(".@", "@").replace("@.", "@").replace(" @ ", "@").replace("@@", "@")
|
||||||
if ':' in b and ';' in b:
|
if ":" in b and ";" in b:
|
||||||
b = b.replace(';', ':')
|
b = b.replace(";", ":")
|
||||||
# A valid email needs an '@'; '%40' is the only cleanup that can create one.
|
# A valid email needs an '@'; '%40' is the only cleanup that can create one.
|
||||||
# Skip the whole separator scan for lines that can never yield a pair.
|
# Skip the whole separator scan for lines that can never yield a pair.
|
||||||
if '@' not in b and '%40' not in b:
|
if "@" not in b and "%40" not in b:
|
||||||
return None
|
return None
|
||||||
for flip in (False, True):
|
for flip in (False, True):
|
||||||
for separator in VALID_SEPARATORS:
|
for separator in VALID_SEPARATORS:
|
||||||
@@ -121,8 +122,12 @@ class Processor:
|
|||||||
per worker is a single read buffer regardless of file size.
|
per worker is a single read buffer regardless of file size.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, success_filename: str, failure_filename: str,
|
def __init__(
|
||||||
hashes_filename: str | None = None):
|
self,
|
||||||
|
success_filename: str,
|
||||||
|
failure_filename: str,
|
||||||
|
hashes_filename: str | None = None,
|
||||||
|
):
|
||||||
self.success_filename = success_filename
|
self.success_filename = success_filename
|
||||||
self.failure_filename = failure_filename
|
self.failure_filename = failure_filename
|
||||||
self.hashes_filename = hashes_filename
|
self.hashes_filename = hashes_filename
|
||||||
@@ -143,14 +148,18 @@ class Processor:
|
|||||||
# Unique per task, not per process: one worker may pull several files
|
# Unique per task, not per process: one worker may pull several files
|
||||||
# from the queue, and a pid-named part would overwrite the previous one.
|
# from the queue, and a pid-named part would overwrite the previous one.
|
||||||
part = uuid.uuid4().hex
|
part = uuid.uuid4().hex
|
||||||
success_part = f'{self.success_filename}.part.{part}'
|
success_part = f"{self.success_filename}.part.{part}"
|
||||||
failure_part = f'{self.failure_filename}.part.{part}'
|
failure_part = f"{self.failure_filename}.part.{part}"
|
||||||
hashes_part = f'{self.hashes_filename}.part.{part}' if self.hashes_filename else None
|
hashes_part = (
|
||||||
|
f"{self.hashes_filename}.part.{part}" if self.hashes_filename else None
|
||||||
|
)
|
||||||
parts = [success_part, failure_part] + ([hashes_part] if hashes_part else [])
|
parts = [success_part, failure_part] + ([hashes_part] if hashes_part else [])
|
||||||
with open(str(filename), encoding='utf8', errors='ignore') as r, \
|
with (
|
||||||
open(success_part, 'w', encoding='utf8') as sw, \
|
open(str(filename), encoding="utf8", errors="ignore") as r,
|
||||||
open(failure_part, 'w', encoding='utf8') as fw:
|
open(success_part, "w", encoding="utf8") as sw,
|
||||||
hashes = open(hashes_part, 'w', encoding='utf8') if hashes_part else None # noqa: SIM115
|
open(failure_part, "w", encoding="utf8") as fw,
|
||||||
|
):
|
||||||
|
hashes = open(hashes_part, "w", encoding="utf8") if hashes_part else None # noqa: SIM115
|
||||||
try:
|
try:
|
||||||
for line in r:
|
for line in r:
|
||||||
result = parseline(line)
|
result = parseline(line)
|
||||||
@@ -160,10 +169,10 @@ class Processor:
|
|||||||
continue
|
continue
|
||||||
mail, password, kind = result
|
mail, password, kind = result
|
||||||
if kind == PasswordType.DECRYPTED:
|
if kind == PasswordType.DECRYPTED:
|
||||||
sw.write(f'{str(mail).lower().strip()}:{password.strip()}\n')
|
sw.write(f"{str(mail).lower().strip()}:{password.strip()}\n")
|
||||||
success += 1
|
success += 1
|
||||||
elif hashes is not None:
|
elif hashes is not None:
|
||||||
hashes.write(f'{str(mail).lower().strip()}:{password}\n')
|
hashes.write(f"{str(mail).lower().strip()}:{password}\n")
|
||||||
else:
|
else:
|
||||||
fw.write(filter_printable(line)) # no hash sink: keep as reject
|
fw.write(filter_printable(line)) # no hash sink: keep as reject
|
||||||
failure += 1
|
failure += 1
|
||||||
@@ -177,7 +186,7 @@ def _clear_parts(*finals: str) -> None:
|
|||||||
# Leftovers mean a previous run died before merging. Those files were never
|
# Leftovers mean a previous run died before merging. Those files were never
|
||||||
# recorded in progress.txt, so they are reprocessed; drop the partial parts.
|
# recorded in progress.txt, so they are reprocessed; drop the partial parts.
|
||||||
for final in finals:
|
for final in finals:
|
||||||
for part in glob(f'{final}.part.*'):
|
for part in glob(f"{final}.part.*"):
|
||||||
os.remove(part)
|
os.remove(part)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,35 +198,50 @@ def _merge_part_list(parts: list[str]) -> None:
|
|||||||
"""
|
"""
|
||||||
groups: dict[str, list[str]] = {}
|
groups: dict[str, list[str]] = {}
|
||||||
for part in parts:
|
for part in parts:
|
||||||
groups.setdefault(part.rsplit('.part.', 1)[0], []).append(part)
|
groups.setdefault(part.rsplit(".part.", 1)[0], []).append(part)
|
||||||
for final, part_files in groups.items():
|
for final, part_files in groups.items():
|
||||||
with open(final, 'ab') as writer:
|
with open(final, "ab") as writer:
|
||||||
for part in part_files:
|
for part in part_files:
|
||||||
with open(part, 'rb') as reader:
|
with open(part, "rb") as reader:
|
||||||
shutil.copyfileobj(reader, writer, length=1024 * 1024)
|
shutil.copyfileobj(reader, writer, length=1024 * 1024)
|
||||||
os.remove(part)
|
os.remove(part)
|
||||||
|
|
||||||
|
|
||||||
def parse_files(files: list[Path], success_filename: str, failure_filename: str,
|
def parse_files(
|
||||||
progress_filename: str | None = None,
|
files: list[Path],
|
||||||
hashes_filename: str | None = None) -> tuple[int, int]:
|
success_filename: str,
|
||||||
|
failure_filename: str,
|
||||||
|
progress_filename: str | None = None,
|
||||||
|
hashes_filename: str | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""Parse an explicit list of files; used by both ``parse`` and ``build``.
|
"""Parse an explicit list of files; used by both ``parse`` and ``build``.
|
||||||
|
|
||||||
Returns the total ``(success, failure)`` counts. Hashed passwords go to
|
Returns the total ``(success, failure)`` counts. Hashed passwords go to
|
||||||
``hashes_filename`` when given. When ``progress_filename`` is given, each
|
``hashes_filename`` when given. When ``progress_filename`` is given, each
|
||||||
file is recorded after its chunk is safely merged, so a rerun can skip it.
|
file is recorded after its chunk is safely merged, so a rerun can skip it.
|
||||||
"""
|
"""
|
||||||
_clear_parts(success_filename, failure_filename,
|
_clear_parts(
|
||||||
*([hashes_filename] if hashes_filename else []))
|
success_filename,
|
||||||
|
failure_filename,
|
||||||
|
*([hashes_filename] if hashes_filename else []),
|
||||||
|
)
|
||||||
success_total = failure_total = 0
|
success_total = failure_total = 0
|
||||||
workers = os.cpu_count() or 1
|
workers = os.cpu_count() or 1
|
||||||
processor = Processor(success_filename, failure_filename, hashes_filename)
|
processor = Processor(success_filename, failure_filename, hashes_filename)
|
||||||
start_time = time()
|
start_time = time()
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
progress = (stack.enter_context(open(progress_filename, 'a', encoding='utf8', buffering=1))
|
progress = (
|
||||||
if progress_filename else None)
|
stack.enter_context(
|
||||||
|
open(progress_filename, "a", encoding="utf8", buffering=1)
|
||||||
|
)
|
||||||
|
if progress_filename
|
||||||
|
else None
|
||||||
|
)
|
||||||
pending_parts: list[str] = []
|
pending_parts: list[str] = []
|
||||||
with ProcessPoolExecutor(max_workers=workers) as executor, tqdm(total=len(files)) as bar:
|
with (
|
||||||
|
ProcessPoolExecutor(max_workers=workers) as executor,
|
||||||
|
tqdm(total=len(files)) as bar,
|
||||||
|
):
|
||||||
# Submit everything and merge each task's own part as it completes:
|
# Submit everything and merge each task's own part as it completes:
|
||||||
# no per-chunk barrier, so one large file cannot idle the other
|
# no per-chunk barrier, so one large file cannot idle the other
|
||||||
# workers. ponytail: all futures live at once (~tens of MB for
|
# workers. ponytail: all futures live at once (~tens of MB for
|
||||||
@@ -232,24 +256,32 @@ def parse_files(files: list[Path], success_filename: str, failure_filename: str,
|
|||||||
_merge_part_list(pending_parts)
|
_merge_part_list(pending_parts)
|
||||||
pending_parts.clear()
|
pending_parts.clear()
|
||||||
bar.update(1)
|
bar.update(1)
|
||||||
bar.set_description(f'success = {success_total:,} failure = {failure_total:,}')
|
bar.set_description(
|
||||||
|
f"success = {success_total:,} failure = {failure_total:,}"
|
||||||
|
)
|
||||||
if progress:
|
if progress:
|
||||||
progress.write(f'{futures[future]}\n')
|
progress.write(f"{futures[future]}\n")
|
||||||
if pending_parts:
|
if pending_parts:
|
||||||
_merge_part_list(pending_parts)
|
_merge_part_list(pending_parts)
|
||||||
print(f'Time elapsed {time() - start_time:.3f} seconds.')
|
print(f"Time elapsed {time() - start_time:.3f} seconds.")
|
||||||
return success_total, failure_total
|
return success_total, failure_total
|
||||||
|
|
||||||
|
|
||||||
def parse_to_files(input_dir: str, success_filename: str, failure_filename: str,
|
def parse_to_files(
|
||||||
hashes_filename: str | None = None) -> None:
|
input_dir: str,
|
||||||
|
success_filename: str,
|
||||||
|
failure_filename: str,
|
||||||
|
hashes_filename: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Parse every file under ``input_dir``, resuming from ``progress.txt``."""
|
"""Parse every file under ``input_dir``, resuming from ``progress.txt``."""
|
||||||
# Stream the progress file; never build a second in-memory copy of it (it
|
# Stream the progress file; never build a second in-memory copy of it (it
|
||||||
# can grow to millions of lines).
|
# can grow to millions of lines).
|
||||||
done: set[str] = set()
|
done: set[str] = set()
|
||||||
if os.path.exists('progress.txt'):
|
if os.path.exists("progress.txt"):
|
||||||
with open('progress.txt', encoding='utf8', errors='ignore') as reader:
|
with open("progress.txt", encoding="utf8", errors="ignore") as reader:
|
||||||
done = {line.rstrip('\n') for line in reader}
|
done = {line.rstrip("\n") for line in reader}
|
||||||
files = [p for p in Path(input_dir).glob('**/*') if p.is_file() and str(p) not in done]
|
files = [p for p in find_files(input_dir) if str(p) not in done]
|
||||||
shuffle(files)
|
shuffle(files)
|
||||||
parse_files(files, success_filename, failure_filename, 'progress.txt', hashes_filename)
|
parse_files(
|
||||||
|
files, success_filename, failure_filename, "progress.txt", hashes_filename
|
||||||
|
)
|
||||||
+42
-27
@@ -10,19 +10,19 @@ def postprocess(results: list[str]) -> list[str]:
|
|||||||
"""Normalise ``email:password`` lines into a sorted, de-duplicated list."""
|
"""Normalise ``email:password`` lines into a sorted, de-duplicated list."""
|
||||||
emails_passwords: set[str] = set()
|
emails_passwords: set[str] = set()
|
||||||
for txt_line in results:
|
for txt_line in results:
|
||||||
if '@' not in txt_line: # does it contain an email address?
|
if "@" not in txt_line: # does it contain an email address?
|
||||||
continue
|
continue
|
||||||
if ':' in txt_line: # '[email protected]:iskandar89
|
if ":" in txt_line: # '[email protected]:iskandar89
|
||||||
separator = ':'
|
separator = ":"
|
||||||
elif ';' in txt_line: # '[email protected];ol1391ga
|
elif ";" in txt_line: # '[email protected];ol1391ga
|
||||||
separator = ';'
|
separator = ";"
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
email, password = txt_line.strip().split(separator)
|
email, password = txt_line.strip().split(separator)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
emails_passwords.add(f'{email}:{password}')
|
emails_passwords.add(f"{email}:{password}")
|
||||||
return sorted(emails_passwords)
|
return sorted(emails_passwords)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,13 +35,13 @@ def glob_to_regex(pattern: str) -> str:
|
|||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
for char in pattern:
|
for char in pattern:
|
||||||
if char == '*':
|
if char == "*":
|
||||||
parts.append('[^:]*')
|
parts.append("[^:]*")
|
||||||
elif char == '?':
|
elif char == "?":
|
||||||
parts.append('[^:]')
|
parts.append("[^:]")
|
||||||
else:
|
else:
|
||||||
parts.append(re.escape(char))
|
parts.append(re.escape(char))
|
||||||
return '^' + ''.join(parts) + ':'
|
return "^" + "".join(parts) + ":"
|
||||||
|
|
||||||
|
|
||||||
class QueryAPI:
|
class QueryAPI:
|
||||||
@@ -66,7 +66,9 @@ class QueryAPI:
|
|||||||
assert path.is_file(), path
|
assert path.is_file(), path
|
||||||
try:
|
try:
|
||||||
output = subprocess.check_output(
|
output = subprocess.check_output(
|
||||||
['rg', '--no-line-number', '-ie', '^' + re.escape(email), str(path)], text=True)
|
["rg", "--no-line-number", "-ie", "^" + re.escape(email), str(path)],
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
return []
|
return []
|
||||||
return [line for line in output.splitlines() if line]
|
return [line for line in output.splitlines() if line]
|
||||||
@@ -79,31 +81,44 @@ class QueryAPI:
|
|||||||
file_list, dir_list = QueryAPI.scanning(path)
|
file_list, dir_list = QueryAPI.scanning(path)
|
||||||
current = characters[index].lower()
|
current = characters[index].lower()
|
||||||
if current in file_list:
|
if current in file_list:
|
||||||
return QueryAPI.search_in_file(file_list[current], ''.join(characters))
|
return QueryAPI.search_in_file(file_list[current], "".join(characters))
|
||||||
if current in dir_list:
|
if current in dir_list:
|
||||||
return QueryAPI.recursive_search(dir_list[current], characters, index + 1)
|
return QueryAPI.recursive_search(dir_list[current], characters, index + 1)
|
||||||
if 'symbols' in file_list:
|
if "symbols" in file_list:
|
||||||
return QueryAPI.search_in_file(file_list['symbols'], ''.join(characters))
|
return QueryAPI.search_in_file(file_list["symbols"], "".join(characters))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def query(data_dir: str, request: str) -> list[str]:
|
def query(data_dir: str, request: str) -> list[str]:
|
||||||
"""Exact email lookup using the bucket path (O(1) file read)."""
|
"""Exact email lookup using the bucket path (O(1) file read)."""
|
||||||
if len(request) <= 3 or '@' not in request:
|
if len(request) <= 3 or "@" not in request:
|
||||||
return []
|
return []
|
||||||
return postprocess(QueryAPI.recursive_search(Path(data_dir), list(request), 0))
|
return postprocess(QueryAPI.recursive_search(Path(data_dir), list(request), 0))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def wildcard(data_dir: str, pattern: str) -> list[str]:
|
def wildcard(data_dir: str, pattern: str):
|
||||||
"""Glob search across the whole dataset with ripgrep.
|
"""Stream matching lines across the dataset with ripgrep.
|
||||||
|
|
||||||
ponytail: full scan of the tree (ripgrep is fast enough); add a
|
Streamed line by line, so a broad wildcard over a huge dataset stays
|
||||||
domain-sharded index only if wildcard latency becomes a problem.
|
memory-bounded. ponytail: no sort/dedup (the dataset is already unique)
|
||||||
|
and a full scan; add a domain-sharded index only if latency matters.
|
||||||
"""
|
"""
|
||||||
try:
|
process = subprocess.Popen(
|
||||||
output = subprocess.check_output(
|
[
|
||||||
['rg', '--no-filename', '--no-line-number', '-i', '-e',
|
"rg",
|
||||||
glob_to_regex(pattern), str(Path(data_dir))], text=True)
|
"--no-filename",
|
||||||
except subprocess.CalledProcessError:
|
"--no-line-number",
|
||||||
return []
|
"-i",
|
||||||
return postprocess(output.splitlines())
|
"-e",
|
||||||
|
glob_to_regex(pattern),
|
||||||
|
str(Path(data_dir)),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
with process:
|
||||||
|
assert process.stdout is not None
|
||||||
|
for line in process.stdout:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if line:
|
||||||
|
yield line
|
||||||
@@ -7,32 +7,32 @@ from time import time
|
|||||||
|
|
||||||
from collectionparser.utils import create_dir_for_file
|
from collectionparser.utils import create_dir_for_file
|
||||||
|
|
||||||
_ALNUM = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
_ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
|
||||||
def split_file(input_file: str, output_dir: str = 'out', restart_from: int = 0) -> None:
|
def split_file(input_file: str, output_dir: str = "out", restart_from: int = 0) -> None:
|
||||||
"""Bucket every ``email:password`` line of ``input_file`` by email prefix.
|
"""Bucket every ``email:password`` line of ``input_file`` by email prefix.
|
||||||
|
|
||||||
Resume from the ``restart_from``-th input line. A few records may land in
|
Resume from the ``restart_from``-th input line.
|
||||||
the wrong bucket; run ``clean`` afterwards.
|
|
||||||
"""
|
"""
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
handles: dict[str, object] = {}
|
handles: dict[str, object] = {}
|
||||||
tick = time()
|
tick = time()
|
||||||
with open(input_file, encoding='utf8', errors='ignore') as reader:
|
with open(input_file, encoding="utf8", errors="ignore") as reader:
|
||||||
for i, line in enumerate(reader):
|
for i, line in enumerate(reader):
|
||||||
if i % 10_000_000 == 0:
|
if i % 10_000_000 == 0:
|
||||||
elapsed = time() - tick
|
elapsed = time() - tick
|
||||||
tick = time()
|
tick = time()
|
||||||
print(f'|- Processed {i:,} rows in {elapsed:.2f} seconds.')
|
print(f"|- Processed {i:,} rows in {elapsed:.2f} seconds.")
|
||||||
if i < restart_from or ':' not in line:
|
if i < restart_from or ":" not in line:
|
||||||
continue
|
continue
|
||||||
email = line.strip().split(':', 1)[0]
|
email = line.strip().split(":", 1)[0]
|
||||||
write_line_to_file(handles, line, f'{output_dir}/{get_output_file(email)}')
|
write_line_to_file(handles, line, f"{output_dir}/{get_output_file(email)}")
|
||||||
|
|
||||||
|
|
||||||
def write_line_to_file(handles: dict[str, object], line: str, output_file: str,
|
def write_line_to_file(
|
||||||
buffer_scale: int = 100) -> None:
|
handles: dict[str, object], line: str, output_file: str, buffer_scale: int = 100
|
||||||
|
) -> None:
|
||||||
"""Append ``line`` to ``output_file``, keeping at most 100 handles open.
|
"""Append ``line`` to ``output_file``, keeping at most 100 handles open.
|
||||||
|
|
||||||
Evicted handles are flushed and closed so the process stays under the file
|
Evicted handles are flushed and closed so the process stays under the file
|
||||||
@@ -45,8 +45,11 @@ def write_line_to_file(handles: dict[str, object], line: str, output_file: str,
|
|||||||
handles.pop(next(iter(handles))).flush() # type: ignore[attr-defined]
|
handles.pop(next(iter(handles))).flush() # type: ignore[attr-defined]
|
||||||
create_dir_for_file(output_file)
|
create_dir_for_file(output_file)
|
||||||
handles[output_file] = open( # noqa: SIM115 - cached handle, not scope-bound
|
handles[output_file] = open( # noqa: SIM115 - cached handle, not scope-bound
|
||||||
output_file, mode='a+', encoding='utf8',
|
output_file,
|
||||||
buffering=buffer_scale * io.DEFAULT_BUFFER_SIZE)
|
mode="a+",
|
||||||
|
encoding="utf8",
|
||||||
|
buffering=buffer_scale * io.DEFAULT_BUFFER_SIZE,
|
||||||
|
)
|
||||||
handles[output_file].write(line) # type: ignore[attr-defined]
|
handles[output_file].write(line) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
@@ -59,5 +62,5 @@ def get_output_file(email: str) -> str:
|
|||||||
break
|
break
|
||||||
parts.append(char)
|
parts.append(char)
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
parts.append('symbols')
|
parts.append("symbols")
|
||||||
return '/'.join(parts)
|
return "/".join(parts)
|
||||||
+49
-18
@@ -1,14 +1,20 @@
|
|||||||
"""Small shared helpers: deterministic shuffling, line streaming, path handling."""
|
"""Small shared helpers: deterministic shuffling, line streaming, path handling."""
|
||||||
|
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
_NON_PRINTABLE = re.compile(f'[^{re.escape(string.printable)}]')
|
_NON_PRINTABLE = re.compile(f"[^{re.escape(string.printable)}]")
|
||||||
|
|
||||||
|
|
||||||
|
def find_files(path) -> list[Path]:
|
||||||
|
"""Return every regular file under ``path`` (recursively)."""
|
||||||
|
root = Path(path).expanduser()
|
||||||
|
return [p for p in root.glob("**/*") if p.is_file()]
|
||||||
|
|
||||||
|
|
||||||
def shuffle(items: list) -> None:
|
def shuffle(items: list) -> None:
|
||||||
@@ -18,40 +24,65 @@ def shuffle(items: list) -> None:
|
|||||||
|
|
||||||
def filter_printable(text: str) -> str:
|
def filter_printable(text: str) -> str:
|
||||||
"""Drop non-printable characters so no binary data is written to text files."""
|
"""Drop non-printable characters so no binary data is written to text files."""
|
||||||
return _NON_PRINTABLE.sub('', text)
|
return _NON_PRINTABLE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
def create_dir_for_file(filename: str) -> None:
|
def create_dir_for_file(filename: str) -> None:
|
||||||
"""Create the parent directory of ``filename`` if it is missing."""
|
"""Create the parent directory of ``filename`` if it is missing."""
|
||||||
if parent := os.path.dirname(filename):
|
parent = Path(filename).parent
|
||||||
os.makedirs(parent, exist_ok=True)
|
if parent != Path("."):
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def input_file() -> click.Path:
|
def input_file() -> click.Path:
|
||||||
return click.Path(exists=True, file_okay=True, dir_okay=False,
|
return click.Path(
|
||||||
writable=False, readable=True, resolve_path=True)
|
exists=True,
|
||||||
|
file_okay=True,
|
||||||
|
dir_okay=False,
|
||||||
|
writable=False,
|
||||||
|
readable=True,
|
||||||
|
resolve_path=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def input_dir() -> click.Path:
|
def input_dir() -> click.Path:
|
||||||
return click.Path(exists=True, file_okay=False, dir_okay=True,
|
return click.Path(
|
||||||
writable=False, readable=True, resolve_path=True)
|
exists=True,
|
||||||
|
file_okay=False,
|
||||||
|
dir_okay=True,
|
||||||
|
writable=False,
|
||||||
|
readable=True,
|
||||||
|
resolve_path=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def output_file() -> click.Path:
|
def output_file() -> click.Path:
|
||||||
return click.Path(exists=False, file_okay=True, dir_okay=False,
|
return click.Path(
|
||||||
writable=True, readable=True, resolve_path=True)
|
exists=False,
|
||||||
|
file_okay=True,
|
||||||
|
dir_okay=False,
|
||||||
|
writable=True,
|
||||||
|
readable=True,
|
||||||
|
resolve_path=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def output_dir() -> click.Path:
|
def output_dir() -> click.Path:
|
||||||
return click.Path(exists=False, file_okay=False, dir_okay=True,
|
return click.Path(
|
||||||
writable=True, readable=True, resolve_path=True)
|
exists=False,
|
||||||
|
file_okay=False,
|
||||||
|
dir_okay=True,
|
||||||
|
writable=True,
|
||||||
|
readable=True,
|
||||||
|
resolve_path=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PasswordType(StrEnum):
|
class PasswordType(StrEnum):
|
||||||
"""Classification of a password field; only ``DECRYPTED`` is queryable."""
|
"""Classification of a password field; only ``DECRYPTED`` is queryable."""
|
||||||
|
|
||||||
MD5 = 'MD5'
|
MD5 = "MD5"
|
||||||
SHA1 = 'SHA1'
|
SHA1 = "SHA1"
|
||||||
SHA256 = 'SHA256'
|
SHA256 = "SHA256"
|
||||||
B_CRYPT = 'B_CRYPT'
|
B_CRYPT = "B_CRYPT"
|
||||||
DECRYPTED = 'DECRYPTED'
|
DECRYPTED = "DECRYPTED"
|
||||||
+7
-2
@@ -33,8 +33,13 @@ include = ["collectionparser*"]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
line-length = 120
|
line-length = 88
|
||||||
src = ["collectionparser", "tests"]
|
src = ["collectionparser", "tests"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "SIM"]
|
||||||
|
ignore = ["E501"] # long lines are handled by the formatter
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
+50
-32
@@ -9,51 +9,69 @@ from collectionparser.parser import Processor
|
|||||||
|
|
||||||
|
|
||||||
class CrackTest(unittest.TestCase):
|
class CrackTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_hash_saved_by_processor(self):
|
def test_hash_saved_by_processor(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
src = Path(tmp) / 'in.txt'
|
src = Path(tmp) / "in.txt"
|
||||||
src.write_text('[email protected]:5f4dcc3b5aa765d61d8327deb882cf99\n[email protected]:plainpw\n',
|
src.write_text(
|
||||||
encoding='utf8')
|
"[email protected]:5f4dcc3b5aa765d61d8327deb882cf99\n[email protected]:plainpw\n",
|
||||||
processor = Processor(str(Path(tmp) / 's.txt'), str(Path(tmp) / 'f.txt'),
|
encoding="utf8",
|
||||||
str(Path(tmp) / 'h.txt'))
|
)
|
||||||
|
processor = Processor(
|
||||||
|
str(Path(tmp) / "s.txt"),
|
||||||
|
str(Path(tmp) / "f.txt"),
|
||||||
|
str(Path(tmp) / "h.txt"),
|
||||||
|
)
|
||||||
processor.process(src)
|
processor.process(src)
|
||||||
hashes = ''.join(Path(p).read_text(encoding='utf8')
|
hashes = "".join(
|
||||||
for p in glob(str(Path(tmp) / 'h.txt.part.*')))
|
Path(p).read_text(encoding="utf8")
|
||||||
success = ''.join(Path(p).read_text(encoding='utf8')
|
for p in glob(str(Path(tmp) / "h.txt.part.*"))
|
||||||
for p in glob(str(Path(tmp) / 's.txt.part.*')))
|
)
|
||||||
self.assertEqual('[email protected]:5f4dcc3b5aa765d61d8327deb882cf99\n', hashes)
|
success = "".join(
|
||||||
self.assertEqual('[email protected]:plainpw\n', success)
|
Path(p).read_text(encoding="utf8")
|
||||||
|
for p in glob(str(Path(tmp) / "s.txt.part.*"))
|
||||||
|
)
|
||||||
|
self.assertEqual("[email protected]:5f4dcc3b5aa765d61d8327deb882cf99\n", hashes)
|
||||||
|
self.assertEqual("[email protected]:plainpw\n", success)
|
||||||
|
|
||||||
def test_crack_md5_and_sha1(self):
|
def test_crack_md5_and_sha1(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
hashes = Path(tmp) / 'hashes.txt'
|
hashes = Path(tmp) / "hashes.txt"
|
||||||
hashes.write_bytes(
|
hashes.write_bytes(
|
||||||
b'[email protected]:' + hashlib.md5(b'hunter2').hexdigest().encode() + b'\n'
|
b"[email protected]:" + hashlib.md5(b"hunter2").hexdigest().encode() + b"\n"
|
||||||
b'[email protected]:' + hashlib.sha1(b'secret').hexdigest().encode() + b'\n'
|
b"[email protected]:" + hashlib.sha1(b"secret").hexdigest().encode() + b"\n"
|
||||||
b'[email protected]:not-a-hash\n')
|
b"[email protected]:not-a-hash\n"
|
||||||
wordlist = Path(tmp) / 'words.txt'
|
)
|
||||||
wordlist.write_bytes(b'foo\nhunter2\nbar\nsecret\n')
|
wordlist = Path(tmp) / "words.txt"
|
||||||
out = Path(tmp) / 'cracked.txt'
|
wordlist.write_bytes(b"foo\nhunter2\nbar\nsecret\n")
|
||||||
|
out = Path(tmp) / "cracked.txt"
|
||||||
total = crack(str(hashes), str(out), wordlists=[str(wordlist)], workers=2)
|
total = crack(str(hashes), str(out), wordlists=[str(wordlist)], workers=2)
|
||||||
self.assertEqual(2, total)
|
self.assertEqual(2, total)
|
||||||
self.assertEqual({b'[email protected]:hunter2', b'[email protected]:secret'},
|
self.assertEqual(
|
||||||
set(out.read_bytes().splitlines()))
|
{b"[email protected]:hunter2", b"[email protected]:secret"},
|
||||||
|
set(out.read_bytes().splitlines()),
|
||||||
|
)
|
||||||
|
|
||||||
def test_crack_from_plaintext_passwords(self):
|
def test_crack_from_plaintext_passwords(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
hashes = Path(tmp) / 'hashes.txt'
|
hashes = Path(tmp) / "hashes.txt"
|
||||||
hashes.write_bytes(b'[email protected]:' + hashlib.md5(b'reused').hexdigest().encode() + b'\n')
|
hashes.write_bytes(
|
||||||
empty = Path(tmp) / 'empty.txt'
|
b"[email protected]:" + hashlib.md5(b"reused").hexdigest().encode() + b"\n"
|
||||||
empty.write_bytes(b'')
|
)
|
||||||
plaintext = Path(tmp) / 'plain.txt'
|
empty = Path(tmp) / "empty.txt"
|
||||||
plaintext.write_bytes(b'[email protected]:reused\n')
|
empty.write_bytes(b"")
|
||||||
out = Path(tmp) / 'cracked.txt'
|
plaintext = Path(tmp) / "plain.txt"
|
||||||
total = crack(str(hashes), str(out), wordlists=[str(empty)],
|
plaintext.write_bytes(b"[email protected]:reused\n")
|
||||||
passwords_from=[str(plaintext)], workers=1)
|
out = Path(tmp) / "cracked.txt"
|
||||||
|
total = crack(
|
||||||
|
str(hashes),
|
||||||
|
str(out),
|
||||||
|
wordlists=[str(empty)],
|
||||||
|
passwords_from=[str(plaintext)],
|
||||||
|
workers=1,
|
||||||
|
)
|
||||||
self.assertEqual(1, total)
|
self.assertEqual(1, total)
|
||||||
self.assertEqual([b'[email protected]:reused'], out.read_bytes().splitlines())
|
self.assertEqual([b"[email protected]:reused"], out.read_bytes().splitlines())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+109
-90
@@ -6,114 +6,133 @@ from collectionparser.utils import PasswordType
|
|||||||
|
|
||||||
|
|
||||||
class ParserTest(unittest.TestCase):
|
class ParserTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_parse_1(self):
|
def test_parse_1(self):
|
||||||
self.assertIsNotNone(p('[email protected]:123'))
|
self.assertIsNotNone(p("[email protected]:123"))
|
||||||
self.assertIsNotNone(p('hello@@gmail.com:123'))
|
self.assertIsNotNone(p("hello@@gmail.com:123"))
|
||||||
self.assertIsNotNone(p('hello @ gmail.com:123'))
|
self.assertIsNotNone(p("hello @ gmail.com:123"))
|
||||||
self.assertIsNotNone(p('hello @@ gmail.com:123'))
|
self.assertIsNotNone(p("hello @@ gmail.com:123"))
|
||||||
self.assertIsNotNone(p('hello @@ gmail.com:123:'))
|
self.assertIsNotNone(p("hello @@ gmail.com:123:"))
|
||||||
self.assertIsNone(p('[email protected]:'))
|
self.assertIsNone(p("[email protected]:"))
|
||||||
self.assertIsNotNone(p('[email protected] 2296'))
|
self.assertIsNotNone(p("[email protected] 2296"))
|
||||||
self.assertIsNotNone(p('hello @@ gmail.com,123'))
|
self.assertIsNotNone(p("hello @@ gmail.com,123"))
|
||||||
self.assertIsNotNone(p('123,hello @@ gmail.com'))
|
self.assertIsNotNone(p("123,hello @@ gmail.com"))
|
||||||
self.assertIsNone(p('[email protected]'))
|
self.assertIsNone(p("[email protected]"))
|
||||||
self.assertIsNone(p('[email protected]:?????? ??? ?????'))
|
self.assertIsNone(p("[email protected]:?????? ??? ?????"))
|
||||||
self.assertIsNone(p('[email protected]:85.242.201.81,felix'))
|
self.assertIsNone(
|
||||||
self.assertIsNone(p('[email protected]:125.26.106.158,nopparat'))
|
p("[email protected]:85.242.201.81,felix")
|
||||||
self.assertIsNone(p('[email protected]:INSERT INTO `users'
|
)
|
||||||
'`(`id`, `verify`, `username`, `email`, `password'))
|
self.assertIsNone(
|
||||||
self.assertIsNotNone(p('[email protected]:Login'))
|
p("[email protected]:125.26.106.158,nopparat")
|
||||||
self.assertIsNotNone(p('[email protected] ilovebois3'))
|
)
|
||||||
self.assertIsNotNone(p('[email protected] atohiyamamot'))
|
self.assertIsNone(
|
||||||
self.assertIsNotNone(p('[email protected]@hotmail.com:8765432f'))
|
p(
|
||||||
self.assertIsNotNone(p('neanta,[email protected],jp:123'))
|
"[email protected]:INSERT INTO `users"
|
||||||
self.assertIsNotNone(p('iemongozalu@gmail@com mizo1210'))
|
"`(`id`, `verify`, `username`, `email`, `password"
|
||||||
self.assertIsNotNone(p('[email protected]@hotmail.com:sayonara'))
|
)
|
||||||
self.assertIsNone(p('iemongozalu@gmail@com:'))
|
)
|
||||||
self.assertIsNone(p('iemongozalu@gmail@com,'))
|
self.assertIsNotNone(p("[email protected]:Login"))
|
||||||
self.assertIsNotNone(p('[email protected] chi chi 123'))
|
self.assertIsNotNone(p("[email protected] ilovebois3"))
|
||||||
self.assertIsNotNone(p('[email protected]@34589@@@@@@:19820429'))
|
self.assertIsNotNone(p("[email protected] atohiyamamot"))
|
||||||
self.assertIsNotNone(p('[email protected]@33584@@@@@@ 03260702'))
|
self.assertIsNotNone(p("[email protected]@hotmail.com:8765432f"))
|
||||||
|
self.assertIsNotNone(p("neanta,[email protected],jp:123"))
|
||||||
|
self.assertIsNotNone(p("iemongozalu@gmail@com mizo1210"))
|
||||||
|
self.assertIsNotNone(p("[email protected]@hotmail.com:sayonara"))
|
||||||
|
self.assertIsNone(p("iemongozalu@gmail@com:"))
|
||||||
|
self.assertIsNone(p("iemongozalu@gmail@com,"))
|
||||||
|
self.assertIsNotNone(p("[email protected] chi chi 123"))
|
||||||
|
self.assertIsNotNone(p("[email protected]@34589@@@@@@:19820429"))
|
||||||
|
self.assertIsNotNone(p("[email protected]@33584@@@@@@ 03260702"))
|
||||||
|
|
||||||
self.assertIsNotNone(p('<[email protected]:sedagirl'))
|
self.assertIsNotNone(p("<[email protected]:sedagirl"))
|
||||||
self.assertIsNotNone(p('<[email protected]>:<phoenix>'))
|
self.assertIsNotNone(p("<[email protected]>:<phoenix>"))
|
||||||
self.assertIsNotNone(p('[email protected]< gorillague'))
|
self.assertIsNotNone(p("[email protected]< gorillague"))
|
||||||
self.assertIsNone(p('</td>@bk.ru:peng7788525'))
|
self.assertIsNone(p("</td>@bk.ru:peng7788525"))
|
||||||
self.assertIsNone(p('<admin/>@mail.ru:andrew123'))
|
self.assertIsNone(p("<admin/>@mail.ru:andrew123"))
|
||||||
self.assertIsNone(p('<i>test</i>@bk.ru:lippens'))
|
self.assertIsNone(p("<i>test</i>@bk.ru:lippens"))
|
||||||
self.assertIsNone(p('ESSENTIALWAY'))
|
self.assertIsNone(p("ESSENTIALWAY"))
|
||||||
self.assertIsNone(p('ESTARRON-ESPINOSA'))
|
self.assertIsNone(p("ESTARRON-ESPINOSA"))
|
||||||
self.assertIsNone(p('ERLONMIRANDA:sha98ne'))
|
self.assertIsNone(p("ERLONMIRANDA:sha98ne"))
|
||||||
# No '@' and no '%40' -> cannot yield a pair, must stay None.
|
# No '@' and no '%40' -> cannot yield a pair, must stay None.
|
||||||
self.assertIsNone(p('no at sign at all'))
|
self.assertIsNone(p("no at sign at all"))
|
||||||
self.assertIsNone(p('plain text line:with:colons'))
|
self.assertIsNone(p("plain text line:with:colons"))
|
||||||
self.assertIsNotNone(p('user%40domain.com:password')) # %40 still becomes '@'
|
self.assertIsNotNone(p("user%40domain.com:password")) # %40 still becomes '@'
|
||||||
self.assertIsNotNone(p('[email protected]:[email protected]:victory'))
|
self.assertIsNotNone(
|
||||||
self.assertIsNotNone(p('[email protected];;shanmuga6'))
|
p("[email protected]:[email protected]:victory")
|
||||||
self.assertIsNotNone(p('[email protected]::shanmuga6'))
|
)
|
||||||
self.assertIsNotNone(p('[email protected]::hell@'))
|
self.assertIsNotNone(p("[email protected];;shanmuga6"))
|
||||||
self.assertIsNotNone(p('[email protected]::hell@:'))
|
self.assertIsNotNone(p("[email protected]::shanmuga6"))
|
||||||
self.assertIsNotNone(p('Aliulfat [email protected]:4849040'))
|
self.assertIsNotNone(p("[email protected]::hell@"))
|
||||||
self.assertIsNotNone(p('harry gill@ yaho.com:7087096771'))
|
self.assertIsNotNone(p("[email protected]::hell@:"))
|
||||||
self.assertIsNotNone(p('shaheen .jafri @gmail.com:destinyuntold'))
|
self.assertIsNotNone(p("Aliulfat [email protected]:4849040"))
|
||||||
self.assertIsNotNone(p('mail@ottino snc.it:04921540011'))
|
self.assertIsNotNone(p("harry gill@ yaho.com:7087096771"))
|
||||||
self.assertIsNone(p('sssssssandhya@gmail.:puleyar1'))
|
self.assertIsNotNone(p("shaheen .jafri @gmail.com:destinyuntold"))
|
||||||
|
self.assertIsNotNone(p("mail@ottino snc.it:04921540011"))
|
||||||
|
self.assertIsNone(p("sssssssandhya@gmail.:puleyar1"))
|
||||||
|
|
||||||
self.assertIsNotNone(p('cjywzy%40eyou.com:Aebdd797'))
|
self.assertIsNotNone(p("cjywzy%40eyou.com:Aebdd797"))
|
||||||
self.assertIsNotNone(p('finanza.rapido2013%40yahoo.it:123456'))
|
self.assertIsNotNone(p("finanza.rapido2013%40yahoo.it:123456"))
|
||||||
self.assertIsNotNone(p('habitacasa3%40yahoo.it:cesira89'))
|
self.assertIsNotNone(p("habitacasa3%40yahoo.it:cesira89"))
|
||||||
self.assertIsNotNone(p('harry gill@ yaho.com:7087096771'))
|
self.assertIsNotNone(p("harry gill@ yaho.com:7087096771"))
|
||||||
self.assertIsNone(p('uwe kopka <[email protected]>:biochemie'))
|
self.assertIsNone(p("uwe kopka <[email protected]>:biochemie"))
|
||||||
|
|
||||||
# multi-line.
|
# multi-line.
|
||||||
self.assertIsNotNone(p('INLOVE12:::1920-05-02:[email protected]:INLOVE12'))
|
self.assertIsNotNone(p("INLOVE12:::1920-05-02:[email protected]:INLOVE12"))
|
||||||
self.assertIsNotNone(p('Nicesweetboobs:1983-10-05:[email protected]:ilovekiss'))
|
self.assertIsNotNone(
|
||||||
self.assertIsNotNone(p('[email protected]:[email protected];97tata1'))
|
p("Nicesweetboobs:1983-10-05:[email protected]:ilovekiss")
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
p("[email protected]:[email protected];97tata1")
|
||||||
|
)
|
||||||
|
|
||||||
multi_lines = [
|
multi_lines = [
|
||||||
'[email protected];[email protected]:CAROLINAPIANTA',
|
"[email protected];[email protected]:CAROLINAPIANTA",
|
||||||
'Nicesweetboobs:1983-10-05:[email protected]:ilovekiss',
|
"Nicesweetboobs:1983-10-05:[email protected]:ilovekiss",
|
||||||
'190.40.252.191,XabelciberX,[email protected],gatita9685567',
|
"190.40.252.191,XabelciberX,[email protected],gatita9685567",
|
||||||
'201.245.164.50,andres vergara,[email protected],barcelona10',
|
"201.245.164.50,andres vergara,[email protected],barcelona10",
|
||||||
'65.94.11.229,stephane,[email protected],ste264',
|
"65.94.11.229,stephane,[email protected],ste264",
|
||||||
'INLOVE12:::1920-05-02:[email protected]:INLOVE12',
|
"INLOVE12:::1920-05-02:[email protected]:INLOVE12",
|
||||||
'agaliha84:::1969-07-02:[email protected]:agaliha84',
|
"agaliha84:::1969-07-02:[email protected]:agaliha84",
|
||||||
'happy8360:::1963-09-12:[email protected]:jdj407',
|
"happy8360:::1963-09-12:[email protected]:jdj407",
|
||||||
'tinkertailor99:1947-10-12:[email protected]:77880577',
|
"tinkertailor99:1947-10-12:[email protected]:77880577",
|
||||||
'Cash5178:1970-11-06:[email protected]:hulk1234',
|
"Cash5178:1970-11-06:[email protected]:hulk1234",
|
||||||
'Creecher0275:1972-04-22:[email protected]:complete',
|
"Creecher0275:1972-04-22:[email protected]:complete",
|
||||||
'[email protected]:[email protected];97tata1',
|
"[email protected]:[email protected];97tata1",
|
||||||
'[email protected]:[email protected];zwezda',
|
"[email protected]:[email protected];zwezda",
|
||||||
'SavvySummer:1973-03-17:[email protected]:sunshine0699',
|
"SavvySummer:1973-03-17:[email protected]:sunshine0699",
|
||||||
'RaWRrr:1990-12-02:[email protected]:nitsua',
|
"RaWRrr:1990-12-02:[email protected]:nitsua",
|
||||||
'[email protected];[email protected]:CAROLINAPIANTA',
|
"[email protected];[email protected]:CAROLINAPIANTA",
|
||||||
'[email protected];[email protected]:RRAHOTEL',
|
"[email protected];[email protected]:RRAHOTEL",
|
||||||
'silent4463:1980-10-14:[email protected]:salinas'
|
"silent4463:1980-10-14:[email protected]:salinas",
|
||||||
]
|
]
|
||||||
|
|
||||||
for multi_line in multi_lines:
|
for multi_line in multi_lines:
|
||||||
self.assertIsNotNone(p(multi_line))
|
self.assertIsNotNone(p(multi_line))
|
||||||
|
|
||||||
self.assertIsNone(p('300396 matelymaly [email protected] 222.155.173.78 '
|
self.assertIsNone(
|
||||||
'3d2c5414a041f518b03b750e62e4ebc3:Nhc'))
|
p(
|
||||||
|
"300396 matelymaly [email protected] 222.155.173.78 "
|
||||||
|
"3d2c5414a041f518b03b750e62e4ebc3:Nhc"
|
||||||
|
)
|
||||||
|
)
|
||||||
# 32-char hex is a hash, not junk: kept (typed) so `crack` can attack it.
|
# 32-char hex is a hash, not junk: kept (typed) so `crack` can attack it.
|
||||||
hashed = p('05-02-2012;5721869;[email protected]:f02368945726d5fc2a14eb576f7276c0')
|
hashed = p(
|
||||||
|
"05-02-2012;5721869;[email protected]:f02368945726d5fc2a14eb576f7276c0"
|
||||||
|
)
|
||||||
self.assertIsNotNone(hashed)
|
self.assertIsNotNone(hashed)
|
||||||
self.assertEqual(PasswordType.MD5, hashed[2])
|
self.assertEqual(PasswordType.MD5, hashed[2])
|
||||||
self.assertIsNotNone(p('[email protected]::g1u3ck'))
|
self.assertIsNotNone(p("[email protected]::g1u3ck"))
|
||||||
self.assertIsNotNone(p('[email protected]:[email protected]:2904198'))
|
self.assertIsNotNone(p("[email protected]:[email protected]:2904198"))
|
||||||
self.assertIsNotNone(p('sanda [email protected]:vasafultv'))
|
self.assertIsNotNone(p("sanda [email protected]:vasafultv"))
|
||||||
self.assertIsNone(p('jsjjd.@Com.:love melon'))
|
self.assertIsNone(p("jsjjd.@Com.:love melon"))
|
||||||
|
|
||||||
def test_parse_2(self):
|
def test_parse_2(self):
|
||||||
self.assertEqual('j/o/s', get_output_file('[email protected]'))
|
self.assertEqual("j/o/s", get_output_file("[email protected]"))
|
||||||
self.assertEqual('c/e/o', get_output_file('[email protected]'))
|
self.assertEqual("c/e/o", get_output_file("[email protected]"))
|
||||||
self.assertEqual('x/symbols', get_output_file('[email protected]'))
|
self.assertEqual("x/symbols", get_output_file("[email protected]"))
|
||||||
self.assertEqual('x/a/symbols', get_output_file('[email protected]'))
|
self.assertEqual("x/a/symbols", get_output_file("[email protected]"))
|
||||||
self.assertEqual('x/symbols', get_output_file('[email protected]'))
|
self.assertEqual("x/symbols", get_output_file("[email protected]"))
|
||||||
self.assertEqual('k/e/v', get_output_file('[email protected]'))
|
self.assertEqual("k/e/v", get_output_file("[email protected]"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user