From 59a4862d435d8a74b0075462f0a7e8bff3f07d54 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 02:10:44 +0000 Subject: [PATCH] fix: bind migration failure redaction --- scripts/src/cicd-delivery-authority.test.ts | 9 ++++++ scripts/src/hwlab-node-web-sentinel-cicd.ts | 10 +++++-- scripts/src/monitor-sqlite-migration.ts | 7 ++++- .../monitor-terminal-ingest-migration.test.ts | 30 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index 9187ec08..9ffa5397 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -304,6 +304,15 @@ describe("migrated CLI 执行 guard 与提示清理", () => { expect(JSON.stringify(failure)).not.toMatch(/PW123|token-value|abc|stack/iu); }); + test("migration Job 先脱敏长 userinfo 再截断 nested message 和日志", () => { + const credential = "LONG-USERINFO-CREDENTIAL-MARKER"; + const longUrl = `custom+ssh://u:${credential.repeat(12)}@host.example.test/path`; + const failure = migrationJobFailureSummary({ podPhase: "Failed", logsTail: `${longUrl} logs` }, { ok: false, error: { message: `${longUrl} nested message` } }); + expect(String(failure.message)).toContain(""); + expect(String(failure.logsTail)).toContain(""); + expect(JSON.stringify(failure)).not.toContain(credential); + }); + test("migration Job 业务失败、创建失败和超时均只返回安全 failure 投影", () => { const business = migrationJobFailed("migration", { jobName: "job" }, "result-failed", { ok: false, error: { message: "https://user:PW123@host failed", stack: "secret stack" } }, { podPhase: "Succeeded", logsTail: "Bearer abc" }) as { projection?: Record }; const createFailed = migrationJobFailed("migration", { jobName: "job" }, "create-failed", {}, { podPhase: "CreateFailed", logsTail: "DATABASE_URL=https://user:PW123@host/db" }) as { projection?: Record }; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index bee5b9b6..9a9f6427 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -262,11 +262,15 @@ export function migrationJobFailed(command: string, plan: Record, payload: Record): Record { - const containers = Array.isArray(probe.containers) ? probe.containers.filter(isRecord).map((item) => ({ name: stringAtNullable(item, "name"), phase: stringAtNullable(item, "phase"), exitCode: finiteNumberOrNull(item.exitCode), reason: stringAtNullable(item, "reason"), message: short(stringAtNullable(item, "message") ?? "") })).filter((item) => item.phase === "terminated" || item.exitCode !== null || item.reason !== null) : []; + const containers = Array.isArray(probe.containers) ? probe.containers.filter(isRecord).map((item) => ({ name: stringAtNullable(item, "name"), phase: stringAtNullable(item, "phase"), exitCode: finiteNumberOrNull(item.exitCode), reason: stringAtNullable(item, "reason"), message: redactedMigrationJobText(stringAtNullable(item, "message") ?? "") })).filter((item) => item.phase === "terminated" || item.exitCode !== null || item.reason !== null) : []; const failed = containers.find((item) => item.exitCode !== null && item.exitCode !== 0) ?? containers[0] ?? null; - const logsTail = redactMigrationJobLogTail(short(String(probe.logsTail ?? ""))); + const logsTail = redactedMigrationJobText(String(probe.logsTail ?? "")); const nestedError = isRecord(payload.error) ? payload.error : null; - return { phase: stringAtNullable(probe, "podPhase") ?? "Failed", container: failed?.name ?? null, exitCode: failed?.exitCode ?? null, status: failed?.reason ?? stringAtNullable(nestedError, "code") ?? stringAtNullable(probe, "reason") ?? "JobFailed", message: redactMigrationJobLogTail(short(stringAtNullable(nestedError, "message") ?? stringAtNullable(payload, "message") ?? failed?.message ?? "migration Job failed before a structured command result was emitted")), logsTail, valuesRedacted: true }; + return { phase: stringAtNullable(probe, "podPhase") ?? "Failed", container: failed?.name ?? null, exitCode: failed?.exitCode ?? null, status: failed?.reason ?? stringAtNullable(nestedError, "code") ?? stringAtNullable(probe, "reason") ?? "JobFailed", message: redactedMigrationJobText(stringAtNullable(nestedError, "message") ?? stringAtNullable(payload, "message") ?? failed?.message ?? "migration Job failed before a structured command result was emitted"), logsTail, valuesRedacted: true }; +} + +function redactedMigrationJobText(value: string): string { + return short(redactMigrationJobLogTail(value)); } function redactMigrationJobLogTail(value: string): string { diff --git a/scripts/src/monitor-sqlite-migration.ts b/scripts/src/monitor-sqlite-migration.ts index 898a47bc..590bce1a 100644 --- a/scripts/src/monitor-sqlite-migration.ts +++ b/scripts/src/monitor-sqlite-migration.ts @@ -212,7 +212,10 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo const rowCount = numberField(row, ["artifact_count", "artifactCount"]); const stateDir = stringField(row, ["state_dir", "stateDir"]); const reportSha256 = stringField(row, ["report_json_sha256", "reportJsonSha256"]); - if (declaredByRunId.has(runId) || manifest.artifactCount !== (rowCount ?? manifest.locators.length) || manifest.stateDir !== stateDir || manifest.reportSha256 !== reportSha256 || (stateDir.length > 0 && manifest.ownerPvc === null) || manifest.locators.some((locator) => locator.ownerPvc !== manifest.ownerPvc || locator.stateDir !== stateDir) || manifest.locators.length !== manifest.artifactCount) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" }); + const locatorRows = tableRows(snapshot.tables, ["artifact_locators", "locators"]); + const expectedLocators = locatorRows.filter((locator) => stringField(locator, ["run_id"]) === runId).map((locator) => locatorBindingFromRow(locator, snapshot)); + if (declaredByRunId.has(runId) || manifest.artifactCount !== (rowCount ?? manifest.locators.length) || manifest.stateDir !== stateDir || manifest.reportSha256 !== reportSha256 || (stateDir.length > 0 && manifest.ownerPvc === null) || manifest.locators.some((locator) => locator.ownerPvc !== manifest.ownerPvc || locator.stateDir !== stateDir) || manifest.locators.length !== manifest.artifactCount || (locatorRows.length > 0 && !sameLocatorBindings(manifest.locators, expectedLocators))) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" }); + verifyReportLocator(row, manifest.locators); continue; } const declaredGap = declaredByRunId.get(runId); @@ -220,6 +223,8 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo } if ([...declaredByRunId.keys()].some((runId) => manifestByRunId.get(runId)?.missingBeforeMigration === undefined)) throw Object.assign(new Error("snapshot declared gap has no matching frozen manifest"), { code: "monitor-migration-snapshot-referential-invalid" }); } +function locatorBindingFromRow(row: Record, snapshot: Pick): MonitorArtifactLocator { return { ownerNode: stringField(row, ["owner_node", "ownerNode"]) || snapshot.source.node, ownerLane: stringField(row, ["owner_lane", "ownerLane"]) || snapshot.source.lane, ownerPvc: stringField(row, ["owner_pvc", "ownerPvc"]), stateDir: stringField(row, ["state_dir", "stateDir"]), relativePath: stringField(row, ["relative_path", "relativePath"]), sha256: stringField(row, ["sha256"]), sizeBytes: numberField(row, ["size_bytes", "sizeBytes"]) ?? -1, kind: stringField(row, ["kind"]) || "artifact", reachable: row.reachable !== false }; } +function sameLocatorBindings(left: readonly MonitorArtifactLocator[], right: readonly MonitorArtifactLocator[]): boolean { const key = (locator: MonitorArtifactLocator): string => [locator.ownerNode, locator.ownerLane, locator.ownerPvc, locator.stateDir, locator.relativePath, locator.sha256, locator.sizeBytes, locator.kind, locator.reachable].join("\u0000"); return left.length === right.length && left.map(key).sort().every((value, index) => value === right.map(key).sort()[index]); } function artifactCountMismatch(runId: string, stateDir: string, expectedCount: number, observedCount: number): Error { return Object.assign(new Error(`legacy artifact locator count mismatch for ${runId}`), { code: "monitor-migration-artifact-count-mismatch", diagnostic: { runId, stateDir, expectedCount, observedCount, valuesRedacted: true } }); } function artifactGapIdentityInvalid(runId: string, expected: MonitorLegacyArtifactGap, observed: Record): Error { return Object.assign(new Error(`legacy artifact gap identity mismatch for ${runId}`), { code: "monitor-migration-artifact-gap-identity-invalid", diagnostic: { runId, expected, observed, valuesRedacted: true } }); } function sameArtifactGap(left: MonitorLegacyArtifactGap, right: MonitorLegacyArtifactGap): boolean { return left.runId === right.runId && left.artifactCount === right.artifactCount && left.stateDir === right.stateDir && left.reportSha256 === right.reportSha256; } diff --git a/scripts/src/monitor-terminal-ingest-migration.test.ts b/scripts/src/monitor-terminal-ingest-migration.test.ts index 556f975f..09ff6823 100644 --- a/scripts/src/monitor-terminal-ingest-migration.test.ts +++ b/scripts/src/monitor-terminal-ingest-migration.test.ts @@ -291,6 +291,36 @@ test("empty normal artifact directories freeze owner state and normal manifests } }); +test("normal report locator hash cannot drift from its SQLite row after a rehashed snapshot", async () => { + const directory = mkdtempSync(join(tmpdir(), "monitor-report-locator-binding-")); + try { + const sqlitePath = join(directory, "index.sqlite"); + const stateDir = ".state/run"; + const reportPath = join(directory, stateDir, "analysis", "report.json"); + const reportA = Buffer.from("report A\n"); + const reportB = Buffer.from("report B with different size\n"); + mkdirSync(join(reportPath, ".."), { recursive: true }); + writeFileSync(reportPath, reportA); + const reportAHash = `sha256:${createHash("sha256").update(reportA).digest("hex")}`; + const reportBHash = `sha256:${createHash("sha256").update(reportB).digest("hex")}`; + const database = new Database(sqlitePath); + database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)"); + database.run("CREATE TABLE artifact_locators (run_id TEXT, owner_pvc TEXT, state_dir TEXT, relative_path TEXT, sha256 TEXT, size_bytes INTEGER)"); + database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?)", ["run", stateDir, 1, reportAHash, "2026-07-12T00:00:00.000Z"]); + database.run("INSERT INTO artifact_locators VALUES (?, ?, ?, ?, ?, ?)", ["run", "fixture-pvc", stateDir, "analysis/report.json", reportAHash, reportA.byteLength]); + database.close(); + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }] }; + const snapshot = exportMonitorSqliteSnapshot(source, join(directory, "snapshot.json")); + writeFileSync(reportPath, reportB); + const tamperedUnsigned = { ...snapshot, artifactManifests: snapshot.artifactManifests.map((manifest) => ({ ...manifest, locators: manifest.locators.map((locator) => ({ ...locator, sha256: reportBHash, sizeBytes: reportB.byteLength })) })) }; + const tampered = { ...tamperedUnsigned, manifestContentHash: monitorSnapshotContentHash(tamperedUnsigned) }; + assert.equal(verifyMonitorSqliteSnapshot(tampered).ok, false); + await assert.rejects(() => importMonitorSqliteSnapshot(tampered, createInMemoryMonitorCentralStore()), (error: unknown) => (error as { code?: string }).code === "monitor-migration-snapshot-invalid"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("artifact count without a registry-backed locator fails closed", async () => { const directory = mkdtempSync(join(tmpdir(), "monitor-artifact-count-")); try {