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
|
@@ -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
|