@procedures/privacy-scan 0.0.0-stage → 0.3.0

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.
package/src/scan.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The scan for private data in text: email addresses, phone numbers, coordinate pairs and the
3
+ * terms of a list that the caller gives. Use it before a text or a file goes public.
4
+ */
5
+
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+ import { join, resolve } from "node:path";
8
+
9
+ /** The kinds of private data that the scan finds. */
10
+ export type FindingKind = "term" | "email" | "phone" | "coordinate";
11
+
12
+ /** All the kinds, in the order of the findings on one position. */
13
+ export const FINDING_KINDS: readonly FindingKind[] = ["term", "email", "phone", "coordinate"];
14
+
15
+ /** A place in a text that holds private data. */
16
+ export interface Finding {
17
+ /** The kind of the data. */
18
+ readonly kind: FindingKind;
19
+ /** The line of the match. The first line is 1. */
20
+ readonly line: number;
21
+ /** The position of the first character of the match in its line. The first position is 1. */
22
+ readonly column: number;
23
+ /** The matched text. With the option `mask`, only its first character stays. */
24
+ readonly match: string;
25
+ }
26
+
27
+ /** A finding in a file. */
28
+ export interface FileFinding extends Finding {
29
+ /** The path of the file. */
30
+ readonly file: string;
31
+ }
32
+
33
+ /** The options of the scan. */
34
+ export interface ScanOptions {
35
+ /** The private terms: names, places, accounts. The match ignores case and finds only whole words. */
36
+ readonly terms?: readonly string[] | undefined;
37
+ /** The values that are not private. The scan does not report them, for each kind. The match ignores case. */
38
+ readonly allow?: readonly string[] | undefined;
39
+ /** The kinds to find (default: all). */
40
+ readonly kinds?: readonly FindingKind[] | undefined;
41
+ /** True: each match shows only its first character, and a `*` for each other character. */
42
+ readonly mask?: boolean | undefined;
43
+ }
44
+
45
+ /** The result of `scanFiles`. */
46
+ export interface FileScan {
47
+ /** The findings, file by file. */
48
+ readonly findings: FileFinding[];
49
+ /** The number of text files that the scan examined. */
50
+ readonly files: number;
51
+ /** The binary files, which the scan does not examine. */
52
+ readonly skipped: string[];
53
+ }
54
+
55
+ /** An email address. Use it with `matchAll`, or make a copy: the `g` flag keeps a state. */
56
+ export const EMAIL_PATTERN: RegExp = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
57
+
58
+ /** A phone number: `+` and 10 to 15 digits, or the North American form `(555) 555-0100`. */
59
+ export const PHONE_PATTERN: RegExp = /\+\d{10,15}\b|\(?\b\d{3}\)?[ .-]\d{3}[ .-]\d{4}\b/g;
60
+
61
+ /** A pair of coordinates with 4 decimals or more: `12.3456, -65.4321`. */
62
+ export const COORDINATE_PATTERN: RegExp = /-?\b\d{1,3}\.\d{4,}\s*,\s*-?\d{1,3}\.\d{4,}/g;
63
+
64
+ /** An address of a domain for examples and tests (RFC 2606) is not private. */
65
+ const EXAMPLE_EMAIL = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost)$/i;
66
+
67
+ /** The folders that the scan of a folder does not enter. */
68
+ const SKIPPED_FOLDERS = new Set(["node_modules", ".git", "dist", "coverage", ".turbo"]);
69
+
70
+ /** The scan reads the first bytes of a file to find a binary file: a text file has no NUL byte. */
71
+ const BINARY_PROBE_BYTES = 8192;
72
+
73
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
+
75
+ /**
76
+ * This function makes the pattern of a term. The match ignores case. A term that starts or ends
77
+ * with a letter or a digit matches only when no letter or digit is next to it.
78
+ */
79
+ function termPattern(term: string): RegExp {
80
+ const before = /^[A-Za-z0-9]/.test(term) ? "(?<![A-Za-z0-9])" : "";
81
+ const after = /[A-Za-z0-9]$/.test(term) ? "(?![A-Za-z0-9])" : "";
82
+ return new RegExp(`${before}${escapeRegExp(term)}${after}`, "gi");
83
+ }
84
+
85
+ /** This function hides a match: it keeps the first character and puts a `*` for each other character. */
86
+ export function maskMatch(text: string): string {
87
+ const characters = Array.from(text);
88
+ if (characters.length <= 1) return "*".repeat(characters.length);
89
+ return `${characters[0]}${"*".repeat(characters.length - 1)}`;
90
+ }
91
+
92
+ interface Rule {
93
+ readonly kind: FindingKind;
94
+ readonly pattern: RegExp;
95
+ readonly keep?: (match: string) => boolean;
96
+ }
97
+
98
+ /** This function makes the scanner of the options. The scanner of a text gives its findings. */
99
+ function createScanner(options: ScanOptions): (text: string) => Finding[] {
100
+ const allowed = new Set((options.allow ?? []).map((value) => value.trim().toLowerCase()));
101
+ const kinds = new Set(options.kinds ?? FINDING_KINDS);
102
+ const rules: Rule[] = [];
103
+ if (kinds.has("term")) {
104
+ const seen = new Set<string>();
105
+ for (const raw of options.terms ?? []) {
106
+ const term = raw.trim();
107
+ const key = term.toLowerCase();
108
+ if (term.length === 0 || seen.has(key) || allowed.has(key)) continue;
109
+ seen.add(key);
110
+ rules.push({ kind: "term", pattern: termPattern(term) });
111
+ }
112
+ }
113
+ if (kinds.has("email")) rules.push({ kind: "email", pattern: EMAIL_PATTERN, keep: (match) => !EXAMPLE_EMAIL.test(match) });
114
+ if (kinds.has("phone")) rules.push({ kind: "phone", pattern: PHONE_PATTERN });
115
+ if (kinds.has("coordinate")) rules.push({ kind: "coordinate", pattern: COORDINATE_PATTERN });
116
+ const order = (kind: FindingKind): number => FINDING_KINDS.indexOf(kind);
117
+
118
+ return (text: string): Finding[] => {
119
+ const findings: Finding[] = [];
120
+ text.split(/\r?\n/).forEach((content, index) => {
121
+ for (const rule of rules) {
122
+ for (const match of content.matchAll(rule.pattern)) {
123
+ const value = match[0];
124
+ if (allowed.has(value.toLowerCase()) || (rule.keep !== undefined && !rule.keep(value))) continue;
125
+ findings.push({ kind: rule.kind, line: index + 1, column: match.index + 1, match: options.mask === true ? maskMatch(value) : value });
126
+ }
127
+ }
128
+ });
129
+ return findings.sort((a, b) => a.line - b.line || a.column - b.column || order(a.kind) - order(b.kind));
130
+ };
131
+ }
132
+
133
+ /**
134
+ * This function finds the private data in a text: the terms of the list, email addresses that
135
+ * are not examples, phone numbers and coordinate pairs. The findings are in text order.
136
+ */
137
+ export function scanText(text: string, options: ScanOptions = {}): Finding[] {
138
+ return createScanner(options)(text);
139
+ }
140
+
141
+ /** This function gives the files of a folder and of its subfolders, in name order. It does not follow links. */
142
+ async function filesIn(dir: string): Promise<string[]> {
143
+ const entries = await readdir(dir, { withFileTypes: true });
144
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
145
+ const files: string[] = [];
146
+ for (const entry of entries) {
147
+ if (SKIPPED_FOLDERS.has(entry.name) || entry.isSymbolicLink()) continue;
148
+ const path = join(dir, entry.name);
149
+ if (entry.isDirectory()) files.push(...(await filesIn(path)));
150
+ else if (entry.isFile()) files.push(path);
151
+ }
152
+ return files;
153
+ }
154
+
155
+ /**
156
+ * This function scans files for private data. A folder in `paths` gives all its files, without
157
+ * `node_modules`, `.git`, `dist`, `coverage` and links. A missing path is an error.
158
+ */
159
+ export async function scanFiles(paths: readonly string[], options: ScanOptions = {}): Promise<FileScan> {
160
+ const scan = createScanner(options);
161
+ const files: string[] = [];
162
+ const seen = new Set<string>();
163
+ for (const path of paths) {
164
+ const list = (await stat(path)).isDirectory() ? await filesIn(path) : [path];
165
+ for (const file of list) {
166
+ const key = resolve(file);
167
+ if (seen.has(key)) continue;
168
+ seen.add(key);
169
+ files.push(file);
170
+ }
171
+ }
172
+ const findings: FileFinding[] = [];
173
+ const skipped: string[] = [];
174
+ for (const file of files) {
175
+ const buffer = await readFile(file);
176
+ if (buffer.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
177
+ skipped.push(file);
178
+ continue;
179
+ }
180
+ // A byte order mark is not a character of the first line
181
+ const text = buffer.toString("utf8").replace(/^/, "");
182
+ for (const finding of scan(text)) findings.push({ file, ...finding });
183
+ }
184
+ return { findings, files: files.length - skipped.length, skipped };
185
+ }
186
+
187
+ /**
188
+ * This function reads a list file: one entry on each line. A line that starts with `#` is a
189
+ * comment. With `optional`, a missing file gives an empty list.
190
+ */
191
+ export async function readTermList(file: string, options: { readonly optional?: boolean | undefined } = {}): Promise<string[]> {
192
+ let text: string;
193
+ try {
194
+ text = await readFile(file, "utf8");
195
+ } catch (error) {
196
+ if (options.optional === true && (error as NodeJS.ErrnoException).code === "ENOENT") return [];
197
+ throw error;
198
+ }
199
+ return text
200
+ .split(/\r?\n/)
201
+ .map((line) => line.trim())
202
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
203
+ }