Skip to content

Commit 718a6bf

Browse files
committed
swap techniques to ast-based
1 parent 1741df9 commit 718a6bf

36 files changed

Lines changed: 4094 additions & 160 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ reportMissingTypeStubs = false
8181
[tool.coverage.run]
8282
branch = true
8383
source = ["python_obfuscator"]
84-
omit = ["tests/*"]
84+
omit = ["tests/*", "python_obfuscator/cli/*"]
8585

8686
[tool.coverage.report]
8787
show_missing = true

python_obfuscator/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from .obfuscator import obfuscator
1+
from .config import ObfuscationConfig
2+
from .obfuscator import Obfuscator, obfuscate
23
from .version import __version__
34

4-
__all__ = ["obfuscator", "__version__"]
5+
__all__ = ["Obfuscator", "ObfuscationConfig", "obfuscate", "__version__"]

python_obfuscator/cli/__init__.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import typer
66

77
import python_obfuscator
8-
from python_obfuscator.techniques import one_liner as one_liner_technique
8+
from python_obfuscator.config import ObfuscationConfig
9+
from python_obfuscator.obfuscator import Obfuscator
10+
from python_obfuscator.techniques import all_technique_names
911
from python_obfuscator.version import __version__
1012

1113
DEFAULT_OUTPUT_DIR = "obfuscated"
@@ -43,24 +45,25 @@ def main(
4345
help="Print obfuscated code to stdout instead of writing a file.",
4446
),
4547
] = False,
46-
include_one_liner: Annotated[
47-
bool,
48+
disable: Annotated[
49+
list[str],
4850
typer.Option(
49-
"--one-liner",
50-
"-ol",
51-
help="Include the one-liner obfuscation technique.",
51+
"--disable",
52+
"-d",
53+
help=(
54+
"Disable a technique by name. May be repeated. "
55+
f"Available: {', '.join(sorted(all_technique_names()))}"
56+
),
5257
),
53-
] = False,
58+
] = [],
5459
) -> None:
5560
resolved = input_path.expanduser().resolve()
5661

57-
obfuscate = python_obfuscator.obfuscator()
58-
remove = []
59-
if not include_one_liner:
60-
remove.append(one_liner_technique)
62+
config = ObfuscationConfig.all_enabled().without(*disable)
63+
obfuscator = Obfuscator(config)
6164

6265
data = resolved.read_text()
63-
obfuscated_data = obfuscate.obfuscate(data, remove_techniques=remove)
66+
obfuscated_data = obfuscator.obfuscate(data)
6467

6568
if stdout:
6669
typer.echo(obfuscated_data)

python_obfuscator/config.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass(frozen=True)
7+
class ObfuscationConfig:
8+
"""Immutable configuration describing which techniques are enabled.
9+
10+
Prefer the factory classmethods over constructing directly::
11+
12+
# Enable everything registered
13+
cfg = ObfuscationConfig.all_enabled()
14+
15+
# Enable a specific subset
16+
cfg = ObfuscationConfig.only("variable_renamer", "string_hex_encoder")
17+
18+
# Start from all-enabled and exclude one
19+
cfg = ObfuscationConfig.all_enabled().without("string_hex_encoder")
20+
"""
21+
22+
enabled_techniques: frozenset[str]
23+
24+
@classmethod
25+
def all_enabled(cls) -> ObfuscationConfig:
26+
from .techniques.registry import all_technique_names
27+
28+
return cls(enabled_techniques=all_technique_names())
29+
30+
@classmethod
31+
def only(cls, *names: str) -> ObfuscationConfig:
32+
return cls(enabled_techniques=frozenset(names))
33+
34+
def without(self, *names: str) -> ObfuscationConfig:
35+
return ObfuscationConfig(
36+
enabled_techniques=self.enabled_techniques - frozenset(names)
37+
)
38+
39+
def with_added(self, *names: str) -> ObfuscationConfig:
40+
return ObfuscationConfig(
41+
enabled_techniques=self.enabled_techniques | frozenset(names)
42+
)
Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
1+
from __future__ import annotations
2+
13
import random
24
import string
3-
import time
5+
from typing import Callable
46

57

68
class RandomDataTypeGenerator:
7-
def __init__(self):
8-
self.generator_options = [self.random_string, self.random_int]
9+
"""Generates random Python literal values (str or int).
10+
11+
Pass a seeded :class:`random.Random` instance for reproducible output::
12+
13+
gen = RandomDataTypeGenerator(rng=random.Random(42))
14+
"""
15+
16+
def __init__(self, rng: random.Random | None = None) -> None:
17+
self._rng = rng or random.Random()
18+
self._generator_options: list[Callable[[], str | int]] = [
19+
self.random_string,
20+
self.random_int,
21+
]
922

10-
def get_random(self):
11-
return random.choice(self.generator_options)()
23+
def get_random(self) -> str | int:
24+
return self._rng.choice(self._generator_options)()
1225

13-
def random_string(self, length=79):
14-
# Why is it 79 by default?
15-
# See: https://stackoverflow.com/a/16920876/11472374
16-
# As kirelagin commented readability is very important
26+
def random_string(self, length: int = 79) -> str:
27+
# 79 chars: see https://stackoverflow.com/a/16920876/11472374
1728
return "".join(
18-
random.choice(string.ascii_lowercase + string.ascii_uppercase)
19-
for i in range(length)
29+
self._rng.choice(string.ascii_lowercase + string.ascii_uppercase)
30+
for _ in range(length)
2031
)
2132

