added pw cracking
This commit is contained in:
1 parent
3205ab2311
commit
b98f53e956
14 files changed
+606
-293
No files matched your search
@@ -39,10 +39,12 @@ dedups each bucket. Peak disk is source + tree, so it fits where a global sort
|
||||
would not.
|
||||
|
||||
```bash
|
||||
# --scratch is a fast disk for per-batch files, sort temp, and build state.
|
||||
# --batch_gb caps each batch (peak disk = source + tree).
|
||||
collectionparser build \
|
||||
--source /path/to/extracted \
|
||||
--dataset /path/to/dataset \
|
||||
--scratch /path/to/scratch \ # fast disk for batch files + sort temp
|
||||
--scratch /path/to/scratch \
|
||||
--batch_gb 120
|
||||
```
|
||||
|
||||
@@ -50,14 +52,18 @@ collectionparser build \
|
||||
same command. Finished batches are recorded in `build_state.json`, and parsed
|
||||
files within the current batch in `progress_<n>.txt`; only unparsed files are
|
||||
re-parsed. The sort+split step of an interrupted batch is redone (duplicates are
|
||||
removed by the final dedup), so nothing is lost. Stats are written to
|
||||
`--scratch/stats.md`.
|
||||
removed by the final dedup), so nothing is lost.
|
||||
|
||||
State and stats live in `--scratch`: `build_state.json`, `stats.md`, and
|
||||
`extract_failures.txt` (archives that failed to extract, never silently skipped).
|
||||
`sort` temp is directed to `--scratch` via `TMPDIR`.
|
||||
|
||||
**Archives** (`.rar`/`.zip`/`.7z`/`.tar.*`/`.gz`/`.bz2`/`.xz`) are extracted
|
||||
automatically into `--scratch` for the duration of their batch and removed once
|
||||
parsed; the source is never modified. `.zip`/`.tar.*`/single-file `.gz`/`.bz2`/`.xz`
|
||||
use the Python standard library; `.rar`/`.7z` need `7z` or `unrar` on `PATH` (use
|
||||
`--no-extract` to skip archives entirely).
|
||||
parsed; the source is never modified. `.zip`/`.tar.*`/single-file
|
||||
`.gz`/`.bz2`/`.xz` use the Python standard library; `.rar`/`.7z` need `7z` or
|
||||
`unrar` on `PATH`. Pass `--no-extract` to skip archives, or `--no-dedup` to
|
||||
skip the final per-bucket de-duplication.
|
||||
|
||||
## Processing (manual)
|
||||
|
||||
@@ -90,6 +96,29 @@ collectionparser query --dataset data --pattern '*@gmail.com'
|
||||
collectionparser export --dataset data --out records.jsonl
|
||||
```
|
||||
|
||||
## Crack hashed passwords
|
||||
|
||||
`parse`/`build` keep hashed passwords (MD5/SHA1/SHA256) separate from plaintext
|
||||
ones instead of discarding them. `build` collects them in `--scratch/hashes.txt`;
|
||||
`parse` writes them to `--hashes_file` when given.
|
||||
|
||||
```bash
|
||||
# Wordlist omitted -> rockyou is downloaded automatically; add the dataset's
|
||||
# own plaintext passwords as extra candidates:
|
||||
collectionparser crack \
|
||||
--hashes hashes.txt \
|
||||
--passwords-from dataset/ \
|
||||
--out cracked.txt
|
||||
# Or bring your own wordlist(s):
|
||||
collectionparser crack --hashes hashes.txt --wordlist words.txt --out cracked.txt
|
||||
```
|
||||
|
||||
Pure standard library: `hashlib` digests with one process per CPU core, each
|
||||
attacking a strided slice of the wordlist, so memory stays bounded; candidate
|
||||
sources are merged through `sort -u`. Only unsalted MD5/SHA1/SHA256 are
|
||||
attackable — bcrypt is ignored. Feed `cracked.txt` back into the index with
|
||||
`split`/`merge`.
|
||||
|
||||
## Measured performance
|
||||
|
||||
Single large-file test on Collections 2-5 (3 files, 19.94 GB, 3 workers):
|
||||
@@ -114,7 +143,8 @@ Run `collectionparser --help` or `collectionparser <command> --help`.
|
||||
| `build` | Batched, resumable: parse + dedup + split into the query tree. |
|
||||
| `query` | Exact or wildcard search over a dataset. |
|
||||
| `export` | Export a dataset as JSON Lines. |
|
||||
| `parse` | Parse an unstructured folder into `success`/`failure` files. |
|
||||
| `crack` | Dictionary-attack collected hashes with a wordlist. |
|
||||
| `parse` | Parse an unstructured folder into `success`/`failure`/`hashes` files. |
|
||||
| `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. |
|
||||
@@ -123,3 +153,11 @@ Run `collectionparser --help` or `collectionparser <command> --help`.
|
||||
| `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
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
ruff check . # lint
|
||||
pytest # tests
|
||||
```
|
||||
@@ -15,8 +15,8 @@ python -m venv venv && source venv/bin/activate
|
||||
pip install -e /path/to/collectionparser
|
||||
```
|
||||
|
||||
External tools it shells out to: `sort`, `split` (coreutils) and `rg`
|
||||
(ripgrep, required only by `test`/`evaluate`).
|
||||
External tools: `sort` and `split` (coreutils); `rg` (ripgrep) for
|
||||
`query`/`test`/`evaluate`; optional `7z`/`unrar` to extract `.rar`/`.7z` archives.
|
||||
|
||||
## How to run it for the user
|
||||
|
||||
@@ -40,7 +40,6 @@ sort -T tmp -u -S 90% --parallel=12 success.txt > success_sorted.txt
|
||||
collectionparser split --file success_sorted.txt --out data
|
||||
|
||||
# 4. Optional repair pass (moves records to the right bucket, re-sorts).
|
||||
ulimit -n 60000
|
||||
collectionparser clean --path data
|
||||
|
||||
# 5. Query.
|
||||
@@ -58,9 +57,10 @@ collectionparser merge --src other_data --dest big_dataset
|
||||
|
||||
| Command | Purpose | Key options |
|
||||
| --- | --- | --- |
|
||||
| `build` | Batched, resumable parse + dedup + split into the query tree. | `--source`, `--dataset`, `--scratch`, `--batch_gb` |
|
||||
| `build` | Batched, resumable parse + dedup + split into the query tree. | `--source`, `--dataset`, `--scratch`, `--batch_gb`, `--extract/--no-extract` |
|
||||
| `query` | Exact or wildcard (`*`, `?`) search over a dataset. | `--dataset`, `--pattern` |
|
||||
| `export` | Export a dataset as JSON Lines (OpenSearch). | `--dataset`, `--out` |
|
||||
| `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` |
|
||||
| `split` | One sorted file -> `a/b/c` tree. | `--file`, `--out`, `--restart_from N` |
|
||||
| `chunk` | Split oversized TXT files. | `--path`, `--size MB` (default 50) |
|
||||
@@ -71,33 +71,38 @@ collectionparser merge --src other_data --dest big_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` |
|
||||
|
||||
Global flag: `--debug` (verbose; also bypasses the open-files check below).
|
||||
Global flag: `--debug` (verbose).
|
||||
|
||||
## Rules an agent must follow
|
||||
|
||||
- **Prefer `build` for large sources.** It parses in size-bounded batches so the
|
||||
disk never needs source + temp + tree at once. Stop it anytime and rerun the
|
||||
same command to resume: finished batches are in `--scratch/build_state.json`,
|
||||
parsed files in `progress_<n>.txt`. Stats land in `--scratch/stats.md`.
|
||||
parsed files in `progress_<n>.txt`. Stats land in `--scratch/stats.md`; any
|
||||
archive that fails extraction is written to `--scratch/extract_failures.txt`.
|
||||
- **Archives are auto-extracted** (`.zip`/`.tar.*`/`.gz` via stdlib; `.rar`/`.7z`
|
||||
need `7z` or `unrar` on `PATH`). The source is never modified. Use
|
||||
`--no-extract` to skip archive handling.
|
||||
- **`split` requires a sorted, unique input.** Always run `sort -u` first; skip
|
||||
it and the index silently contains duplicates and unsorted buckets.
|
||||
- **`split` and `clean` need `ulimit -n >= 60000`.** The CLI refuses to run
|
||||
otherwise. Raising the limit is the real fix; `--debug` only skips the check.
|
||||
- **Memory is already flat.** Don't "optimize" by loading files in Python, and
|
||||
don't pre-chunk unless a single file is unwieldy — the tools stream.
|
||||
- **`merge` is destructive**: it appends into `--dest` and deletes each `--src`
|
||||
file. Back up `--dest` before running.
|
||||
- **`--dataset` is a path** to a query-friendly folder built by `split`.
|
||||
- **`test`/`evaluate` need `rg` on `PATH`.** If `rg` is missing, queries return
|
||||
nothing; install ripgrep instead of working around it.
|
||||
- **`query`/`test`/`evaluate` need `rg` on `PATH`.** If `rg` is missing, queries
|
||||
return nothing; install ripgrep instead of working around it.
|
||||
- **`query` wildcards are anchored to the email field**: `*`/`?` never cross the
|
||||
`:` separator (`*@gmail.com` works, matching into the password does not).
|
||||
- **`parse` restarts safely.** Completed inputs are recorded in `progress.txt`
|
||||
in the current directory; rerun the same command to resume. Don't delete it
|
||||
mid-run.
|
||||
- `parse` counts a `success` only for plaintext passwords. Hashes
|
||||
(MD5/SHA1/SHA256/bcrypt) are intentionally not written to `success.txt`.
|
||||
- `parse`/`build` write only plaintext passwords to `success`; hashed ones
|
||||
(MD5/SHA1/SHA256) go to a separate hashes file (`--scratch/hashes.txt` from
|
||||
`build`, `--hashes_file` from `parse`) so they can be attacked later:
|
||||
`collectionparser crack --hashes hashes.txt --out cracked.txt` (omit
|
||||
`--wordlist` to auto-download rockyou; add `--passwords-from dataset/` to reuse
|
||||
known plaintext passwords). bcrypt is not attackable and is left untouched.
|
||||
|
||||
## Verify the environment
|
||||
|
||||
|
||||
+78
-61
@@ -1,3 +1,5 @@
|
||||
"""Implementations behind the CLI commands: parse, split, sort, merge, query."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -10,8 +12,7 @@ from tqdm import tqdm
|
||||
from collectionparser.parser import parse_to_files
|
||||
from collectionparser.query import QueryAPI
|
||||
from collectionparser.splitter import get_output_file, write_line_to_file
|
||||
from collectionparser.utils import (create_dir_for_file, filter_printable,
|
||||
iter_lines, shuffle)
|
||||
from collectionparser.utils import create_dir_for_file, filter_printable, iter_lines, shuffle
|
||||
|
||||
# ponytail: buckets under this size sort byte-exactly in RAM; larger buckets
|
||||
# shell out to `sort -u`, which spills to disk.
|
||||
@@ -19,16 +20,18 @@ _IN_MEMORY_SORT_LIMIT = 8 * 1024 * 1024
|
||||
|
||||
|
||||
class API:
|
||||
"""Command implementations shared by the CLI."""
|
||||
|
||||
@staticmethod
|
||||
def evaluate(old, new):
|
||||
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 = []
|
||||
with open(random.choice(files), 'r', encoding='utf8', errors='ignore') as r:
|
||||
for count, line in enumerate(r):
|
||||
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:
|
||||
@@ -41,116 +44,127 @@ class API:
|
||||
print(f'OLD: {random_line}, NEW: {hit}, HIT_RATE = {hit_count / total_count:.3f}.')
|
||||
|
||||
@staticmethod
|
||||
def merge(src, dest):
|
||||
def merge(src: str, dest: str) -> None:
|
||||
"""Merge the bucket tree ``src`` into ``dest`` (destructive)."""
|
||||
src_root = Path(src).expanduser()
|
||||
dest_root = Path(dest).expanduser()
|
||||
dest_files = {str(f).replace(str(dest_root), ''): f for f in find_files(dest_root)}
|
||||
for src_file in find_files(src_root):
|
||||
src_index = str(src_file).replace(str(src_root), '')
|
||||
if src_index in dest_files:
|
||||
merge(src_file, dest_files[src_index])
|
||||
append_file(src_file, dest_files[src_index])
|
||||
else:
|
||||
dst = dest_root / src_index.lstrip('/')
|
||||
create_dir_for_file(str(dst))
|
||||
move(str(src_file), str(dst))
|
||||
move_file(src_file, dst)
|
||||
|
||||
@staticmethod
|
||||
def parse(path, success_file, failure_file):
|
||||
parse_to_files(path, success_file, failure_file)
|
||||
def parse(path: str, success_file: str, failure_file: str,
|
||||
hashes_file: str | None = None) -> None:
|
||||
parse_to_files(path, success_file, failure_file, hashes_file)
|
||||
|
||||
@staticmethod
|
||||
def sort(path):
|
||||
files = [p for p in Path(path).expanduser().glob('**/*') if p.is_file() and p.suffix != '.sorted']
|
||||
for p in tqdm(files, desc='Unique > Sorting > Replacing existing files'):
|
||||
output_path = p.with_suffix('.sorted')
|
||||
sort_unique(p, output_path)
|
||||
shutil.move(output_path, p)
|
||||
def sort(path: str) -> None:
|
||||
"""Sort and de-duplicate every file under ``path`` in place."""
|
||||
files = [p for p in Path(path).expanduser().glob('**/*')
|
||||
if p.is_file() and p.suffix != '.sorted']
|
||||
for file in tqdm(files, desc='Unique > Sorting > Replacing existing files'):
|
||||
output_path = file.with_suffix('.sorted')
|
||||
sort_unique(file, output_path)
|
||||
shutil.move(output_path, file)
|
||||
|
||||
@staticmethod
|
||||
def clean(path):
|
||||
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
|
||||
files_handlers = {} # u limit here!
|
||||
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), 'r', encoding='utf8', errors='ignore') as r, \
|
||||
open(unique_file, 'w', encoding='utf8') as w:
|
||||
for line in r:
|
||||
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]
|
||||
ground_truth_file = get_output_file(email)
|
||||
if ground_truth_file != key:
|
||||
output_file = Path(path) / ground_truth_file
|
||||
write_line_to_file(files_handlers, line, output_file, buffer_scale=10)
|
||||
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. '
|
||||
f'move: {email}: {str(file).replace(path, "")}'
|
||||
f' -> {str(output_file).replace(path, "")}')
|
||||
bar.set_description(f'clean. {num_files_moved:,} moved.')
|
||||
else:
|
||||
w.write(line)
|
||||
writer.write(line)
|
||||
sort_unique(unique_file, file)
|
||||
os.remove(unique_file)
|
||||
|
||||
@staticmethod
|
||||
def test(file: str, dataset: str):
|
||||
cracked_rate = total = 0
|
||||
def test(file: str, dataset: str) -> None:
|
||||
"""Look up every email in ``file`` and print the cracked rate."""
|
||||
cracked = total = 0
|
||||
for line in iter_lines(file):
|
||||
results = QueryAPI.query(dataset, line.strip(), 'email')
|
||||
if results:
|
||||
passwords = [p.partition(':')[2] for p in results]
|
||||
print('FOUND:', line, ':'.join(passwords))
|
||||
cracked_rate += 1
|
||||
cracked += 1
|
||||
total += 1
|
||||
print(f'Cracked rate: {cracked_rate / total if total else 0.0}')
|
||||
print(f'Cracked rate: {cracked / total if total else 0.0}')
|
||||
|
||||
@staticmethod
|
||||
def chunk_files(path: str, max_size: int):
|
||||
# -d (numeric suffixes) is not compatible with MacOS.
|
||||
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 f in tqdm(files, desc='chunk large files'):
|
||||
if f.stat().st_size / 1e6 > max_size:
|
||||
cmd = ['split', '-b', str(int(max_size * 1e6)), str(f), str(f) + '_']
|
||||
print(f'File size: {f.stat().st_size / 1e6:.2f} Mb. ' + ' '.join(cmd))
|
||||
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)
|
||||
f.unlink()
|
||||
file.unlink()
|
||||
|
||||
@staticmethod
|
||||
def export_jsonl(dataset: str, out: str):
|
||||
def export_jsonl(dataset: str, out: str) -> None:
|
||||
"""Export the dataset as JSON Lines (`{"email":..., "password":...}`)."""
|
||||
create_dir_for_file(out)
|
||||
total = 0
|
||||
with open(out, 'w', encoding='utf8') as w:
|
||||
with open(out, 'w', encoding='utf8') as writer:
|
||||
for path in tqdm(find_files(Path(dataset).expanduser()), desc='export jsonl'):
|
||||
with open(path, 'r', encoding='utf8', errors='ignore') as r:
|
||||
for line in r:
|
||||
with open(path, encoding='utf8', errors='ignore') as reader:
|
||||
for line in reader:
|
||||
email, _, password = line.rstrip('\n').partition(':')
|
||||
w.write(json.dumps({'email': email, 'password': password},
|
||||
writer.write(json.dumps({'email': email, 'password': password},
|
||||
ensure_ascii=False) + '\n')
|
||||
total += 1
|
||||
print(f'Exported {total:,} records to {out}.')
|
||||
|
||||
@staticmethod
|
||||
def filter_lines(file, out, less_than, more_than):
|
||||
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 w, \
|
||||
open(file, 'r', encoding='utf8', errors='ignore') as r:
|
||||
for line in r:
|
||||
line2 = filter_printable(line)
|
||||
if less_than <= len(line2.strip()) <= more_than:
|
||||
w.write(line2)
|
||||
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):
|
||||
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):
|
||||
def sort_unique(src, dst) -> None:
|
||||
"""Write the sorted, de-duplicated lines of ``src`` to ``dst``.
|
||||
|
||||
In-memory for small files, ``sort -u`` for large ones. ``src`` may equal
|
||||
``dst`` (in-place).
|
||||
"""
|
||||
src = Path(src)
|
||||
if src.stat().st_size <= _IN_MEMORY_SORT_LIMIT:
|
||||
lines = src.read_bytes().split(b'\n')
|
||||
@@ -159,19 +173,22 @@ def sort_unique(src, dst):
|
||||
lines = sorted(set(lines))
|
||||
Path(dst).write_bytes(b'\n'.join(lines) + b'\n' if lines else b'')
|
||||
else:
|
||||
with open(dst, 'wb') as w:
|
||||
subprocess.run(['sort', '-u', str(src)], stdout=w, check=True,
|
||||
tmp = f'{dst}.tmp'
|
||||
with open(tmp, 'wb') as writer:
|
||||
subprocess.run(['sort', '-u', str(src)], stdout=writer, check=True,
|
||||
env={**os.environ, 'LC_ALL': 'C'})
|
||||
os.replace(tmp, dst)
|
||||
|
||||
|
||||
def merge(src, dst):
|
||||
def append_file(src, dst) -> None:
|
||||
"""Append ``src`` to ``dst`` and delete ``src`` (streams, constant memory)."""
|
||||
print(f'merge {src} {dst}')
|
||||
with open(src, 'rb') as r, open(dst, 'ab') as w:
|
||||
shutil.copyfileobj(r, w, length=1024 * 1024)
|
||||
with open(src, 'rb') as reader, open(dst, 'ab') as writer:
|
||||
shutil.copyfileobj(reader, writer, length=1024 * 1024)
|
||||
print(f'del {src}')
|
||||
os.remove(src)
|
||||
|
||||
|
||||
def move(src, dst):
|
||||
def move_file(src, dst) -> None:
|
||||
print(f'mv {src} {dst}')
|
||||
shutil.move(src, dst)
|
||||
+56
-35
@@ -27,7 +27,7 @@ import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from collectionparser.api import API
|
||||
from collectionparser.api import API, find_files, sort_unique
|
||||
from collectionparser.parser import parse_files
|
||||
from collectionparser.splitter import split_file
|
||||
|
||||
@@ -41,7 +41,8 @@ def _is_archive(path: Path) -> bool:
|
||||
return path.name.lower().endswith(ARCHIVE_SUFFIXES)
|
||||
|
||||
|
||||
def _which(*names):
|
||||
def _which(*names: str) -> str | None:
|
||||
"""Return the path of the first available command among ``names``."""
|
||||
for name in names:
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
@@ -56,7 +57,7 @@ def _safe_target(dest: Path, name: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def _extract_compressed(path: Path, dest: Path):
|
||||
def _extract_compressed(path: Path, dest: Path) -> None:
|
||||
"""Try the available RAR/7z extractors in turn.
|
||||
|
||||
No single tool wins: `unrar` handles real RARs, `7z` handles the many
|
||||
@@ -65,10 +66,7 @@ def _extract_compressed(path: Path, dest: Path):
|
||||
"""
|
||||
seven = _which('7z', '7za')
|
||||
unrar = _which('unrar')
|
||||
if path.name.lower().endswith('.rar'):
|
||||
candidates = [unrar, seven]
|
||||
else:
|
||||
candidates = [seven, unrar]
|
||||
candidates = [unrar, seven] if path.name.lower().endswith('.rar') else [seven, unrar]
|
||||
last = None
|
||||
for tool in (c for c in candidates if c):
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
@@ -77,14 +75,14 @@ def _extract_compressed(path: Path, dest: Path):
|
||||
cmd = [tool, 'x', '-o+', '-idq', str(path), str(dest) + '/']
|
||||
else:
|
||||
cmd = [tool, 'x', '-y', f'-o{dest}', str(path)]
|
||||
last = subprocess.run(cmd, stdout=DEVNULL, stderr=DEVNULL).returncode
|
||||
last = subprocess.run(cmd, check=False, stdout=DEVNULL, stderr=DEVNULL).returncode
|
||||
if last in (0, 1):
|
||||
return
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
raise RuntimeError(f'extractors failed (last rc={last})')
|
||||
|
||||
|
||||
def _extract_tar(path: Path, dest: Path):
|
||||
def _extract_tar(path: Path, dest: Path) -> None:
|
||||
with tarfile.open(path) as t:
|
||||
try:
|
||||
t.extractall(dest, filter='data') # py3.12+
|
||||
@@ -92,7 +90,7 @@ def _extract_tar(path: Path, dest: Path):
|
||||
t.extractall(dest)
|
||||
|
||||
|
||||
def _extract_archive(path: Path, dest: Path):
|
||||
def _extract_archive(path: Path, dest: Path) -> None:
|
||||
name = path.name.lower()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
if name.endswith('.zip'):
|
||||
@@ -110,16 +108,16 @@ def _extract_archive(path: Path, dest: Path):
|
||||
shutil.copyfileobj(r, w, length=1 << 20)
|
||||
|
||||
|
||||
def _discover(source):
|
||||
root = Path(source).expanduser()
|
||||
files = [p for p in root.glob('**/*') if p.is_file()]
|
||||
def _discover(source: str) -> list[Path]:
|
||||
files = find_files(source)
|
||||
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)
|
||||
return files
|
||||
|
||||
|
||||
def _batches(files, batch_bytes):
|
||||
batch, size = [], 0
|
||||
def _batches(files: list[Path], batch_bytes: int):
|
||||
batch: list[Path] = []
|
||||
size = 0
|
||||
for f in files:
|
||||
sz = f.stat().st_size
|
||||
if batch and size + sz > batch_bytes:
|
||||
@@ -131,7 +129,7 @@ def _batches(files, batch_bytes):
|
||||
yield batch
|
||||
|
||||
|
||||
def _count_lines(path):
|
||||
def _count_lines(path: str | Path) -> int:
|
||||
total = 0
|
||||
with open(path, 'rb') as r:
|
||||
for chunk in iter(lambda: r.read(1 << 20), b''):
|
||||
@@ -139,7 +137,7 @@ def _count_lines(path):
|
||||
return total
|
||||
|
||||
|
||||
def _stats_markdown(state):
|
||||
def _stats_markdown(state: dict) -> str:
|
||||
stats = state['stats']
|
||||
total_in = sum(s['input_bytes'] for s in stats)
|
||||
total_pairs = sum(s['pairs'] for s in stats)
|
||||
@@ -156,6 +154,8 @@ def _stats_markdown(state):
|
||||
]
|
||||
if total_s:
|
||||
lines.append(f'- aggregate throughput: {total_in / 1e6 / total_s:.1f} MB/s')
|
||||
if state.get('hashes') is not None:
|
||||
lines.append(f'- hashed passwords collected: {state["hashes"]:,}')
|
||||
if state.get('dedup_s') is not None:
|
||||
lines.append(f'- dedup seconds: {state["dedup_s"]:,.0f}')
|
||||
if state.get('unique_records') is not None:
|
||||
@@ -163,32 +163,42 @@ def _stats_markdown(state):
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def _extract_batch(archives, extract_dir, index):
|
||||
def _extract_batch(archives: list[Path], extract_dir: Path,
|
||||
index: int) -> tuple[list[Path], list[str]]:
|
||||
"""Extract archives; return ``(files, failures)``.
|
||||
|
||||
Failures are logged, never silently dropped, so they can be recovered later.
|
||||
"""
|
||||
if not archives:
|
||||
return []
|
||||
return [], []
|
||||
t0 = time.time()
|
||||
failed = 0
|
||||
errors = []
|
||||
failures: list[str] = []
|
||||
for i, archive in enumerate(archives):
|
||||
try:
|
||||
_extract_archive(archive, extract_dir / f'{i:05d}_{archive.name}')
|
||||
except Exception as exc: # one bad archive must not stop the build
|
||||
failed += 1
|
||||
if len(errors) < 5:
|
||||
errors.append(f'{archive.name}: {exc}')
|
||||
failures.append(f'{archive}\t{exc}')
|
||||
files = [p for p in extract_dir.glob('**/*') if p.is_file()]
|
||||
print(f'batch {index}: {len(archives)} archives -> {len(files)} files, '
|
||||
f'{failed} failed ({time.time() - t0:.0f}s)', flush=True)
|
||||
if errors:
|
||||
print(f'WARN: extract errors (first {len(errors)}): {errors}', flush=True)
|
||||
return files
|
||||
f'{len(failures)} failed ({time.time() - t0:.0f}s)', flush=True)
|
||||
for failure in failures[:5]:
|
||||
print(f'WARN: {failure}', flush=True)
|
||||
return files, failures
|
||||
|
||||
|
||||
def build(source, dataset, scratch, batch_bytes=DEFAULT_BATCH_BYTES, dedup=True, extract=True):
|
||||
def build(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``.
|
||||
|
||||
``scratch`` (a fast disk) holds per-batch files, sort temp and build state.
|
||||
Safe to stop and rerun: completed batches are skipped and unparsed files of
|
||||
the current batch are re-queued.
|
||||
"""
|
||||
dataset = Path(dataset).expanduser()
|
||||
scratch = Path(scratch).expanduser()
|
||||
dataset.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
|
||||
for stale in scratch.glob('extract_*'):
|
||||
shutil.rmtree(stale, ignore_errors=True)
|
||||
|
||||
@@ -204,6 +214,8 @@ def build(source, dataset, scratch, batch_bytes=DEFAULT_BATCH_BYTES, dedup=True,
|
||||
print(f'{len(files)} files, {len(batches)} batches, {len(done)} already done.', flush=True)
|
||||
|
||||
failures = scratch / 'failures.txt'
|
||||
extract_failures = scratch / 'extract_failures.txt'
|
||||
hashes = scratch / 'hashes.txt'
|
||||
for index, batch in enumerate(batches):
|
||||
if index in done:
|
||||
continue
|
||||
@@ -217,22 +229,27 @@ def build(source, dataset, scratch, batch_bytes=DEFAULT_BATCH_BYTES, dedup=True,
|
||||
progress_path.unlink(missing_ok=True)
|
||||
parsed = set()
|
||||
if success.exists() and progress_path.exists():
|
||||
with open(progress_path, 'r', encoding='utf8', errors='ignore') as r:
|
||||
with open(progress_path, encoding='utf8', errors='ignore') as r:
|
||||
parsed = {line.rstrip('\n') for line in r}
|
||||
|
||||
inputs = [f for f in batch if not _is_archive(f)]
|
||||
if extract:
|
||||
inputs.extend(_extract_batch([f for f in batch if _is_archive(f)], extract_dir, index))
|
||||
extracted, failures_ = _extract_batch(
|
||||
[f for f in batch if _is_archive(f)], extract_dir, index)
|
||||
inputs.extend(extracted)
|
||||
if failures_:
|
||||
with open(extract_failures, 'a', encoding='utf8') as w:
|
||||
w.write('\n'.join(failures_) + '\n')
|
||||
pending = [f for f in inputs if str(f) not in parsed]
|
||||
|
||||
t0 = time.time()
|
||||
if pending:
|
||||
parse_files(pending, str(success), str(failure), progress_filename=str(progress_path))
|
||||
parse_files(pending, str(success), str(failure),
|
||||
progress_filename=str(progress_path), hashes_filename=str(hashes))
|
||||
if success.exists():
|
||||
pairs = _count_lines(success)
|
||||
rejects = _count_lines(failure) if failure.exists() else 0
|
||||
subprocess.run(['sort', '-u', '-o', str(success), str(success)], check=True,
|
||||
env={**os.environ, 'LC_ALL': 'C', 'TMPDIR': str(scratch)})
|
||||
sort_unique(success, success)
|
||||
split_file(str(success), str(dataset))
|
||||
success.unlink(missing_ok=True)
|
||||
if failure.exists():
|
||||
@@ -255,8 +272,12 @@ def build(source, dataset, scratch, batch_bytes=DEFAULT_BATCH_BYTES, dedup=True,
|
||||
(scratch / 'stats.md').write_text(_stats_markdown(state))
|
||||
print('BATCH ' + json.dumps(stat), flush=True)
|
||||
|
||||
if hashes.exists():
|
||||
sort_unique(hashes, hashes) # de-duplicate collected hashed passwords
|
||||
state['hashes'] = _count_lines(hashes)
|
||||
state_path.write_text(json.dumps(state, indent=2))
|
||||
|
||||
if dedup and not state.get('deduped'):
|
||||
os.environ['TMPDIR'] = str(scratch)
|
||||
t0 = time.time()
|
||||
API.sort(str(dataset)) # per-bucket sort -u dedups across batches
|
||||
state['dedup_s'] = round(time.time() - t0, 1)
|
||||
|
||||
+24
-13
@@ -1,24 +1,18 @@
|
||||
"""Command line entry point."""
|
||||
|
||||
import click
|
||||
|
||||
from collectionparser.api import API
|
||||
from collectionparser.query import QueryAPI
|
||||
from collectionparser.splitter import split_file
|
||||
from collectionparser.utils import (input_dir, input_file, num_open_files_limit,
|
||||
output_dir, output_file)
|
||||
|
||||
# Extraction: nohup find . -name '*.tar.gz' -execdir tar --keep-newer-files -xzvf '{}' -C ../extracted \; &
|
||||
from collectionparser.utils import input_dir, input_file, output_dir, output_file
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option('--debug/--no-debug')
|
||||
@click.pass_context
|
||||
def cli(ctx, debug):
|
||||
def cli(debug):
|
||||
if debug:
|
||||
click.echo('Debugging mode enabled.')
|
||||
if not debug and num_open_files_limit() < 60_000 and ctx.invoked_subcommand in ('split', 'clean'):
|
||||
click.echo('ERROR: Increase the limit of the number of open files to at least 60,000.')
|
||||
click.echo('ERROR: ulimit -n 60000')
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@cli.command(short_help='Converts a large FILE to a query friendly folder OUT (e.g. a/b/c). '
|
||||
@@ -58,11 +52,13 @@ def test(file: str, dataset: str):
|
||||
|
||||
@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=output_dir())
|
||||
@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())
|
||||
def parse(path, success_file, failure_file):
|
||||
API.parse(path, success_file, failure_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):
|
||||
API.parse(path, success_file, failure_file, hashes_file)
|
||||
|
||||
|
||||
@cli.command(short_help='Merges dataset SRC into dataset DEST. Duplicates are removed in the second pass.')
|
||||
@@ -109,6 +105,21 @@ def export(dataset: str, out: str):
|
||||
API.export_jsonl(dataset, out)
|
||||
|
||||
|
||||
@cli.command(short_help='Dictionary-attacks collected MD5/SHA1/SHA256 hashes.')
|
||||
@click.option('--hashes', 'hashes_path', required=True, type=input_file())
|
||||
@click.option('--wordlist', multiple=True, type=click.Path(),
|
||||
help='Plain wordlist(s); missing files are downloaded from rockyou.')
|
||||
@click.option('--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
|
||||
run_crack(hashes_path, out, wordlists=wordlist, passwords_from=passwords_from,
|
||||
workers=workers or None)
|
||||
|
||||
|
||||
@cli.command(short_help='Builds the deduped query dataset from a raw source folder in batches. '
|
||||
'Stop and rerun to resume.')
|
||||
@click.option('--source', required=True, type=input_dir())
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Dictionary attacks over the unsalted hashes collected by ``parse``.
|
||||
|
||||
Candidate sources: plain wordlists, the password column of existing
|
||||
``email:password`` datasets, and rockyou (downloaded on demand). They are merged
|
||||
through ``sort -u`` so memory stays bounded for arbitrarily large inputs.
|
||||
|
||||
Only unsalted MD5/SHA1/SHA256 are attackable; bcrypt is intentionally not
|
||||
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.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from collectionparser.api import find_files
|
||||
|
||||
DEFAULT_WORDLIST_URL = 'https://weakpass.com/download/90/rockyou.txt.gz'
|
||||
|
||||
# hex digest length -> hashlib constructor
|
||||
_ALGOS = {32: hashlib.md5, 40: hashlib.sha1, 64: hashlib.sha256}
|
||||
_HEX_DIGITS = frozenset(b'0123456789abcdefABCDEF')
|
||||
|
||||
|
||||
def download_rockyou(dest: Path) -> Path:
|
||||
"""Download and gunzip the default rockyou wordlist if not already present."""
|
||||
dest = Path(dest)
|
||||
if dest.exists() and dest.stat().st_size:
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f'Downloading wordlist {DEFAULT_WORDLIST_URL} ...', flush=True)
|
||||
with 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)
|
||||
return dest
|
||||
|
||||
|
||||
def _write_passwords(source: Path, out) -> None:
|
||||
"""Append the password column of an ``email:password`` file to ``out``."""
|
||||
with open(source, encoding='utf8', errors='ignore') as reader:
|
||||
for line in reader:
|
||||
out.write(line.partition(':')[2].encode('utf8'))
|
||||
|
||||
|
||||
def prepare_candidates(wordlists, passwords_from, work_dir: Path) -> Path:
|
||||
"""Return a single, de-duplicated wordlist built from all candidate sources."""
|
||||
work_dir = Path(work_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
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]
|
||||
if len(wordlists) == 1 and not passwords_from:
|
||||
return wordlists[0]
|
||||
|
||||
unique = work_dir / 'candidates.unique.txt'
|
||||
env = {**os.environ, 'LC_ALL': 'C', 'TMPDIR': str(work_dir)}
|
||||
process = subprocess.Popen(['sort', '-u'], stdin=subprocess.PIPE,
|
||||
stdout=open(unique, 'wb'), env=env) # noqa: SIM115
|
||||
with process.stdin as sink:
|
||||
for wordlist in wordlists:
|
||||
with open(wordlist, 'rb') as reader:
|
||||
shutil.copyfileobj(reader, sink, length=1 << 20)
|
||||
for source in passwords_from:
|
||||
root = Path(source).expanduser()
|
||||
for path in ([root] if root.is_file() else find_files(root)):
|
||||
_write_passwords(path, sink)
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError('failed to build the candidate wordlist')
|
||||
return unique
|
||||
|
||||
|
||||
def load_targets(hashes_path: str | Path) -> dict[int, dict[bytes, list[bytes]]]:
|
||||
"""Return ``{hex_length: {digest_bytes: [email, ...]}}`` from a hashes file."""
|
||||
targets: dict[int, dict[bytes, list[bytes]]] = {}
|
||||
with open(hashes_path, 'rb') as reader:
|
||||
for raw in reader:
|
||||
line = raw.rstrip(b'\r\n')
|
||||
email, separator, digest = line.rpartition(b':')
|
||||
if not separator or len(digest) not in _ALGOS or not _HEX_DIGITS.issuperset(digest):
|
||||
continue
|
||||
try:
|
||||
key = bytes.fromhex(digest.decode('ascii'))
|
||||
except ValueError:
|
||||
continue
|
||||
targets.setdefault(len(digest), {}).setdefault(key, []).append(email)
|
||||
return targets
|
||||
|
||||
|
||||
def _scan_stripe(args: tuple) -> list[bytes]:
|
||||
"""Attack one residue class of the wordlist (``index`` of every ``total``)."""
|
||||
hashes_path, wordlist_path, index, total = args
|
||||
targets = load_targets(hashes_path)
|
||||
checks = [(targets[length], _ALGOS[length]) for length in targets]
|
||||
matches: list[bytes] = []
|
||||
with open(wordlist_path, 'rb') as reader:
|
||||
for n, raw in enumerate(reader):
|
||||
if n % total != index:
|
||||
continue
|
||||
word = raw.rstrip(b'\r\n')
|
||||
if not word:
|
||||
continue
|
||||
for target, constructor in checks:
|
||||
emails = target.get(constructor(word).digest())
|
||||
if emails:
|
||||
matches.extend(email + b':' + word + b'\n' for email in emails)
|
||||
return matches
|
||||
|
||||
|
||||
def crack(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``."""
|
||||
targets = load_targets(hashes_path)
|
||||
if not targets:
|
||||
print('No crackable MD5/SHA1/SHA256 hashes found.')
|
||||
return 0
|
||||
work_dir = Path(work_dir) if work_dir else Path(out_path).parent
|
||||
wordlist = prepare_candidates(list(wordlists), list(passwords_from), work_dir)
|
||||
|
||||
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())
|
||||
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)]
|
||||
total = 0
|
||||
with open(out_path, 'wb') as writer:
|
||||
if workers == 1:
|
||||
results = map(_scan_stripe, jobs)
|
||||
else:
|
||||
executor = ProcessPoolExecutor(max_workers=workers)
|
||||
results = executor.map(_scan_stripe, jobs)
|
||||
try:
|
||||
for matches in results:
|
||||
for line in matches:
|
||||
writer.write(line)
|
||||
total += 1
|
||||
finally:
|
||||
if workers != 1:
|
||||
executor.shutdown()
|
||||
print(f'Cracked {total:,} hashes -> {out_path}.')
|
||||
return total
|
||||
+60
-28
@@ -4,6 +4,7 @@ import shutil
|
||||
import string
|
||||
import uuid
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from contextlib import ExitStack
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
@@ -38,7 +39,8 @@ def password_type(password: str) -> PasswordType:
|
||||
return PasswordType.DECRYPTED
|
||||
|
||||
|
||||
def parse_line(row, separator, flip=False):
|
||||
def parse_line(row: str, separator: str, flip: bool = False):
|
||||
"""Try to read one ``email<sep>password`` record; ``None`` if implausible."""
|
||||
if separator not in row:
|
||||
return None
|
||||
if separator != ' ':
|
||||
@@ -50,7 +52,12 @@ def parse_line(row, separator, flip=False):
|
||||
if flip:
|
||||
mail, password = password, mail
|
||||
password = password.replace(' ', '')
|
||||
if not password or len(password) > 30 or '\n\n' in password:
|
||||
if not password or '\n\n' in password:
|
||||
return None
|
||||
kind = password_type(password)
|
||||
# Plaintext beyond 30 chars is junk; hashes are longer by nature and are kept
|
||||
# so they can be attacked later with `crack`.
|
||||
if kind == PasswordType.DECRYPTED and len(password) > 30:
|
||||
return None
|
||||
if set(password) == {'?'}: # [email protected]:?????? ??? ?????
|
||||
return None
|
||||
@@ -79,11 +86,12 @@ def parse_line(row, separator, flip=False):
|
||||
if mail.count('@hotmail.com') == 2: # [email protected]@hotmail.com:8765432f
|
||||
mail = mail.replace('@hotmail.com', '', 1)
|
||||
if valid_email(mail) and not valid_email(password):
|
||||
return mail, password, password_type(password)
|
||||
return mail, password, kind
|
||||
return None
|
||||
|
||||
|
||||
def parseline(s: str):
|
||||
"""Clean one raw line and return ``(email, password, type)`` or ``None``."""
|
||||
b = filter_printable(s.strip())
|
||||
while '..' in b:
|
||||
b = b.replace('..', '')
|
||||
@@ -113,9 +121,11 @@ class Processor:
|
||||
per worker is a single read buffer regardless of file size.
|
||||
"""
|
||||
|
||||
def __init__(self, success_filename=None, failure_filename=None):
|
||||
def __init__(self, success_filename: str, failure_filename: str,
|
||||
hashes_filename: str | None = None):
|
||||
self.success_filename = success_filename
|
||||
self.failure_filename = failure_filename
|
||||
self.hashes_filename = hashes_filename
|
||||
|
||||
# Each worker writes its own buffered part file; the parent concatenates
|
||||
# parts after every chunk. Buffered writes avoid the one-syscall-per-line
|
||||
@@ -123,27 +133,39 @@ class Processor:
|
||||
# interleaving (a >4KB line would otherwise split across writes).
|
||||
# ponytail: parts cost one extra read+copy pass; merge in-worker only if
|
||||
# that pass ever dominates.
|
||||
def process(self, filename: Path):
|
||||
def process(self, filename: Path) -> tuple[int, int]:
|
||||
success = failure = 0
|
||||
# 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.
|
||||
part = uuid.uuid4().hex
|
||||
with open(str(filename), 'r', encoding='utf8', errors='ignore') as r, \
|
||||
with open(str(filename), encoding='utf8', errors='ignore') as r, \
|
||||
open(f'{self.success_filename}.part.{part}', 'w', encoding='utf8') as sw, \
|
||||
open(f'{self.failure_filename}.part.{part}', 'w', encoding='utf8') as fw:
|
||||
hashes = (open(f'{self.hashes_filename}.part.{part}', 'w', encoding='utf8') # noqa: SIM115
|
||||
if self.hashes_filename else None)
|
||||
try:
|
||||
for line in r:
|
||||
result = parseline(line)
|
||||
if result is None:
|
||||
fw.write(filter_printable(line)) # stop writing binary data to file.
|
||||
fw.write(filter_printable(line)) # stop writing binary data.
|
||||
failure += 1
|
||||
elif result[2] == PasswordType.DECRYPTED:
|
||||
mail, password, _ = result
|
||||
continue
|
||||
mail, password, kind = result
|
||||
if kind == PasswordType.DECRYPTED:
|
||||
sw.write(f'{str(mail).lower().strip()}:{password.strip()}\n')
|
||||
success += 1
|
||||
elif hashes is not None:
|
||||
hashes.write(f'{str(mail).lower().strip()}:{password}\n')
|
||||
else:
|
||||
fw.write(filter_printable(line)) # no hash sink: keep as reject
|
||||
failure += 1
|
||||
finally:
|
||||
if hashes is not None:
|
||||
hashes.close()
|
||||
return success, failure
|
||||
|
||||
|
||||
def _clear_parts(*finals):
|
||||
def _clear_parts(*finals: str) -> None:
|
||||
# Leftovers mean a previous run died before merging. Those files were never
|
||||
# recorded in progress.txt, so they are reprocessed; drop the partial parts.
|
||||
for final in finals:
|
||||
@@ -151,7 +173,7 @@ def _clear_parts(*finals):
|
||||
os.remove(part)
|
||||
|
||||
|
||||
def _merge_parts(final):
|
||||
def _merge_parts(final: str) -> None:
|
||||
parts = glob(f'{final}.part.*')
|
||||
if not parts:
|
||||
return
|
||||
@@ -162,15 +184,24 @@ def _merge_parts(final):
|
||||
os.remove(part)
|
||||
|
||||
|
||||
def parse_files(files, success_filename, failure_filename, progress_filename=None):
|
||||
"""Parse an explicit list of files; used by both `parse` and `build`."""
|
||||
_clear_parts(success_filename, failure_filename)
|
||||
def parse_files(files: list[Path], 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``.
|
||||
|
||||
Returns the total ``(success, failure)`` counts. Hashed passwords go to
|
||||
``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.
|
||||
"""
|
||||
_clear_parts(success_filename, failure_filename,
|
||||
*([hashes_filename] if hashes_filename else []))
|
||||
success_total = failure_total = 0
|
||||
workers = os.cpu_count() or 1
|
||||
processor = Processor(success_filename, failure_filename)
|
||||
processor = Processor(success_filename, failure_filename, hashes_filename)
|
||||
start_time = time()
|
||||
progress = open(progress_filename, 'a+', encoding='utf8') if progress_filename else None
|
||||
try:
|
||||
with ExitStack() as stack:
|
||||
progress = (stack.enter_context(open(progress_filename, 'a', encoding='utf8'))
|
||||
if progress_filename else None)
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor, tqdm(files) as bar:
|
||||
for i in range(0, len(files), workers):
|
||||
chunk = files[i:i + workers]
|
||||
@@ -181,25 +212,26 @@ def parse_files(files, success_filename, failure_filename, progress_filename=Non
|
||||
# a crash never records a file whose output was not written.
|
||||
_merge_parts(success_filename)
|
||||
_merge_parts(failure_filename)
|
||||
if hashes_filename:
|
||||
_merge_parts(hashes_filename)
|
||||
bar.update(len(chunk))
|
||||
bar.set_description(f'success = {success_total:,} failure = {failure_total:,}')
|
||||
if progress:
|
||||
for path in chunk:
|
||||
if progress:
|
||||
progress.write(f'{path}\n')
|
||||
finally:
|
||||
if progress:
|
||||
progress.close()
|
||||
print(f'Time elapsed {time() - start_time:.3f} seconds.')
|
||||
return success_total, failure_total
|
||||
|
||||
|
||||
def parse_to_files(input_dir, success_filename, failure_filename):
|
||||
# Restart from where we stopped. Stream the progress file; never build a
|
||||
# second in-memory copy of it (it can grow to millions of lines).
|
||||
done = set()
|
||||
def parse_to_files(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``."""
|
||||
# Stream the progress file; never build a second in-memory copy of it (it
|
||||
# can grow to millions of lines).
|
||||
done: set[str] = set()
|
||||
if os.path.exists('progress.txt'):
|
||||
with open('progress.txt', 'r', encoding='utf8', errors='ignore') as p:
|
||||
done = {line.rstrip('\n') for line in p}
|
||||
with open('progress.txt', encoding='utf8', errors='ignore') as 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]
|
||||
shuffle(files)
|
||||
parse_files(files, success_filename, failure_filename, 'progress.txt')
|
||||
parse_files(files, success_filename, failure_filename, 'progress.txt', hashes_filename)
|
||||
+39
-33
@@ -1,12 +1,15 @@
|
||||
"""Exact and wildcard queries over a bucketed dataset."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_emails_and_passwords(txt_lines):
|
||||
emails_passwords = []
|
||||
for txt_line in txt_lines:
|
||||
def postprocess(results: list[str]) -> list[str]:
|
||||
"""Normalise ``email:password`` lines into a sorted, de-duplicated list."""
|
||||
emails_passwords: set[str] = set()
|
||||
for txt_line in results:
|
||||
if '@' not in txt_line: # does it contain an email address?
|
||||
continue
|
||||
if ':' in txt_line: # '[email protected]:iskandar89
|
||||
@@ -19,46 +22,47 @@ def extract_emails_and_passwords(txt_lines):
|
||||
email, password = txt_line.strip().split(separator)
|
||||
except ValueError:
|
||||
continue
|
||||
emails_passwords.append((email, password))
|
||||
return emails_passwords
|
||||
|
||||
|
||||
def postprocess(results):
|
||||
return sorted({':'.join(pair) for pair in extract_emails_and_passwords(results)})
|
||||
emails_passwords.add(f'{email}:{password}')
|
||||
return sorted(emails_passwords)
|
||||
|
||||
|
||||
def glob_to_regex(pattern: str) -> str:
|
||||
"""Glob -> regex anchored to the email field (`*`/`?` never cross the ':').
|
||||
"""Convert a glob to a regex anchored to the email field.
|
||||
|
||||
ponytail: `*` matches within the email only; a `*` that must cross the
|
||||
separator needs a different index.
|
||||
``*`` and ``?`` never cross the ``:`` separator.
|
||||
ponytail: ``*`` matches within the email only; a wildcard that must cross
|
||||
the separator needs a different index.
|
||||
"""
|
||||
parts = []
|
||||
for ch in pattern:
|
||||
if ch == '*':
|
||||
for char in pattern:
|
||||
if char == '*':
|
||||
parts.append('[^:]*')
|
||||
elif ch == '?':
|
||||
elif char == '?':
|
||||
parts.append('[^:]')
|
||||
else:
|
||||
parts.append(re.escape(ch))
|
||||
parts.append(re.escape(char))
|
||||
return '^' + ''.join(parts) + ':'
|
||||
|
||||
|
||||
class QueryAPI:
|
||||
"""Read-only lookups: O(1) exact match or a ripgrep wildcard scan."""
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=4096) # ponytail: cached dir listing is stale if the index changes mid-run
|
||||
def scanning(directory: Path):
|
||||
file_list, dir_list = {}, {}
|
||||
for x in directory.iterdir():
|
||||
if x.is_file():
|
||||
file_list[x.name] = x
|
||||
@lru_cache(maxsize=4096) # ponytail: stale if the index changes mid-run
|
||||
def scanning(directory: Path) -> tuple[dict[str, Path], dict[str, Path]]:
|
||||
"""Split a directory's entries into (files, subdirectories)."""
|
||||
file_list: dict[str, Path] = {}
|
||||
dir_list: dict[str, Path] = {}
|
||||
for entry in directory.iterdir():
|
||||
if entry.is_file():
|
||||
file_list[entry.name] = entry
|
||||
else:
|
||||
dir_list[x.name] = x
|
||||
dir_list[entry.name] = entry
|
||||
return file_list, dir_list
|
||||
|
||||
@staticmethod
|
||||
def search_in_file(path: Path, email):
|
||||
def search_in_file(path: Path, email: str) -> list[str]:
|
||||
"""Return matching lines for ``email`` from a single bucket file."""
|
||||
assert path.is_file(), path
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
@@ -68,21 +72,23 @@ class QueryAPI:
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
@staticmethod
|
||||
def recursive_search(path: Path, characters: list, index: int):
|
||||
def recursive_search(path: Path, characters: list[str], index: int) -> list[str]:
|
||||
"""Descend the bucket tree one character at a time, then search."""
|
||||
if index >= len(characters):
|
||||
return []
|
||||
file_list, dir_list = QueryAPI.scanning(path)
|
||||
cur = characters[index].lower()
|
||||
if cur in file_list:
|
||||
return QueryAPI.search_in_file(file_list[cur], ''.join(characters))
|
||||
if cur in dir_list:
|
||||
return QueryAPI.recursive_search(dir_list[cur], characters, index + 1)
|
||||
current = characters[index].lower()
|
||||
if current in file_list:
|
||||
return QueryAPI.search_in_file(file_list[current], ''.join(characters))
|
||||
if current in dir_list:
|
||||
return QueryAPI.recursive_search(dir_list[current], characters, index + 1)
|
||||
if 'symbols' in file_list:
|
||||
return QueryAPI.search_in_file(file_list['symbols'], ''.join(characters))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def query(data_dir: str, request: str, query_type: str):
|
||||
def query(data_dir: str, request: str, query_type: str) -> list[str]:
|
||||
"""Exact email lookup using the bucket path (O(1) file read)."""
|
||||
if len(request) <= 3:
|
||||
return []
|
||||
if query_type == 'email' and '@' not in request:
|
||||
@@ -90,8 +96,8 @@ class QueryAPI:
|
||||
return postprocess(QueryAPI.recursive_search(Path(data_dir), list(request), 0))
|
||||
|
||||
@staticmethod
|
||||
def wildcard(data_dir: str, pattern: str):
|
||||
"""Substring/wildcard search across the whole dataset with ripgrep.
|
||||
def wildcard(data_dir: str, pattern: str) -> list[str]:
|
||||
"""Glob search across the whole dataset with ripgrep.
|
||||
|
||||
ponytail: full scan of the tree (ripgrep is fast enough); add a
|
||||
domain-sharded index only if wildcard latency becomes a problem.
|
||||
|
||||
@@ -1,47 +1,63 @@
|
||||
"""Turn a large ``email:password`` file into a query-friendly ``a/b/c`` tree."""
|
||||
|
||||
import io
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from itertools import takewhile
|
||||
from time import time
|
||||
|
||||
from collectionparser.utils import RingDictionary, create_dir_for_file
|
||||
from collectionparser.utils import create_dir_for_file
|
||||
|
||||
_ALNUM = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
|
||||
def split_file(breach_compilation_file, output_dir='out', restart_from=0):
|
||||
# This can leave a few records in the wrong bucket; run `clean` afterwards.
|
||||
# The cause is not fully understood, likely a limit on how many output files
|
||||
# can stay open at once combined with buffering.
|
||||
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.
|
||||
|
||||
Resume from the ``restart_from``-th input line. A few records may land in
|
||||
the wrong bucket; run ``clean`` afterwards.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
files_handlers = RingDictionary()
|
||||
t1 = time()
|
||||
with open(breach_compilation_file, 'r', encoding='utf8', errors='ignore') as r:
|
||||
for i, line in enumerate(r):
|
||||
handles: dict[str, object] = {}
|
||||
tick = time()
|
||||
with open(input_file, encoding='utf8', errors='ignore') as reader:
|
||||
for i, line in enumerate(reader):
|
||||
if i % 10_000_000 == 0:
|
||||
elapsed = time() - t1
|
||||
t1 = time()
|
||||
elapsed = time() - tick
|
||||
tick = time()
|
||||
print(f'|- Processed {i:,} rows in {elapsed:.2f} seconds.')
|
||||
if i < restart_from or ':' not in line:
|
||||
continue
|
||||
email = line.strip().split(':', 1)[0]
|
||||
write_line_to_file(files_handlers, 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(files_handlers, line, output_file, buffer_scale=100):
|
||||
try: # faster than if: https://stackoverflow.com/questions/1835756/using-try-vs-if-in-python
|
||||
files_handlers[output_file].write(line)
|
||||
def write_line_to_file(handles: dict[str, object], line: str, output_file: str,
|
||||
buffer_scale: int = 100, max_open: int = 100) -> None:
|
||||
"""Append ``line`` to ``output_file``, keeping at most ``max_open`` handles.
|
||||
|
||||
Evicted handles are flushed and closed so the process stays under the file
|
||||
descriptor limit.
|
||||
"""
|
||||
try:
|
||||
handles[output_file].write(line) # type: ignore[attr-defined]
|
||||
except KeyError:
|
||||
if len(handles) >= max_open:
|
||||
handles.pop(next(iter(handles))).flush() # type: ignore[attr-defined]
|
||||
create_dir_for_file(output_file)
|
||||
files_handlers[output_file] = open(output_file, mode='a+', encoding='utf8',
|
||||
handles[output_file] = open( # noqa: SIM115 - cached handle, not scope-bound
|
||||
output_file, mode='a+', encoding='utf8',
|
||||
buffering=buffer_scale * io.DEFAULT_BUFFER_SIZE)
|
||||
files_handlers[output_file].write(line)
|
||||
handles[output_file].write(line) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@lru_cache(maxsize=8192)
|
||||
def get_output_file(email: str) -> str:
|
||||
"""Map an email to a b/c/d path using its first three alphanumeric chars."""
|
||||
letters = list(takewhile(_ALNUM.__contains__, email.lower()[:3]))
|
||||
if len(letters) < 3:
|
||||
letters.append('symbols')
|
||||
return '/'.join(letters)
|
||||
"""Map an email to its bucket path from its first three alphanumeric chars."""
|
||||
parts: list[str] = []
|
||||
for char in email.lower()[:3]:
|
||||
if char not in _ALNUM:
|
||||
break
|
||||
parts.append(char)
|
||||
if len(parts) < 3:
|
||||
parts.append('symbols')
|
||||
return '/'.join(parts)
|
||||
+18
-43
@@ -1,89 +1,64 @@
|
||||
"""Small shared helpers: deterministic shuffling, line streaming, path handling."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
from enum import StrEnum
|
||||
from resource import RLIMIT_NOFILE, getrlimit
|
||||
|
||||
import click
|
||||
|
||||
_NON_PRINTABLE = re.compile(f'[^{re.escape(string.printable)}]')
|
||||
|
||||
|
||||
def shuffle(items):
|
||||
random.seed(123)
|
||||
random.shuffle(items)
|
||||
|
||||
|
||||
def num_open_files_limit() -> int:
|
||||
return getrlimit(RLIMIT_NOFILE)[0]
|
||||
def shuffle(items: list) -> None:
|
||||
"""Shuffle ``items`` in place with a fixed seed (reproducible ordering)."""
|
||||
random.Random(123).shuffle(items)
|
||||
|
||||
|
||||
def filter_printable(text: str) -> str:
|
||||
"""Drop non-printable characters so no binary data is written to text files."""
|
||||
return _NON_PRINTABLE.sub('', text)
|
||||
|
||||
|
||||
def iter_lines(filename):
|
||||
with open(filename, 'r', encoding='utf8', errors='ignore') as r:
|
||||
for line in r:
|
||||
def iter_lines(filename: str):
|
||||
"""Yield lines from ``filename`` without the trailing newline, streaming."""
|
||||
with open(filename, encoding='utf8', errors='ignore') as reader:
|
||||
for line in reader:
|
||||
yield line.rstrip('\n')
|
||||
|
||||
|
||||
def create_dir_for_file(filename):
|
||||
def create_dir_for_file(filename: str) -> None:
|
||||
"""Create the parent directory of ``filename`` if it is missing."""
|
||||
if parent := os.path.dirname(filename):
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
def input_file():
|
||||
def input_file() -> click.Path:
|
||||
return click.Path(exists=True, file_okay=True, dir_okay=False,
|
||||
writable=False, readable=True, resolve_path=True)
|
||||
|
||||
|
||||
def input_dir():
|
||||
def input_dir() -> click.Path:
|
||||
return click.Path(exists=True, file_okay=False, dir_okay=True,
|
||||
writable=False, readable=True, resolve_path=True)
|
||||
|
||||
|
||||
def output_file():
|
||||
def output_file() -> click.Path:
|
||||
return click.Path(exists=False, file_okay=True, dir_okay=False,
|
||||
writable=True, readable=True, resolve_path=True)
|
||||
|
||||
|
||||
def output_dir():
|
||||
def output_dir() -> click.Path:
|
||||
return click.Path(exists=False, file_okay=False, dir_okay=True,
|
||||
writable=True, readable=True, resolve_path=True)
|
||||
|
||||
|
||||
class PasswordType(StrEnum):
|
||||
"""Classification of a password field; only ``DECRYPTED`` is queryable."""
|
||||
|
||||
MD5 = 'MD5'
|
||||
SHA1 = 'SHA1'
|
||||
SHA256 = 'SHA256'
|
||||
B_CRYPT = 'B_CRYPT'
|
||||
DECRYPTED = 'DECRYPTED'
|
||||
|
||||
|
||||
class RingDictionary:
|
||||
"""Dict of at most ``max_num_keys`` entries; evicting flushes the oldest.
|
||||
|
||||
Used to keep a bounded number of open file handles while splitting a large
|
||||
file into many output files.
|
||||
"""
|
||||
|
||||
def __init__(self, max_num_keys=100):
|
||||
self.d = {}
|
||||
self.k = []
|
||||
self.max_num_keys = max_num_keys
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.d[key]
|
||||
|
||||
def __setitem__(self, key, item):
|
||||
if key not in self.d:
|
||||
if len(self.k) >= self.max_num_keys:
|
||||
oldest = self.k.pop(0)
|
||||
try:
|
||||
self.d.pop(oldest).flush()
|
||||
except Exception:
|
||||
pass
|
||||
self.k.append(key)
|
||||
self.d[key] = item
|
||||
+20
-1
@@ -5,8 +5,18 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "collectionparser"
|
||||
version = "3.0.0"
|
||||
description = "Command line tools to parse and query multi-billion password collections."
|
||||
description = "Parse, deduplicate, and query multi-billion password collections."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["passwords", "breach", "email", "dataset", "search"]
|
||||
classifiers = [
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Topic :: Security",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"click>=8.1",
|
||||
"tqdm>=4.66",
|
||||
@@ -14,9 +24,18 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8"]
|
||||
dev = ["pytest>=8", "ruff>=0.6"]
|
||||
|
||||
[project.scripts]
|
||||
collectionparser = "collectionparser.cli:cli"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["collectionparser*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
src = ["collectionparser", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM"]
|
||||
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
|
||||
from collectionparser.crack import crack
|
||||
from collectionparser.parser import Processor
|
||||
|
||||
|
||||
class CrackTest(unittest.TestCase):
|
||||
|
||||
def test_hash_saved_by_processor(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / 'in.txt'
|
||||
src.write_text('[email protected]:5f4dcc3b5aa765d61d8327deb882cf99\n[email protected]:plainpw\n',
|
||||
encoding='utf8')
|
||||
processor = Processor(str(Path(tmp) / 's.txt'), str(Path(tmp) / 'f.txt'),
|
||||
str(Path(tmp) / 'h.txt'))
|
||||
processor.process(src)
|
||||
hashes = ''.join(Path(p).read_text(encoding='utf8')
|
||||
for p in glob(str(Path(tmp) / 'h.txt.part.*')))
|
||||
success = ''.join(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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
hashes = Path(tmp) / 'hashes.txt'
|
||||
hashes.write_bytes(
|
||||
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]:not-a-hash\n')
|
||||
wordlist = Path(tmp) / 'words.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)
|
||||
self.assertEqual(2, total)
|
||||
self.assertEqual({b'[email protected]:hunter2', b'[email protected]:secret'},
|
||||
set(out.read_bytes().splitlines()))
|
||||
|
||||
def test_crack_from_plaintext_passwords(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
hashes = Path(tmp) / 'hashes.txt'
|
||||
hashes.write_bytes(b'[email protected]:' + hashlib.md5(b'reused').hexdigest().encode() + b'\n')
|
||||
empty = Path(tmp) / 'empty.txt'
|
||||
empty.write_bytes(b'')
|
||||
plaintext = Path(tmp) / 'plain.txt'
|
||||
plaintext.write_bytes(b'[email protected]:reused\n')
|
||||
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([b'[email protected]:reused'], out.read_bytes().splitlines())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -3,6 +3,7 @@ from time import time
|
||||
|
||||
from collectionparser.parser import parseline as p
|
||||
from collectionparser.splitter import get_output_file
|
||||
from collectionparser.utils import PasswordType
|
||||
|
||||
|
||||
class ParserTest(unittest.TestCase):
|
||||
@@ -97,7 +98,10 @@ class ParserTest(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(p('300396 matelymaly [email protected] 222.155.173.78 '
|
||||
'3d2c5414a041f518b03b750e62e4ebc3:Nhc'))
|
||||
self.assertIsNone(p('05-02-2012;5721869;[email protected]:f02368945726d5fc2a14eb576f7276c0'))
|
||||
# 32-char hex is a hash, not junk: kept (typed) so `crack` can attack it.
|
||||
hashed = p('05-02-2012;5721869;[email protected]:f02368945726d5fc2a14eb576f7276c0')
|
||||
self.assertIsNotNone(hashed)
|
||||
self.assertEqual(PasswordType.MD5, hashed[2])
|
||||
self.assertIsNotNone(p('[email protected]::g1u3ck'))
|
||||
self.assertIsNotNone(p('[email protected]:[email protected]:2904198'))
|
||||
self.assertIsNotNone(p('sanda [email protected]:vasafultv'))
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from collectionparser.utils import RingDictionary
|
||||
|
||||
|
||||
class UtilsTest(unittest.TestCase):
|
||||
|
||||
def test_ring_dictionary_1(self):
|
||||
rd = RingDictionary(max_num_keys=3)
|
||||
rd['a'] = 2
|
||||
self.assertListEqual(sorted(rd.d), ['a'])
|
||||
self.assertListEqual(sorted(rd.k), ['a'])
|
||||
rd['b'] = 2
|
||||
self.assertListEqual(sorted(rd.d), ['a', 'b'])
|
||||
self.assertListEqual(sorted(rd.k), ['a', 'b'])
|
||||
rd['c'] = 2
|
||||
self.assertListEqual(sorted(rd.d), ['a', 'b', 'c'])
|
||||
self.assertListEqual(sorted(rd.k), ['a', 'b', 'c'])
|
||||
rd['d'] = 2
|
||||
self.assertListEqual(sorted(rd.d), ['b', 'c', 'd'])
|
||||
self.assertListEqual(sorted(rd.k), ['b', 'c', 'd'])
|
||||
|
||||
rd = RingDictionary(max_num_keys=1)
|
||||
rd['c'] = 1
|
||||
rd['c'] = 2
|
||||
rd['c'] = 3
|
||||
self.assertListEqual(sorted(rd.d), ['c'])
|
||||
self.assertListEqual(sorted(rd.k), ['c'])
|
||||
|
||||
rd['d'] = 3
|
||||
self.assertListEqual(sorted(rd.d), ['d'])
|
||||
self.assertListEqual(sorted(rd.k), ['d'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user