Skip to content

Commit b49b27c

Browse files
committed
Add de-obfuscation scanner and related tests
- Implemented a safe, execution-free de-obfuscation scanner in `deobfuscate.py` to decode common obfuscation idioms such as hex, octal, unicode escapes, and base64-encoded strings. - Added functionality to detect dead "chaff" literal statements in various programming languages. - Introduced a new SVG icon for unlock functionality in `index.html`. - Updated CSS styles to accommodate new features and improve visual consistency. - Created comprehensive unit tests in `test_deobfuscate.py` to ensure the reliability of the de-obfuscation logic and edge cases.
1 parent d130209 commit b49b27c

9 files changed

Lines changed: 1265 additions & 47 deletions

File tree

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ the map already scanned.
148148
- **Warnings, never silence** — unreadable files, unsupported languages and
149149
permission problems show up as ⚠ badges and in the warnings list, never
150150
hidden. Secrets found in config files are always masked.
151+
- **De-obfuscation, inline** — hex/octal escapes, `chr()`-style
152+
character-code math, base64 blobs, and dead "chaff" statements get
153+
statically decoded and highlighted right in the code viewer — hover a
154+
🔓-underlined span to see what it originally said. See
155+
[De-obfuscation](#de-obfuscation) below.
151156

152157
## Language support
153158

@@ -184,6 +189,51 @@ Only Python's `ast`-based parser captures real parameter type annotations,
184189
return types, and docstrings — every regex-based parser above extracts
185190
parameter *names* only.
186191

192+
## De-obfuscation
193+
194+
Some codebases — commercial/nulled PHP scripts especially — ship with
195+
constant-literal obfuscation: hex/octal escape sequences, `chr()`-style
196+
character-code arithmetic, base64-encoded string constants, and dead
197+
"chaff" statements built and thrown away purely to make the file harder to
198+
read. CodeBread finds and decodes these **statically** and highlights the
199+
decoded value inline in the code viewer (dashed underline — hover to see
200+
the original), plus a 🔓 badge on any file with findings.
201+
202+
**The one rule that matters: nothing here is ever executed.** Every
203+
decoder in [`codebread/deobfuscate.py`](codebread/deobfuscate.py) only
204+
resolves *constant* expressions — integer literals combined with
205+
`+ - * / % << >> | & ^`, inside a recognized decode call (`chr(...)`,
206+
`base64_decode(...)`, ...). A restricted AST walker
207+
(`safe_eval_int_expr`) is the enforcement point: if an expression touches
208+
a variable, a function call, a float, or anything else that isn't a bare
209+
integer literal or one of those operators, it's refused and left alone —
210+
never guessed at, never evaluated by actually running the target
211+
language. That's what makes it safe to point at code you don't trust yet,
212+
which is the same posture as the rest of CodeBread (read-only static
213+
analysis, nothing scanned is ever run).
214+
215+
| Language | Hex `\xHH` | Octal `\NNN` | Unicode `\uHHHH` / `\u{HHHH}` | Char-code math | Base64 literals | Dead "chaff" statements |
216+
|---|:---:|:---:|:---:|:---:|:---:|:---:|
217+
| Python |||`\uHHHH` |`chr(...)` |`base64.b64decode(...)` | –³ |
218+
| JavaScript / TypeScript / Vue / Svelte ||| ✅ both forms |`String.fromCharCode` / `fromCodePoint` |`atob(...)` | ✅ (`+` concat) |
219+
| Java |||`\uHHHH` |`(char)(...)` cast |`Base64.getDecoder().decode(...)` | ✅ (`+` concat) |
220+
| C# |||`\uHHHH` |`(char)(...)` cast |`Convert.FromBase64String(...)` | ✅ (`+` concat) |
221+
| Go |||`\uHHHH` |`string(rune(...))` |`base64.StdEncoding.DecodeString(...)` | ✅ (`+` concat) |
222+
| PHP |||`\u{HHHH}` only |`chr(...)` |`base64_decode(...)` | ✅ (`.` concat) |
223+
| Ruby ||| ✅ both forms |`<int>.chr` |`Base64.decode64(...)` | –³ |
224+
225+
Decoded base64 output is re-checked against the same credential-masking
226+
rule used on config files (`URL_CREDS_RE`) — revealing a hidden literal
227+
can't be used to route around the "credentials always masked" guarantee
228+
elsewhere in the tool.
229+
230+
³ Chaff detection needs a reliable statement boundary to prove "this
231+
literal chain is the *entire* statement, nothing else on either side" —
232+
the `;`-terminated languages above have one. Python and Ruby don't require
233+
a terminator, so v1 intentionally reports nothing there rather than guess
234+
and risk false positives; the escape/char-code/base64 decoders still work
235+
fully on both.
236+
187237
## How it classifies layers
188238

189239
Score-based heuristics combining framework import signatures

codebread/analyzer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from . import __version__
1010
from .classifier import classify
1111
from .connections import build_graph
12+
from .deobfuscate import scan_obfuscation
1213
from .parsers import parse_file
1314
from .scanner import read_text, scan
1415

@@ -40,6 +41,11 @@ def analyze(root: str,
4041
from .parsers.generic_parser import redact_secrets
4142
src = redact_secrets(src)
4243
info.source = src
44+
if text:
45+
info.obfuscation = [
46+
{"line": f.line, "kind": f.kind, "original": f.original, "decoded": f.decoded}
47+
for f in scan_obfuscation(text, meta["language"])
48+
]
4349
parsed.append(info)
4450
for w in info.warnings:
4551
if w.startswith("Unsupported:"):
@@ -78,6 +84,8 @@ def _annotate_tree(node: Dict, by_path: Dict) -> None:
7884
node["nFunctions"] = len(info.functions)
7985
if info.warnings and "warning" not in node:
8086
node["warning"] = "parse"
87+
if info.obfuscation:
88+
node["obfuscated"] = True
8189
return
8290
layers = set()
8391
for child in node.get("children", []):

codebread/connections.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def add_edge(src: str, dst: str, kind: str, label: str = ""):
209209
"loc": f.loc, "warnings": f.warnings,
210210
"nFunctions": len(f.functions),
211211
"imports": f.imports[:30], "dbConfig": f.db_config,
212-
"source": f.source,
212+
"source": f.source, "obfuscation": f.obfuscation,
213213
})
214214
for fn in f.functions:
215215
nid = f"{f.path}::{fn.name}@{fn.line}"
@@ -393,6 +393,7 @@ def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
393393
# ---- stats ---------------------------------------------------------
394394
n_fns = sum(1 for n in nodes if n["kind"] in ("function", "method"))
395395
n_orphans = sum(1 for n in nodes if n.get("orphan"))
396+
n_obfuscated = sum(1 for f in files if f.obfuscation)
396397
stats = {
397398
"files": len(files),
398399
"functions": n_fns,
@@ -402,6 +403,7 @@ def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
402403
"warnings": len(warnings) + sum(len(f.warnings) for f in files),
403404
"orphans": n_orphans,
404405
"cycles": len(cycles),
406+
"obfuscated": n_obfuscated,
405407
}
406408
return {"nodes": nodes, "edges": edges, "tree": tree,
407409
"warnings": warnings, "cycles": cycles, "stats": stats}

0 commit comments

Comments
 (0)