294 行
8.8 KiB
JavaScript
294 行
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const READINESS_STEP_NAMES = [
|
|
"environment",
|
|
"publicReady",
|
|
"publicBundle",
|
|
"pendingOcr",
|
|
"liveSmoke",
|
|
"liveOcr",
|
|
"privacy",
|
|
];
|
|
|
|
const CHILD_STEP_NAMES = ["environment", "pendingOcr", "liveSmoke", "liveOcr"];
|
|
const BLOCKING_STATUS_PATTERN = /FAIL|ERROR|BLOCKED/i;
|
|
const MANUAL_STATUS_PATTERN = /SKIP|MISSING|PAUSED|PARTIAL|PASS_WITH_FINDINGS/i;
|
|
|
|
export function toReportRef(rootDir, file) {
|
|
const absolute = path.resolve(file);
|
|
const relative = path.relative(path.resolve(rootDir), absolute);
|
|
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
return relative.split(path.sep).join("/");
|
|
}
|
|
return path.basename(absolute);
|
|
}
|
|
|
|
function safeBaseUrl(value) {
|
|
try {
|
|
const url = new URL(String(value || ""));
|
|
url.username = "";
|
|
url.password = "";
|
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeFindingCount(status, value) {
|
|
const parsed = Number(value || 0);
|
|
const count = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
return status === "PASS" ? count : Math.max(1, count);
|
|
}
|
|
|
|
function normalizeStep(name, input = {}) {
|
|
const status = String(input.status || "MISSING").toUpperCase();
|
|
return {
|
|
name,
|
|
status,
|
|
kind: input.kind === "required" ? "required" : "finding_allowed",
|
|
code: Number(input.code || 0),
|
|
findingCount: normalizeFindingCount(status, input.findingCount),
|
|
detail: String(input.detail || "").slice(0, 240),
|
|
logRef: input.logRef || null,
|
|
artifactRef: input.artifactRef || null,
|
|
checkedAt: input.checkedAt || null,
|
|
};
|
|
}
|
|
|
|
export function evaluateReadinessSteps(stepInputs) {
|
|
const steps = Object.fromEntries(
|
|
READINESS_STEP_NAMES.map((name) => [name, normalizeStep(name, stepInputs?.[name])]),
|
|
);
|
|
const findings = [];
|
|
|
|
for (const [name, step] of Object.entries(steps)) {
|
|
if (step.status === "PASS" && step.findingCount === 0) {
|
|
continue;
|
|
}
|
|
const blocking = step.kind === "required" && BLOCKING_STATUS_PATTERN.test(step.status);
|
|
findings.push({
|
|
source: name,
|
|
status: step.status,
|
|
severity: blocking
|
|
? "blocker"
|
|
: MANUAL_STATUS_PATTERN.test(step.status)
|
|
? "manual_or_config"
|
|
: "finding",
|
|
findingCount: step.findingCount,
|
|
reportRef: step.artifactRef || step.logRef,
|
|
});
|
|
}
|
|
|
|
const findingCount = findings.reduce((total, finding) => total + Math.max(1, finding.findingCount), 0);
|
|
const status = findings.some((finding) => finding.severity === "blocker")
|
|
? "FAIL"
|
|
: findingCount > 0
|
|
? "PASS_WITH_FINDINGS"
|
|
: "PASS";
|
|
|
|
return { status, findingCount, findings, steps };
|
|
}
|
|
|
|
function readJson(file) {
|
|
if (!existsSync(file)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function currentCommit(rootDir) {
|
|
try {
|
|
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
export function buildReadinessSummary({
|
|
rootDir,
|
|
reportDir,
|
|
baseUrl,
|
|
commit = currentCommit(rootDir),
|
|
checkedAt = new Date().toISOString(),
|
|
}) {
|
|
const stepInputs = Object.fromEntries(
|
|
READINESS_STEP_NAMES.map((name) => [
|
|
name,
|
|
readJson(path.join(reportDir, `${name}.step.json`)) || {
|
|
status: "MISSING",
|
|
kind: "required",
|
|
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
|
|
},
|
|
]),
|
|
);
|
|
const evaluated = evaluateReadinessSteps(stepInputs);
|
|
const environmentReport = readJson(path.join(reportDir, "environment.json"));
|
|
const childReports = Object.fromEntries(
|
|
CHILD_STEP_NAMES.map((name) => {
|
|
const step = evaluated.steps[name];
|
|
return [name, {
|
|
status: step.status,
|
|
findingCount: step.findingCount,
|
|
reportRef: step.artifactRef || step.logRef,
|
|
}];
|
|
}),
|
|
);
|
|
|
|
return {
|
|
issueId: process.env.QA_ISSUE_ID?.trim() || null,
|
|
mode: "live-smoke-readiness",
|
|
status: evaluated.status,
|
|
conclusion: evaluated.status,
|
|
baseUrl: safeBaseUrl(baseUrl),
|
|
commit,
|
|
sourceCommit: commit,
|
|
expectedBuildSha: environmentReport?.publicReady?.expectedBuildSha ?? null,
|
|
observedBuildSha: environmentReport?.publicReady?.observedBuildSha ?? null,
|
|
buildShaMatches: environmentReport?.publicReady?.buildShaMatches ?? null,
|
|
checkedAt,
|
|
reportRef: toReportRef(rootDir, reportDir),
|
|
findingCount: evaluated.findingCount,
|
|
findings: evaluated.findings,
|
|
childReports,
|
|
steps: evaluated.steps,
|
|
boundaries: {
|
|
readOnlyChecksOnly: true,
|
|
noCredentialValues: true,
|
|
noInvoiceSamplesEmbedded: true,
|
|
noAbsoluteWorkspacePaths: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile }) {
|
|
if (!READINESS_STEP_NAMES.includes(name)) {
|
|
throw new Error(`unknown readiness step: ${name}`);
|
|
}
|
|
const step = normalizeStep(name, {
|
|
status,
|
|
kind,
|
|
code,
|
|
findingCount,
|
|
detail,
|
|
logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)),
|
|
artifactRef: artifactFile ? toReportRef(rootDir, artifactFile) : null,
|
|
checkedAt: new Date().toISOString(),
|
|
});
|
|
mkdirSync(reportDir, { recursive: true });
|
|
writeFileSync(path.join(reportDir, `${name}.step.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function collectReportFiles(directory) {
|
|
if (!existsSync(directory)) {
|
|
return [];
|
|
}
|
|
const files = [];
|
|
for (const entry of readdirSync(directory)) {
|
|
const file = path.join(directory, entry);
|
|
const stat = statSync(file);
|
|
if (stat.isDirectory()) {
|
|
files.push(...collectReportFiles(file));
|
|
} else if (/\.(json|log|txt)$/i.test(entry) && entry !== "privacy-findings.json") {
|
|
files.push(file);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export function scanReadinessPrivacy({ rootDir, reportDir }) {
|
|
const patterns = [
|
|
{ category: "workspace_path", pattern: new RegExp(escapeRegExp(path.resolve(rootDir)), "g") },
|
|
{ category: "secret_field", pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|ENCODING_AES_KEY)[A-Z0-9_]*\b/g },
|
|
{ category: "bearer_value", pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/gi },
|
|
{ category: "private_key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
{ category: "email", pattern: /[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}/g },
|
|
{ category: "phone", pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
|
|
];
|
|
const findings = [];
|
|
|
|
for (const file of collectReportFiles(reportDir)) {
|
|
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
|
lines.forEach((line, index) => {
|
|
for (const { category, pattern } of patterns) {
|
|
pattern.lastIndex = 0;
|
|
if (pattern.test(line)) {
|
|
findings.push({
|
|
fileRef: toReportRef(rootDir, file),
|
|
line: index + 1,
|
|
category,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const report = {
|
|
status: findings.length ? "FAIL" : "PASS",
|
|
findingCount: findings.length,
|
|
findings,
|
|
checkedAt: new Date().toISOString(),
|
|
};
|
|
writeFileSync(path.join(reportDir, "privacy-findings.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
return report;
|
|
}
|
|
|
|
function usage() {
|
|
console.error("Usage: live-smoke-readiness-report.mjs <write-step|summarize|privacy> ...");
|
|
}
|
|
|
|
function main() {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (command === "write-step") {
|
|
const [rootDir, reportDir, name, status, kind, code, findingCount, detail = "", artifactFile = ""] = args;
|
|
writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile });
|
|
return;
|
|
}
|
|
if (command === "summarize") {
|
|
const [rootDir, reportDir, baseUrl] = args;
|
|
const summary = buildReadinessSummary({ rootDir, reportDir, baseUrl });
|
|
writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
console.log(JSON.stringify({ status: summary.status, findingCount: summary.findingCount }));
|
|
return;
|
|
}
|
|
if (command === "privacy") {
|
|
const [rootDir, reportDir] = args;
|
|
const report = scanReadinessPrivacy({ rootDir, reportDir });
|
|
console.log(JSON.stringify({ status: report.status, findingCount: report.findingCount }));
|
|
if (report.findingCount > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
usage();
|
|
process.exitCode = 2;
|
|
}
|
|
|
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isMain) {
|
|
main();
|
|
}
|