scripts/check-public-fixtures.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { isIP } from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const projectDirectory = resolve(scriptDirectory, "..");
const policy = JSON.parse(readFileSync(resolve(scriptDirectory, "privacy-policy.json"), "utf8"));
const zeroOID = /^0+$/;
const ipv4Pattern = /(?<![0-9.])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9.])/g;
// Consume an embedded dotted IPv4 tail as part of the IPv6 token. Otherwise a
// documentation fixture such as ::ffff:203.0.113.1 is otherwise truncated and
// incorrectly classified as an unrelated routable IPv6 address.
const ipv6Pattern = /(?<![0-9a-f:])(?:(?:[0-9a-f]{0,4}:){2,}(?:[0-9]{1,3}\.){3}[0-9]{1,3}|(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4})(?![0-9a-f:.])/gi;
const domainPattern = /(?<![a-z0-9_-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:com|net|org|io|dev|cloud|top|xyz|cn|co|me|tech|site|online|no|gov|edu|invalid|test)(?![a-z0-9_-])/gi;
const wordPattern = /[a-z0-9][a-z0-9._-]*/gi;
const allowedPublicIPv4 = new Set(policy.allowedPublicIPv4.map((entry) => entry.value));
const allowedPublicIPv6 = new Set(policy.allowedPublicIPv6.map((entry) => entry.value.toLowerCase()));
const allowedDomainRoots = policy.allowedDomainRoots.map((entry) => entry.value.toLowerCase());
const blockedFingerprints = new Set(policy.blockedFingerprints.map((entry) => entry.sha256));
function git(args, options = {}) {
const execOptions = { cwd: projectDirectory, input: options.input };
if (options.encoding) execOptions.encoding = options.encoding;
return execFileSync("git", args, execOptions);
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function normalizeIdentifier(value) {
return value.normalize("NFKC").toLowerCase().replace(/[-_|/]+/g, " ").replace(/[^\p{L}\p{N}. ]+/gu, " ").replace(/\s+/g, " ").trim();
}
function fingerprint(value) {
return sha256(normalizeIdentifier(value));
}
function decodePaths(raw) {
return raw.toString("utf8").split("\0").filter(Boolean);
}
function lineNumber(text, offset) {
let line = 1;
for (let index = 0; index < offset; index += 1) if (text.charCodeAt(index) === 10) line += 1;
return line;
}
function violation(kind, path, text, offset, value) {
return { kind, path, line: lineNumber(text, offset), fingerprint: fingerprint(value).slice(0, 12) };
}
function ipv4Number(value) {
const parts = value.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
}
function inCIDR(value, base, bits) {
const address = ipv4Number(value);
const network = ipv4Number(base);
if (address === null || network === null) return false;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (address & mask) === (network & mask);
}
function publicIPv4(value) {
const reserved = [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]
];
return ipv4Number(value) !== null && !reserved.some(([base, bits]) => inCIDR(value, base, bits));
}
function publicIPv6(value) {
if (isIP(value) !== 6) return false;
// Apply the same reserved-address policy to the underlying IPv4 address in
// both dotted and hexadecimal mapped forms. Routable mappings still fail;
// this is classification, not an allowlist or scanner bypass.
const canonical = new URL(`http://[${value}]/`).hostname.slice(1, -1);
const normalized = canonical.toLowerCase();
const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/i.exec(canonical);
if (mapped) {
const high = Number.parseInt(mapped[1], 16);
const low = Number.parseInt(mapped[2], 16);
return publicIPv4([high >>> 8, high & 255, low >>> 8, low & 255].join("."));
}
return normalized !== "::" && normalized !== "::1" && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("fe8") && !normalized.startsWith("fe9") && !normalized.startsWith("fea") && !normalized.startsWith("feb") && !normalized.startsWith("ff") && !/^2001:0*db8:/i.test(normalized) && !normalized.startsWith("64:ff9b:1:") && !normalized.startsWith("100:");
}
function allowedDomain(value) {
const domain = value.toLowerCase().replace(/\.$/, "");
return allowedDomainRoots.some((root) => domain === root || domain.endsWith(`.${root}`));
}
function domainLooksLikeData(text, offset, value) {
const before = text.slice(Math.max(0, offset - 16), offset);
const after = text.slice(offset + value.length, offset + value.length + 8);
if (/https?:\/\/$/i.test(before)) return true;
return /["'`]$/.test(before) && /^(?::[0-9]+)?(?:[\/"'`]|$)/.test(after);
}
function ignoredPath(path) {
return path === "web/package-lock.json" || path.endsWith(".sum") || path.includes("/vendor/");
}
export function scanContent(path, buffer) {
if (ignoredPath(path) || !buffer.length || buffer.subarray(0, 8192).includes(0)) return [];
const text = buffer.toString("utf8");
const violations = [];
for (const match of text.matchAll(ipv4Pattern)) {
if (publicIPv4(match[0]) && !allowedPublicIPv4.has(match[0])) violations.push(violation("public-ipv4", path, text, match.index, match[0]));
}
for (const match of text.matchAll(ipv6Pattern)) {
if (/\d/.test(match[0]) && publicIPv6(match[0]) && !allowedPublicIPv6.has(match[0].toLowerCase())) violations.push(violation("public-ipv6", path, text, match.index, match[0]));
}
for (const match of text.matchAll(domainPattern)) {
if (domainLooksLikeData(text, match.index, match[0]) && !allowedDomain(match[0])) violations.push(violation("unapproved-domain", path, text, match.index, match[0]));
}
const lines = text.split("\n");
let offset = 0;
for (const line of lines) {
const words = [...line.matchAll(wordPattern)].map((match) => normalizeIdentifier(match[0])).filter(Boolean);
for (let start = 0; start < words.length; start += 1) {
for (let length = 1; length <= 4 && start + length <= words.length; length += 1) {
const candidate = words.slice(start, start + length).join(" ");
const digest = sha256(candidate);
if (blockedFingerprints.has(digest)) violations.push(violation("blocked-identifier", path, text, offset, candidate));
}
}
offset += line.length + 1;
}
return violations;
}
function worktreeSources() {
return decodePaths(git(["ls-files", "-z"])).flatMap((path) => {
try { return [{ path, buffer: readFileSync(resolve(projectDirectory, path)) }]; } catch { return []; }
});
}
function indexSources() {
return decodePaths(git(["ls-files", "-z"])).flatMap((path) => {
try { return [{ path, buffer: git(["show", `:${path}`]) }]; } catch { return []; }
});
}
function commitSources(commit) {
const paths = decodePaths(git(["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit]));
return paths.flatMap((path) => {
try { return [{ path, buffer: git(["show", `${commit}:${path}`]) }]; } catch { return []; }
});
}
function prePushSources(input) {
const commits = new Set();
for (const line of input.trim().split("\n").filter(Boolean)) {
const [, localOID, , remoteOID] = line.trim().split(/\s+/);
if (!localOID || zeroOID.test(localOID)) continue;
const args = zeroOID.test(remoteOID) ? ["rev-list", localOID, "--not", "--remotes"] : ["rev-list", `${remoteOID}..${localOID}`];
for (const commit of git(args, { encoding: "utf8" }).trim().split("\n").filter(Boolean)) commits.add(commit);
}
return [...commits].flatMap(commitSources);
}
function deduplicate(violations) {
const seen = new Set();
return violations.filter((entry) => {
const key = `${entry.kind}:${entry.path}:${entry.line}:${entry.fingerprint}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function formatViolation(entry) {
return `${entry.kind} ${entry.path}:${entry.line} fingerprint=${entry.fingerprint}`;
}
function main() {
const mode = process.argv[2] ?? "--worktree";
let sources;
if (mode === "--worktree") sources = worktreeSources();
else if (mode === "--staged") sources = indexSources();
else if (mode === "--pre-push") sources = prePushSources(readFileSync(0, "utf8"));
else throw new Error(`unsupported privacy-check mode: ${mode}`);
const violations = deduplicate(sources.flatMap(({ path, buffer }) => scanContent(path, buffer)));
if (!violations.length) return;
console.error(`privacy-check rejected ${violations.length} finding(s); raw values are intentionally omitted:`);
for (const entry of violations) console.error(`- ${formatViolation(entry)}`);
process.exitCode = 1;
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) main();