privacy-check.js
privacy-check.js
#!/usr/bin/env node
// Checks the files this repository would publish for personal information.
//
// Run it before every release: `node privacy-check.js` from the repository folder, and
// `node privacy-check.js --all` when the upload may include files git ignores.
// It exits with status 1 and lists every hit if it finds something.
//
// What it looks for:
// - Windows and macOS/Linux home folder paths with a real user name in them
// - email addresses (except example domains and no-reply addresses)
// - private network addresses
// - long hex strings that look like secrets or keys
// - your own terms, one regular expression per line, from a .privacy-patterns.txt file
// outside every repository: in any folder above the one being checked (such as the folder
// that holds your repositories), or in your home folder, or wherever PRIVACY_PATTERNS
// points (lines starting with # are comments).
// Keeping that list outside the repositories means the check never publishes
// the very details it protects.
//
// Which files: in a git repository, exactly the files git would commit (tracked files
// plus untracked files that .gitignore does not exclude). Elsewhere, every file except
// node_modules and .git. Binary files are skipped; check archives by unpacking them first.
//
// Exceptions: a line containing "privacy-check: allow" is skipped, and paths listed in a
// .privacy-ignore file in the repository (one path prefix per line) are not scanned. Use
// both sparingly, for content that is safe but looks like a hit, such as a fixed test hash.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const args = process.argv.slice(2);
// --all scans every file except node_modules and .git, including files git ignores. Use it
// before a release whose upload tool may not respect .gitignore, such as `lms push`.
const scanAll = args.includes("--all");
const root = path.resolve(args.find((a) => !a.startsWith("--")) || process.cwd());
const GENERIC = [
{ name: "Windows user folder", re: /[A-Za-z]:\\{1,2}Users\\{1,2}(?!YourName\b|someone\b|Public\b|Default\b|<)[A-Za-z0-9._ -]+/g },
{ name: "home folder", re: /\/(?:home|Users)\/(?!yourname\b|someone\b|runner\b|<)[A-Za-z0-9._-]+/g },
{ name: "email address", re: /[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b|users\.noreply\.)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g },
{ name: "private network address", re: /\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b/g },
{ name: "possible secret (64 hex characters)", re: /\b[0-9a-f]{64}\b/gi, mask: true },
];
function personalPatterns() {
// Looked for in order: the PRIVACY_PATTERNS environment variable, then each folder above the
// one being checked, then the home folder. All of these are outside the folder being checked.
const above = [];
for (let dir = path.dirname(root); ; dir = path.dirname(dir)) {
above.push(path.join(dir, ".privacy-patterns.txt"));
if (path.dirname(dir) === dir) break;
}
const candidates = [process.env.PRIVACY_PATTERNS, ...above, path.join(os.homedir(), ".privacy-patterns.txt")].filter(Boolean);
let text;
let file;
for (const c of candidates) {
try {
text = fs.readFileSync(c, "utf8");
file = c;
break;
} catch {
// try the next place
}
}
if (text === undefined) {
console.warn("note: no .privacy-patterns.txt found above this folder or in the home folder, so only the generic checks ran.");
return [];
}
const out = [];
for (const line of text.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
try {
out.push({ name: "personal term", re: new RegExp(t, "gi"), mask: true });
} catch (e) {
console.warn(`note: skipped an invalid pattern in ${file}: ${e.message}`);
}
}
return out;
}
function listFiles() {
try {
if (scanAll) throw new Error("scan everything");
const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return out.split("\0").filter(Boolean).map((f) => path.join(root, f));
} catch {
const files = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name === ".git") continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.isFile()) files.push(p);
}
};
walk(root);
return files;
}
}
function ignoredPrefixes() {
try {
return fs
.readFileSync(path.join(root, ".privacy-ignore"), "utf8")
.split(/\r?\n/)
.map((l) => l.trim().replace(/\\/g, "/"))
.filter((l) => l && !l.startsWith("#"));
} catch {
return [];
}
}
const checks = [...GENERIC, ...personalPatterns()];
const ignored = ignoredPrefixes();
const hits = [];
let scanned = 0;
for (const file of listFiles()) {
const rel = path.relative(root, file).replace(/\\/g, "/");
if (rel === ".privacy-ignore" || ignored.some((p) => rel === p || rel.startsWith(p.endsWith("/") ? p : p + "/"))) continue;
let buf;
try {
buf = fs.readFileSync(file);
} catch {
continue;
}
if (buf.includes(0)) continue;
scanned++;
const lines = buf.toString("utf8").split(/\r?\n/);
lines.forEach((line, i) => {
if (line.includes("privacy-check: allow")) return;
for (const c of checks) {
c.re.lastIndex = 0;
for (const m of line.matchAll(c.re)) {
const shown = c.mask ? `${m[0].slice(0, 2)}…(${m[0].length} chars)` : m[0];
hits.push(`${path.relative(root, file)}:${i + 1}: ${c.name}: ${shown}`);
}
}
});
}
if (hits.length) {
console.log(hits.join("\n"));
console.log(`\n${hits.length} possible leak(s) in ${scanned} files. Fix them, or add the file to .gitignore if it should not be published.`);
process.exit(1);
}
console.log(`No personal information found in ${scanned} files.`);
#!/usr/bin/env node
// Checks the files this repository would publish for personal information.
//
// Run it before every release: `node privacy-check.js` from the repository folder, and
// `node privacy-check.js --all` when the upload may include files git ignores.
// It exits with status 1 and lists every hit if it finds something.
//
// What it looks for:
// - Windows and macOS/Linux home folder paths with a real user name in them
// - email addresses (except example domains and no-reply addresses)
// - private network addresses
// - long hex strings that look like secrets or keys
// - your own terms, one regular expression per line, from a .privacy-patterns.txt file
// outside every repository: in any folder above the one being checked (such as the folder
// that holds your repositories), or in your home folder, or wherever PRIVACY_PATTERNS
// points (lines starting with # are comments).
// Keeping that list outside the repositories means the check never publishes
// the very details it protects.
//
// Which files: in a git repository, exactly the files git would commit (tracked files
// plus untracked files that .gitignore does not exclude). Elsewhere, every file except
// node_modules and .git. Binary files are skipped; check archives by unpacking them first.
//
// Exceptions: a line containing "privacy-check: allow" is skipped, and paths listed in a
// .privacy-ignore file in the repository (one path prefix per line) are not scanned. Use
// both sparingly, for content that is safe but looks like a hit, such as a fixed test hash.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const args = process.argv.slice(2);
// --all scans every file except node_modules and .git, including files git ignores. Use it
// before a release whose upload tool may not respect .gitignore, such as `lms push`.
const scanAll = args.includes("--all");
const root = path.resolve(args.find((a) => !a.startsWith("--")) || process.cwd());
const GENERIC = [
{ name: "Windows user folder", re: /[A-Za-z]:\\{1,2}Users\\{1,2}(?!YourName\b|someone\b|Public\b|Default\b|<)[A-Za-z0-9._ -]+/g },
{ name: "home folder", re: /\/(?:home|Users)\/(?!yourname\b|someone\b|runner\b|<)[A-Za-z0-9._-]+/g },
{ name: "email address", re: /[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b|users\.noreply\.)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g },
{ name: "private network address", re: /\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b/g },
{ name: "possible secret (64 hex characters)", re: /\b[0-9a-f]{64}\b/gi, mask: true },
];
function personalPatterns() {
// Looked for in order: the PRIVACY_PATTERNS environment variable, then each folder above the
// one being checked, then the home folder. All of these are outside the folder being checked.
const above = [];
for (let dir = path.dirname(root); ; dir = path.dirname(dir)) {
above.push(path.join(dir, ".privacy-patterns.txt"));
if (path.dirname(dir) === dir) break;
}
const candidates = [process.env.PRIVACY_PATTERNS, ...above, path.join(os.homedir(), ".privacy-patterns.txt")].filter(Boolean);
let text;
let file;
for (const c of candidates) {
try {
text = fs.readFileSync(c, "utf8");
file = c;
break;
} catch {
// try the next place
}
}
if (text === undefined) {
console.warn("note: no .privacy-patterns.txt found above this folder or in the home folder, so only the generic checks ran.");
return [];
}
const out = [];
for (const line of text.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
try {
out.push({ name: "personal term", re: new RegExp(t, "gi"), mask: true });
} catch (e) {
console.warn(`note: skipped an invalid pattern in ${file}: ${e.message}`);
}
}
return out;
}
function listFiles() {
try {
if (scanAll) throw new Error("scan everything");
const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return out.split("\0").filter(Boolean).map((f) => path.join(root, f));
} catch {
const files = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name === ".git") continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.isFile()) files.push(p);
}
};
walk(root);
return files;
}
}
function ignoredPrefixes() {
try {
return fs
.readFileSync(path.join(root, ".privacy-ignore"), "utf8")
.split(/\r?\n/)
.map((l) => l.trim().replace(/\\/g, "/"))
.filter((l) => l && !l.startsWith("#"));
} catch {
return [];
}
}
const checks = [...GENERIC, ...personalPatterns()];
const ignored = ignoredPrefixes();
const hits = [];
let scanned = 0;
for (const file of listFiles()) {
const rel = path.relative(root, file).replace(/\\/g, "/");
if (rel === ".privacy-ignore" || ignored.some((p) => rel === p || rel.startsWith(p.endsWith("/") ? p : p + "/"))) continue;
let buf;
try {
buf = fs.readFileSync(file);
} catch {
continue;
}
if (buf.includes(0)) continue;
scanned++;
const lines = buf.toString("utf8").split(/\r?\n/);
lines.forEach((line, i) => {
if (line.includes("privacy-check: allow")) return;
for (const c of checks) {
c.re.lastIndex = 0;
for (const m of line.matchAll(c.re)) {
const shown = c.mask ? `${m[0].slice(0, 2)}…(${m[0].length} chars)` : m[0];
hits.push(`${path.relative(root, file)}:${i + 1}: ${c.name}: ${shown}`);
}
}
});
}
if (hits.length) {
console.log(hits.join("\n"));
console.log(`\n${hits.length} possible leak(s) in ${scanned} files. Fix them, or add the file to .gitignore if it should not be published.`);
process.exit(1);
}
console.log(`No personal information found in ${scanned} files.`);