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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
rprt/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python -m rprt ...` to run the CLI. Used in development by the process supervisor
2
+ to spawn the recovery worker; the frozen build spawns scancrypt.exe instead."""
3
+ import sys
4
+
5
+ from rprt.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
rprt/batch.py ADDED
@@ -0,0 +1,186 @@
1
+ """rprt.batch — incident-level triage across a directory tree of encrypted files.
2
+
3
+ A ransomware incident hits a *share*, not one file. This walks a directory, runs the
4
+ single-file scan on each file, and aggregates the answer the victim actually needs on day
5
+ one: "across N files totalling X, this fraction is recoverable for free" -- the concrete,
6
+ evidenced number that removes ransom-payment leverage before anyone considers paying.
7
+
8
+ Composes the rest of rprt: each file is scanned (boundary/adaptive), classified, family-
9
+ fingerprinted, and format-detected. Read-only throughout. Per-file hashing is optional
10
+ (slow at scale) for an evidence-grade manifest.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from collections import Counter
16
+ from dataclasses import dataclass, field
17
+ from typing import Callable, Optional
18
+
19
+ from . import engine
20
+
21
+ ProgressFn = Optional[Callable[[float, str], None]]
22
+
23
+
24
+ @dataclass
25
+ class FileResult:
26
+ path: str
27
+ size: int
28
+ pattern: str = ""
29
+ recoverable_bytes: int = 0
30
+ recoverable_pct: float = 0.0
31
+ family: Optional[str] = None
32
+ formats: list = field(default_factory=list)
33
+ sha256: Optional[str] = None
34
+ error: Optional[str] = None
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "path": self.path, "size": self.size, "pattern": self.pattern,
39
+ "recoverable_bytes": self.recoverable_bytes,
40
+ "recoverable_pct": self.recoverable_pct,
41
+ "family": self.family, "formats": [f["name"] for f in self.formats],
42
+ "sha256": self.sha256, "error": self.error,
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class BatchResult:
48
+ root: str
49
+ files: list = field(default_factory=list) # list[FileResult]
50
+ scanned: int = 0
51
+ skipped: int = 0
52
+ errors: int = 0
53
+ notes: list = field(default_factory=list) # list[ransomnote.NoteIdentification]
54
+
55
+ @property
56
+ def total_bytes(self) -> int:
57
+ return sum(f.size for f in self.files)
58
+
59
+ @property
60
+ def recoverable_bytes(self) -> int:
61
+ return sum(f.recoverable_bytes for f in self.files)
62
+
63
+ @property
64
+ def recoverable_pct(self) -> float:
65
+ tb = self.total_bytes
66
+ return round(100 * self.recoverable_bytes / tb, 2) if tb else 0.0
67
+
68
+ def pattern_breakdown(self) -> dict:
69
+ c = Counter(f.pattern for f in self.files if f.pattern)
70
+ return dict(c.most_common())
71
+
72
+ def bytes_by_pattern(self) -> dict:
73
+ out = Counter()
74
+ for f in self.files:
75
+ if f.pattern:
76
+ out[f.pattern] += f.size
77
+ return dict(out.most_common())
78
+
79
+ def family_breakdown(self) -> dict:
80
+ c = Counter(f.family for f in self.files if f.family)
81
+ return dict(c.most_common())
82
+
83
+ def note_families(self) -> dict:
84
+ from . import ransomnote
85
+ return ransomnote.families_seen(self.notes)
86
+
87
+ def identified_strain(self) -> Optional[str]:
88
+ """Best single strain name for the incident: a note-confirmed family if any,
89
+ else the most common family seen across encrypted files."""
90
+ nf = self.note_families()
91
+ if nf:
92
+ return next(iter(nf))
93
+ fb = self.family_breakdown()
94
+ return next(iter(fb)) if fb else None
95
+
96
+ def to_dict(self) -> dict:
97
+ return {
98
+ "root": self.root,
99
+ "scanned": self.scanned, "skipped": self.skipped, "errors": self.errors,
100
+ "total_bytes": self.total_bytes,
101
+ "recoverable_bytes": self.recoverable_bytes,
102
+ "recoverable_pct": self.recoverable_pct,
103
+ "pattern_breakdown": self.pattern_breakdown(),
104
+ "bytes_by_pattern": self.bytes_by_pattern(),
105
+ "family_breakdown": self.family_breakdown(),
106
+ "ransom_notes_found": len(self.notes),
107
+ "note_families": self.note_families(),
108
+ "identified_strain": self.identified_strain(),
109
+ "files": [f.to_dict() for f in self.files],
110
+ }
111
+
112
+
113
+ def _iter_files(root: str, min_size: int):
114
+ for dirpath, _dirs, filenames in os.walk(root):
115
+ for name in sorted(filenames):
116
+ fp = os.path.join(dirpath, name)
117
+ try:
118
+ if not os.path.isfile(fp) or os.path.islink(fp):
119
+ continue
120
+ size = os.path.getsize(fp)
121
+ except OSError:
122
+ continue
123
+ if size < max(min_size, 1):
124
+ continue
125
+ yield fp, size
126
+
127
+
128
+ def scan_tree(root: str, min_size: int = 1, full: bool = False, do_hash: bool = False,
129
+ progress: ProgressFn = None, cancel_check=None) -> BatchResult:
130
+ """Scan every regular file under `root` (>= min_size bytes) and aggregate. Per-file
131
+ errors are recorded, never fatal. Read-only."""
132
+ result = BatchResult(root=root)
133
+
134
+ # Ransom-note sweep first: identifying the strain up front sets expectations for the
135
+ # per-file scan and gives the incident report a confirmed family name.
136
+ try:
137
+ from . import ransomnote
138
+ result.notes = ransomnote.find_notes(root)
139
+ except Exception: # noqa: BLE001 -- advisory
140
+ result.notes = []
141
+
142
+ files = list(_iter_files(root, min_size))
143
+ total = len(files) or 1
144
+
145
+ for i, (fp, size) in enumerate(files):
146
+ if cancel_check is not None and cancel_check():
147
+ break
148
+ if progress:
149
+ progress(i / total, os.path.basename(fp))
150
+ fr = FileResult(path=fp, size=size)
151
+ try:
152
+ report = engine.scan(fp, full=full)
153
+ fr.pattern = report.pattern
154
+ fr.recoverable_bytes = report.recoverable_bytes
155
+ fr.recoverable_pct = report.recoverable_pct
156
+ fr.family = report.family["family"] if report.family else None
157
+ fr.formats = report.formats
158
+ if do_hash:
159
+ from .report import sha256_of_input
160
+ fr.sha256 = sha256_of_input(fp)
161
+ result.scanned += 1
162
+ except Exception as exc: # noqa: BLE001 -- one bad file can't stop the incident scan
163
+ fr.error = str(exc)
164
+ result.errors += 1
165
+ result.files.append(fr)
166
+
167
+ if progress:
168
+ progress(1.0, "Done")
169
+ return result
170
+
171
+
172
+ def write_csv(result: BatchResult, out_path: str) -> str:
173
+ """Write a per-file CSV: path, size, pattern, recoverable %, recoverable bytes,
174
+ family, formats, sha256, error."""
175
+ import csv
176
+
177
+ with open(out_path, "w", newline="", encoding="utf-8") as f:
178
+ w = csv.writer(f)
179
+ w.writerow(["path", "size_bytes", "pattern", "recoverable_pct",
180
+ "recoverable_bytes", "family", "formats", "sha256", "error"])
181
+ for fr in result.files:
182
+ w.writerow([fr.path, fr.size, fr.pattern, fr.recoverable_pct,
183
+ fr.recoverable_bytes, fr.family or "",
184
+ "; ".join(x["name"] for x in fr.formats),
185
+ fr.sha256 or "", fr.error or ""])
186
+ return out_path
rprt/carve.py ADDED
@@ -0,0 +1,186 @@
1
+ """rprt.carve — orchestrate PhotoRec for loose-file carving of the recoverable region.
2
+
3
+ For generic files (not a disk image or a database we can extract structurally), the right
4
+ move is to hand the intact byte-range to a mature signature carver rather than reinvent
5
+ one. PhotoRec (part of TestDisk) is the standard choice. This module finds it, runs it
6
+ non-interactively against the recoverable data, and summarises what it recovered.
7
+
8
+ Two modes:
9
+ - carve the source directly (PhotoRec skips the encrypted region as unrecognisable), or
10
+ - isolate the intact byte-range to a temp file first (via the engine) and carve only
11
+ that -- more precise, at the cost of the intermediate file's disk space.
12
+
13
+ PhotoRec is an optional, external dependency; everything else in rprt works without it.
14
+ This module never writes to the scanned input -- only to the chosen output directory.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from collections import Counter
22
+ from typing import Callable, List, Optional
23
+
24
+ ProgressFn = Optional[Callable[[float, str], None]]
25
+ LogFn = Optional[Callable[[str], None]]
26
+
27
+ # Env override lets a user point at a specific binary; otherwise search PATH then a few
28
+ # common Windows install locations for the TestDisk/PhotoRec distribution.
29
+ _ENV_OVERRIDE = "RPRT_PHOTOREC"
30
+ _WINDOWS_HINTS = [
31
+ r"C:\Program Files\testdisk\photorec_win.exe",
32
+ r"C:\Program Files (x86)\testdisk\photorec_win.exe",
33
+ r"C:\testdisk\photorec_win.exe",
34
+ ]
35
+
36
+
37
+ def find_photorec() -> Optional[str]:
38
+ """Locate the PhotoRec binary: $RPRT_PHOTOREC, then PATH, then common Windows dirs."""
39
+ override = os.environ.get(_ENV_OVERRIDE)
40
+ if override and os.path.isfile(override) and os.access(override, os.X_OK):
41
+ return override
42
+ for name in ("photorec", "photorec.exe", "photorec_win.exe", "photorec_static"):
43
+ found = shutil.which(name)
44
+ if found:
45
+ return found
46
+ for hint in _WINDOWS_HINTS:
47
+ if os.path.isfile(hint):
48
+ return hint
49
+ return None
50
+
51
+
52
+ def available() -> bool:
53
+ return find_photorec() is not None
54
+
55
+
56
+ def build_command(photorec: str, image: str, dest_prefix: str,
57
+ whole_space: bool = True, file_types: Optional[List[str]] = None) -> List[str]:
58
+ """Construct PhotoRec's non-interactive (/cmd) argv.
59
+
60
+ dest_prefix is the recovery directory prefix; PhotoRec appends .1, .2, ... to it.
61
+ whole_space carves the entire input (vs only free space). file_types, if given,
62
+ restricts carving to those extensions (e.g. ["jpg", "pdf", "docx"]); otherwise every
63
+ known signature is enabled.
64
+ """
65
+ parts = ["partition_none"]
66
+ if file_types:
67
+ # disable everything, then re-enable just the requested extensions
68
+ parts += ["options", "fileopt", "everything", "disable"]
69
+ for t in file_types:
70
+ parts += [t, "enable"]
71
+ parts.append("wholespace" if whole_space else "freespace")
72
+ parts.append("search")
73
+ return [photorec, "/log", "/d", dest_prefix, "/cmd", image, ",".join(parts)]
74
+
75
+
76
+ def collect_recovered(dest_prefix: str) -> dict:
77
+ """Summarise what PhotoRec wrote under <dest_prefix>.1, .2, ... -- file count, total
78
+ bytes, and a breakdown by extension."""
79
+ parent = os.path.dirname(dest_prefix) or "."
80
+ base = os.path.basename(dest_prefix)
81
+ dirs = []
82
+ if os.path.isdir(parent):
83
+ for entry in sorted(os.listdir(parent)):
84
+ full = os.path.join(parent, entry)
85
+ if os.path.isdir(full) and entry.startswith(base + "."):
86
+ dirs.append(full)
87
+
88
+ by_ext = Counter()
89
+ total_bytes = 0
90
+ file_count = 0
91
+ for d in dirs:
92
+ for root, _sub, files in os.walk(d):
93
+ for fn in files:
94
+ if fn in ("report.xml",):
95
+ continue
96
+ fp = os.path.join(root, fn)
97
+ try:
98
+ total_bytes += os.path.getsize(fp)
99
+ except OSError:
100
+ continue
101
+ file_count += 1
102
+ ext = os.path.splitext(fn)[1].lstrip(".").lower() or "(none)"
103
+ by_ext[ext] += 1
104
+ return {
105
+ "recovery_dirs": dirs,
106
+ "file_count": file_count,
107
+ "total_bytes": total_bytes,
108
+ "by_extension": dict(by_ext.most_common()),
109
+ }
110
+
111
+
112
+ def carve(image: str, out_dir: str, whole_space: bool = True,
113
+ file_types: Optional[List[str]] = None, log: LogFn = None,
114
+ cancel_check=None, timeout: Optional[int] = None) -> dict:
115
+ """Run PhotoRec against `image`, writing recovered files under `out_dir`, and return
116
+ a summary from collect_recovered(). Raises RuntimeError if PhotoRec isn't found."""
117
+ photorec = find_photorec()
118
+ if photorec is None:
119
+ raise RuntimeError(
120
+ "PhotoRec not found. Install TestDisk/PhotoRec, or set RPRT_PHOTOREC to the "
121
+ "binary path.")
122
+ os.makedirs(out_dir, exist_ok=True)
123
+ dest_prefix = os.path.join(out_dir, "recup_dir")
124
+ cmd = build_command(photorec, image, dest_prefix, whole_space, file_types)
125
+
126
+ if log:
127
+ log("running: " + " ".join(cmd))
128
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
129
+ text=True, bufsize=1)
130
+ try:
131
+ for line in proc.stdout:
132
+ if log:
133
+ log(line.rstrip())
134
+ if cancel_check is not None and cancel_check():
135
+ proc.terminate()
136
+ break
137
+ proc.wait(timeout=timeout)
138
+ except subprocess.TimeoutExpired:
139
+ proc.kill()
140
+ raise
141
+ finally:
142
+ if proc.stdout:
143
+ proc.stdout.close()
144
+
145
+ summary = collect_recovered(dest_prefix)
146
+ summary["returncode"] = proc.returncode
147
+ summary["cancelled"] = bool(cancel_check is not None and cancel_check())
148
+ return summary
149
+
150
+
151
+ def carve_recoverable(path: str, report, out_dir: str, isolate_intact: bool = True,
152
+ keep_intact: bool = False, progress: ProgressFn = None,
153
+ log: LogFn = None, cancel_check=None) -> dict:
154
+ """Carve the recoverable region of `path`.
155
+
156
+ isolate_intact=True (default when the scan found a front boundary): extract the intact
157
+ byte-range to a temp file with the engine, then carve only that -- so PhotoRec never
158
+ wastes time on, or mis-carves from, the encrypted bytes. Otherwise carve `path`
159
+ directly and let PhotoRec skip the unrecognisable region itself.
160
+ """
161
+ from . import engine
162
+
163
+ if report is not None and report.pattern == "fully-encrypted":
164
+ raise ValueError("nothing to carve: the input reads as fully encrypted")
165
+
166
+ os.makedirs(out_dir, exist_ok=True)
167
+ target = path
168
+ temp_file = None
169
+ try:
170
+ if isolate_intact and report is not None and report.boundary_offset:
171
+ temp_file = os.path.join(out_dir, "_intact_region.bin")
172
+ if progress:
173
+ progress(0.0, "Isolating recoverable region")
174
+ engine.extract_intact_ranges(path, report, temp_file,
175
+ progress=progress, cancel_check=cancel_check)
176
+ target = temp_file
177
+ summary = carve(target, out_dir, log=log, cancel_check=cancel_check)
178
+ finally:
179
+ if temp_file and not keep_intact and os.path.exists(temp_file):
180
+ try:
181
+ os.remove(temp_file)
182
+ except OSError:
183
+ pass
184
+ if progress:
185
+ progress(1.0, "Done")
186
+ return summary