-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscan-export.ts
More file actions
134 lines (120 loc) · 6.38 KB
/
Copy pathscan-export.ts
File metadata and controls
134 lines (120 loc) · 6.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env bun
/**
* Scan an IRIS class/DTL export for things that should not leave a boundary.
*
* bun scan-export.ts export.xml
* bun scan-export.ts export.xml --context show the matching line
* bun scan-export.ts export.xml --names "Acme,Contoso,St Elsewhere"
*
* Exit 0 = nothing flagged. Exit 1 = findings to review.
*
* Built for reviewing a bulk DTL export before deciding what to do with it.
* A 200k-line export is not reviewable by eye, and the risky content in one is
* rarely the transformation logic -- it is the incidental material around it:
*
* - hardcoded lookup values (real facility codes, real provider identifiers)
* - test data a developer left in a condition, which is often a real MRN
* - comments and <Description> blocks naming people, sites and ticket numbers
* - connection details, share paths and endpoints inside <code> blocks
*
* Heuristics, not proof. This narrows 200,000 lines to a page you can actually
* read. It does not certify anything as clean.
*/
const args = process.argv.slice(2);
const file = args.find(a => !a.startsWith("--"));
const showContext = args.includes("--context");
const namesArg = args[args.indexOf("--names") + 1];
const NAMES = args.includes("--names") && namesArg
? namesArg.split(",").map(s => s.trim()).filter(Boolean) : [];
if (!file) {
console.error("usage: bun scan-export.ts <export.xml> [--context] [--names \"A,B\"]");
process.exit(2);
}
type Rule = { cat: string; re: RegExp; why: string };
const RULES: Rule[] = [
{ cat: "email", re: /[\w.+-]+@[\w-]+\.[\w.]+/g, why: "email address" },
{ cat: "url", re: /https?:\/\/[^\s"'<>]+/g, why: "URL" },
{ cat: "unc-path", re: /\\\\[\w.-]+\\[\w$.-]+/g, why: "UNC share path" },
{ cat: "local-path", re: /\b[A-Za-z]:\\[\w.$-]+(?:\\[\w.$-]+)*/g, why: "Windows path -- leaks server layout" },
{ cat: "unix-path", re: /(?:^|[\s"'>=(])\/(?:opt|srv|mnt|home|var|usr|data)\/[\w./-]+/g,
why: "Unix path -- leaks server layout" },
{ cat: "ip", re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, why: "IP address" },
{ cat: "hostname", re: /\b(?:[\w-]+\.)+(?:com|org|net|edu|gov|local|internal|corp|lan)\b/gi,
why: "hostname / domain" },
{ cat: "credential", re: /\b(?:password|passwd|pwd|secret|api[_-]?key|token|credential)\s*[:=]\s*\S/gi,
why: "credential assignment" },
{ cat: "ssn", re: /\b\d{3}-\d{2}-\d{4}\b/g, why: "SSN-shaped" },
{ cat: "npi", re: /\b\d{10}\b/g, why: "10 digits -- possible NPI" },
{ cat: "long-id", re: /\b\d{7,}\b/g, why: "long numeric -- MRN/account shaped" },
{ cat: "conn-string", re: /\b(?:server|data source|initial catalog|uid|user id)\s*=/gi,
why: "connection string fragment" },
{ cat: "ticket", re: /\b(?:JIRA|TICKET|INC|CHG|REQ|SR)[-# ]?\d{3,}\b/gi,
why: "ticket reference" },
];
// Shapes that are structurally not identifiers, or are ours. Without these the
// report is mostly HL7 timestamps and nobody reads past the first screen.
const ALLOW: RegExp[] = [
/^(?:19|20)\d{6}$/, /^(?:19|20)\d{10}$/, /^(?:19|20)\d{12}$/, /^(?:19|20)\d{4}$/,
/^555\d{4}$/, /^\d{3}555\d{4}$/,
/^127\.0\.0\.1$/, /^0\.0\.0\.0$/, /^255\.255\.255\.\d+$/,
/intersystems\.com/i, /w3\.org/i, /hl7\.org/i, /schemas\./i,
];
const text = await Bun.file(file).text();
const lines = text.split(/\r?\n/);
type Hit = { line: number; value: string; why: string; text: string };
const byCat = new Map<string, Hit[]>();
lines.forEach((raw, i) => {
for (const r of RULES) {
r.re.lastIndex = 0;
for (const m of raw.matchAll(r.re)) {
const v = m[0];
if (ALLOW.some(a => a.test(v))) continue;
if (!byCat.has(r.cat)) byCat.set(r.cat, []);
byCat.get(r.cat)!.push({ line: i + 1, value: v, why: r.why, text: raw.trim() });
}
}
for (const n of NAMES) {
const re = new RegExp(n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
if (re.test(raw)) {
if (!byCat.has("flagged-name")) byCat.set("flagged-name", []);
byCat.get("flagged-name")!.push({ line: i + 1, value: n, why: "configured name", text: raw.trim() });
}
}
});
// Comment and description bodies get called out separately: they are where the
// human-written material lives, and human-written material is what names people.
const commentLines = lines
.map((l, i) => ({ n: i + 1, t: l.trim() }))
.filter(o => /^(\/\/|;|<Description>|<!--)/.test(o.t) || /<Description>/.test(o.t))
.filter(o => o.t.length > 12);
console.log(`\n${file} -- ${lines.length.toLocaleString()} lines\n${"-".repeat(60)}`);
let total = 0;
const order = [...byCat.keys()].sort((a, b) => byCat.get(b)!.length - byCat.get(a)!.length);
for (const cat of order) {
const hits = byCat.get(cat)!;
const uniq = [...new Map(hits.map(h => [h.value, h])).values()];
total += uniq.length;
console.log(`\n${cat} (${uniq.length} distinct, ${hits.length} occurrences)`);
for (const h of uniq.slice(0, 15)) {
console.log(` line ${String(h.line).padStart(6)} ${h.value}`);
if (showContext) console.log(` ${h.text.slice(0, 110)}`);
}
if (uniq.length > 15) console.log(` ... ${uniq.length - 15} more distinct`);
}
console.log(`\n${"-".repeat(60)}`);
console.log(`comments / descriptions: ${commentLines.length} lines.`);
console.log(` These are the highest-value ones to read by eye -- names of people,`);
console.log(` sites and tickets live in prose, not in patterns. Dump them with:`);
console.log(` bun scan-export.ts ${file} --comments`);
if (args.includes("--comments")) {
console.log(`\n${"-".repeat(60)}\nCOMMENT / DESCRIPTION LINES\n`);
for (const c of commentLines) console.log(` ${String(c.n).padStart(6)} ${c.t.slice(0, 140)}`);
}
console.log();
if (total === 0) {
console.log(`Nothing matched the patterns. Still read the comments.`);
} else {
console.log(`${total} distinct values to review.`);
console.log(`Heuristics only -- a clean run is not a clearance.`);
}
process.exit(total === 0 ? 0 : 1);