22-
def random_int(self):
23-
return random.randint(random.randint(0, 300), random.randint(300, 999))
33+
def random_int(self) -> int:
34+
return self._rng.randint(self._rng.randint(0, 300), self._rng.randint(300, 999))
Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
from __future__ import annotations
2+
13
import random
24
import string
3-
import time
5+
from typing import Callable
46

57

68
class VariableNameGenerator:
7-
def __init__(self):
8-
self.generator_options = [
9+
"""Generates obfuscated-looking variable names.
10+
11+
Pass a seeded :class:`random.Random` instance for reproducible output::
12+
13+
gen = VariableNameGenerator(rng=random.Random(42))
14+
"""
15+
16+
def __init__(self, rng: random.Random | None = None) -> None:
17+
self._rng = rng or random.Random()
18+
self._generator_options: list[Callable[[int], str]] = [
919
self.random_string,
1020
self.l_and_i,
1121
self.time_based,
@@ -14,33 +24,29 @@ def __init__(self):
1424
self.single_letter_a_lot,
1525
]
1626

17-
def get_random(self, id):
18-
return random.choice(self.generator_options)(id)
27+
def get_random(self, id: int) -> str:
28+
return self._rng.choice(self._generator_options)(id)
1929

20-
def random_string(self, id, length=79):
21-
# Why is it 79 by default?
22-
# See: https://stackoverflow.com/a/16920876/11472374
23-
# As kirelagin commented readability is very important
30+
def random_string(self, id: int, length: int = 79) -> str:
31+
# 79 chars: see https://stackoverflow.com/a/16920876/11472374
2432
return "".join(
25-
random.choice(string.ascii_letters) for i in range(length)
33+
self._rng.choice(string.ascii_letters) for _ in range(length)
2634
) + str(id)
2735

28-
def l_and_i(self, id):
29-
return "".join(random.choice("Il") for i in range(id))
36+
def l_and_i(self, id: int) -> str:
37+
return "".join(self._rng.choice("Il") for _ in range(id))
3038

31-
def time_based(self, id):
32-
return (
33-
random.choice(string.ascii_letters)
34-
+ str(time.time()).replace(".", "")
35-
+ str(id)
36-
)
39+
def time_based(self, id: int) -> str:
40+
# Use the rng to produce a large pseudo-time value so that this
41+
# generator is fully deterministic when the rng is seeded.
42+
pseudo_time = str(self._rng.randint(10**12, 10**13))
43+
return self._rng.choice(string.ascii_letters) + pseudo_time + str(id)
3744

38-
def just_id(self, id):
39-
# python doesn't work with numbers for variable names
40-
return random.choice(string.ascii_letters) + str(id)
45+
def just_id(self, id: int) -> str:
46+
return self._rng.choice(string.ascii_letters) + str(id)
4147

42-
def scream(self, id):
43-
return "".join(random.choice("Aa") for i in range(id))
48+
def scream(self, id: int) -> str:
49+
return "".join(self._rng.choice("Aa") for _ in range(id))
4450

45-
def single_letter_a_lot(self, id):
46-
return random.choice(string.ascii_letters) * id
51+
def single_letter_a_lot(self, id: int) -> str:
52+
return self._rng.choice(string.ascii_letters) * id

python_obfuscator/obfuscator.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
1-
import logging
2-
from .techniques import obfuscate
1+
from __future__ import annotations
32

3+
import ast
44

5-
class obfuscator:
6-
def __init__(self, logging_level=logging.error):
7-
pass
5+
from .config import ObfuscationConfig
6+
from .techniques.registry import all_technique_names, get_transforms
87

9-
def obfuscate(self, code, remove_techniques=[]):
10-
return obfuscate(code, remove_techniques)
8+
9+
def _validate_config(config: ObfuscationConfig) -> None:
10+
unknown = config.enabled_techniques - all_technique_names()
11+
if unknown:
12+
raise ValueError(
13+
f"Unknown technique(s): {sorted(unknown)}. "
14+
f"Available: {sorted(all_technique_names())}"
15+
)
16+
17+
18+
def obfuscate(source: str, config: ObfuscationConfig | None = None) -> str:
19+
"""Obfuscate *source* using the techniques described by *config*.
20+
21+
When *config* is ``None`` all registered techniques are applied.
22+
"""
23+
resolved = config if config is not None else ObfuscationConfig.all_enabled()
24+
_validate_config(resolved)
25+
26+
tree = ast.parse(source)
27+
for transform_cls in get_transforms(resolved.enabled_techniques):
28+
tree = transform_cls().apply(tree)
29+
return ast.unparse(tree)
30+
31+
32+
class Obfuscator:
33+
"""Stateful wrapper that pre-builds and caches the transform pipeline.
34+
35+
Prefer this over the module-level :func:`obfuscate` when processing many
36+
files with the same configuration, since the pipeline is validated and
37+
sorted once at construction time.
38+
"""
39+
40+
def __init__(self, config: ObfuscationConfig | None = None) -> None:
41+
self._config = config if config is not None else ObfuscationConfig.all_enabled()
42+
_validate_config(self._config)
43+
self._transforms = get_transforms(self._config.enabled_techniques)
44+
45+
@property
46+
def config(self) -> ObfuscationConfig:
47+
return self._config
48+
49+
def obfuscate(self, source: str) -> str:
50+
tree = ast.parse(source)
51+
for transform_cls in self._transforms:
52+
tree = transform_cls().apply(tree)
53+
return ast.unparse(tree)

python_obfuscator/techniques.py

Lines changed: 0 additions & 87 deletions
This file was deleted.

0 commit comments

Comments
 (0)