mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
594 lines
18 KiB
JavaScript
594 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const FIXTURE = 'qinglong/cloudnativepg-disaster-recovery@v1';
|
|
const EXPECTED_ROLES = Object.freeze([
|
|
'ql3_admin',
|
|
'ql3_ai_credential_manager',
|
|
'ql3_ai_credential_tester',
|
|
'ql3_ai_maintenance',
|
|
'ql3_approval_manager',
|
|
'ql3_automation_manager',
|
|
'ql3_migration',
|
|
'ql3_package_executor',
|
|
'ql3_package_manager',
|
|
'ql3_runtime',
|
|
'ql3_worker_credential_executor',
|
|
'ql3_worker_credential_manager',
|
|
'ql3_worker_ingress',
|
|
]);
|
|
const EXPECTED_PLATFORM_IMAGES = Object.freeze({
|
|
amd64: Object.freeze({
|
|
barmanController:
|
|
'sha256:417449fe4f6f0a56acdeb30e4131930815f2b46b9afeb808059b57aa8b4c2ef5',
|
|
barmanSidecar:
|
|
'sha256:15cb1a01e7c5235eedac2061cab8208e5f7c39dbda292f9c2d4ddaa0c1f211e6',
|
|
certManager: Object.freeze([
|
|
'sha256:1e4af57beb469cc3bb0fb48b9201caea2723819b9ffd3c3ea98568f55b4dd38b',
|
|
'sha256:a2b12d27950d1603d2c8168c3ccd95d07b93ce6ec4b530316196a31db592a9c0',
|
|
'sha256:953a97df613f7da7eda8ce4b1c8d8e6b50963db0800fab595d040db6eb5cb060',
|
|
]),
|
|
}),
|
|
arm64: Object.freeze({
|
|
barmanController:
|
|
'sha256:de612e3ad8633a198b91ffbea53848407424155daf2183d656490d843a83b100',
|
|
barmanSidecar:
|
|
'sha256:f53e168e341661cd76334215ead9dfd69f06117685d3232206192cf25218da71',
|
|
certManager: Object.freeze([
|
|
'sha256:af62a025ae4f8fd03209b5e0760868296bad5a9370aab0c91ad3b5476bcb282d',
|
|
'sha256:3c052c134ad1b93122b957f4d214aaa9d85a37b5ff15acc5b4d86f50e3ed822e',
|
|
'sha256:7c510875e038f79f7fba707b5f86d8736777a4dfefcd42179b08844ee75e685b',
|
|
]),
|
|
}),
|
|
});
|
|
const BANNED_KEYS = new Set([
|
|
'password',
|
|
'secret',
|
|
'token',
|
|
'accesskey',
|
|
'accesskeyid',
|
|
'secretaccesskey',
|
|
'privatekey',
|
|
'tlskey',
|
|
'connectionstring',
|
|
]);
|
|
|
|
function finding(code, detail) {
|
|
return Object.freeze({ code, detail });
|
|
}
|
|
|
|
function isSha256(value) {
|
|
return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
}
|
|
|
|
function isSourceRevision(value) {
|
|
return typeof value === 'string' && /^[a-f0-9]{40,64}$/.test(value);
|
|
}
|
|
|
|
function isIsoTime(value) {
|
|
return (
|
|
typeof value === 'string' &&
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) &&
|
|
Number.isFinite(Date.parse(value))
|
|
);
|
|
}
|
|
|
|
function isUuid(value) {
|
|
return (
|
|
typeof value === 'string' &&
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
|
value,
|
|
)
|
|
);
|
|
}
|
|
|
|
function isWal(value) {
|
|
return typeof value === 'string' && /^[0-9A-F]{24}$/.test(value);
|
|
}
|
|
|
|
function exactKeys(value, expected) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value).sort()) ===
|
|
JSON.stringify([...expected].sort())
|
|
);
|
|
}
|
|
|
|
function exactJson(actual, expected) {
|
|
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
}
|
|
|
|
function containsSensitiveMaterial(value, key = '') {
|
|
if (BANNED_KEYS.has(key.toLowerCase())) return true;
|
|
if (typeof value === 'string') {
|
|
return (
|
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(value) ||
|
|
/postgres(?:ql)?:\/\/[^/\s]+:[^@\s]+@/i.test(value) ||
|
|
/\bAKIA[0-9A-Z]{16}\b/.test(value) ||
|
|
/\bql3w_[A-Za-z0-9_-]+\b/.test(value)
|
|
);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some((entry) => containsSensitiveMaterial(entry));
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return Object.entries(value).some(([childKey, child]) =>
|
|
containsSensitiveMaterial(child, childKey),
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function exactRoles(roles) {
|
|
return (
|
|
Array.isArray(roles) &&
|
|
roles.length === EXPECTED_ROLES.length &&
|
|
roles.every(
|
|
(role, index) =>
|
|
exactKeys(role, [
|
|
'name',
|
|
'superuser',
|
|
'createdb',
|
|
'createrole',
|
|
'replication',
|
|
'bypassrls',
|
|
]) &&
|
|
role.name === EXPECTED_ROLES[index] &&
|
|
role.superuser === false &&
|
|
role.createdb === false &&
|
|
role.createrole === false &&
|
|
role.replication === false &&
|
|
role.bypassrls === false,
|
|
)
|
|
);
|
|
}
|
|
|
|
function validRestore(restore, expectedCluster, includeTargetTime = false) {
|
|
const keys = [
|
|
'cluster',
|
|
'sourceObjectStore',
|
|
'sourceServerName',
|
|
'sourceClusterUnmodified',
|
|
'targetWalArchiver',
|
|
'instances',
|
|
'ready',
|
|
'migrationCount',
|
|
'controlCoreCapability',
|
|
'databaseOwner',
|
|
'synchronousCommit',
|
|
'synchronousStandbys',
|
|
'roles',
|
|
'beforeMarkerPresent',
|
|
'afterMarkerPresent',
|
|
];
|
|
if (includeTargetTime) keys.push('targetTime');
|
|
return (
|
|
exactKeys(restore, keys) &&
|
|
restore.cluster === expectedCluster &&
|
|
restore.sourceObjectStore === 'ql3-postgres-recovery-source' &&
|
|
restore.sourceServerName === 'ql3-postgres' &&
|
|
restore.sourceClusterUnmodified === true &&
|
|
restore.targetWalArchiver === false &&
|
|
restore.instances === 3 &&
|
|
restore.ready === true &&
|
|
restore.migrationCount === 54 &&
|
|
restore.controlCoreCapability === 53 &&
|
|
restore.databaseOwner === 'ql3_migration' &&
|
|
restore.synchronousCommit === 'remote_apply' &&
|
|
restore.synchronousStandbys === 1 &&
|
|
exactRoles(restore.roles) &&
|
|
restore.beforeMarkerPresent === true
|
|
);
|
|
}
|
|
|
|
function validRotationIdentity(identity) {
|
|
return (
|
|
exactKeys(identity, [
|
|
'previousSerialSha256',
|
|
'currentSerialSha256',
|
|
'previousSecretResourceVersion',
|
|
'currentSecretResourceVersion',
|
|
]) &&
|
|
isSha256(identity.previousSerialSha256) &&
|
|
isSha256(identity.currentSerialSha256) &&
|
|
identity.previousSerialSha256 !== identity.currentSerialSha256 &&
|
|
typeof identity.previousSecretResourceVersion === 'string' &&
|
|
identity.previousSecretResourceVersion.length > 0 &&
|
|
typeof identity.currentSecretResourceVersion === 'string' &&
|
|
identity.currentSecretResourceVersion.length > 0 &&
|
|
identity.previousSecretResourceVersion !==
|
|
identity.currentSecretResourceVersion
|
|
);
|
|
}
|
|
|
|
function validateCloudNativePgDrEvidence(report) {
|
|
const findings = [];
|
|
if (
|
|
!exactKeys(report, [
|
|
'schemaVersion',
|
|
'fixture',
|
|
'observedAt',
|
|
'sourceRevision',
|
|
'platform',
|
|
'source',
|
|
'latestRestore',
|
|
'pitrRestore',
|
|
'certificateRotation',
|
|
'objectStoreAuthority',
|
|
'serviceLevels',
|
|
'gates',
|
|
]) ||
|
|
report?.schemaVersion !== 1 ||
|
|
report?.fixture !== FIXTURE ||
|
|
!isIsoTime(report?.observedAt) ||
|
|
!isSourceRevision(report?.sourceRevision)
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_REPORT_SHAPE',
|
|
'the disaster-recovery report must use the exact versioned non-secret envelope',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (containsSensitiveMaterial(report)) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_SECRET_EXPOSURE',
|
|
'the disaster-recovery report must not contain credentials, tokens, connection strings or private keys',
|
|
),
|
|
);
|
|
}
|
|
|
|
const platform = report?.platform;
|
|
const expectedPlatformImages =
|
|
EXPECTED_PLATFORM_IMAGES[platform?.architecture];
|
|
if (
|
|
!exactKeys(platform, [
|
|
'kubernetesVersion',
|
|
'architecture',
|
|
'cloudNativePgVersion',
|
|
'cloudNativePgImageId',
|
|
'postgresVersionNumber',
|
|
'postgresImageId',
|
|
'barmanVersion',
|
|
'barmanControllerImageId',
|
|
'barmanSidecarImageIds',
|
|
'certManagerVersion',
|
|
'certManagerImageIds',
|
|
]) ||
|
|
platform?.kubernetesVersion !== '1.32.8' ||
|
|
!['amd64', 'arm64'].includes(platform?.architecture) ||
|
|
platform?.cloudNativePgVersion !== '1.30.0' ||
|
|
!isSha256(platform?.cloudNativePgImageId) ||
|
|
platform?.postgresVersionNumber !== 180004 ||
|
|
!isSha256(platform?.postgresImageId) ||
|
|
platform?.barmanVersion !== '0.13.0' ||
|
|
platform?.barmanControllerImageId !==
|
|
expectedPlatformImages?.barmanController ||
|
|
!Array.isArray(platform?.barmanSidecarImageIds) ||
|
|
platform.barmanSidecarImageIds.length !== 3 ||
|
|
!platform.barmanSidecarImageIds.every(
|
|
(imageId) => imageId === expectedPlatformImages?.barmanSidecar,
|
|
) ||
|
|
platform?.certManagerVersion !== '1.20.3' ||
|
|
!exactJson(
|
|
platform?.certManagerImageIds,
|
|
expectedPlatformImages?.certManager,
|
|
)
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_PLATFORM_PROVENANCE',
|
|
'the report must bind the exact Kubernetes, CNPG, PostgreSQL, Barman and cert-manager runtime images',
|
|
),
|
|
);
|
|
}
|
|
|
|
const source = report?.source;
|
|
const markers = source?.markers;
|
|
const backup = source?.backup;
|
|
const beforeAt = Date.parse(markers?.before?.createdAt);
|
|
const afterAt = Date.parse(markers?.after?.createdAt);
|
|
if (
|
|
!exactKeys(source, ['cluster', 'backup', 'markers', 'wal']) ||
|
|
source?.cluster !== 'ql3-postgres' ||
|
|
backup?.phase !== 'completed' ||
|
|
typeof backup?.name !== 'string' ||
|
|
backup.name.length === 0 ||
|
|
!isIsoTime(backup?.startedAt) ||
|
|
!isIsoTime(backup?.completedAt) ||
|
|
Date.parse(backup.completedAt) < Date.parse(backup.startedAt) ||
|
|
!isWal(backup?.beginWal) ||
|
|
!isWal(backup?.endWal) ||
|
|
!isUuid(markers?.before?.id) ||
|
|
!isIsoTime(markers?.before?.createdAt) ||
|
|
!isWal(markers?.before?.wal) ||
|
|
!isUuid(markers?.after?.id) ||
|
|
markers.before.id === markers.after.id ||
|
|
!isIsoTime(markers?.after?.createdAt) ||
|
|
!isWal(markers?.after?.wal) ||
|
|
!Number.isFinite(beforeAt) ||
|
|
!Number.isFinite(afterAt) ||
|
|
beforeAt >= afterAt ||
|
|
source?.wal?.archiveHealthy !== true ||
|
|
source?.wal?.continuous !== true ||
|
|
source?.wal?.noGaps !== true ||
|
|
!isWal(source?.wal?.lastArchivedWal)
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_SOURCE_BACKUP_WAL',
|
|
'the source must prove one completed backup, two ordered unique markers and continuous gap-free WAL',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (
|
|
!validRestore(report?.latestRestore, 'ql3-postgres-restore-latest') ||
|
|
report?.latestRestore?.afterMarkerPresent !== true
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_LATEST_RESTORE',
|
|
'latest restore must be isolated, HA-ready and contain both ordered markers with the exact schema and roles',
|
|
),
|
|
);
|
|
}
|
|
|
|
const pitr = report?.pitrRestore;
|
|
const targetAt = Date.parse(pitr?.targetTime);
|
|
if (
|
|
!validRestore(pitr, 'ql3-postgres-restore-pitr', true) ||
|
|
pitr?.afterMarkerPresent !== false ||
|
|
!isIsoTime(pitr?.targetTime) ||
|
|
!Number.isFinite(targetAt) ||
|
|
targetAt <= beforeAt ||
|
|
targetAt >= afterAt
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_PITR_RESTORE',
|
|
'PITR must target between the markers, contain only the first marker and preserve the exact HA/schema/role contract',
|
|
),
|
|
);
|
|
}
|
|
|
|
const authority = report?.objectStoreAuthority;
|
|
if (
|
|
!exactKeys(authority, [
|
|
'sourceObjectStore',
|
|
'recoveryObjectStore',
|
|
'sourceWriterIdentitySha256',
|
|
'recoveryReaderIdentitySha256',
|
|
'recoveryReadOnly',
|
|
'versioning',
|
|
'immutability',
|
|
'lifecycleDays',
|
|
]) ||
|
|
authority?.sourceObjectStore !== 'ql3-postgres-backup' ||
|
|
authority?.recoveryObjectStore !== 'ql3-postgres-recovery-source' ||
|
|
!isSha256(authority?.sourceWriterIdentitySha256) ||
|
|
!isSha256(authority?.recoveryReaderIdentitySha256) ||
|
|
authority.sourceWriterIdentitySha256 ===
|
|
authority.recoveryReaderIdentitySha256 ||
|
|
authority?.recoveryReadOnly !== true ||
|
|
authority?.versioning !== true ||
|
|
authority?.immutability !== true ||
|
|
!Number.isInteger(authority?.lifecycleDays) ||
|
|
authority.lifecycleDays < 30
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_OBJECT_STORE_AUTHORITY',
|
|
'source and recovery identities must be distinct, recovery read-only, and storage protected for at least 30 days',
|
|
),
|
|
);
|
|
}
|
|
|
|
const rotation = report?.certificateRotation;
|
|
if (
|
|
!exactKeys(rotation, [
|
|
'client',
|
|
'server',
|
|
'walArchivedDuringRotation',
|
|
'backupCompletedAfterRotation',
|
|
'latestRestoreCompletedAfterRotation',
|
|
'pitrCompletedAfterRotation',
|
|
'maxObservedInterruptionSeconds',
|
|
]) ||
|
|
!validRotationIdentity(rotation?.client) ||
|
|
!validRotationIdentity(rotation?.server) ||
|
|
rotation?.walArchivedDuringRotation !== true ||
|
|
rotation?.backupCompletedAfterRotation !== true ||
|
|
rotation?.latestRestoreCompletedAfterRotation !== true ||
|
|
rotation?.pitrCompletedAfterRotation !== true ||
|
|
typeof rotation?.maxObservedInterruptionSeconds !== 'number' ||
|
|
rotation.maxObservedInterruptionSeconds < 0
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_CERTIFICATE_ROTATION',
|
|
'both plugin certificates must rotate while WAL, backup, latest restore and PITR continue',
|
|
),
|
|
);
|
|
}
|
|
|
|
const levels = report?.serviceLevels;
|
|
const numericLevels = [
|
|
levels?.targetMaxRpoSeconds,
|
|
levels?.observedRpoSeconds,
|
|
levels?.targetMaxDatabaseRtoSeconds,
|
|
levels?.latestDatabaseRtoSeconds,
|
|
levels?.pitrDatabaseRtoSeconds,
|
|
levels?.targetMaxApplicationRtoSeconds,
|
|
levels?.latestApplicationRtoSeconds,
|
|
levels?.pitrApplicationRtoSeconds,
|
|
];
|
|
if (
|
|
!exactKeys(levels, [
|
|
'targetMaxRpoSeconds',
|
|
'observedRpoSeconds',
|
|
'targetMaxDatabaseRtoSeconds',
|
|
'latestDatabaseRtoSeconds',
|
|
'pitrDatabaseRtoSeconds',
|
|
'targetMaxApplicationRtoSeconds',
|
|
'latestApplicationRtoSeconds',
|
|
'pitrApplicationRtoSeconds',
|
|
]) ||
|
|
numericLevels.some(
|
|
(value) =>
|
|
typeof value !== 'number' || !Number.isFinite(value) || value < 0,
|
|
) ||
|
|
levels?.observedRpoSeconds > levels?.targetMaxRpoSeconds ||
|
|
levels?.latestDatabaseRtoSeconds > levels?.targetMaxDatabaseRtoSeconds ||
|
|
levels?.pitrDatabaseRtoSeconds > levels?.targetMaxDatabaseRtoSeconds ||
|
|
levels?.latestApplicationRtoSeconds >
|
|
levels?.targetMaxApplicationRtoSeconds ||
|
|
levels?.pitrApplicationRtoSeconds > levels?.targetMaxApplicationRtoSeconds
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_SERVICE_LEVELS',
|
|
'observed RPO and both database/application RTO values must meet explicit deployment targets',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (
|
|
!exactKeys(report?.gates, [
|
|
'latestRestore',
|
|
'pointInTimeRestore',
|
|
'schemaAndRoles',
|
|
'sourceIsolation',
|
|
'certificateRotation',
|
|
'serviceLevels',
|
|
'passed',
|
|
]) ||
|
|
Object.values(report?.gates || {}).some((value) => value !== true)
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_GATE_SUMMARY',
|
|
'every independent disaster-recovery gate must be explicitly true',
|
|
),
|
|
);
|
|
}
|
|
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
fixture: FIXTURE,
|
|
findings: Object.freeze(findings),
|
|
compatible: findings.length === 0,
|
|
});
|
|
}
|
|
|
|
function validateCloudNativePgDrReleaseEvidence(report, options = {}) {
|
|
const base = validateCloudNativePgDrEvidence(report);
|
|
const findings = [...base.findings];
|
|
const sourceCommit = options.sourceCommit;
|
|
const releaseVersion = options.releaseVersion;
|
|
const nowMs = options.nowMs ?? Date.now();
|
|
const maximumAgeSeconds = 24 * 60 * 60;
|
|
const observedAtMs = Date.parse(report?.observedAt);
|
|
if (
|
|
!isSourceRevision(sourceCommit) ||
|
|
report?.sourceRevision !== sourceCommit
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_RELEASE_SOURCE',
|
|
'the disaster-recovery evidence must bind the exact release source commit',
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
typeof releaseVersion !== 'string' ||
|
|
!/^3\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(
|
|
releaseVersion,
|
|
)
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_RELEASE_VERSION',
|
|
'the disaster-recovery release gate requires one exact QingLong 3 SemVer',
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
!Number.isFinite(nowMs) ||
|
|
!Number.isFinite(observedAtMs) ||
|
|
observedAtMs > nowMs + 5 * 60 * 1_000 ||
|
|
nowMs - observedAtMs > maximumAgeSeconds * 1_000
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_DR_RELEASE_FRESHNESS',
|
|
'the disaster-recovery evidence must be no older than 24 hours and not more than five minutes in the future',
|
|
),
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
fixture: FIXTURE,
|
|
sourceCommit,
|
|
releaseVersion,
|
|
maximumAgeSeconds,
|
|
findings: Object.freeze(findings),
|
|
compatible: findings.length === 0,
|
|
});
|
|
}
|
|
|
|
function readEvidenceFile(filePath) {
|
|
if (!path.isAbsolute(filePath)) {
|
|
throw new Error('evidence report path must be absolute');
|
|
}
|
|
const stat = fs.lstatSync(filePath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error('evidence report must be a regular non-symlink file');
|
|
}
|
|
if (stat.size < 2 || stat.size > 1024 * 1024) {
|
|
throw new Error('evidence report must be between 2 bytes and 1 MiB');
|
|
}
|
|
if ((stat.mode & 0o022) !== 0) {
|
|
throw new Error('evidence report must not be group/world writable');
|
|
}
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const args = process.argv.slice(2);
|
|
if (args.length !== 1 || !args[0].startsWith('--report=')) {
|
|
process.stderr.write(
|
|
'usage: ql3-cloudnativepg-dr-evidence-audit --report=/absolute/report.json\n',
|
|
);
|
|
process.exitCode = 2;
|
|
} else {
|
|
try {
|
|
const report = readEvidenceFile(args[0].slice('--report='.length));
|
|
const result = validateCloudNativePgDrEvidence(report);
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
if (!result.compatible) process.exitCode = 1;
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${
|
|
error instanceof Error ? error.message : 'unknown evidence error'
|
|
}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
readEvidenceFile,
|
|
validateCloudNativePgDrEvidence,
|
|
validateCloudNativePgDrReleaseEvidence,
|
|
};
|