scancrypt 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rprt/__init__.py +1 -0
- rprt/__main__.py +8 -0
- rprt/batch.py +186 -0
- rprt/carve.py +186 -0
- rprt/cli.py +700 -0
- rprt/engine.py +832 -0
- rprt/fingerprint.py +256 -0
- rprt/forensics.py +158 -0
- rprt/formats.py +179 -0
- rprt/gui.py +2044 -0
- rprt/ntfs.py +1055 -0
- rprt/ransomnote.py +139 -0
- rprt/report.py +525 -0
- rprt/signatures.py +183 -0
- rprt/signatures.yaml +111 -0
- rprt/source.py +133 -0
- rprt/sqlpages.py +309 -0
- rprt/stats.py +106 -0
- rprt/supervise.py +485 -0
- rprt/worker.py +140 -0
- scancrypt-1.0.0.dist-info/METADATA +270 -0
- scancrypt-1.0.0.dist-info/RECORD +27 -0
- scancrypt-1.0.0.dist-info/WHEEL +5 -0
- scancrypt-1.0.0.dist-info/entry_points.txt +5 -0
- scancrypt-1.0.0.dist-info/licenses/LICENSE +201 -0
- scancrypt-1.0.0.dist-info/licenses/NOTICE +14 -0
- scancrypt-1.0.0.dist-info/top_level.txt +1 -0
rprt/cli.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""rprt.cli — command-line entry point (kept alongside the GUI for scripting/CI use)."""
|
|
2
|
+
import argparse
|
|
3
|
+
import faulthandler
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import engine
|
|
9
|
+
|
|
10
|
+
# Dump a native traceback if the process is killed by a fatal signal (segfault, abort). On a
|
|
11
|
+
# hostile/half-destroyed disk the parser can, in principle, crash below the Python level; this
|
|
12
|
+
# turns a silent death into a diagnosable stack. Guard on stderr: a windowed spawn can have
|
|
13
|
+
# sys.stderr = None, where enable() would raise.
|
|
14
|
+
if sys.stderr is not None:
|
|
15
|
+
faulthandler.enable()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _contact_from_args(args):
|
|
19
|
+
"""Optional IronSights-style contact block on the report. CLI flags win; otherwise fall back
|
|
20
|
+
to environment variables so a firm can configure it once. None keeps the report neutral."""
|
|
21
|
+
from . import report as report_mod
|
|
22
|
+
contact = report_mod.contact_from_env()
|
|
23
|
+
if args.firm_name:
|
|
24
|
+
return {"firm": args.firm_name, "url": args.firm_url or "",
|
|
25
|
+
"blurb": args.firm_blurb or ""}
|
|
26
|
+
if contact and (args.firm_url or args.firm_blurb):
|
|
27
|
+
return {**contact, "url": args.firm_url or contact.get("url", ""),
|
|
28
|
+
"blurb": args.firm_blurb or contact.get("blurb", "")}
|
|
29
|
+
return contact
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv=None):
|
|
33
|
+
ap = argparse.ArgumentParser(
|
|
34
|
+
prog="rprt",
|
|
35
|
+
description="Ransomware partial-recovery entropy mapper. Read-only scan; "
|
|
36
|
+
"never modifies the input.",
|
|
37
|
+
)
|
|
38
|
+
ap.add_argument("path", help="file, disk image, or (Windows, as Administrator) a "
|
|
39
|
+
"raw device path like \\\\.\\PhysicalDrive1 or \\\\.\\D:")
|
|
40
|
+
ap.add_argument("--full", action="store_true", help="force full block-by-block scan")
|
|
41
|
+
ap.add_argument("--boundary-only", action="store_true",
|
|
42
|
+
help="accept the fast front-boundary result and never escalate to a full "
|
|
43
|
+
"block scan, even if high-entropy content is seen past the boundary "
|
|
44
|
+
"(avoids a whole-file read on a large slow disk)")
|
|
45
|
+
ap.add_argument("--block-size", type=int, default=8192)
|
|
46
|
+
ap.add_argument("--sample-size", type=int, default=65536)
|
|
47
|
+
ap.add_argument("--json", metavar="FILE", help="write full report as JSON")
|
|
48
|
+
ap.add_argument("--report", metavar="FILE", help="write a standalone HTML triage report")
|
|
49
|
+
ap.add_argument("--hash", action="store_true",
|
|
50
|
+
help="compute the input SHA-256 for the report (slow on large disks)")
|
|
51
|
+
ap.add_argument("--extract", metavar="FILE", help="write recovered intact bytes here")
|
|
52
|
+
ap.add_argument("--extract-volume", metavar="FILE",
|
|
53
|
+
help="cut a detected intact NTFS volume out of the scanned image as a "
|
|
54
|
+
"standalone partition image (opens in 7-Zip and forensic tools)")
|
|
55
|
+
ap.add_argument("--list-files", action="store_true",
|
|
56
|
+
help="list files in an intact NTFS volume found in the recoverable region")
|
|
57
|
+
ap.add_argument("--extract-files", metavar="DIR",
|
|
58
|
+
help="extract files from the intact NTFS volume to DIR (structure-aware)")
|
|
59
|
+
ap.add_argument("--ntfs-offset", type=lambda x: int(x, 0),
|
|
60
|
+
help="byte offset of the NTFS boot sector (else auto-detected from the scan)")
|
|
61
|
+
ap.add_argument("--min-file-size", type=int, default=0,
|
|
62
|
+
help="only list/extract volume files at least this many bytes")
|
|
63
|
+
ap.add_argument("--recover-files", metavar="DIR",
|
|
64
|
+
help="recover files from a virtual disk (VHDX/VHD/VMDK) to DIR, including "
|
|
65
|
+
"one whose header the ransomware encrypted: locates the NTFS volume "
|
|
66
|
+
"(rebuilding a dynamic disk from its surviving block map) and extracts")
|
|
67
|
+
ap.add_argument("--jsonl", action="store_true",
|
|
68
|
+
help="with --recover-files, emit newline-delimited JSON progress events on "
|
|
69
|
+
"stdout (used by the GUI when it supervises this as a child process)")
|
|
70
|
+
ap.add_argument("--debug", action="store_true",
|
|
71
|
+
help="with --recover-files, write a verbose diagnostic log (includes file "
|
|
72
|
+
"paths, which may contain filenames from the disk)")
|
|
73
|
+
ap.add_argument("--list-volume", action="store_true",
|
|
74
|
+
help="list every file in the NTFS volume inside a virtual disk (streams "
|
|
75
|
+
"path+size); used by the GUI's browse/search over the volume")
|
|
76
|
+
ap.add_argument("--serve", action="store_true",
|
|
77
|
+
help="open the volume once and answer directory-listing requests read as "
|
|
78
|
+
"JSON lines on stdin; used by the GUI's lazy browse tree so a folder "
|
|
79
|
+
"is read only when it is expanded")
|
|
80
|
+
ap.add_argument("--only-paths", metavar="FILE",
|
|
81
|
+
help="with --recover-files, extract only the volume paths listed in FILE "
|
|
82
|
+
"(one per line); used by the GUI's extract-selected flow")
|
|
83
|
+
ap.add_argument("--validate-sql", action="store_true",
|
|
84
|
+
help="validate the path as a recovered SQL Server MDF/NDF at the page "
|
|
85
|
+
"level (recovery-quality check) instead of an entropy scan")
|
|
86
|
+
ap.add_argument("--sample", type=int, default=1,
|
|
87
|
+
help="with --validate-sql, check every Nth page (default 1 = all)")
|
|
88
|
+
ap.add_argument("--fingerprint", action="store_true",
|
|
89
|
+
help="emit a privacy-safe family signature stub for this encrypted file "
|
|
90
|
+
"(extension, magic-candidate bytes, encryption pattern, nearby ransom "
|
|
91
|
+
"note) and a pre-filled GitHub issue link, to help ScanCrypt recognise "
|
|
92
|
+
"a new strain. Reads no file contents.")
|
|
93
|
+
ap.add_argument("--carve", metavar="DIR",
|
|
94
|
+
help="carve loose files from the recoverable region into DIR with PhotoRec")
|
|
95
|
+
ap.add_argument("--carve-source", action="store_true",
|
|
96
|
+
help="with --carve, run PhotoRec on the whole input instead of first "
|
|
97
|
+
"isolating the intact byte-range")
|
|
98
|
+
ap.add_argument("--csv", metavar="FILE",
|
|
99
|
+
help="with a directory path (batch mode), write a per-file CSV")
|
|
100
|
+
ap.add_argument("--min-size", type=int, default=1,
|
|
101
|
+
help="batch mode: skip files smaller than this many bytes")
|
|
102
|
+
ap.add_argument("--audit-log", metavar="FILE",
|
|
103
|
+
help="write a tamper-evident (hash-chained) JSONL audit trail; enables "
|
|
104
|
+
"before/after integrity hashing of the input")
|
|
105
|
+
ap.add_argument("--case-id", help="case identifier for the audit log / report")
|
|
106
|
+
ap.add_argument("--examiner", help="examiner name for the audit log / report")
|
|
107
|
+
ap.add_argument("--evidence-id", help="evidence identifier for the audit log / report")
|
|
108
|
+
ap.add_argument("--firm-name", help="add a 'recovery assistance' contact block to the HTML "
|
|
109
|
+
"report (defaults to $RPRT_FIRM_NAME)")
|
|
110
|
+
ap.add_argument("--firm-url", help="link for the --firm-name contact block "
|
|
111
|
+
"(defaults to $RPRT_FIRM_URL)")
|
|
112
|
+
ap.add_argument("--firm-blurb", help="custom text for the contact block "
|
|
113
|
+
"(defaults to $RPRT_FIRM_BLURB)")
|
|
114
|
+
args = ap.parse_args(argv)
|
|
115
|
+
|
|
116
|
+
contact = _contact_from_args(args)
|
|
117
|
+
|
|
118
|
+
# Progress rendering. On a TTY: overwrite one line in place (throttled to 0.1% so we
|
|
119
|
+
# don't flush thousands of identical frames). Piped/redirected (nohup, CI, a log file):
|
|
120
|
+
# a bare '\r' just concatenates thousands of updates onto one unreadable mega-line, so
|
|
121
|
+
# instead emit a newline-terminated line only when the phase changes or every 5%.
|
|
122
|
+
_tty = sys.stderr is not None and sys.stderr.isatty()
|
|
123
|
+
_prog = {"key": None}
|
|
124
|
+
|
|
125
|
+
def progress(frac, label):
|
|
126
|
+
if sys.stderr is None:
|
|
127
|
+
return
|
|
128
|
+
pct = frac * 100
|
|
129
|
+
if _tty:
|
|
130
|
+
key = (label, round(pct, 1))
|
|
131
|
+
if key == _prog["key"]:
|
|
132
|
+
return
|
|
133
|
+
_prog["key"] = key
|
|
134
|
+
print(f"\r{label}: {pct:5.1f}%", end="", file=sys.stderr, flush=True)
|
|
135
|
+
else:
|
|
136
|
+
key = (label, int(pct // 5))
|
|
137
|
+
if key == _prog["key"]:
|
|
138
|
+
return
|
|
139
|
+
_prog["key"] = key
|
|
140
|
+
print(f"{label}: {pct:.0f}%", file=sys.stderr, flush=True)
|
|
141
|
+
|
|
142
|
+
# Browse mode: list the files in the volume (streamed), for the GUI's search/extract UI.
|
|
143
|
+
if args.list_volume:
|
|
144
|
+
return _handle_list_volume(args)
|
|
145
|
+
|
|
146
|
+
# Lazy-browse server: open the volume once and answer per-directory listing requests, so
|
|
147
|
+
# the GUI's tree reads a folder only when it is expanded.
|
|
148
|
+
if args.serve:
|
|
149
|
+
return _handle_serve(args)
|
|
150
|
+
|
|
151
|
+
# Recovered-volume file extraction is a distinct mode: it locates the NTFS volume itself
|
|
152
|
+
# (find_ntfs_volume, incl. the encrypted-header rebuild) rather than using a prior scan.
|
|
153
|
+
if args.recover_files is not None:
|
|
154
|
+
return _handle_recover_files(args)
|
|
155
|
+
|
|
156
|
+
# A directory path means incident-level batch mode: scan every file and aggregate.
|
|
157
|
+
if os.path.isdir(args.path):
|
|
158
|
+
return _handle_batch(args, progress)
|
|
159
|
+
|
|
160
|
+
if not engine.is_scannable(args.path):
|
|
161
|
+
print(f"error: {args.path} is not a readable file or raw device path", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
if args.fingerprint:
|
|
165
|
+
from . import fingerprint
|
|
166
|
+
fp = fingerprint.build(args.path)
|
|
167
|
+
print(fingerprint.render_text(fp))
|
|
168
|
+
if args.json:
|
|
169
|
+
with open(args.json, "w") as f:
|
|
170
|
+
json.dump({**fp.to_dict(), "issue_url": fingerprint.issue_url(fp),
|
|
171
|
+
"yaml_stub": fingerprint.to_yaml_stub(fp)}, f, indent=2)
|
|
172
|
+
print(f"\nfingerprint written to {args.json}", file=sys.stderr)
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
if args.validate_sql:
|
|
176
|
+
from . import sqlpages
|
|
177
|
+
result = sqlpages.validate(args.path, sample_every=max(args.sample, 1), progress=progress)
|
|
178
|
+
print(file=sys.stderr)
|
|
179
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
180
|
+
if args.json:
|
|
181
|
+
with open(args.json, "w") as f:
|
|
182
|
+
json.dump(result.to_dict(), f, indent=2)
|
|
183
|
+
print(f"validation written to {args.json}", file=sys.stderr)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
# Optional forensic audit trail: hash the input up front (proof-of-state) and open a
|
|
187
|
+
# hash-chained log; integrity is re-verified at the end.
|
|
188
|
+
audit = None
|
|
189
|
+
before_hash = None
|
|
190
|
+
if args.audit_log or args.case_id or args.examiner or args.evidence_id:
|
|
191
|
+
from . import forensics
|
|
192
|
+
case = forensics.CaseContext(case_id=args.case_id or "", examiner=args.examiner or "",
|
|
193
|
+
evidence_id=args.evidence_id or "")
|
|
194
|
+
audit = forensics.AuditLog(path=args.audit_log, case=case)
|
|
195
|
+
before_hash = forensics.sha256_file(args.path, progress=progress)
|
|
196
|
+
print(file=sys.stderr)
|
|
197
|
+
audit.input_opened(args.path, sha256=before_hash)
|
|
198
|
+
|
|
199
|
+
report = engine.scan(
|
|
200
|
+
args.path, full=args.full, sample_size=args.sample_size,
|
|
201
|
+
block_size=args.block_size, progress=progress,
|
|
202
|
+
boundary_only=args.boundary_only,
|
|
203
|
+
)
|
|
204
|
+
print(file=sys.stderr)
|
|
205
|
+
if audit:
|
|
206
|
+
audit.record("scan_complete", pattern=report.pattern,
|
|
207
|
+
recoverable_bytes=report.recoverable_bytes,
|
|
208
|
+
recoverable_pct=report.recoverable_pct,
|
|
209
|
+
family=(report.family or {}).get("family"))
|
|
210
|
+
|
|
211
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
212
|
+
|
|
213
|
+
if args.json:
|
|
214
|
+
with open(args.json, "w") as f:
|
|
215
|
+
json.dump(report.to_dict(), f, indent=2)
|
|
216
|
+
print(f"full report written to {args.json}", file=sys.stderr)
|
|
217
|
+
|
|
218
|
+
if args.report:
|
|
219
|
+
from . import report as report_mod
|
|
220
|
+
sha = before_hash
|
|
221
|
+
if sha is None and args.hash:
|
|
222
|
+
sha = report_mod.sha256_of_input(args.path, progress=progress)
|
|
223
|
+
print(file=sys.stderr)
|
|
224
|
+
report_mod.write_html_report(
|
|
225
|
+
args.path, report, args.report, sha256=sha,
|
|
226
|
+
case=(audit.case.to_dict() if audit else None),
|
|
227
|
+
contact=contact,
|
|
228
|
+
scan_params={"scan mode": "full" if args.full else "adaptive",
|
|
229
|
+
"block size": args.block_size, "sample size": args.sample_size},
|
|
230
|
+
)
|
|
231
|
+
print(f"HTML triage report written to {args.report}", file=sys.stderr)
|
|
232
|
+
|
|
233
|
+
rc = 0
|
|
234
|
+
if args.extract:
|
|
235
|
+
if report.pattern in ("fully-encrypted",):
|
|
236
|
+
print("nothing to extract: file reads as fully encrypted", file=sys.stderr)
|
|
237
|
+
rc = 1
|
|
238
|
+
else:
|
|
239
|
+
written = engine.extract_intact_ranges(args.path, report, args.extract, progress=progress)
|
|
240
|
+
print(file=sys.stderr)
|
|
241
|
+
print(f"wrote {written:,} recovered bytes to {args.extract}", file=sys.stderr)
|
|
242
|
+
if audit:
|
|
243
|
+
from . import forensics
|
|
244
|
+
out_hash = forensics.sha256_file(args.extract)
|
|
245
|
+
audit.output_written(args.extract, written, sha256=out_hash)
|
|
246
|
+
|
|
247
|
+
if rc == 0 and args.extract_volume:
|
|
248
|
+
ntfs_off = next((f["offset"] for f in report.formats if "NTFS" in f["name"]), None)
|
|
249
|
+
if ntfs_off is None:
|
|
250
|
+
print("no intact NTFS volume was detected in this scan; --extract-volume "
|
|
251
|
+
"applies to disk images and raw devices", file=sys.stderr)
|
|
252
|
+
rc = 1
|
|
253
|
+
else:
|
|
254
|
+
length = engine.ntfs_volume_length(args.path, ntfs_off)
|
|
255
|
+
written = engine.extract_range(args.path, ntfs_off, length,
|
|
256
|
+
args.extract_volume, progress=progress)
|
|
257
|
+
print(file=sys.stderr)
|
|
258
|
+
print(f"wrote {written:,}-byte NTFS volume image to {args.extract_volume} "
|
|
259
|
+
f"(open it with 7-Zip or forensic tools)", file=sys.stderr)
|
|
260
|
+
if audit:
|
|
261
|
+
from . import forensics
|
|
262
|
+
audit.output_written(args.extract_volume, written,
|
|
263
|
+
sha256=forensics.sha256_file(args.extract_volume))
|
|
264
|
+
|
|
265
|
+
if rc == 0 and (args.list_files or args.extract_files):
|
|
266
|
+
rc = _handle_ntfs(args, report, progress)
|
|
267
|
+
|
|
268
|
+
if rc == 0 and args.carve:
|
|
269
|
+
rc = _handle_carve(args, report, progress)
|
|
270
|
+
|
|
271
|
+
_finalize_audit(audit, args.path, before_hash, progress)
|
|
272
|
+
return rc
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _finalize_audit(audit, path, before_hash, progress):
|
|
276
|
+
"""Re-hash the input to prove it was never modified, record the result, and seal the
|
|
277
|
+
hash-chained log."""
|
|
278
|
+
if not audit:
|
|
279
|
+
return
|
|
280
|
+
from . import forensics
|
|
281
|
+
after_hash = forensics.sha256_file(path, progress=progress)
|
|
282
|
+
print(file=sys.stderr)
|
|
283
|
+
audit.integrity_verified(path, before_hash, after_hash)
|
|
284
|
+
audit.session_end()
|
|
285
|
+
status = "UNCHANGED" if before_hash == after_hash else "MODIFIED (!)"
|
|
286
|
+
print(f"evidence integrity: input {status} (sha256 before == after: "
|
|
287
|
+
f"{before_hash == after_hash}); audit-log seal {audit.seal()[:16]}…",
|
|
288
|
+
file=sys.stderr)
|
|
289
|
+
if audit.path:
|
|
290
|
+
print(f"tamper-evident audit log written to {audit.path}", file=sys.stderr)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _handle_batch(args, progress):
|
|
294
|
+
from . import batch
|
|
295
|
+
result = batch.scan_tree(args.path, min_size=args.min_size, full=args.full,
|
|
296
|
+
do_hash=args.hash, progress=progress)
|
|
297
|
+
print(file=sys.stderr)
|
|
298
|
+
|
|
299
|
+
summary = {k: v for k, v in result.to_dict().items() if k != "files"}
|
|
300
|
+
print(json.dumps(summary, indent=2))
|
|
301
|
+
print(f"\n{result.recoverable_pct:.1f}% of {result.total_bytes:,} bytes recoverable "
|
|
302
|
+
f"across {result.scanned:,} file(s)", file=sys.stderr)
|
|
303
|
+
|
|
304
|
+
if args.json:
|
|
305
|
+
with open(args.json, "w") as f:
|
|
306
|
+
json.dump(result.to_dict(), f, indent=2)
|
|
307
|
+
print(f"full per-file JSON written to {args.json}", file=sys.stderr)
|
|
308
|
+
if args.csv:
|
|
309
|
+
batch.write_csv(result, args.csv)
|
|
310
|
+
print(f"per-file CSV written to {args.csv}", file=sys.stderr)
|
|
311
|
+
if args.report:
|
|
312
|
+
from . import report as report_mod
|
|
313
|
+
report_mod.write_incident_report(result, args.report, contact=_contact_from_args(args))
|
|
314
|
+
print(f"incident HTML report written to {args.report}", file=sys.stderr)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _handle_carve(args, report, progress):
|
|
319
|
+
from . import carve
|
|
320
|
+
if not carve.available():
|
|
321
|
+
print("PhotoRec not found. Install TestDisk/PhotoRec or set RPRT_PHOTOREC to the "
|
|
322
|
+
"binary path.", file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
if report.pattern == "fully-encrypted":
|
|
325
|
+
print("nothing to carve: the input reads as fully encrypted", file=sys.stderr)
|
|
326
|
+
return 1
|
|
327
|
+
try:
|
|
328
|
+
summary = carve.carve_recoverable(
|
|
329
|
+
args.path, report, args.carve,
|
|
330
|
+
isolate_intact=not args.carve_source,
|
|
331
|
+
progress=progress, log=lambda line: print(line, file=sys.stderr))
|
|
332
|
+
except Exception as exc: # noqa: BLE001
|
|
333
|
+
print(f"carving failed: {exc}", file=sys.stderr)
|
|
334
|
+
return 1
|
|
335
|
+
print(file=sys.stderr)
|
|
336
|
+
print(f"carved {summary['file_count']:,} file(s), {summary['total_bytes']:,} bytes "
|
|
337
|
+
f"into {args.carve}", file=sys.stderr)
|
|
338
|
+
if summary["by_extension"]:
|
|
339
|
+
top = ", ".join(f"{ext}:{n}" for ext, n in list(summary["by_extension"].items())[:12])
|
|
340
|
+
print(f" by type: {top}", file=sys.stderr)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _ntfs_offset_from(args, report):
|
|
345
|
+
"""Resolve the NTFS volume offset: explicit --ntfs-offset, else the first NTFS
|
|
346
|
+
finding from format detection, else a boot-sector scan of the recoverable region."""
|
|
347
|
+
if args.ntfs_offset is not None:
|
|
348
|
+
return args.ntfs_offset
|
|
349
|
+
for f in report.formats:
|
|
350
|
+
if "NTFS" in f["name"]:
|
|
351
|
+
return f["offset"]
|
|
352
|
+
from . import ntfs
|
|
353
|
+
return ntfs.find_ntfs_boot_sector(args.path)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _handle_list_volume(args):
|
|
357
|
+
"""List every file in the NTFS volume inside a virtual disk, streaming path+size events.
|
|
358
|
+
The GUI runs this as a supervised child to populate its browse/search view; like
|
|
359
|
+
_handle_recover_files it never raises out, so a crash is the OS killing this process."""
|
|
360
|
+
from . import ntfs, worker
|
|
361
|
+
|
|
362
|
+
em = worker.Emitter(jsonl=args.jsonl)
|
|
363
|
+
if not ntfs.available():
|
|
364
|
+
em.emit("fatal", code="NTFS_UNAVAILABLE", message="dissect.ntfs not installed")
|
|
365
|
+
if not args.jsonl:
|
|
366
|
+
print("NTFS support needs dissect.ntfs", file=sys.stderr)
|
|
367
|
+
return worker.EXIT_FATAL
|
|
368
|
+
try:
|
|
369
|
+
method = ntfs.disk_read_method(args.path)
|
|
370
|
+
em.emit("started", reader=method)
|
|
371
|
+
em.emit("phase", name="locate_volume")
|
|
372
|
+
base = ntfs.find_ntfs_volume(args.path)
|
|
373
|
+
if base is None:
|
|
374
|
+
em.emit("fatal", code="NO_VOLUME", message="no readable NTFS volume")
|
|
375
|
+
if not args.jsonl:
|
|
376
|
+
print("no readable NTFS volume found in this disk", file=sys.stderr)
|
|
377
|
+
return worker.EXIT_NO_VOLUME
|
|
378
|
+
em.emit("phase", name="list")
|
|
379
|
+
count = 0
|
|
380
|
+
coverage = None
|
|
381
|
+
with ntfs.MountedNTFS(args.path, base) as m:
|
|
382
|
+
total = ntfs._total_mft_records(m.fs)
|
|
383
|
+
for idx, seg in ntfs._iter_records(m.fs):
|
|
384
|
+
if args.jsonl and total and idx % 4000 == 0:
|
|
385
|
+
em.emit("progress", fraction=round(min(idx / total, 0.99), 4), files=count)
|
|
386
|
+
got = ntfs._file_from_segment(seg, args.min_file_size)
|
|
387
|
+
if got is None:
|
|
388
|
+
continue
|
|
389
|
+
vpath, size = got
|
|
390
|
+
count += 1
|
|
391
|
+
em.emit("file", path=vpath, size=size)
|
|
392
|
+
if not args.jsonl:
|
|
393
|
+
print(f"{size:>14,} {vpath}")
|
|
394
|
+
coverage = ntfs.mft_coverage(m.fs)
|
|
395
|
+
missing = (coverage or {}).get("missing_in_range", 0)
|
|
396
|
+
if missing:
|
|
397
|
+
em.emit("warning", code="PARTIAL_MFT", missing_records=missing,
|
|
398
|
+
present_records=coverage.get("present_records"),
|
|
399
|
+
message="listing is partial: some MFT records were unrecoverable")
|
|
400
|
+
em.emit("completed", files=count, partial=bool(missing),
|
|
401
|
+
mft_missing_records=missing)
|
|
402
|
+
if not args.jsonl:
|
|
403
|
+
if missing:
|
|
404
|
+
print(f"WARNING: listing is PARTIAL -- {missing:,} MFT record(s) within the "
|
|
405
|
+
f"used range were unrecoverable (their disk blocks did not survive), so "
|
|
406
|
+
f"some files are missing from this list.", file=sys.stderr)
|
|
407
|
+
print(f"{count:,} file(s) in the volume"
|
|
408
|
+
f"{' (partial)' if missing else ''}", file=sys.stderr)
|
|
409
|
+
return worker.EXIT_OK
|
|
410
|
+
except KeyboardInterrupt:
|
|
411
|
+
em.emit("fatal", code="CANCELLED", message="cancelled")
|
|
412
|
+
return worker.EXIT_CANCELLED
|
|
413
|
+
except Exception as exc: # noqa: BLE001
|
|
414
|
+
em.emit("fatal", code="ERROR", message=str(exc))
|
|
415
|
+
if not args.jsonl:
|
|
416
|
+
print(f"listing failed: {exc}", file=sys.stderr)
|
|
417
|
+
return worker.EXIT_FATAL
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _handle_serve(args):
|
|
421
|
+
"""Open the NTFS volume once and answer directory-listing requests read as JSON lines on
|
|
422
|
+
stdin, one response line per request. This backs the GUI's lazy browse tree: the untrusted
|
|
423
|
+
disk is parsed a single time in this isolated child, and a folder's children are read only
|
|
424
|
+
when the tree asks for them. Like the other supervised modes it never raises out -- a crash
|
|
425
|
+
is the OS killing this process, which the parent (rprt.supervise) reports cleanly.
|
|
426
|
+
|
|
427
|
+
Requests, one JSON object per line:
|
|
428
|
+
{"op":"listdir","id":N,"path":"/Users"} -> {"type":"listing","id":N,...,"entries":[...]}
|
|
429
|
+
{"op":"listtree","id":N,"path":"/Users/x"} -> {"type":"tree","id":N,...,"files":[[p,s],...]}
|
|
430
|
+
{"op":"close"} -> exit (stdin EOF also exits cleanly)."""
|
|
431
|
+
from . import ntfs, worker
|
|
432
|
+
|
|
433
|
+
em = worker.Emitter(jsonl=True)
|
|
434
|
+
if not ntfs.available():
|
|
435
|
+
em.emit("fatal", code="NTFS_UNAVAILABLE", message="dissect.ntfs not installed")
|
|
436
|
+
return worker.EXIT_FATAL
|
|
437
|
+
try:
|
|
438
|
+
method = ntfs.disk_read_method(args.path)
|
|
439
|
+
base = ntfs.find_ntfs_volume(args.path)
|
|
440
|
+
if base is None:
|
|
441
|
+
em.emit("fatal", code="NO_VOLUME", message="no readable NTFS volume")
|
|
442
|
+
return worker.EXIT_NO_VOLUME
|
|
443
|
+
with ntfs.MountedNTFS(args.path, base) as m:
|
|
444
|
+
em.emit("ready", reader=method, base=base)
|
|
445
|
+
for line in sys.stdin: # blocks until the parent sends a request
|
|
446
|
+
line = line.strip()
|
|
447
|
+
if not line:
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
req = json.loads(line)
|
|
451
|
+
except (ValueError, TypeError):
|
|
452
|
+
continue
|
|
453
|
+
op = req.get("op")
|
|
454
|
+
if op == "close":
|
|
455
|
+
break
|
|
456
|
+
rid = req.get("id")
|
|
457
|
+
path = req.get("path") or "/"
|
|
458
|
+
try:
|
|
459
|
+
if op == "listdir":
|
|
460
|
+
entries = [{"name": e["name"], "path": e["path"],
|
|
461
|
+
"is_dir": e["is_dir"], "size": e["size"]}
|
|
462
|
+
for e in ntfs.list_dir(m.fs, path)]
|
|
463
|
+
em.emit("listing", id=rid, path=path, entries=entries)
|
|
464
|
+
elif op == "listtree":
|
|
465
|
+
cap = int(req.get("limit") or 2_000_000)
|
|
466
|
+
files, truncated = [], False
|
|
467
|
+
for p, s in ntfs.list_tree(m.fs, path):
|
|
468
|
+
files.append([p, s])
|
|
469
|
+
if len(files) >= cap:
|
|
470
|
+
truncated = True
|
|
471
|
+
break
|
|
472
|
+
em.emit("tree", id=rid, path=path, files=files, truncated=truncated)
|
|
473
|
+
else:
|
|
474
|
+
em.emit("op_error", id=rid, message=f"unknown op {op!r}")
|
|
475
|
+
except Exception as exc: # noqa: BLE001 -- one bad request can't kill the server
|
|
476
|
+
em.emit("op_error", id=rid, path=path, message=str(exc))
|
|
477
|
+
return worker.EXIT_OK
|
|
478
|
+
except KeyboardInterrupt:
|
|
479
|
+
return worker.EXIT_CANCELLED
|
|
480
|
+
except Exception as exc: # noqa: BLE001
|
|
481
|
+
em.emit("fatal", code="ERROR", message=str(exc))
|
|
482
|
+
return worker.EXIT_FATAL
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _handle_recover_files(args):
|
|
486
|
+
"""Recover files from a virtual disk to a directory, emitting the worker protocol.
|
|
487
|
+
|
|
488
|
+
This is the code path the GUI runs as a supervised child process: the only process that
|
|
489
|
+
opens and parses the untrusted disk. It reports structured progress on stdout and exits
|
|
490
|
+
with a code the parent classifies. It never raises out to the caller -- any failure
|
|
491
|
+
becomes a `fatal` event and a non-zero exit, so a crash is the OS killing this process,
|
|
492
|
+
not an unhandled exception."""
|
|
493
|
+
import faulthandler
|
|
494
|
+
import platform
|
|
495
|
+
import traceback
|
|
496
|
+
from . import __version__, ntfs, worker
|
|
497
|
+
|
|
498
|
+
em = worker.Emitter(jsonl=args.jsonl)
|
|
499
|
+
dest = args.recover_files
|
|
500
|
+
# Reader stats power the diagnostic log; turn them on for the whole run (the flag is read
|
|
501
|
+
# when each reader is constructed, so setting it here, before find_ntfs_volume, is enough).
|
|
502
|
+
ntfs._READER_STATS = True
|
|
503
|
+
ntfs._READER_STATS_TOTAL = {"readers": 0, "reads": 0, "max_len": 0, "max_off": 0,
|
|
504
|
+
"rejected": 0, "hist": {}}
|
|
505
|
+
os.makedirs(dest, exist_ok=True)
|
|
506
|
+
diag = worker.DiagLog(worker.diag_path(dest), verbose=args.debug)
|
|
507
|
+
if diag.file is not None:
|
|
508
|
+
# A native/OS-level death now leaves its traceback in the diagnostic file too.
|
|
509
|
+
faulthandler.enable(file=diag.file, all_threads=True)
|
|
510
|
+
diag.section("ScanCrypt recovery diagnostic")
|
|
511
|
+
diag.kv(version=__version__, platform=platform.platform(),
|
|
512
|
+
python=platform.python_version(),
|
|
513
|
+
disk=worker.redact_disk_name(args.path),
|
|
514
|
+
disk_size=_safe_size(args.path), verbose=args.debug)
|
|
515
|
+
|
|
516
|
+
if not ntfs.available():
|
|
517
|
+
em.emit("fatal", code="NTFS_UNAVAILABLE", message="dissect.ntfs not installed")
|
|
518
|
+
diag.line("FATAL: dissect.ntfs not installed"); diag.close()
|
|
519
|
+
if not args.jsonl:
|
|
520
|
+
print("NTFS support needs dissect.ntfs: pip install \"rprt[ntfs]\"", file=sys.stderr)
|
|
521
|
+
return worker.EXIT_FATAL
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
method = ntfs.disk_read_method(args.path)
|
|
525
|
+
diag.kv(reader=method)
|
|
526
|
+
geo = ntfs.disk_geometry(args.path)
|
|
527
|
+
if geo:
|
|
528
|
+
diag.kv(**{f"geometry_{k}": v for k, v in geo.items()})
|
|
529
|
+
em.emit("started", reader=method, dest=os.path.basename(dest))
|
|
530
|
+
em.emit("phase", name="locate_volume")
|
|
531
|
+
diag.section("locate volume")
|
|
532
|
+
base = ntfs.find_ntfs_volume(args.path)
|
|
533
|
+
diag.kv(volume_base=base)
|
|
534
|
+
if base is None:
|
|
535
|
+
em.emit("fatal", code="NO_VOLUME",
|
|
536
|
+
message="no readable NTFS volume (encryption likely reached the block "
|
|
537
|
+
"map or the volume start)")
|
|
538
|
+
diag.line("NO_VOLUME: no mountable NTFS volume found")
|
|
539
|
+
diag.close()
|
|
540
|
+
if not args.jsonl:
|
|
541
|
+
print("no readable NTFS volume found in this disk", file=sys.stderr)
|
|
542
|
+
return worker.EXIT_NO_VOLUME
|
|
543
|
+
|
|
544
|
+
only = None
|
|
545
|
+
if args.only_paths:
|
|
546
|
+
try:
|
|
547
|
+
with open(args.only_paths, encoding="utf-8") as f:
|
|
548
|
+
only = [ln.rstrip("\n") for ln in f if ln.strip()]
|
|
549
|
+
diag.kv(only_paths=len(only))
|
|
550
|
+
except OSError as exc:
|
|
551
|
+
em.emit("fatal", code="ERROR", message=f"could not read --only-paths: {exc}")
|
|
552
|
+
diag.close()
|
|
553
|
+
return worker.EXIT_FATAL
|
|
554
|
+
|
|
555
|
+
em.emit("phase", name="extract", base=base)
|
|
556
|
+
diag.section("extract")
|
|
557
|
+
counters = {"ok": 0, "failed": 0, "bytes": 0, "warned": 0}
|
|
558
|
+
|
|
559
|
+
def on_file(r):
|
|
560
|
+
rel = r.get("path", "")
|
|
561
|
+
if "error" in r:
|
|
562
|
+
counters["failed"] += 1
|
|
563
|
+
em.emit("warning", code="FILE_FAILED", path=rel, message=r["error"])
|
|
564
|
+
# Cap warning volume in the log; paths only in verbose mode.
|
|
565
|
+
if counters["warned"] < 500:
|
|
566
|
+
counters["warned"] += 1
|
|
567
|
+
diag.line(f"WARN {r['error']}" + (f" {rel}" if args.debug else ""))
|
|
568
|
+
else:
|
|
569
|
+
counters["ok"] += 1
|
|
570
|
+
counters["bytes"] += r.get("written", 0)
|
|
571
|
+
em.emit("file_recovered", path=rel, bytes_written=r.get("written", 0))
|
|
572
|
+
if args.debug:
|
|
573
|
+
diag.line(f"OK {r.get('written', 0):>14,} {rel}")
|
|
574
|
+
|
|
575
|
+
_tty = sys.stderr is not None and sys.stderr.isatty()
|
|
576
|
+
_pk = {"k": None}
|
|
577
|
+
|
|
578
|
+
def progress(frac, label):
|
|
579
|
+
em.emit("progress", fraction=round(frac, 4), files_recovered=counters["ok"],
|
|
580
|
+
bytes_written=counters["bytes"])
|
|
581
|
+
if args.jsonl or sys.stderr is None:
|
|
582
|
+
return
|
|
583
|
+
pct = frac * 100
|
|
584
|
+
if _tty: # overwrite one line, throttled to 0.1%
|
|
585
|
+
key = (label, round(pct, 1))
|
|
586
|
+
if key == _pk["k"]:
|
|
587
|
+
return
|
|
588
|
+
_pk["k"] = key
|
|
589
|
+
print(f"\r{label}: {pct:5.1f}%", end="", file=sys.stderr, flush=True)
|
|
590
|
+
else: # log: one newline-terminated line per 5%
|
|
591
|
+
key = (label, int(pct // 5))
|
|
592
|
+
if key == _pk["k"]:
|
|
593
|
+
return
|
|
594
|
+
_pk["k"] = key
|
|
595
|
+
print(f"{label}: {pct:.0f}%", file=sys.stderr, flush=True)
|
|
596
|
+
|
|
597
|
+
if only is not None:
|
|
598
|
+
# Direct path lookup per selected file -- near-instant, no full-MFT walk.
|
|
599
|
+
ntfs.extract_selected(args.path, base, dest, only,
|
|
600
|
+
progress=progress, on_file=on_file)
|
|
601
|
+
else:
|
|
602
|
+
ntfs.extract_all(args.path, base, dest, min_size=args.min_file_size,
|
|
603
|
+
progress=progress, on_file=on_file)
|
|
604
|
+
# Full recovery only: flag if MFT holes mean some files could not be listed at all.
|
|
605
|
+
coverage = None
|
|
606
|
+
if only is None:
|
|
607
|
+
try:
|
|
608
|
+
with ntfs.MountedNTFS(args.path, base) as _m:
|
|
609
|
+
coverage = ntfs.mft_coverage(_m.fs)
|
|
610
|
+
except Exception: # noqa: BLE001 -- coverage is advisory, never fatal
|
|
611
|
+
coverage = None
|
|
612
|
+
missing = (coverage or {}).get("missing_in_range", 0)
|
|
613
|
+
summary = {"files_recovered": counters["ok"], "files_failed": counters["failed"],
|
|
614
|
+
"bytes_written": counters["bytes"], "reader": method, "base": base,
|
|
615
|
+
"partial": bool(missing), "mft_missing_records": missing,
|
|
616
|
+
"reader_stats": ntfs.reader_stats()}
|
|
617
|
+
worker.write_summary(dest, summary)
|
|
618
|
+
diag.section("summary")
|
|
619
|
+
diag.kv(files_recovered=counters["ok"], files_failed=counters["failed"],
|
|
620
|
+
bytes_written=counters["bytes"], mft_missing_records=missing,
|
|
621
|
+
reader_stats=ntfs.reader_stats())
|
|
622
|
+
diag.close()
|
|
623
|
+
if missing:
|
|
624
|
+
em.emit("warning", code="PARTIAL_MFT", missing_records=missing,
|
|
625
|
+
message="recovery is partial: some MFT records were unrecoverable")
|
|
626
|
+
em.emit("completed", **summary)
|
|
627
|
+
if not args.jsonl:
|
|
628
|
+
print(file=sys.stderr)
|
|
629
|
+
if missing:
|
|
630
|
+
print(f"WARNING: recovery is PARTIAL -- {missing:,} MFT record(s) within the "
|
|
631
|
+
f"used range were unrecoverable (their disk blocks did not survive), so "
|
|
632
|
+
f"some files could not be recovered.", file=sys.stderr)
|
|
633
|
+
print(f"recovered {counters['ok']:,} file(s), {counters['bytes']:,} bytes to {dest}"
|
|
634
|
+
f"{' (partial)' if missing else ''}", file=sys.stderr)
|
|
635
|
+
print(f"diagnostic log: {worker.diag_path(dest)}", file=sys.stderr)
|
|
636
|
+
return worker.EXIT_OK
|
|
637
|
+
except KeyboardInterrupt:
|
|
638
|
+
em.emit("fatal", code="CANCELLED", message="cancelled")
|
|
639
|
+
diag.line("CANCELLED"); diag.close()
|
|
640
|
+
return worker.EXIT_CANCELLED
|
|
641
|
+
except Exception as exc: # noqa: BLE001 -- never propagate; report and exit non-zero
|
|
642
|
+
em.emit("fatal", code="ERROR", message=str(exc))
|
|
643
|
+
diag.section("fatal error")
|
|
644
|
+
diag.line("".join(traceback.format_exc()))
|
|
645
|
+
diag.close()
|
|
646
|
+
if not args.jsonl:
|
|
647
|
+
print(f"recovery failed: {exc}", file=sys.stderr)
|
|
648
|
+
return worker.EXIT_FATAL
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _safe_size(path):
|
|
652
|
+
try:
|
|
653
|
+
return os.path.getsize(path)
|
|
654
|
+
except OSError:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _handle_ntfs(args, report, progress):
|
|
659
|
+
from . import ntfs
|
|
660
|
+
if not ntfs.available():
|
|
661
|
+
print("NTFS support needs dissect.ntfs: pip install \"rprt[ntfs]\"", file=sys.stderr)
|
|
662
|
+
return 1
|
|
663
|
+
base = _ntfs_offset_from(args, report)
|
|
664
|
+
if base is None:
|
|
665
|
+
print("no intact NTFS volume found in the recoverable region "
|
|
666
|
+
"(pass --ntfs-offset to force one)", file=sys.stderr)
|
|
667
|
+
return 1
|
|
668
|
+
print(f"NTFS volume at offset {base:,}", file=sys.stderr)
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
if args.list_files:
|
|
672
|
+
with ntfs.MountedNTFS(args.path, base) as m:
|
|
673
|
+
files = ntfs.list_files(m.fs, min_size=args.min_file_size)
|
|
674
|
+
for path, size in sorted(files, key=lambda x: -x[1]):
|
|
675
|
+
print(f"{size/2**20:12,.1f} MB {path}")
|
|
676
|
+
print(f"{len(files):,} file(s)", file=sys.stderr)
|
|
677
|
+
|
|
678
|
+
if args.extract_files:
|
|
679
|
+
results = ntfs.extract_all(args.path, base, args.extract_files,
|
|
680
|
+
min_size=args.min_file_size, progress=progress)
|
|
681
|
+
print(file=sys.stderr)
|
|
682
|
+
ok = [r for r in results if "error" not in r]
|
|
683
|
+
failed = [r for r in results if "error" in r]
|
|
684
|
+
total = sum(r["written"] for r in ok)
|
|
685
|
+
print(f"extracted {len(ok):,} file(s), {total:,} bytes to {args.extract_files}",
|
|
686
|
+
file=sys.stderr)
|
|
687
|
+
if failed:
|
|
688
|
+
print(f"{len(failed)} file(s) failed:", file=sys.stderr)
|
|
689
|
+
for r in failed[:20]:
|
|
690
|
+
print(f" {r['path']}: {r['error']}", file=sys.stderr)
|
|
691
|
+
except Exception as exc: # noqa: BLE001 -- a bad/partial volume shouldn't crash the CLI
|
|
692
|
+
print(f"could not read NTFS volume at offset {base:,}: {exc}\n"
|
|
693
|
+
f"(the detected boot-sector signature may be a false positive, or the volume "
|
|
694
|
+
f"metadata itself may be inside the encrypted region)", file=sys.stderr)
|
|
695
|
+
return 1
|
|
696
|
+
return 0
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
if __name__ == "__main__":
|
|
700
|
+
sys.exit(main())
|