snakecharm 0.1.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.
Files changed (47) hide show
  1. snakecharm/__init__.py +385 -0
  2. snakecharm/clean/__init__.py +26 -0
  3. snakecharm/clean/passes.py +352 -0
  4. snakecharm/clean/rename_survey.py +174 -0
  5. snakecharm/clean/session.py +239 -0
  6. snakecharm/cli.py +327 -0
  7. snakecharm/decompile/__init__.py +20 -0
  8. snakecharm/decompile/backends.py +146 -0
  9. snakecharm/decompile/router.py +154 -0
  10. snakecharm/errors.py +84 -0
  11. snakecharm/evalx/__init__.py +0 -0
  12. snakecharm/evalx/budgets.py +265 -0
  13. snakecharm/evalx/interp.py +1267 -0
  14. snakecharm/evalx/resolve.py +263 -0
  15. snakecharm/evalx/whitelist.py +748 -0
  16. snakecharm/ingest/__init__.py +0 -0
  17. snakecharm/ingest/normalize.py +184 -0
  18. snakecharm/ingest/pyc.py +90 -0
  19. snakecharm/ingest/registry.py +117 -0
  20. snakecharm/ingest/source.py +73 -0
  21. snakecharm/ir/__init__.py +0 -0
  22. snakecharm/ir/codeunit.py +186 -0
  23. snakecharm/ir/disasm.py +295 -0
  24. snakecharm/ir/gen_tables.py +287 -0
  25. snakecharm/ir/marshal_.py +450 -0
  26. snakecharm/ir/tables/py310.json +765 -0
  27. snakecharm/ir/tables/py311.json +944 -0
  28. snakecharm/ir/tables/py312.json +1122 -0
  29. snakecharm/ir/tables/py313.json +1145 -0
  30. snakecharm/ir/tables/py314.json +1175 -0
  31. snakecharm/ir/tables/py38.json +460 -0
  32. snakecharm/ir/tables/py39.json +453 -0
  33. snakecharm/peel/__init__.py +0 -0
  34. snakecharm/peel/driver.py +341 -0
  35. snakecharm/peel/layer.py +73 -0
  36. snakecharm/report/__init__.py +0 -0
  37. snakecharm/report/emit.py +83 -0
  38. snakecharm/triage/__init__.py +0 -0
  39. snakecharm/triage/db/families.yaml +288 -0
  40. snakecharm/triage/detect.py +321 -0
  41. snakecharm/triage/signatures.py +276 -0
  42. snakecharm/unresolved.py +116 -0
  43. snakecharm-0.1.0.dist-info/METADATA +203 -0
  44. snakecharm-0.1.0.dist-info/RECORD +47 -0
  45. snakecharm-0.1.0.dist-info/WHEEL +4 -0
  46. snakecharm-0.1.0.dist-info/entry_points.txt +3 -0
  47. snakecharm-0.1.0.dist-info/licenses/LICENSE +21 -0
