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.
- snakecharm/__init__.py +385 -0
- snakecharm/clean/__init__.py +26 -0
- snakecharm/clean/passes.py +352 -0
- snakecharm/clean/rename_survey.py +174 -0
- snakecharm/clean/session.py +239 -0
- snakecharm/cli.py +327 -0
- snakecharm/decompile/__init__.py +20 -0
- snakecharm/decompile/backends.py +146 -0
- snakecharm/decompile/router.py +154 -0
- snakecharm/errors.py +84 -0
- snakecharm/evalx/__init__.py +0 -0
- snakecharm/evalx/budgets.py +265 -0
- snakecharm/evalx/interp.py +1267 -0
- snakecharm/evalx/resolve.py +263 -0
- snakecharm/evalx/whitelist.py +748 -0
- snakecharm/ingest/__init__.py +0 -0
- snakecharm/ingest/normalize.py +184 -0
- snakecharm/ingest/pyc.py +90 -0
- snakecharm/ingest/registry.py +117 -0
- snakecharm/ingest/source.py +73 -0
- snakecharm/ir/__init__.py +0 -0
- snakecharm/ir/codeunit.py +186 -0
- snakecharm/ir/disasm.py +295 -0
- snakecharm/ir/gen_tables.py +287 -0
- snakecharm/ir/marshal_.py +450 -0
- snakecharm/ir/tables/py310.json +765 -0
- snakecharm/ir/tables/py311.json +944 -0
- snakecharm/ir/tables/py312.json +1122 -0
- snakecharm/ir/tables/py313.json +1145 -0
- snakecharm/ir/tables/py314.json +1175 -0
- snakecharm/ir/tables/py38.json +460 -0
- snakecharm/ir/tables/py39.json +453 -0
- snakecharm/peel/__init__.py +0 -0
- snakecharm/peel/driver.py +341 -0
- snakecharm/peel/layer.py +73 -0
- snakecharm/report/__init__.py +0 -0
- snakecharm/report/emit.py +83 -0
- snakecharm/triage/__init__.py +0 -0
- snakecharm/triage/db/families.yaml +288 -0
- snakecharm/triage/detect.py +321 -0
- snakecharm/triage/signatures.py +276 -0
- snakecharm/unresolved.py +116 -0
- snakecharm-0.1.0.dist-info/METADATA +203 -0
- snakecharm-0.1.0.dist-info/RECORD +47 -0
- snakecharm-0.1.0.dist-info/WHEEL +4 -0
- snakecharm-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|