156 行
4.7 KiB
JavaScript
156 行
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const DEFAULT_SCAN_FILES = [
|
|
"AGENTS.md",
|
|
"README.md",
|
|
"server/README.md",
|
|
"client/README.md",
|
|
"server/.env.example",
|
|
"docker-compose.yml",
|
|
"deploy/trialrun/deploy.sh",
|
|
"deploy/trialrun/.env.example",
|
|
"deploy/trialrun/runbook.md",
|
|
"deploy/trialrun/checklist.md",
|
|
"deploy/trialrun/setup-server.sh",
|
|
"docs/Agent开发交付实践.md",
|
|
"docs/文档索引.md",
|
|
"docs/文档事实源状态矩阵.md",
|
|
"docs/产品设计规则.md",
|
|
"docs/报销发票附件规则.md",
|
|
"docs/试运行与自动化验证手册.md",
|
|
];
|
|
const CURRENT_DOCUMENT_REGISTRY = "docs/文档事实源状态矩阵.md";
|
|
|
|
const TEXT_PATTERNS = [
|
|
{
|
|
category: "private_key",
|
|
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
},
|
|
{
|
|
category: "bearer_value",
|
|
pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i,
|
|
},
|
|
{
|
|
category: "credential_in_url",
|
|
pattern: /postgres(?:ql)?:\/\/[^\s:/<>]+:[^\s@<>]+@/i,
|
|
},
|
|
{
|
|
category: "assigned_secret",
|
|
pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_KEY)[A-Z0-9_]*\b\s*[:=]\s*["']?(?!\s*(?:<|\$\{|\*|""|''|REDACTED|example))[A-Za-z0-9._/+=:-]{8,}/,
|
|
},
|
|
{
|
|
category: "personal_workspace_path",
|
|
pattern: /\/(?:home|Users)\/(?!<)[^/\s`'"()]+\/[A-Za-z0-9._/ -]+/,
|
|
},
|
|
{
|
|
category: "email_account",
|
|
pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
|
|
},
|
|
{
|
|
category: "phone_account",
|
|
pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/,
|
|
},
|
|
];
|
|
|
|
function isPublicIpv4(value) {
|
|
const parts = value.split(".").map(Number);
|
|
if (parts.length !== 4 || parts.some((item) => !Number.isInteger(item) || item < 0 || item > 255)) {
|
|
return false;
|
|
}
|
|
if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return false;
|
|
if (parts[0] === 169 && parts[1] === 254) return false;
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
|
|
if (parts[0] === 192 && parts[1] === 168) return false;
|
|
if (parts[0] >= 224) return false;
|
|
return true;
|
|
}
|
|
|
|
export function scanSensitiveText(text, fileRef) {
|
|
const findings = [];
|
|
String(text).split(/\r?\n/).forEach((line, index) => {
|
|
for (const { category, pattern } of TEXT_PATTERNS) {
|
|
if (category === "credential_in_url" && (/\$\{/.test(line) || /<[^>]+>/.test(line))) {
|
|
continue;
|
|
}
|
|
if (pattern.test(line)) {
|
|
findings.push({ fileRef, line: index + 1, category });
|
|
}
|
|
}
|
|
const ipv4Values = line.match(/(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)/g) || [];
|
|
if (ipv4Values.some(isPublicIpv4)) {
|
|
findings.push({ fileRef, line: index + 1, category: "public_ipv4" });
|
|
}
|
|
});
|
|
return findings;
|
|
}
|
|
|
|
export function extractCurrentDocumentFiles(matrixText) {
|
|
const files = new Set();
|
|
for (const line of String(matrixText).split(/\r?\n/)) {
|
|
if (!line.startsWith("|")) {
|
|
continue;
|
|
}
|
|
const cells = line
|
|
.split("|")
|
|
.slice(1, -1)
|
|
.map((cell) => cell.trim());
|
|
if (!cells.some((cell) => cell === "`CURRENT`" || cell === "CURRENT")) {
|
|
continue;
|
|
}
|
|
const contractCell = cells[1] ?? "";
|
|
for (const match of contractCell.matchAll(/`(docs\/[^`|]+\.(?:md|csv))`/g)) {
|
|
const normalized = path.posix.normalize(match[1]);
|
|
if (normalized.startsWith("docs/") && !normalized.includes("..")) {
|
|
files.add(normalized);
|
|
}
|
|
}
|
|
}
|
|
return [...files].sort();
|
|
}
|
|
|
|
export function resolveDefaultScanFiles(rootDir) {
|
|
const registryPath = path.join(rootDir, CURRENT_DOCUMENT_REGISTRY);
|
|
const currentDocuments = existsSync(registryPath)
|
|
? extractCurrentDocumentFiles(readFileSync(registryPath, "utf8"))
|
|
: [];
|
|
return [...new Set([...DEFAULT_SCAN_FILES, ...currentDocuments])];
|
|
}
|
|
|
|
export function scanSensitiveContent({ rootDir, files }) {
|
|
const findings = [];
|
|
let filesScanned = 0;
|
|
const scanFiles = files ?? resolveDefaultScanFiles(rootDir);
|
|
for (const fileRef of scanFiles) {
|
|
const file = path.join(rootDir, fileRef);
|
|
if (!existsSync(file)) {
|
|
continue;
|
|
}
|
|
filesScanned += 1;
|
|
findings.push(...scanSensitiveText(readFileSync(file, "utf8"), fileRef));
|
|
}
|
|
return {
|
|
status: findings.length ? "FAIL" : "PASS",
|
|
findingCount: findings.length,
|
|
filesScanned,
|
|
findings,
|
|
};
|
|
}
|
|
|
|
function main() {
|
|
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
|
|
const result = scanSensitiveContent({ rootDir });
|
|
console.log(JSON.stringify(result, null, 2));
|
|
if (result.findingCount > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isMain) {
|
|
main();
|
|
}
|