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.
@@ -0,0 +1,275 @@
1
+ """Text literals in a native VB6 build — including the ones every scanner misses.
2
+
3
+ Why this exists
4
+ ---------------
5
+ VB6 compiles a string literal to a **BSTR in the data area**: a 4-byte byte-length
6
+ prefix, the text as **UTF-16LE**, then a NUL terminator. It is not an ASCII string,
7
+ and on the reference target it does not live in ``.data`` at all — that section is
8
+ 4 KB while ``.text`` is 106 MB, and the literals sit inside ``.text``. Consequences,
9
+ both measured on the same 111 MB file:
10
+
11
+ * A generic engine that scans data sections for ASCII reported **59 strings**, all
12
+ of them noise from ``.rsrc``, and **0 occurrences of a table name that is present
13
+ 260 times** in the file. Its own limitation text says "strings are read from data
14
+ sections", which is exactly the wrong place.
15
+ * A decompiler-listing scanner that only matched ``push <addr> ; "text"`` missed
16
+ **170,645 literals** (170,555 of them were loaded with ``mov``), because native
17
+ VB6 loads a string address with whichever instruction the register allocator
18
+ chose.
19
+
20
+ So this module reads two sources and keeps them apart:
21
+
22
+ 1. :func:`scan_binary` — BSTR runs from the image itself, with the length prefix
23
+ checked. That check is what makes a run a literal and not a coincidence.
24
+ 2. :func:`scan_listings` — ``<addr>h ; "text"`` comments from a decompiler's text
25
+ output, *any* instruction, with the VA and the procedure it belongs to.
26
+ 3. :func:`merge` — union keyed by (procedure VA, text address), never by text.
27
+
28
+ Nothing here decides what a literal *means*. SQL detection is a flag, and a flag is
29
+ not a table list: see :mod:`vb6decompiler.sqlstitch` for what can and cannot be
30
+ closed statically.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass
38
+
39
+ from . import pe as pe_mod
40
+ from .mask import mask_text
41
+
42
+ #: A run must be at least this long to be plausible (shorter runs are alignment
43
+ #: noise). VB6 allows 1-character literals; this threshold is a precision/recall
44
+ #: choice, documented rather than hidden.
45
+ MIN_CHARS = 4
46
+
47
+ #: Longer than this and it is not a literal someone typed; it is a blob.
48
+ MAX_CHARS = 400
49
+
50
+ #: Characters allowed inside a recovered literal. Turkish letters are listed because
51
+ #: VB6 stores them as UTF-16 code units above 0x7F: a "high byte must be zero" rule
52
+ #: silently drops ``ş``, ``ğ`` and ``İ`` and splits the literal around them.
53
+ _ALLOWED = (
54
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
55
+ " _.,;:!?'\"()[]{}<>+-*/=%&|\\@#$^~`\t\r\n"
56
+ "çÇğĞıİöÖşŞüÜâÂîÎûÛéÉàÀ°²³€£½¼¾"
57
+ )
58
+ _RUN = re.compile("[" + re.escape(_ALLOWED) + "]{%d,%d}" % (MIN_CHARS, MAX_CHARS))
59
+
60
+ #: SQL verbs. Used as a flag only; a literal with a verb is a *candidate*.
61
+ SQL_HINT = re.compile(
62
+ r"\b(SELECT\s|INSERT\s+INTO\b|INSERT\b|UPDATE\s+\w+\s+SET\b|UPDATE\b|"
63
+ r"DELETE\s+FROM\b|DELETE\b|EXEC\b|CREATE\s+TABLE\b|ALTER\s+TABLE\b)",
64
+ re.I,
65
+ )
66
+
67
+ #: Any instruction's literal comment: ``0042C3A0h ; "text"``. The instruction is
68
+ #: deliberately not named — that was the measured trap.
69
+ LISTING_LITERAL = re.compile(r"([0-9A-Fa-f]{6,8})h\s*;\s*\"([^\"]{1,400})\"")
70
+
71
+ #: Procedure header in a decompiler listing; the trailing hex is the procedure VA.
72
+ LISTING_PROC = re.compile(
73
+ r"^(?:Private|Public|Friend|Static)?\s*"
74
+ r"(?:Sub|Function|Property\s+(?:Get|Let|Set))\s+([A-Za-z_]\w*)?\s*\(?.*?'\s*([0-9A-Fa-f]{6,8})\s*$"
75
+ )
76
+
77
+
78
+ @dataclass
79
+ class Literal:
80
+ va: int # virtual address of the text
81
+ text: str
82
+ encoding: str # "utf16", "cp1254" or "listing"
83
+ section: str | None = None
84
+ length_prefix_ok: bool = False # the 4 bytes before the text equal 2*len(text)
85
+ proc_va: int | None = None # only known when it came from a listing
86
+ source: str = "binary" # "binary" or "listing"
87
+
88
+ @property
89
+ def is_sql(self) -> bool:
90
+ return bool(SQL_HINT.search(self.text))
91
+
92
+ def as_row(self) -> dict:
93
+ return {
94
+ "va": f"{self.va:X}",
95
+ "proc_va": f"{self.proc_va:X}" if self.proc_va else "",
96
+ "encoding": self.encoding,
97
+ "section": self.section or "",
98
+ "bstr": "yes" if self.length_prefix_ok else "no",
99
+ "sql": "yes" if self.is_sql else "no",
100
+ "source": self.source,
101
+ "text": mask_text(self.text),
102
+ }
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # 1. the image itself
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def scan_binary(
111
+ path: str,
112
+ min_chars: int = MIN_CHARS,
113
+ include_cp1254: bool = True,
114
+ sections: tuple[str, ...] | None = None,
115
+ ) -> list[Literal]:
116
+ """BSTR (UTF-16LE) literals, plus optional ASCII runs, from one binary.
117
+
118
+ Both byte alignments are decoded: VB6 does not promise that a literal starts on
119
+ a 2-byte boundary, and one odd-length neighbour shifts everything after it.
120
+ """
121
+ with open(path, "rb") as fh:
122
+ data = fh.read()
123
+ image = pe_mod.read(path)
124
+ out: list[Literal] = []
125
+ for sec in image.sections:
126
+ if sections is not None and sec.name not in sections:
127
+ continue
128
+ if sec.name.lower() in (".rsrc", ".reloc", ".pdata", ".idata"):
129
+ continue
130
+ start = sec.raw_offset
131
+ end = min(len(data), sec.raw_offset + sec.raw_size)
132
+ out.extend(_scan_utf16(data, start, end, image, sec))
133
+ if include_cp1254:
134
+ out.extend(_scan_ascii(data, start, end, image, sec))
135
+ return out
136
+
137
+
138
+ def _va_of(image, section, offset: int) -> int:
139
+ return image.image_base + (offset - section.raw_offset) + section.virtual_address
140
+
141
+
142
+ def _scan_utf16(data: bytes, start: int, end: int, image, section):
143
+ out: list[Literal] = []
144
+ for align in (0, 1):
145
+ chunk = data[start + align : end]
146
+ if len(chunk) % 2:
147
+ chunk = chunk[:-1]
148
+ for m in _RUN.finditer(chunk.decode("utf-16-le", "replace")):
149
+ byte_off = start + align + 2 * m.start()
150
+ prefix_ok = False
151
+ if byte_off >= 4:
152
+ declared = int.from_bytes(data[byte_off - 4 : byte_off], "little")
153
+ prefix_ok = declared == 2 * len(m.group(0))
154
+ out.append(
155
+ Literal(
156
+ va=_va_of(image, section, byte_off),
157
+ text=m.group(0),
158
+ encoding="utf16",
159
+ section=section.name,
160
+ length_prefix_ok=prefix_ok,
161
+ )
162
+ )
163
+ return out
164
+
165
+
166
+ _ASCII_RUN = re.compile(rb"[\x20-\x7e]{%d,%d}" % (MIN_CHARS, MAX_CHARS))
167
+
168
+
169
+ def _scan_ascii(data: bytes, start: int, end: int, image, section):
170
+ """ASCII runs terminated by a NUL: connection strings and paths look like this.
171
+
172
+ Turkish CP1254 bytes (0x80+) are deliberately excluded here: a literal that
173
+ needs them is a UI message, recovered by the UTF-16 pass instead. Including
174
+ 0x80+ in this pass turned dense code bytes into thousands of false strings.
175
+ """
176
+ out: list[Literal] = []
177
+ blob = data[start:end]
178
+ for m in _ASCII_RUN.finditer(blob):
179
+ if m.end() < len(blob) and blob[m.end()] != 0:
180
+ continue # not NUL-terminated: inside a longer binary run
181
+ out.append(
182
+ Literal(
183
+ va=_va_of(image, section, start + m.start()),
184
+ text=m.group(0).decode("cp1254", "replace"),
185
+ encoding="cp1254",
186
+ section=section.name,
187
+ )
188
+ )
189
+ return out
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # 2. decompiler listings
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def scan_listings(directory: str) -> list[Literal]:
198
+ """Literals from ``.frm/.bas/.cls`` decompiler output, with procedure VAs."""
199
+ out: list[Literal] = []
200
+ for root, _dirs, files in os.walk(directory):
201
+ for name in sorted(files):
202
+ if not name.lower().endswith((".frm", ".bas", ".cls")):
203
+ continue
204
+ current: int | None = None
205
+ with open(os.path.join(root, name), encoding="cp1254", errors="replace") as fh:
206
+ for line in fh:
207
+ # The procedure header is matched FIRST: it carries no ';', so a
208
+ # "skip lines without a literal comment" filter placed above it
209
+ # throws every header away and leaves proc_va None on every
210
+ # literal (measured: 441,607 literals, 0 procedures).
211
+ head = LISTING_PROC.match(line)
212
+ if head:
213
+ current = int(head.group(2), 16)
214
+ continue
215
+ if ";" not in line or '"' not in line:
216
+ continue
217
+ for m in LISTING_LITERAL.finditer(line):
218
+ out.append(
219
+ Literal(
220
+ va=int(m.group(1), 16),
221
+ text=m.group(2),
222
+ encoding="listing",
223
+ proc_va=current,
224
+ source="listing",
225
+ )
226
+ )
227
+ return out
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # 3. merge / summary
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def merge(*groups: list[Literal]) -> list[Literal]:
236
+ """Union keyed by (proc_va, va) — never by text.
237
+
238
+ The same sentence at two addresses is two literals; an inventory keyed by text
239
+ hides the second one, and any later count built on it is wrong.
240
+ """
241
+ seen: set[tuple[int | None, int]] = set()
242
+ out: list[Literal] = []
243
+ for group in groups:
244
+ for lit in group:
245
+ key = (lit.proc_va, lit.va)
246
+ if key in seen:
247
+ continue
248
+ seen.add(key)
249
+ out.append(lit)
250
+ return out
251
+
252
+
253
+ def summary(literals: list[Literal]) -> dict:
254
+ by_section: dict[str, int] = {}
255
+ for lit in literals:
256
+ key = lit.section or "?"
257
+ by_section[key] = by_section.get(key, 0) + 1
258
+ return {
259
+ "literals": len(literals),
260
+ "sql_candidates": sum(1 for l in literals if l.is_sql),
261
+ "bstr_length_checked": sum(1 for l in literals if l.length_prefix_ok),
262
+ "utf16": sum(1 for l in literals if l.encoding == "utf16"),
263
+ "cp1254": sum(1 for l in literals if l.encoding == "cp1254"),
264
+ "from_listing": sum(1 for l in literals if l.source == "listing"),
265
+ "sections": by_section,
266
+ }
267
+
268
+
269
+ def find(path_or_dir: str, needle: str, case_sensitive: bool = False) -> list[Literal]:
270
+ """Substring search across either source."""
271
+ pool = scan_listings(path_or_dir) if os.path.isdir(path_or_dir) else scan_binary(path_or_dir)
272
+ if case_sensitive:
273
+ return [l for l in pool if needle in l.text]
274
+ low = needle.lower()
275
+ return [l for l in pool if low in l.text.lower()]
vb6decompiler/mask.py ADDED
@@ -0,0 +1,192 @@
1
+ """Hide credentials before anything reaches disk.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A recovered VB6 build contains **plain-text connection strings with ``uid=`` and
6
+ ``pwd=`` values** (250 of them in the reference target, plus server names and UNC
7
+ shares). Any artefact written from that data — CSV, Markdown, JSON, a commit —
8
+ leaks the credentials of a live SQL Server. Every writer in this package runs its
9
+ text through :func:`mask_text`; :func:`audit` is the gate that proves nothing else
10
+ did.
11
+
12
+ What is masked
13
+ --------------
14
+ * ``password=`` / ``pwd=`` values -> ``[REDACTED]``
15
+ * ``user id=`` / ``uid=`` values -> ``[REDACTED]``
16
+ * ``server=`` / ``data source=`` hosts -> ``[HOST]``
17
+ * ``\\\\host\\share`` UNC prefixes -> ``\\\\[HOST]\\``
18
+ * IPv4 literals -> ``[IP]``
19
+
20
+ What is *not* masked, on purpose: database names. They are evidence about the
21
+ program's data model, not a credential, and masking them would destroy the very
22
+ information the toolkit exists to produce.
23
+
24
+ The measured trap: a masking regex written with ``\\s`` crosses newlines. When a
25
+ literal ends with ``Server=``, ``\\s`` swallows the line break and "masks" the first
26
+ word of the next line — corrupting the file and producing a false count. All
27
+ whitespace classes here are ``[ \\t]``, never ``\\s``, and the file-level pass counts
28
+ lines before and after and refuses to write when they differ.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import re
35
+ from dataclasses import dataclass
36
+
37
+ REDACTED = "[REDACTED]"
38
+ HOST = "[HOST]"
39
+ IP = "[IP]"
40
+
41
+ #: Every pattern is line-bounded: ``[ \t]`` and ``[^;\s"']``-style ranges that can
42
+ #: never cross a newline, and the value stops at the next separator.
43
+ def _keep_key(match: "re.Match[str]") -> str:
44
+ """Keep the key and separator as written, replace only the value.
45
+
46
+ A replacement *string* cannot carry a literal backslash: ``re`` reads ``\\``
47
+ and ``\\1`` as escapes, and a UNC replacement raised "bad escape". A callable
48
+ has no such problem, so every pattern below uses one.
49
+ """
50
+ return match.group(1) + match.group(2) + REDACTED
51
+
52
+
53
+ def _keep_host_key(match: "re.Match[str]") -> str:
54
+ return match.group(1) + match.group(2) + HOST
55
+
56
+
57
+ def _unc(match: "re.Match[str]") -> str:
58
+ return "\\\\" + HOST + "\\"
59
+
60
+
61
+ #: RFC 5737 documentation ranges. Docs and tests use them on purpose as the
62
+ #: stand-in for a real address, so they are not leaks and must survive masking —
63
+ #: a gate that redacts its own examples teaches people to disable the gate.
64
+ DOC_NETS = ("192.0.2.", "198.51.100.", "203.0.113.")
65
+ _LIVE_IP = re.compile(r"\b(?!(?:192\.0\.2|198\.51\.100|203\.0\.113)\.)(?:\d{1,3}\.){3}\d{1,3}\b")
66
+
67
+ _PATTERNS: list[tuple[re.Pattern[str], object]] = [
68
+ (re.compile(r"(?i)\b(password|pwd)([ \t]*=[ \t]*)([^;\"'\r\n \t`]+)"), _keep_key),
69
+ (re.compile(r"(?i)\b(user[ \t]+id|uid)([ \t]*=[ \t]*)([^;\"'\r\n \t`]+)"), _keep_key),
70
+ (re.compile(r"(?i)\b(server|data[ \t]+source)([ \t]*=[ \t]*)([^;,\"'\r\n \t`]+)"), _keep_host_key),
71
+ (re.compile(r"\\\\[A-Za-z0-9._-]+\\"), _unc),
72
+ (_LIVE_IP, IP),
73
+ ]
74
+
75
+ #: Marker that means a value was already masked; re-masking it produces nested
76
+ #: ``[REDACTED]`` inside ``[REDACTED]`` and then a false "still leaking" alarm.
77
+ _ALREADY = re.compile(r"\[(REDACTED|HOST|IP)\]")
78
+
79
+
80
+ def mask_text(text: str) -> str:
81
+ """Mask one string. Idempotent: masked values stay as they are."""
82
+ if _ALREADY.search(text):
83
+ # Still run the patterns: a string can carry one masked and one live value.
84
+ pass
85
+ out = text
86
+ for pattern, repl in _PATTERNS:
87
+ out = pattern.sub(repl, out)
88
+ return out
89
+
90
+
91
+ @dataclass
92
+ class AuditResult:
93
+ files: int
94
+ lines: int
95
+ findings: dict[str, int]
96
+
97
+ @property
98
+ def clean(self) -> bool:
99
+ return not any(self.findings.values())
100
+
101
+
102
+ def audit(path: str, extensions: tuple[str, ...] = (".csv", ".md", ".json", ".txt", ".sql", ".log")) -> AuditResult:
103
+ """Count unmasked credentials in the text files under ``path``.
104
+
105
+ Only *values* count. A file that mentions the word ``uid=`` with nothing after
106
+ it is a connection-string fragment, not a leak — counting keywords produced
107
+ 211 false positives against 92 real ones in the reference corpus.
108
+ """
109
+ findings = {"password": 0, "uid": 0, "host": 0, "unc": 0, "ip": 0}
110
+ files = 0
111
+ lines = 0
112
+ targets = [path] if os.path.isfile(path) else []
113
+ if not targets:
114
+ for root, _dirs, names in os.walk(path):
115
+ for n in names:
116
+ if n.lower().endswith(extensions):
117
+ targets.append(os.path.join(root, n))
118
+ # The audit uses the same token rule as the masker: a value must have at least one
119
+ # character that is not a separator or a markdown backtick. Otherwise prose such as
120
+ # "`uid=`/`pwd=` values" is counted as two leaks, and a gate that cries wolf on
121
+ # documentation gets ignored — which is how a real leak survives.
122
+ live = [
123
+ (re.compile(r"(?i)\b(password|pwd)[ \t]*=[ \t]*[^;\"'\r\n \t`]"), "password"),
124
+ (re.compile(r"(?i)\b(user[ \t]+id|uid)[ \t]*=[ \t]*[^;\"'\r\n \t`]"), "uid"),
125
+ (re.compile(r"(?i)\b(server|data[ \t]+source)[ \t]*=[ \t]*[^;,\"'\r\n \t`]"), "host"),
126
+ (re.compile(r"\\\\[A-Za-z0-9._-]+\\"), "unc"),
127
+ (_LIVE_IP, "ip"),
128
+ ]
129
+ for f in targets:
130
+ files += 1
131
+ with open(f, encoding=_codec_for(f), errors="replace") as fh:
132
+ for line in fh:
133
+ lines += 1
134
+ for pattern, key in live:
135
+ if pattern.search(line) and not _ALREADY.search(line):
136
+ findings[key] += 1
137
+ return AuditResult(files=files, lines=lines, findings=findings)
138
+
139
+
140
+ def _codec_for(path: str) -> str:
141
+ """BOM-aware codec pick. A UTF-16 artefact (``command > out.txt`` in
142
+ PowerShell) read as UTF-8 with ``replace`` turns ``uid=`` into ``u?i?d?``:
143
+ every pattern misses it and the gate passes a live leak.
144
+ shortcut: detection is BOM-only; a BOM-less UTF-16 file is still blind to
145
+ the gate — upgrade if a corpus proves that case is real.
146
+ """
147
+ with open(path, "rb") as fh:
148
+ head = fh.read(2)
149
+ if head in (b"\xff\xfe", b"\xfe\xff"):
150
+ return "utf-16" # the codec strips the BOM on read and rewrites it on write
151
+ if head == b"\xef\xbb": # EF BB BF prefix
152
+ return "utf-8-sig" # strips on read, rewrites on write
153
+ return "utf-8"
154
+
155
+
156
+ def mask_file(path: str, dry_run: bool = False) -> int:
157
+ """Mask one file in place. Returns the number of changed lines.
158
+
159
+ The line count is compared before and after: a whitespace class that crosses
160
+ newlines would silently join lines, and this function refuses to write in that
161
+ case. Decoding is BOM-aware, and undecodable bytes round-trip via
162
+ surrogateescape: a cp1254 artefact leaves every byte outside the masked
163
+ values unchanged.
164
+ """
165
+ codec = _codec_for(path)
166
+ with open(path, encoding=codec, errors="surrogateescape", newline="") as fh:
167
+ original = fh.read()
168
+ lines = original.splitlines(keepends=True)
169
+ changed = [mask_text(line) for line in lines]
170
+ n_changed = sum(1 for a, b in zip(lines, changed) if a != b)
171
+ if len(changed) != len(lines):
172
+ raise RuntimeError(
173
+ f"{path}: masking changed the line count ({len(lines)} -> {len(changed)}); not writing"
174
+ )
175
+ if not dry_run and n_changed:
176
+ with open(path, "w", encoding=codec, errors="surrogateescape", newline="") as fh:
177
+ fh.write("".join(changed))
178
+ return n_changed
179
+
180
+
181
+ def mask_tree(path: str, extensions: tuple[str, ...] | None = None, dry_run: bool = False) -> dict:
182
+ """Mask every text file under a directory; returns per-file changed-line counts."""
183
+ exts = extensions or (".csv", ".md", ".json", ".txt", ".sql", ".log")
184
+ out: dict[str, int] = {}
185
+ for root, _dirs, names in os.walk(path):
186
+ for n in names:
187
+ if n.lower().endswith(exts):
188
+ p = os.path.join(root, n)
189
+ # A symlink would let 'mask my artefacts dir' rewrite a file that
190
+ # lives outside it; the audit may still read through it (stricter).
191
+ out[p] = 0 if os.path.islink(p) else mask_file(p, dry_run=dry_run)
192
+ return out