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/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())