snakecharm/__init__.py ADDED
@@ -0,0 +1,385 @@
1
+ """snakecharm -- deobfuscate Python without executing it.
2
+
3
+ >>> import snakecharm
4
+ >>> result = snakecharm.crack(open("sample.py", "rb").read())
5
+ >>> print(result.code)
6
+ >>> result.complete
7
+ False
8
+
9
+ The library API is the primary interface and the CLI is a thin shell over it,
10
+ because the audiences that most need this -- registry scanners, triage
11
+ pipelines, CI gates -- consume it as a library and need deterministic, gradable
12
+ output.
13
+
14
+ Nothing in the default path executes the sample. See :mod:`snakecharm.evalx`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .errors import (
25
+ BudgetExceeded,
26
+ MalformedInput,
27
+ Opaque,
28
+ PathTraversal,
29
+ SnakecharmError,
30
+ UnsupportedFormat,
31
+ )
32
+ from .evalx.budgets import Budget
33
+ from .ingest.registry import Registry, Unit, default_registry
34
+ from .peel.driver import PeelOptions, PeelResult, peel
35
+ from .peel.layer import Layer, LayerKind
36
+ from .report.emit import write_text
37
+ from .triage.detect import TriageReport, detect
38
+ from .triage.signatures import Detection
39
+ from .unresolved import EXIT_COMPLETE, EXIT_INCOMPLETE, Reason, Unresolved
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "crack",
45
+ "Options",
46
+ "Result",
47
+ "Budget",
48
+ "Unresolved",
49
+ "Reason",
50
+ "Detection",
51
+ "Layer",
52
+ "SnakecharmError",
53
+ "Opaque",
54
+ "BudgetExceeded",
55
+ "UnsupportedFormat",
56
+ "MalformedInput",
57
+ "PathTraversal",
58
+ "__version__",
59
+ ]
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Options:
64
+ """Knobs for :func:`crack`. Defaults are the safe, deterministic path."""
65
+
66
+ budget: Budget = field(default_factory=Budget)
67
+ max_layers: int | None = 512
68
+ max_depth: int | None = 128
69
+ peel_enabled: bool = True
70
+ detect_enabled: bool = True
71
+ target_python: tuple[int, int] | None = None
72
+ """Version to assume when unmarshalling. None means the host version."""
73
+
74
+ load_plugins: bool = False
75
+ """Off by default: third-party adapters run in-process next to malware."""
76
+
77
+ decompiler: str | None = None
78
+ """Force a specific decompiler backend by name, or None to route by version.
79
+
80
+ Backends are GPL and are never imported -- they are optional subprocesses
81
+ discovered on PATH. With none installed, output is annotated disassembly.
82
+ """
83
+
84
+ clean: bool = False
85
+ """Run the source-cleanup tier over the recovered payload.
86
+
87
+ Off by default, and that is a considered position rather than caution. On a
88
+ sample that peels cleanly the deepest layer *is* the original source, so
89
+ cleanup has nothing to remove and would only strip its formatting. Cleanup
90
+ earns its place when the payload is still mangled after the layers come
91
+ off -- which the caller knows and we do not.
92
+ """
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class Result:
97
+ """Everything crack() learned, and the artifacts it produced."""
98
+
99
+ code: str = ""
100
+ """Best-effort readable source: the deepest recovered layer."""
101
+
102
+ layers: list[Layer] = field(default_factory=list[Layer])
103
+ detections: list[Detection] = field(default_factory=list[Detection])
104
+ unresolved: list[Unresolved] = field(default_factory=list[Unresolved])
105
+ triage: TriageReport | None = None
106
+ stats: dict[str, Any] = field(default_factory=dict[str, Any])
107
+ plugins: list[str] = field(default_factory=list[str])
108
+
109
+ cleaned: str | None = None
110
+ """Cleanup output, when requested. A derived view, never the evidence.
111
+
112
+ `code` and `layers` remain byte-exact regardless: a layer's header carries
113
+ a SHA-256 of its own text, and rewriting it would break that contract."""
114
+
115
+ clean_report: dict[str, Any] = field(default_factory=dict[str, Any])
116
+
117
+ decompiled_by: str | None = None
118
+ """Which backend produced `code`, for compiled input."""
119
+
120
+ is_source: bool = True
121
+ """False when `code` is disassembly rather than recovered source.
122
+
123
+ The two are never conflated: disassembly is real output, but presenting it
124
+ as source would mislead an analyst about what they are reading.
125
+ """
126
+
127
+ @property
128
+ def complete(self) -> bool:
129
+ """True when nothing was left unresolved.
130
+
131
+ This is the whole verdict. A deobfuscator either answered the question
132
+ or it did not; there is no severity ladder, and recognising the
133
+ obfuscator is not something to report as a problem.
134
+ """
135
+ return not self.unresolved
136
+
137
+ @property
138
+ def exit_code(self) -> int:
139
+ """0 complete, 1 incomplete. (2 is reserved for unusable input, which
140
+ never produces a Result at all.)"""
141
+ return EXIT_COMPLETE if self.complete else EXIT_INCOMPLETE
142
+
143
+ def to_json(self) -> dict[str, Any]:
144
+ return {
145
+ "version": __version__,
146
+ "complete": self.complete,
147
+ "stats": self.stats,
148
+ "triage": self.triage.to_json() if self.triage else None,
149
+ "detections": [d.to_json() for d in self.detections],
150
+ "layers": [
151
+ {
152
+ "index": layer.index,
153
+ "kind": layer.kind,
154
+ "depth": layer.depth,
155
+ "parent": layer.parent,
156
+ "provenance": layer.provenance,
157
+ "sha256": layer.digest,
158
+ "bytes": layer.size(),
159
+ "terminal": layer.terminal,
160
+ "site": layer.site,
161
+ }
162
+ for layer in self.layers
163
+ ],
164
+ "unresolved": [u.to_json() for u in self.unresolved],
165
+ "plugins": self.plugins,
166
+ "clean": self.clean_report or None,
167
+ "decompiled_by": self.decompiled_by,
168
+ "is_source": self.is_source,
169
+ }
170
+
171
+ def save(self, outdir: str | Path) -> Path:
172
+ """Write the full artifact tree, guarding every path.
173
+
174
+ Produces ``deobfuscated.py``, ``report.json``, and ``layers/NN_*`` with
175
+ a provenance header on each -- so any single file can be traced back to
176
+ the input without re-running the tool.
177
+ """
178
+ outdir = Path(outdir)
179
+ outdir.mkdir(parents=True, exist_ok=True)
180
+
181
+ if self.code:
182
+ write_text(outdir, "deobfuscated.py", self.code)
183
+ if self.cleaned is not None:
184
+ write_text(outdir, "cleaned.py", self.cleaned)
185
+ write_text(outdir, "report.json", json.dumps(self.to_json(), indent=2) + "\n")
186
+
187
+ for layer in self.layers:
188
+ if layer.kind == LayerKind.CODE and layer.code is not None:
189
+ from .ir.disasm import format_tree
190
+
191
+ body = format_tree(layer.code)
192
+ suffix = "dis.txt"
193
+ elif layer.text:
194
+ body, suffix = layer.text, "py"
195
+ elif layer.raw is not None:
196
+ body = f"# {len(layer.raw)} bytes of non-UTF-8 data; not rendered as source\n"
197
+ suffix = "txt"
198
+ else:
199
+ continue
200
+ write_text(
201
+ outdir,
202
+ f"layers/{layer.index:02d}_depth{layer.depth}.{suffix}",
203
+ f"{layer.header()}\n\n{body}",
204
+ )
205
+
206
+ return outdir
207
+
208
+
209
+ def _crack_bytecode(
210
+ units: list[Unit],
211
+ data: bytes,
212
+ result: Result,
213
+ options: Options,
214
+ filename: str | None = None,
215
+ ) -> Result:
216
+ """Handle compiled input: one terminal layer per recovered code object.
217
+
218
+ There is no decompiler in v0.1, so the output is annotated disassembly. That
219
+ is stated in a finding rather than implied -- disassembly is not source, and
220
+ a report that blurred the two would be worse than useless to an analyst.
221
+ """
222
+ for index, unit in enumerate(units):
223
+ layer = Layer(
224
+ index=index,
225
+ kind=LayerKind.CODE,
226
+ code=unit.code,
227
+ depth=0,
228
+ chain=list(unit.provenance),
229
+ terminal=True,
230
+ )
231
+ result.layers.append(layer)
232
+
233
+ version = units[0].metadata.get("python_version", "unknown") if units else "unknown"
234
+ if units and units[0].metadata.get("header_repaired"):
235
+ result.unresolved.append(
236
+ Unresolved(
237
+ reason=Reason.DECODE_ERROR,
238
+ message=(
239
+ "no .pyc header was present; the Python version was assumed "
240
+ "rather than read, so field decoding may be wrong if it is incorrect"
241
+ ),
242
+ )
243
+ )
244
+
245
+ if options.detect_enabled:
246
+ result.triage = detect(data, None, filename=filename)
247
+ result.detections = result.triage.detections
248
+
249
+ # Try to recover source. Falls through to annotated disassembly, which never
250
+ # fails -- and which the header labels as disassembly, not source.
251
+ from .decompile import decompile
252
+
253
+ renderings: list[str] = []
254
+ for index, unit in enumerate(units):
255
+ rendered = decompile(unit.code, raw_pyc=unit.raw, prefer=options.decompiler)
256
+ result.unresolved.extend(rendered.unresolved)
257
+ renderings.append(rendered.header() + rendered.text)
258
+ if index == 0:
259
+ result.decompiled_by = rendered.backend
260
+ result.is_source = rendered.is_source
261
+ result.code = "\n\n".join(renderings)
262
+ result.stats = {
263
+ "layers": len(result.layers),
264
+ "code_objects": sum(len(list(layer.code.walk())) for layer in result.layers if layer.code),
265
+ "python_version": version,
266
+ }
267
+
268
+ return result
269
+
270
+
271
+ def _run_cleanup(result: Result, options: Options) -> None:
272
+ """Attach a cleaned view of the recovered source, if asked."""
273
+ from .clean import CleanOptions, Session
274
+
275
+ # No target version here on purpose: the gate ends in `compile()`, which
276
+ # only ever targets the host, so accepting one would imply a guarantee that
277
+ # cannot be kept. `CleanOptions` has never had the field -- passing it made
278
+ # every `--clean` run raise TypeError.
279
+ session = Session(CleanOptions(budget=options.budget))
280
+ outcome = session.run(result.code)
281
+ result.cleaned = outcome.code
282
+ result.clean_report = {
283
+ "changed": outcome.changed,
284
+ "rounds": outcome.rounds,
285
+ "applied": outcome.applied,
286
+ "refused": outcome.refused,
287
+ "rolled_back": outcome.rolled_back,
288
+ "summary": outcome.summary(),
289
+ }
290
+
291
+
292
+ def crack(
293
+ data: bytes | str,
294
+ options: Options | None = None,
295
+ *,
296
+ filename: str | None = None,
297
+ ) -> Result:
298
+ """Deobfuscate `data`, executing nothing.
299
+
300
+ Accepts raw bytes (preferred -- byte-level detection and PEP 263 handling
301
+ both need them) or a ``str`` for convenience. `filename` is an optional hint
302
+ used only by detection; analysis never depends on it.
303
+ """
304
+ options = options or Options()
305
+ if isinstance(data, str):
306
+ data = data.encode("utf-8")
307
+
308
+ result = Result()
309
+ assume = None
310
+ if options.target_python is not None:
311
+ from .ir.codeunit import PyVersion
312
+
313
+ assume = PyVersion(*options.target_python)
314
+
315
+ registry: Registry = default_registry(load_plugins=options.load_plugins, assume_version=assume)
316
+ result.plugins = list(registry.loaded_plugins)
317
+
318
+ try:
319
+ units: list[Unit] = registry.extract(data)
320
+ except UnsupportedFormat:
321
+ # Detection is byte-level pattern matching -- it needs no adapter, and
322
+ # gating it behind one refuses exactly when the answer is worth most.
323
+ # For a packed executable, naming the packer *is* the whole deliverable:
324
+ # the PyInstaller and Nuitka signatures exist to say "reach for this
325
+ # tool next", which they can only do on input we cannot ingest.
326
+ if options.peel_enabled or not options.detect_enabled:
327
+ raise
328
+ result.triage = detect(data, None, filename=filename)
329
+ result.detections = result.triage.detections
330
+ result.unresolved.append(
331
+ Unresolved(
332
+ reason=Reason.PARSE_ERROR,
333
+ message=(
334
+ "no ingest adapter recognised this input, so only byte-level "
335
+ "detection ran; nothing was unwrapped"
336
+ ),
337
+ )
338
+ )
339
+ return result
340
+
341
+ unit = units[0] if units else Unit(name="<input>", source="")
342
+ source = unit.source or ""
343
+
344
+ # Bytecode input: no decompiler in this version, so the unit becomes a
345
+ # single terminal layer rendered as annotated disassembly.
346
+ if unit.code is not None and unit.source is None:
347
+ return _crack_bytecode(units, data, result, options, filename)
348
+
349
+ result.unresolved.extend(unit.metadata.get("problems", []))
350
+
351
+ if options.detect_enabled:
352
+ result.triage = detect(data, source, filename=filename)
353
+ result.detections = result.triage.detections
354
+
355
+ if options.peel_enabled:
356
+ peeled: PeelResult = peel(
357
+ source,
358
+ PeelOptions(
359
+ max_layers=options.max_layers,
360
+ max_depth=options.max_depth,
361
+ budget=options.budget,
362
+ target_version=options.target_python,
363
+ # An ingest-time decode (PEP 263) is the first link of the chain.
364
+ root_chain=tuple(unit.metadata.get("chain", ())),
365
+ ),
366
+ )
367
+ result.layers = peeled.layers
368
+ result.unresolved.extend(peeled.unresolved)
369
+ result.code = peeled.code or source
370
+ if peeled.tracker is not None:
371
+ result.stats = {
372
+ "layers": len(peeled.layers),
373
+ "max_depth": max((layer.depth for layer in peeled.layers), default=0),
374
+ "bytes_folded": peeled.tracker.total_output,
375
+ "eval_steps": peeled.tracker.iterations,
376
+ "seconds": round(peeled.tracker.elapsed, 3),
377
+ }
378
+ else:
379
+ result.code = source
380
+ result.layers = [Layer(index=0, text=source, terminal=True)]
381
+
382
+ if options.clean and result.code:
383
+ _run_cleanup(result, options)
384
+
385
+ return result
@@ -0,0 +1,26 @@
1
+ """Source cleanup: make a peeled payload readable.
2
+
3
+ Opt-in and off by default. On a sample that peels cleanly there is nothing to
4
+ clean, and running anyway would only strip formatting from the analyst's prize.
5
+ Cleanup is for payloads that are still mangled once the layers come off.
6
+
7
+ Output is a *derived view* written to ``cleaned.py``. The ``layers/`` tree stays
8
+ byte-exact -- each file's header carries a SHA-256 of its own contents, and that
9
+ is the evidence artifact.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ # The import itself is the point -- it runs the @register decorators -- so the
15
+ # bound name is never read and no annotation can make it look used.
16
+ from . import (
17
+ passes as _passes, # noqa: F401 (registers the passes) # pyright: ignore[reportUnusedImport]
18
+ )
19
+ from .session import PASSES, CleanOptions, CleanResult, Pass, Refusal, Session
20
+
21
+ __all__ = ["Session", "CleanOptions", "CleanResult", "Pass", "Refusal", "PASSES", "clean"]
22
+
23
+
24
+ def clean(source: str, options: CleanOptions | None = None) -> CleanResult:
25
+ """Run the cleanup pipeline over `source`."""
26
+ return Session(options).run(source)