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/__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
|