#!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, ""); const reportDir = process.env.OA_PERMISSION_MATRIX_REPORT_DIR || path.resolve(`artifacts/qa-flow/oa-permission-matrix-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`); const loginCooldownMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_LOGIN_COOLDOWN_MS || "1500", 10) || 0); const retryAfterRateLimitMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_RATE_LIMIT_WAIT_MS || "65000", 10) || 0); const ROLE_TARGETS = [ { key: "employee", identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", passwordEnv: "LIVE_SMOKE_EMPLOYEE_PASSWORD", expectedRoles: ["employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: false }, { path: "data.roleCapabilities.canUseFinance", equals: false }, { path: "data.roleCapabilities.canManageSystem", equals: false }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "own-invoices", path: "/api/invoice-pool?ownerScope=own&pageSize=5", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/portal", "/reimbursements", "/assets"], hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"], }, }, ], negative: [ { id: "budget", path: "/api/budgets", statuses: [403] }, { id: "ap", path: "/api/ap-operations", statuses: [403, 409] }, { id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] }, { id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] }, { id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [403] }, { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] }, { id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] }, { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] }, { id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [403] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] }, { id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] }, { id: "global-search-sensitive", path: "/api/search?q=TEST&limit=10", statuses: [200, 409], searchExpectations: { forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"], forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"], }, }, ], }, { key: "manager", identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER", passwordEnv: "LIVE_SMOKE_MANAGER_PASSWORD", expectedRoles: ["manager", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: true }, { path: "data.roleCapabilities.canUseFinance", equals: false }, { path: "data.roleCapabilities.canManageSystem", equals: false }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "workflow", path: "/api/workflow/tasks?page=1&pageSize=5", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/workflow/tasks"], hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"], }, }, ], negative: [ { id: "ap", path: "/api/ap-operations", statuses: [403, 409] }, { id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] }, { id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] }, { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] }, { id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [403] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] }, { id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] }, { id: "global-search-sensitive", path: "/api/search?q=TEST&limit=10", statuses: [200, 409], searchExpectations: { forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"], forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"], }, }, ], }, { key: "finance", identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER", passwordEnv: "LIVE_SMOKE_FINANCE_PASSWORD", expectedRoles: ["finance", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: true }, { path: "data.roleCapabilities.canUseFinance", equals: true }, { path: "data.roleCapabilities.canManageSystem", equals: false }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] }, { id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] }, { id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/invoice-pool", "/payment-batches", "/ap-operations"], hidden: ["/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"], }, }, ], negative: [ { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] }, { id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] }, { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] }, { id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [403] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] }, { id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] }, { id: "global-search-sensitive", path: "/api/search?q=TEST&limit=10", statuses: [200, 409], searchExpectations: { forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"], forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"], }, }, ], }, { key: "finance_mgr", identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", passwordEnv: "LIVE_SMOKE_FINANCE_MGR_PASSWORD", expectedRoles: ["finance", "finance_mgr", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: true }, { path: "data.roleCapabilities.canUseFinance", equals: true }, { path: "data.roleCapabilities.canManageSystem", equals: true }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] }, { id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] }, { id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] }, { id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [200] }, { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] }, { id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] }, { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/invoice-pool", "/payment-batches", "/ap-operations", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"], hidden: [], }, }, ], negative: [], }, { key: "hr", identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER", passwordEnv: "LIVE_SMOKE_HR_PASSWORD", expectedRoles: ["hr", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: false }, { path: "data.roleCapabilities.canUseFinance", equals: false }, { path: "data.roleCapabilities.canManageSystem", equals: true }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "wecom-archive", path: "/api/wecom/approval-archive?page=1&pageSize=5&scope=all", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/people", "/system/settings"], hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"], }, }, ], negative: [ { id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] }, { id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] }, { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] }, { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [403] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] }, { id: "global-search-sensitive", path: "/api/search?q=TEST&limit=10", statuses: [200, 409], searchExpectations: { forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"], forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"], }, }, ], }, { key: "ceo", identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER", passwordEnv: "LIVE_SMOKE_CEO_PASSWORD", expectedRoles: ["ceo", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: true }, { path: "data.roleCapabilities.canUseFinance", equals: false }, { path: "data.roleCapabilities.canManageSystem", equals: false }, { path: "data.roleCapabilities.isAdminOnly", equals: false }, ], }, { id: "dashboard", path: "/api/dashboard/expense-overview", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/workflow/tasks", "/budgets"], hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"], }, }, ], negative: [ { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] }, { id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [403] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] }, { id: "global-search-sensitive", path: "/api/search?q=TEST&limit=10", statuses: [200, 409], searchExpectations: { forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"], forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"], }, }, ], }, { key: "admin", identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER", passwordEnv: "LIVE_SMOKE_ADMIN_PASSWORD", expectedRoles: ["admin", "employee"], positive: [ { id: "me", path: "/api/me", statuses: [200] }, { id: "portal", path: "/api/portal/home", statuses: [200], dataExpectations: [ { path: "data.roleCapabilities.canApproveTasks", equals: false }, { path: "data.roleCapabilities.canUseFinance", equals: false }, { path: "data.roleCapabilities.canManageSystem", equals: true }, { path: "data.roleCapabilities.isAdminOnly", equals: true }, ], }, { id: "users", path: "/api/users?page=1&pageSize=1", statuses: [200] }, { id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] }, { id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] }, { id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] }, { id: "payroll-items", path: "/api/payroll/items", statuses: [200] }, { id: "performance-cycles", path: "/api/performance/cycles", statuses: [200, 409] }, { id: "global-search", path: "/api/search?q=TEST&limit=10", statuses: [200, 409] }, { id: "assets", path: "/api/assets?pageSize=5", statuses: [200] }, { id: "navigation", path: "/api/system/navigation", statuses: [200], navigationExpectations: { visible: ["/audit-logs", "/system/settings", "/system/feature-flags"], hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"], }, }, ], negative: [], }, ]; function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function maskIdentifier(value) { const text = String(value || "").trim(); if (!text) return null; if (text.includes("@")) { const [local, domain] = text.split("@"); const suffix = String(domain || "").split(".").at(-1) || "***"; return `${local.slice(0, 2) || "*"}***@***.${suffix}`; } if (text.length <= 4) return "***"; return `${text.slice(0, 2)}***${text.slice(-2)}`; } function sanitizeError(value) { return String(value ?? "") .replace(/access_token=[^&\s]+/gi, "access_token=***") .replace(/authorization:\s*bearer\s+[^\s]+/gi, "authorization: bearer ***") .replace(/[A-Za-z0-9_-]{24,}/g, "***") .slice(0, 160); } function summarizeData(json) { const data = json?.data; if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null }; if (!data || typeof data !== "object") return {}; if (data.sections && typeof data.sections === "object") { const hrefs = collectNavigationHrefs(json); return { navigationItemCount: hrefs.length }; } const summary = {}; for (const [key, value] of Object.entries(data)) { if (key === "roleCodes" && Array.isArray(value)) { summary.roleCodes = value.filter((item) => typeof item === "string").sort(); continue; } if (key === "permissionCodes" && Array.isArray(value)) { summary.permissionCount = value.length; continue; } if (key === "roleCapabilities" && value && typeof value === "object") { summary.roleCapabilities = Object.fromEntries( Object.entries(value) .filter(([, capabilityValue]) => typeof capabilityValue === "boolean") .sort(([left], [right]) => left.localeCompare(right)), ); continue; } if (typeof value === "number" || typeof value === "boolean") summary[key] = value; if (/^(status|mode|conclusion|ownerScope)$/i.test(key) && typeof value === "string" && /^[a-z0-9_/-]{1,60}$/i.test(value)) { summary[key] = value; } } return summary; } function getByPath(value, dottedPath) { return String(dottedPath || "") .split(".") .filter(Boolean) .reduce((current, key) => { if (!current || typeof current !== "object") return undefined; return current[key]; }, value); } function evaluateDataExpectations(json, expectations = []) { return expectations.map((expectation) => { const actual = getByPath(json, expectation.path); const pass = actual === expectation.equals; return { path: expectation.path, status: pass ? "PASS" : "FAIL", expected: expectation.equals, actual: typeof actual === "boolean" || typeof actual === "number" || typeof actual === "string" || actual === null ? actual : typeof actual, }; }); } function collectNavigationHrefs(json) { const sections = json?.data?.sections; if (!sections || typeof sections !== "object") return []; const hrefs = []; for (const sectionItems of Object.values(sections)) { if (!Array.isArray(sectionItems)) continue; for (const item of sectionItems) { if (item && typeof item === "object" && typeof item.href === "string") { hrefs.push(item.href); } } } return Array.from(new Set(hrefs)).sort(); } function evaluateNavigationExpectations(json, expectations) { if (!expectations) return []; const hrefs = new Set(collectNavigationHrefs(json)); const visible = (expectations.visible || []).map((href) => ({ type: "visible", href, status: hrefs.has(href) ? "PASS" : "FAIL", })); const hidden = (expectations.hidden || []).map((href) => ({ type: "hidden", href, status: hrefs.has(href) ? "FAIL" : "PASS", })); return [...visible, ...hidden]; } function evaluateSearchExpectations(json, expectations) { if (!expectations || !Array.isArray(json?.data?.items)) return []; const items = json.data.items.filter((item) => item && typeof item === "object"); const forbiddenEntityTypes = new Set(expectations.forbiddenEntityTypes || []); const forbiddenHrefPrefixes = expectations.forbiddenHrefPrefixes || []; const results = []; for (const entityType of forbiddenEntityTypes) { const matchedCount = items.filter((item) => item.entityType === entityType).length; results.push({ type: "forbidden_entity_type", value: entityType, status: matchedCount === 0 ? "PASS" : "FAIL", matchedCount, }); } for (const hrefPrefix of forbiddenHrefPrefixes) { const matchedCount = items.filter((item) => typeof item.href === "string" && item.href.startsWith(hrefPrefix)).length; results.push({ type: "forbidden_href_prefix", value: hrefPrefix, status: matchedCount === 0 ? "PASS" : "FAIL", matchedCount, }); } return results; } function collectSensitiveKeyHits(value, currentPath = "data", hits = []) { if (hits.length >= 12) return hits; if (Array.isArray(value)) { value.slice(0, 5).forEach((item, index) => collectSensitiveKeyHits(item, `${currentPath}[${index}]`, hits)); return hits; } if (!value || typeof value !== "object") return hits; for (const [key, child] of Object.entries(value)) { if (/(password|token|secret|encodingaeskey|privatekey)/i.test(key)) { hits.push(`${currentPath}.${key}`); if (hits.length >= 12) return hits; } collectSensitiveKeyHits(child, `${currentPath}.${key}`, hits); } return hits; } async function fetchJson(url, init = {}) { try { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(20_000), }); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: response.ok, status: response.status, json, text: text.slice(0, 120) }; } catch (error) { return { ok: false, status: 0, error: sanitizeError(error instanceof Error ? error.message : String(error)) }; } } async function login(target, attempt = 0) { const identifier = process.env[target.identifierEnv]?.trim(); const password = process.env[target.passwordEnv]?.trim(); if (!identifier || !password) { return { status: "SKIP", reason: "missing_credentials", maskedIdentifier: maskIdentifier(identifier), }; } if (loginCooldownMs > 0) await wait(loginCooldownMs); const result = await fetchJson(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `oa-permission-matrix-${target.key}`, }, body: JSON.stringify({ identifier, password }), }); const code = result.json?.error?.code ?? null; if (result.status === 429 && attempt === 0 && retryAfterRateLimitMs > 0) { await wait(retryAfterRateLimitMs); return login(target, attempt + 1); } const accessToken = result.json?.data?.accessToken; if (!result.ok || typeof accessToken !== "string" || !accessToken) { return { status: "FAIL", reason: `login_http_${result.status}`, code, maskedIdentifier: maskIdentifier(identifier), }; } return { status: "PASS", accessToken, maskedIdentifier: maskIdentifier(identifier), }; } async function probe(accessToken, probe, kind) { const response = await fetchJson(`${baseUrl}${probe.path}`, { headers: { accept: "application/json", authorization: `Bearer ${accessToken}`, }, }); const expected = probe.statuses.includes(response.status); const dataExpectations = response.ok ? evaluateDataExpectations(response.json, probe.dataExpectations) : []; const navigationExpectations = response.ok ? evaluateNavigationExpectations(response.json, probe.navigationExpectations) : []; const searchExpectations = response.ok ? evaluateSearchExpectations(response.json, probe.searchExpectations) : []; const dataExpectationFailed = dataExpectations.some((item) => item.status === "FAIL"); const navigationExpectationFailed = navigationExpectations.some((item) => item.status === "FAIL"); const searchExpectationFailed = searchExpectations.some((item) => item.status === "FAIL"); const sensitiveKeyHits = response.ok ? collectSensitiveKeyHits(response.json?.data).slice(0, 12) : []; return { id: probe.id, kind, status: expected && !dataExpectationFailed && !navigationExpectationFailed && !searchExpectationFailed && sensitiveKeyHits.length === 0 ? "PASS" : "FAIL", httpStatus: response.status, expectedStatuses: probe.statuses, dataExpectations, navigationExpectations, searchExpectations, sensitiveKeyHits, summary: response.ok ? summarizeData(response.json) : { code: response.json?.error?.code ?? null, message: sanitizeError(response.json?.error?.message ?? response.error ?? response.text), }, }; } async function runTarget(target) { const loginState = await login(target); const result = { key: target.key, expectedRoles: target.expectedRoles, maskedIdentifier: loginState.maskedIdentifier, login: { status: loginState.status, reason: loginState.reason || null, code: loginState.code || null, }, roleMatch: "SKIP", actualRoles: [], probes: [], }; if (loginState.status !== "PASS") { return result; } for (const item of target.positive) { const probeResult = await probe(loginState.accessToken, item, "positive"); result.probes.push(probeResult); if (item.id === "me" && probeResult.status === "PASS") { const actualRoles = probeResult.summary?.roleCodes || []; const missingRoles = target.expectedRoles.filter((role) => !actualRoles.includes(role)); result.roleMatch = missingRoles.length ? "FAIL" : "PASS"; result.actualRoles = actualRoles; result.missingRoles = missingRoles; } } for (const item of target.negative) { result.probes.push(await probe(loginState.accessToken, item, "negative")); } return result; } function collectIssues(results) { return results.flatMap((target) => { const issues = []; if (target.login.status === "FAIL") issues.push({ key: target.key, source: "login", status: "FAIL", code: target.login.code || target.login.reason }); if (target.roleMatch === "FAIL") issues.push({ key: target.key, source: "roles", status: "FAIL", missingRoles: target.missingRoles }); for (const probeResult of target.probes) { if (probeResult.status === "FAIL") { issues.push({ key: target.key, source: probeResult.id, kind: probeResult.kind, status: "FAIL", httpStatus: probeResult.httpStatus, expectedStatuses: probeResult.expectedStatuses, code: probeResult.summary?.code ?? null, failedDataExpectations: probeResult.dataExpectations?.filter((item) => item.status === "FAIL") || [], failedNavigationExpectations: probeResult.navigationExpectations?.filter((item) => item.status === "FAIL") || [], failedSearchExpectations: probeResult.searchExpectations?.filter((item) => item.status === "FAIL") || [], sensitiveKeyHits: probeResult.sensitiveKeyHits || [], }); } } return issues; }); } function writeReports(summary) { mkdirSync(reportDir, { recursive: true }); writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); const lines = [ "# OA 权限矩阵脱敏自动验证报告", "", `生成时间:${summary.checkedAt}`, `结论:${summary.status}`, "", "## 统计", "", `- 角色数:${summary.counts.roles}`, `- 正向探针:${summary.counts.positiveProbes}`, `- 负向探针:${summary.counts.negativeProbes}`, `- 问题数:${summary.issues.length}`, "", "## 角色结果", "", "| 角色 | 登录 | 角色匹配 | 探针通过/总数 |", "| --- | --- | --- | ---: |", ...summary.results.map((item) => { const passed = item.probes.filter((probeResult) => probeResult.status === "PASS").length; return `| ${item.key} | ${item.login.status} | ${item.roleMatch} | ${passed}/${item.probes.length} |`; }), "", "## 说明", "", "- 本报告只输出角色、HTTP 状态、脱敏账号和摘要计数,不输出密码、token、Secret、完整账号、合同正文、票据内容或原始 payload。", "- 409 表示功能门禁关闭且未泄露数据;对财务/AP/付款等冻结或受控功能可作为可解释拒绝。", ]; writeFileSync(path.join(reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf-8"); } async function main() { const results = []; for (const target of ROLE_TARGETS) { results.push(await runTarget(target)); } const issues = collectIssues(results); const skipped = results.filter((item) => item.login.status === "SKIP"); let status = "PASS_PERMISSION_MATRIX_READY"; if (issues.length) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (skipped.length) { status = "PASS_WITH_FINDINGS"; } const summary = { issueId: "OANEXT2-4", status, checkedAt: new Date().toISOString(), baseUrl, privacy: "redacted role/status/count evidence only; no password, token, Secret, full account, invoice, contract, attachment, or raw payload", counts: { roles: results.length, positiveProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "positive").length, 0), negativeProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "negative").length, 0), }, issues, results, }; writeReports(summary); console.log(`[oa-permission-matrix] status=${summary.status} report=${reportDir}`); if (issues.length) { process.exitCode = 1; } } main().catch((error) => { console.error(`[oa-permission-matrix][ERROR] ${sanitizeError(error instanceof Error ? error.message : String(error))}`); process.exit(1); });