vb6decompiler 0.2.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.
- vb6decompiler/__init__.py +11 -0
- vb6decompiler/blob.py +1067 -0
- vb6decompiler/cli.py +292 -0
- vb6decompiler/datareport.py +886 -0
- vb6decompiler/frm.py +1107 -0
- vb6decompiler/literals.py +275 -0
- vb6decompiler/mask.py +192 -0
- vb6decompiler/pe.py +387 -0
- vb6decompiler/procs.py +444 -0
- vb6decompiler/sqlstitch.py +227 -0
- vb6decompiler-0.2.0.dist-info/METADATA +279 -0
- vb6decompiler-0.2.0.dist-info/RECORD +16 -0
- vb6decompiler-0.2.0.dist-info/WHEEL +5 -0
- vb6decompiler-0.2.0.dist-info/entry_points.txt +2 -0
- vb6decompiler-0.2.0.dist-info/licenses/LICENSE +21 -0
- vb6decompiler-0.2.0.dist-info/top_level.txt +1 -0
vb6decompiler/cli.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Command line entry point: one subcommand per stage, JSON or CSV on stdout.
|
|
2
|
+
|
|
3
|
+
Every subcommand prints what it measured and what it could not do. Exit code is 0
|
|
4
|
+
when the run completed, whatever it found — a missing thing is a result, not a
|
|
5
|
+
failure. An unusable input (not a PE, no ``VB5!`` header, missing file) exits 2
|
|
6
|
+
with one line on stderr; ``VB6D_DEBUG=1`` keeps the traceback for bug reports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import csv
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _emit(rows, as_json: bool, fields: list[str] | None = None) -> None:
|
|
19
|
+
if as_json:
|
|
20
|
+
print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
|
|
21
|
+
return
|
|
22
|
+
if isinstance(rows, dict):
|
|
23
|
+
for k, v in rows.items():
|
|
24
|
+
print(f"{k}\t{v}")
|
|
25
|
+
return
|
|
26
|
+
if not rows:
|
|
27
|
+
return
|
|
28
|
+
if isinstance(rows[0], dict):
|
|
29
|
+
cols = fields or list(rows[0].keys())
|
|
30
|
+
w = csv.DictWriter(sys.stdout, fieldnames=cols, extrasaction="ignore")
|
|
31
|
+
w.writeheader()
|
|
32
|
+
for r in rows:
|
|
33
|
+
w.writerow(r)
|
|
34
|
+
else:
|
|
35
|
+
for r in rows:
|
|
36
|
+
print(r)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_pe(args) -> int:
|
|
40
|
+
from . import pe
|
|
41
|
+
|
|
42
|
+
rows = pe.scan_directory(args.path, recursive=args.recursive)
|
|
43
|
+
_emit(rows, args.json)
|
|
44
|
+
if not args.json:
|
|
45
|
+
vb6 = [r for r in rows if r.get("vb6")]
|
|
46
|
+
errs = [r for r in rows if r.get("error")]
|
|
47
|
+
print(f"# {len(rows)} images, {len(vb6)} import a VB6 runtime, {len(errs)} unreadable", file=sys.stderr)
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_procs(args) -> int:
|
|
52
|
+
from . import procs
|
|
53
|
+
|
|
54
|
+
project = procs.read(args.exe)
|
|
55
|
+
stats = project.stats()
|
|
56
|
+
if args.names:
|
|
57
|
+
names = procs.merge_listing_names(args.names)
|
|
58
|
+
stats["names_from_listing"] = len(names)
|
|
59
|
+
if args.validate:
|
|
60
|
+
stats["prologue"] = procs.prologue_report(args.exe, project)
|
|
61
|
+
if args.json:
|
|
62
|
+
_emit(
|
|
63
|
+
{
|
|
64
|
+
"stats": stats,
|
|
65
|
+
"objects": [
|
|
66
|
+
{
|
|
67
|
+
"index": o.index,
|
|
68
|
+
"name": o.name,
|
|
69
|
+
"type": o.object_type,
|
|
70
|
+
"methods": o.method_count,
|
|
71
|
+
}
|
|
72
|
+
for o in project.objects
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
True,
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
for k, v in stats.items():
|
|
79
|
+
print(f"{k}\t{v}")
|
|
80
|
+
print(f"\n# {len(project.objects)} objects")
|
|
81
|
+
for o in project.objects[: args.limit]:
|
|
82
|
+
print(f" [{o.index:4d}] {o.name:<24} {o.object_type:<12} methods={o.method_count}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_literals(args) -> int:
|
|
87
|
+
from . import literals
|
|
88
|
+
|
|
89
|
+
src = args.source
|
|
90
|
+
if os.path.isdir(src):
|
|
91
|
+
lits = literals.scan_listings(src)
|
|
92
|
+
else:
|
|
93
|
+
lits = literals.scan_binary(src, min_chars=args.min_chars)
|
|
94
|
+
if args.sql_only:
|
|
95
|
+
lits = [l for l in lits if l.is_sql]
|
|
96
|
+
_emit([l.as_row() for l in lits], False)
|
|
97
|
+
print(f"# {len(lits)} literals", file=sys.stderr)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_stitch(args) -> int:
|
|
102
|
+
from . import literals, sqlstitch
|
|
103
|
+
|
|
104
|
+
src = args.source
|
|
105
|
+
lits = literals.scan_listings(src) if os.path.isdir(src) else literals.scan_binary(src)
|
|
106
|
+
cands = sqlstitch.stitch_all(lits, max_more=args.max_more)
|
|
107
|
+
rows = [c.as_row() for c in cands]
|
|
108
|
+
_emit(rows, args.json)
|
|
109
|
+
if not args.json:
|
|
110
|
+
print(f"# {sqlstitch.summarise(cands)}", file=sys.stderr)
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_frm(args) -> int:
|
|
115
|
+
from . import frm
|
|
116
|
+
|
|
117
|
+
res = frm.scan_directory(args.path, recursive=args.recursive)
|
|
118
|
+
if args.json:
|
|
119
|
+
_emit(
|
|
120
|
+
{
|
|
121
|
+
"files_seen": res.files_seen,
|
|
122
|
+
"forms": [
|
|
123
|
+
{
|
|
124
|
+
"name": f.name,
|
|
125
|
+
"type": f.type,
|
|
126
|
+
"controls": len(f.controls),
|
|
127
|
+
"client_size_twips": f.client_size,
|
|
128
|
+
"warnings": len(f.warnings),
|
|
129
|
+
}
|
|
130
|
+
for f in res.forms
|
|
131
|
+
],
|
|
132
|
+
"no_design_block": res.no_design,
|
|
133
|
+
"failures": res.failures,
|
|
134
|
+
},
|
|
135
|
+
True,
|
|
136
|
+
)
|
|
137
|
+
return 0
|
|
138
|
+
for f in res.forms:
|
|
139
|
+
size = f.client_size
|
|
140
|
+
print(f"{f.name} {size if size else 'unknown'} controls={len(f.controls)}")
|
|
141
|
+
print(
|
|
142
|
+
f"# {res.files_seen} files: {len(res.forms)} parsed, "
|
|
143
|
+
f"{len(res.no_design)} without a design block, {len(res.failures)} failed",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_blob(args) -> int:
|
|
150
|
+
from dataclasses import asdict
|
|
151
|
+
|
|
152
|
+
from . import blob
|
|
153
|
+
|
|
154
|
+
if args.validate:
|
|
155
|
+
report = blob.validate(args.exe, frm_dir=args.frm_dir)
|
|
156
|
+
payload = asdict(report)
|
|
157
|
+
payload["field_diffs"] = dict(report.field_diffs)
|
|
158
|
+
payload["control_rate"] = round(report.control_rate, 2)
|
|
159
|
+
payload["form_rate"] = round(report.form_rate, 2)
|
|
160
|
+
_emit(payload, True)
|
|
161
|
+
print(report.summary(), file=sys.stderr)
|
|
162
|
+
return 0
|
|
163
|
+
forms = blob.extract_forms(args.exe)
|
|
164
|
+
_emit(
|
|
165
|
+
[
|
|
166
|
+
{
|
|
167
|
+
"name": f.name,
|
|
168
|
+
"controls": len(f.controls),
|
|
169
|
+
"width": f.width,
|
|
170
|
+
"height": f.height,
|
|
171
|
+
}
|
|
172
|
+
for f in forms
|
|
173
|
+
],
|
|
174
|
+
args.json,
|
|
175
|
+
)
|
|
176
|
+
print(f"# {len(forms)} forms from form-data blobs", file=sys.stderr)
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_datareport(args) -> int:
|
|
181
|
+
from . import datareport
|
|
182
|
+
|
|
183
|
+
files = datareport.iter_files([args.path]) if os.path.isdir(args.path) else [args.path]
|
|
184
|
+
designs = datareport.read_paths(files)
|
|
185
|
+
if args.json:
|
|
186
|
+
_emit({"summary": datareport.summary(designs), "designs": [d.as_dict() for d in designs]}, True)
|
|
187
|
+
return 0
|
|
188
|
+
for d in designs:
|
|
189
|
+
print(f"{os.path.basename(d.path)} {d.name} records={len(d.records)} sections={len(d.sections)} controls={len(d.controls)}")
|
|
190
|
+
for k, v in datareport.summary(designs).items():
|
|
191
|
+
print(f"# {k} {v}", file=sys.stderr)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_mask(args) -> int:
|
|
196
|
+
from . import mask
|
|
197
|
+
|
|
198
|
+
# A gate aimed at a typo'd or missing path found nothing because it looked at
|
|
199
|
+
# nothing; exiting 0 there would make every CI run of this gate meaningless.
|
|
200
|
+
os.stat(args.path)
|
|
201
|
+
result = mask.mask_tree(args.path, dry_run=args.dry_run)
|
|
202
|
+
changed = {k: v for k, v in result.items() if v}
|
|
203
|
+
print(f"# {len(result)} files scanned, {len(changed)} changed, {sum(changed.values())} lines")
|
|
204
|
+
audit = mask.audit(args.path)
|
|
205
|
+
print(f"# audit: files={audit.files} lines={audit.lines} findings={audit.findings} clean={audit.clean}")
|
|
206
|
+
# --dry-run skips the *write*, not the check: a gate that exits 0 in dry-run can
|
|
207
|
+
# never fail in CI, which is how a real leak would sail through.
|
|
208
|
+
return 0 if audit.clean else 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
212
|
+
p = argparse.ArgumentParser(
|
|
213
|
+
prog="vb6d",
|
|
214
|
+
description="Evidence-based recovery of native-compiled VB6 programs.",
|
|
215
|
+
)
|
|
216
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
217
|
+
|
|
218
|
+
q = sub.add_parser("pe", help="PE surface of a file or install directory")
|
|
219
|
+
q.add_argument("path")
|
|
220
|
+
q.add_argument("--recursive", action="store_true")
|
|
221
|
+
q.add_argument("--json", action="store_true")
|
|
222
|
+
q.set_defaults(func=cmd_pe)
|
|
223
|
+
|
|
224
|
+
q = sub.add_parser("procs", help="VB6 object table: objects, method counts, addresses")
|
|
225
|
+
q.add_argument("exe")
|
|
226
|
+
q.add_argument("--names", metavar="LISTING_DIR", help="attach names from a decompiler listing")
|
|
227
|
+
q.add_argument("--validate", action="store_true", help="check that entries are real prologues")
|
|
228
|
+
q.add_argument("--limit", type=int, default=40)
|
|
229
|
+
q.add_argument("--json", action="store_true")
|
|
230
|
+
q.set_defaults(func=cmd_procs)
|
|
231
|
+
|
|
232
|
+
q = sub.add_parser("literals", help="BSTR/UTF-16 and ASCII literals, with addresses")
|
|
233
|
+
q.add_argument("source", help="an .exe/.dll, or a directory of decompiler listings")
|
|
234
|
+
q.add_argument("--min-chars", type=int, default=4)
|
|
235
|
+
q.add_argument("--sql-only", action="store_true")
|
|
236
|
+
q.set_defaults(func=cmd_literals)
|
|
237
|
+
|
|
238
|
+
q = sub.add_parser("stitch", help="join SQL fragments and classify what did not close")
|
|
239
|
+
q.add_argument("source")
|
|
240
|
+
q.add_argument("--max-more", type=int, default=6)
|
|
241
|
+
q.add_argument("--json", action="store_true")
|
|
242
|
+
q.set_defaults(func=cmd_stitch)
|
|
243
|
+
|
|
244
|
+
q = sub.add_parser("frm", help="parse .frm design blocks")
|
|
245
|
+
q.add_argument("path")
|
|
246
|
+
q.add_argument("--recursive", action="store_true")
|
|
247
|
+
q.add_argument("--json", action="store_true")
|
|
248
|
+
q.set_defaults(func=cmd_frm)
|
|
249
|
+
|
|
250
|
+
q = sub.add_parser("blob", help="form geometry from the executable's form-data blobs")
|
|
251
|
+
q.add_argument("exe")
|
|
252
|
+
q.add_argument("--validate", action="store_true")
|
|
253
|
+
q.add_argument("--frm-dir", help="ground-truth .frm directory for --validate")
|
|
254
|
+
q.add_argument("--json", action="store_true")
|
|
255
|
+
q.set_defaults(func=cmd_blob)
|
|
256
|
+
|
|
257
|
+
q = sub.add_parser("datareport", help="DataReport layouts from report DLLs")
|
|
258
|
+
q.add_argument("path", help="a DLL, or the install directory holding rpt*.dll")
|
|
259
|
+
q.add_argument("--json", action="store_true")
|
|
260
|
+
q.set_defaults(func=cmd_datareport)
|
|
261
|
+
|
|
262
|
+
q = sub.add_parser("mask", help="mask credentials in generated artefacts and audit the result")
|
|
263
|
+
q.add_argument("path")
|
|
264
|
+
q.add_argument("--dry-run", action="store_true")
|
|
265
|
+
q.set_defaults(func=cmd_mask)
|
|
266
|
+
|
|
267
|
+
return p
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main(argv: list[str] | None = None) -> int:
|
|
271
|
+
args = build_parser().parse_args(argv)
|
|
272
|
+
# A stranger pipes output to a file on a cp1252 console; literals recovered
|
|
273
|
+
# from UTF-16 are not encodable there. Cosmetic only: never changes exit codes.
|
|
274
|
+
for stream in (sys.stdout, sys.stderr):
|
|
275
|
+
try:
|
|
276
|
+
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
277
|
+
except (AttributeError, OSError):
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
return args.func(args)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
# Named errors (VBError, PEError, FrmError) already carry their path and
|
|
283
|
+
# cause; one line keeps a stranger's first crash readable, not a wall of
|
|
284
|
+
# traceback. VB6D_DEBUG restores the full traceback for bug reports.
|
|
285
|
+
if os.environ.get("VB6D_DEBUG"):
|
|
286
|
+
raise
|
|
287
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
raise SystemExit(main())
|