mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
1097 lines
32 KiB
JavaScript
1097 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const net = require('node:net');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
|
|
const {
|
|
validatePluginPackageManagementLiveEvidence,
|
|
} = require('./ql3-plugin-package-management-live-evidence-audit.cjs');
|
|
|
|
const EXERCISE_FIXTURE = 'qinglong/plugin-package-management-live-exercise@v1';
|
|
const REPORT_FIXTURE = 'qinglong/plugin-package-management-live-evidence@v1';
|
|
const NAMESPACE = 'qinglong3-system';
|
|
const MANAGEMENT_NAME = 'ql3-plugin-package-management';
|
|
const MANAGEMENT_CONTAINER = 'management';
|
|
const POSTGRES_SELECTOR = 'cnpg.io/cluster=ql3-postgres';
|
|
const MAX_PRIVATE_FILE_BYTES = 1024 * 1024;
|
|
const MAX_OIDC_DOCUMENT_BYTES = 1024 * 1024;
|
|
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
const UUID_V4_PATTERN =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
|
|
class PluginPackageManagementLiveEvidenceCollectionError extends Error {
|
|
constructor(message) {
|
|
super(
|
|
`Plugin Package management live evidence collection failed: ${message}`,
|
|
);
|
|
this.name = 'PluginPackageManagementLiveEvidenceCollectionError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new PluginPackageManagementLiveEvidenceCollectionError(message);
|
|
}
|
|
|
|
function exactKeys(value, expected, label) {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
JSON.stringify(Object.keys(value).sort()) !==
|
|
JSON.stringify([...expected].sort())
|
|
) {
|
|
fail(`${label} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function token(value, label, maximum = 256) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
CONTROL_PATTERN.test(value)
|
|
) {
|
|
fail(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function canonicalPrivatePath(filePath, label) {
|
|
if (!path.isAbsolute(filePath)) fail(`${label} path must be absolute`);
|
|
const stat = fs.lstatSync(filePath);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 1 ||
|
|
stat.size > MAX_PRIVATE_FILE_BYTES ||
|
|
(stat.mode & 0o077) !== 0 ||
|
|
fs.realpathSync(filePath) !== filePath
|
|
) {
|
|
fail(
|
|
`${label} must be a canonical private regular file no larger than 1 MiB`,
|
|
);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function readPrivateJson(filePath, label) {
|
|
canonicalPrivatePath(filePath, label);
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch (error) {
|
|
fail(
|
|
`${label} is not valid JSON: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function digest(domain, value) {
|
|
return `sha256:${crypto
|
|
.createHash('sha256')
|
|
.update(domain)
|
|
.update('\0')
|
|
.update(value)
|
|
.digest('hex')}`;
|
|
}
|
|
|
|
function rawDigest(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function imageDigest(value, label) {
|
|
const matches =
|
|
typeof value === 'string' ? value.match(/sha256:[a-f0-9]{64}/g) : null;
|
|
if (!matches || matches.length !== 1) fail(`${label} image ID is not exact`);
|
|
return matches[0];
|
|
}
|
|
|
|
function ready(object) {
|
|
return object?.status?.conditions?.some(
|
|
(condition) => condition.type === 'Ready' && condition.status === 'True',
|
|
);
|
|
}
|
|
|
|
function containerStatus(pod, name) {
|
|
const statuses = pod?.status?.containerStatuses;
|
|
const status = Array.isArray(statuses)
|
|
? statuses.find((candidate) => candidate.name === name)
|
|
: undefined;
|
|
if (!status || status.ready !== true) {
|
|
fail(
|
|
`${pod?.metadata?.name ?? 'unknown Pod'} container ${name} is not ready`,
|
|
);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function parseCniReference(value) {
|
|
const match =
|
|
typeof value === 'string'
|
|
? /^([a-z0-9](?:[-a-z0-9]{0,62}[a-z0-9])?)\/([a-z0-9](?:[-a-z0-9]{0,62}[a-z0-9])?)$/.exec(
|
|
value,
|
|
)
|
|
: null;
|
|
if (!match) fail('CNI DaemonSet reference must be namespace/name');
|
|
return Object.freeze({ namespace: match[1], name: match[2] });
|
|
}
|
|
|
|
function cniVersion(image) {
|
|
const withoutDigest = token(image, 'CNI image', 512).split('@')[0];
|
|
const match = /:v?([0-9]+\.[0-9]+\.[0-9]+(?:[-._][A-Za-z0-9.-]+)?)$/.exec(
|
|
withoutDigest,
|
|
);
|
|
if (!match) fail('CNI image must carry an exact semantic version tag');
|
|
return match[1];
|
|
}
|
|
|
|
function jsonOutput(result, label) {
|
|
if (!result || result.status !== 0) {
|
|
fail(`${label} failed with status ${String(result?.status)}`);
|
|
}
|
|
try {
|
|
return JSON.parse(result.stdout);
|
|
} catch {
|
|
fail(`${label} did not return JSON`);
|
|
}
|
|
}
|
|
|
|
function canIOutput(result, label) {
|
|
if (!result || result.status !== 0 || result.stdout.trim() !== 'no') {
|
|
fail(`${label} must be denied by the Kubernetes authorizer`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function policyIsBounded(policy) {
|
|
const spec = policy?.spec;
|
|
return (
|
|
JSON.stringify(spec?.podSelector) ===
|
|
JSON.stringify({
|
|
matchLabels: {
|
|
'app.kubernetes.io/name': MANAGEMENT_NAME,
|
|
'app.kubernetes.io/component': 'plugin-package-management',
|
|
},
|
|
}) &&
|
|
JSON.stringify([...(spec.policyTypes ?? [])].sort()) ===
|
|
JSON.stringify(['Egress', 'Ingress']) &&
|
|
JSON.stringify(spec.ingress) ===
|
|
JSON.stringify([
|
|
{
|
|
from: [
|
|
{
|
|
podSelector: {
|
|
matchLabels: {
|
|
'qinglong.io/plugin-package-management-client': 'true',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
ports: [{ port: 8443, protocol: 'TCP' }],
|
|
},
|
|
]) &&
|
|
JSON.stringify(spec.egress) ===
|
|
JSON.stringify([
|
|
{
|
|
to: [
|
|
{
|
|
namespaceSelector: {
|
|
matchLabels: {
|
|
'kubernetes.io/metadata.name': 'kube-system',
|
|
},
|
|
},
|
|
podSelector: { matchLabels: { 'k8s-app': 'kube-dns' } },
|
|
},
|
|
],
|
|
ports: [
|
|
{ port: 53, protocol: 'UDP' },
|
|
{ port: 53, protocol: 'TCP' },
|
|
],
|
|
},
|
|
{
|
|
to: [
|
|
{
|
|
podSelector: {
|
|
matchLabels: { 'cnpg.io/cluster': 'ql3-postgres' },
|
|
},
|
|
},
|
|
],
|
|
ports: [{ port: 5432, protocol: 'TCP' }],
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
function collectKubernetesSnapshot(options, runKubectl) {
|
|
const getJson = (args, label) =>
|
|
jsonOutput(runKubectl([...args, '-o', 'json']), label);
|
|
const version = getJson(['version'], 'Kubernetes version');
|
|
const nodes = getJson(['get', 'nodes'], 'Kubernetes nodes').items ?? [];
|
|
const activeNodes = nodes.filter(
|
|
(node) => ready(node) && node.spec?.unschedulable !== true,
|
|
);
|
|
const controlPlanes = activeNodes.filter((node) => {
|
|
const labels = node.metadata?.labels ?? {};
|
|
return (
|
|
Object.hasOwn(labels, 'node-role.kubernetes.io/control-plane') ||
|
|
Object.hasOwn(labels, 'node-role.kubernetes.io/master')
|
|
);
|
|
});
|
|
const workers = activeNodes.filter((node) => !controlPlanes.includes(node));
|
|
|
|
const deployment = getJson(
|
|
['-n', NAMESPACE, 'get', 'deployment', MANAGEMENT_NAME],
|
|
'management Deployment',
|
|
);
|
|
const service = getJson(
|
|
['-n', NAMESPACE, 'get', 'service', MANAGEMENT_NAME],
|
|
'management Service',
|
|
);
|
|
const serviceAccount = getJson(
|
|
['-n', NAMESPACE, 'get', 'serviceaccount', MANAGEMENT_NAME],
|
|
'management ServiceAccount',
|
|
);
|
|
const policy = getJson(
|
|
['-n', NAMESPACE, 'get', 'networkpolicy', MANAGEMENT_NAME],
|
|
'management NetworkPolicy',
|
|
);
|
|
const pods = (
|
|
getJson(
|
|
[
|
|
'-n',
|
|
NAMESPACE,
|
|
'get',
|
|
'pods',
|
|
'-l',
|
|
'app.kubernetes.io/name=ql3-plugin-package-management,app.kubernetes.io/component=plugin-package-management',
|
|
],
|
|
'management Pods',
|
|
).items ?? []
|
|
).filter((pod) => !pod.metadata?.deletionTimestamp);
|
|
if (
|
|
deployment.spec?.replicas !== 2 ||
|
|
deployment.status?.readyReplicas !== 2 ||
|
|
deployment.status?.unavailableReplicas > 0 ||
|
|
pods.length !== 2 ||
|
|
!pods.every(ready) ||
|
|
serviceAccount.automountServiceAccountToken !== false ||
|
|
!pods.every(
|
|
(pod) =>
|
|
pod.spec?.serviceAccountName === MANAGEMENT_NAME &&
|
|
pod.spec?.automountServiceAccountToken === false,
|
|
) ||
|
|
service.spec?.ports?.length !== 1 ||
|
|
service.spec.ports[0]?.port !== 8443 ||
|
|
service.spec.ports[0]?.protocol !== 'TCP' ||
|
|
!policyIsBounded(policy)
|
|
) {
|
|
fail(
|
|
'live management Deployment, ServiceAccount, Service or policy drifted',
|
|
);
|
|
}
|
|
|
|
const nodeByName = new Map(nodes.map((node) => [node.metadata?.name, node]));
|
|
const podNodes = pods.map((pod) => nodeByName.get(pod.spec?.nodeName));
|
|
if (
|
|
podNodes.some((node) => !node) ||
|
|
new Set(pods.map((pod) => pod.spec.nodeName)).size !== 2
|
|
) {
|
|
fail('management replicas must be ready on two distinct known nodes');
|
|
}
|
|
const architectures = new Set(
|
|
podNodes.map((node) => node.metadata.labels?.['kubernetes.io/arch']),
|
|
);
|
|
if (
|
|
architectures.size !== 1 ||
|
|
!['amd64', 'arm64'].includes([...architectures][0])
|
|
) {
|
|
fail('management replica architecture is unsupported or inconsistent');
|
|
}
|
|
const managementImageIds = new Set(
|
|
pods.map((pod) =>
|
|
imageDigest(
|
|
containerStatus(pod, MANAGEMENT_CONTAINER).imageID,
|
|
'management',
|
|
),
|
|
),
|
|
);
|
|
if (managementImageIds.size !== 1) {
|
|
fail('management replicas do not use one immutable image ID');
|
|
}
|
|
|
|
const cni = getJson(
|
|
[
|
|
'-n',
|
|
options.cniDaemonSet.namespace,
|
|
'get',
|
|
'daemonset',
|
|
options.cniDaemonSet.name,
|
|
],
|
|
'CNI DaemonSet',
|
|
);
|
|
const cniContainer = cni.spec?.template?.spec?.containers?.find(
|
|
(container) => container.name === options.cniContainer,
|
|
);
|
|
if (
|
|
!cniContainer ||
|
|
cni.status?.desiredNumberScheduled < activeNodes.length ||
|
|
cni.status?.numberReady !== cni.status?.desiredNumberScheduled
|
|
) {
|
|
fail('CNI DaemonSet is not ready on every schedulable node');
|
|
}
|
|
|
|
const postgresPods = (
|
|
getJson(
|
|
['-n', NAMESPACE, 'get', 'pods', '-l', POSTGRES_SELECTOR],
|
|
'CloudNativePG Pods',
|
|
).items ?? []
|
|
).filter((pod) => !pod.metadata?.deletionTimestamp && ready(pod));
|
|
if (postgresPods.length < 3) {
|
|
fail('three ready CloudNativePG Pods are required');
|
|
}
|
|
const postgresImageIds = new Set(
|
|
postgresPods.map((pod) => {
|
|
const status = pod.status?.containerStatuses?.find(
|
|
(candidate) => candidate.name === 'postgres',
|
|
);
|
|
if (!status) {
|
|
fail(
|
|
`${pod.metadata?.name ?? 'unknown Pod'} has no postgres container`,
|
|
);
|
|
}
|
|
return imageDigest(status?.imageID, 'PostgreSQL');
|
|
}),
|
|
);
|
|
if (postgresImageIds.size !== 1) {
|
|
fail('CloudNativePG Pods do not use one immutable PostgreSQL image ID');
|
|
}
|
|
|
|
const asManager = `system:serviceaccount:${NAMESPACE}:${MANAGEMENT_NAME}`;
|
|
const deniedSecretRead = canIOutput(
|
|
runKubectl([
|
|
'auth',
|
|
'can-i',
|
|
'get',
|
|
'secrets',
|
|
'-n',
|
|
NAMESPACE,
|
|
`--as=${asManager}`,
|
|
]),
|
|
'manager Secret read',
|
|
);
|
|
const deniedExecutorMutations = [
|
|
['create', 'jobs.batch'],
|
|
['patch', 'deployments.apps'],
|
|
['update', 'configmaps'],
|
|
].every((args) => {
|
|
canIOutput(
|
|
runKubectl([
|
|
'auth',
|
|
'can-i',
|
|
...args,
|
|
'-n',
|
|
NAMESPACE,
|
|
`--as=${asManager}`,
|
|
]),
|
|
`manager ${args.join(' ')}`,
|
|
);
|
|
return true;
|
|
});
|
|
|
|
return Object.freeze({
|
|
kubernetesVersion: version.serverVersion?.gitVersion,
|
|
architecture: [...architectures][0],
|
|
managementImageId: [...managementImageIds][0],
|
|
postgresImageId: [...postgresImageIds][0],
|
|
cniName: options.cniName,
|
|
cniVersion: cniVersion(cniContainer.image),
|
|
controlPlaneNodes: controlPlanes.length,
|
|
workerNodes: workers.length,
|
|
replicas: deployment.spec.replicas,
|
|
readyReplicas: deployment.status.readyReplicas,
|
|
podIdentitySha256: Object.freeze(
|
|
pods
|
|
.map((pod) => digest('qinglong3.management-pod.v1', pod.metadata.uid))
|
|
.sort(),
|
|
),
|
|
nodeIdentitySha256: Object.freeze(
|
|
podNodes
|
|
.map((node) =>
|
|
digest('qinglong3.management-node.v1', node.metadata.uid),
|
|
)
|
|
.sort(),
|
|
),
|
|
serviceAccount: serviceAccount.metadata.name,
|
|
automountServiceAccountToken: serviceAccount.automountServiceAccountToken,
|
|
boundedNetworkPolicy: true,
|
|
managerSecretReadDenied: deniedSecretRead === false,
|
|
managerExecutorMutationDenied: deniedExecutorMutations,
|
|
});
|
|
}
|
|
|
|
function validateExercise(exercise, nowMs = Date.now()) {
|
|
exactKeys(
|
|
exercise,
|
|
[
|
|
'schemaVersion',
|
|
'fixture',
|
|
'observedAt',
|
|
'identity',
|
|
'ceremony',
|
|
'isolation',
|
|
'rotation',
|
|
],
|
|
'exercise',
|
|
);
|
|
if (
|
|
exercise.schemaVersion !== 1 ||
|
|
exercise.fixture !== EXERCISE_FIXTURE ||
|
|
!Number.isFinite(Date.parse(exercise.observedAt)) ||
|
|
Date.parse(exercise.observedAt) > nowMs + 5 * 60_000 ||
|
|
nowMs - Date.parse(exercise.observedAt) > 24 * 60 * 60_000
|
|
) {
|
|
fail('exercise version or observation time is invalid');
|
|
}
|
|
exactKeys(
|
|
exercise.identity,
|
|
[
|
|
'issuer',
|
|
'audience',
|
|
'requesterSubject',
|
|
'reviewerSubject',
|
|
'requesterAssurance',
|
|
'reviewerAssurance',
|
|
'keysetGenerations',
|
|
],
|
|
'exercise identity',
|
|
);
|
|
const identity = exercise.identity;
|
|
token(identity.issuer, 'identity issuer', 512);
|
|
token(identity.audience, 'identity audience', 256);
|
|
token(identity.requesterSubject, 'requester subject', 512);
|
|
token(identity.reviewerSubject, 'reviewer subject', 512);
|
|
if (
|
|
identity.requesterSubject === identity.reviewerSubject ||
|
|
!['multi_factor', 'hardware'].includes(identity.requesterAssurance) ||
|
|
!['multi_factor', 'hardware'].includes(identity.reviewerAssurance) ||
|
|
!Array.isArray(identity.keysetGenerations) ||
|
|
identity.keysetGenerations.length !== 3 ||
|
|
!identity.keysetGenerations.every(
|
|
(generation) => Number.isSafeInteger(generation) && generation >= 1,
|
|
) ||
|
|
!(identity.keysetGenerations[0] < identity.keysetGenerations[1]) ||
|
|
!(identity.keysetGenerations[1] < identity.keysetGenerations[2])
|
|
) {
|
|
fail(
|
|
'exercise identity does not prove two strong users and three generations',
|
|
);
|
|
}
|
|
|
|
exactKeys(
|
|
exercise.ceremony,
|
|
[
|
|
'proposalAuditEventId',
|
|
'approvalAuditEventId',
|
|
'decisionAuditEventId',
|
|
'proposeStatus',
|
|
'proposeOperation',
|
|
'selfDecisionStatus',
|
|
'selfDecisionError',
|
|
'reviewerDecisionStatus',
|
|
'reviewerDecisionOperation',
|
|
'inspectionStatus',
|
|
'inspectionOperation',
|
|
],
|
|
'exercise ceremony',
|
|
);
|
|
const ceremony = exercise.ceremony;
|
|
if (
|
|
![
|
|
ceremony.proposalAuditEventId,
|
|
ceremony.approvalAuditEventId,
|
|
ceremony.decisionAuditEventId,
|
|
].every((value) => UUID_V4_PATTERN.test(value)) ||
|
|
new Set([
|
|
ceremony.proposalAuditEventId,
|
|
ceremony.approvalAuditEventId,
|
|
ceremony.decisionAuditEventId,
|
|
]).size !== 3 ||
|
|
ceremony.proposeStatus !== 200 ||
|
|
ceremony.proposeOperation !== 'plugin-package.propose' ||
|
|
ceremony.selfDecisionStatus !== 403 ||
|
|
ceremony.selfDecisionError !== 'forbidden' ||
|
|
ceremony.reviewerDecisionStatus !== 200 ||
|
|
ceremony.reviewerDecisionOperation !== 'plugin-package.decide' ||
|
|
ceremony.inspectionStatus !== 200 ||
|
|
ceremony.inspectionOperation !== 'plugin-package.inspect'
|
|
) {
|
|
fail('exercise ceremony did not prove the exact separation-of-duty flow');
|
|
}
|
|
|
|
exactKeys(
|
|
exercise.isolation,
|
|
[
|
|
'labelledClientOutcome',
|
|
'unlabelledClientOutcome',
|
|
'wrongPortOutcome',
|
|
'kubernetesApiEgressOutcome',
|
|
'publicInternetEgressOutcome',
|
|
'postgresEgressOutcome',
|
|
],
|
|
'exercise isolation',
|
|
);
|
|
if (
|
|
exercise.isolation.labelledClientOutcome !== 'tls13_connected' ||
|
|
exercise.isolation.unlabelledClientOutcome !== 'timeout' ||
|
|
exercise.isolation.wrongPortOutcome !== 'timeout' ||
|
|
exercise.isolation.kubernetesApiEgressOutcome !== 'timeout' ||
|
|
exercise.isolation.publicInternetEgressOutcome !== 'timeout' ||
|
|
exercise.isolation.postgresEgressOutcome !== 'postgres_ready'
|
|
) {
|
|
fail('exercise isolation outcomes are incomplete');
|
|
}
|
|
|
|
exactKeys(
|
|
exercise.rotation,
|
|
[
|
|
'overlapOldStatus',
|
|
'newStatus',
|
|
'revokedOldStatus',
|
|
'revokedOldError',
|
|
'previousTlsSerial',
|
|
'currentTlsSerial',
|
|
'previousTlsSecretResourceVersion',
|
|
'currentTlsSecretResourceVersion',
|
|
'readinessSamples',
|
|
],
|
|
'exercise rotation',
|
|
);
|
|
const rotation = exercise.rotation;
|
|
token(rotation.previousTlsSerial, 'previous TLS serial', 256);
|
|
token(rotation.currentTlsSerial, 'current TLS serial', 256);
|
|
token(
|
|
rotation.previousTlsSecretResourceVersion,
|
|
'previous TLS resourceVersion',
|
|
256,
|
|
);
|
|
token(
|
|
rotation.currentTlsSecretResourceVersion,
|
|
'current TLS resourceVersion',
|
|
256,
|
|
);
|
|
const phases = ['before', 'overlap', 'revoked'];
|
|
if (
|
|
rotation.overlapOldStatus !== 200 ||
|
|
rotation.newStatus !== 200 ||
|
|
rotation.revokedOldStatus !== 401 ||
|
|
rotation.revokedOldError !== 'authentication_required' ||
|
|
rotation.previousTlsSerial === rotation.currentTlsSerial ||
|
|
rotation.previousTlsSecretResourceVersion ===
|
|
rotation.currentTlsSecretResourceVersion ||
|
|
!Array.isArray(rotation.readinessSamples) ||
|
|
rotation.readinessSamples.length !== 3 ||
|
|
rotation.readinessSamples.some((sample, index) => {
|
|
try {
|
|
exactKeys(
|
|
sample,
|
|
[
|
|
'phase',
|
|
'replicas',
|
|
'readyReplicas',
|
|
'unavailableReplicas',
|
|
'tlsProtocol',
|
|
],
|
|
`rotation sample ${index + 1}`,
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
return (
|
|
sample.phase !== phases[index] ||
|
|
sample.replicas !== 2 ||
|
|
sample.readyReplicas !== 2 ||
|
|
sample.unavailableReplicas !== 0 ||
|
|
sample.tlsProtocol !== 'TLSv1.3'
|
|
);
|
|
})
|
|
) {
|
|
fail('exercise rotation did not preserve two TLS 1.3 replicas');
|
|
}
|
|
return exercise;
|
|
}
|
|
|
|
function databaseSql(auditIds) {
|
|
const values = auditIds.map((value) => `('${value}'::varchar)`).join(',');
|
|
return `
|
|
WITH expected_audit(event_id) AS (VALUES ${values})
|
|
SELECT json_build_object(
|
|
'currentRole', current_user,
|
|
'serverVersionNumber', current_setting('server_version_num')::integer,
|
|
'migrationIds', (
|
|
SELECT coalesce(json_agg(migration_id ORDER BY migration_id), '[]'::json)
|
|
FROM ql3.schema_migrations
|
|
WHERE stream_id = 'postgresql-main' AND dialect = 'postgresql'
|
|
),
|
|
'controlCoreCapability', (
|
|
SELECT contract_version
|
|
FROM ql3.schema_capabilities
|
|
WHERE contract_name = 'control-core'
|
|
),
|
|
'tableCount', (
|
|
SELECT count(*)::integer
|
|
FROM pg_catalog.pg_tables
|
|
WHERE schemaname = 'ql3'
|
|
),
|
|
'auditRows', (
|
|
SELECT count(*)::integer
|
|
FROM ql3.security_audit_events AS audit
|
|
JOIN expected_audit USING (event_id)
|
|
),
|
|
'ledger', (
|
|
SELECT json_build_object(
|
|
'generation', generation,
|
|
'issuer', issuer,
|
|
'audience', audience,
|
|
'revokedKeyCount', jsonb_array_length(revoked_key_ids)
|
|
)
|
|
FROM ql3.plugin_package_identity_keyset_ledger
|
|
WHERE authority = 'plugin-package-management'
|
|
)
|
|
)::text;
|
|
`.trim();
|
|
}
|
|
|
|
function collectDatabaseSnapshot(exercise, options, runPsql) {
|
|
const auditIds = [
|
|
exercise.ceremony.proposalAuditEventId,
|
|
exercise.ceremony.approvalAuditEventId,
|
|
exercise.ceremony.decisionAuditEventId,
|
|
];
|
|
const result = runPsql([
|
|
'--no-psqlrc',
|
|
'--no-align',
|
|
'--tuples-only',
|
|
'--set=ON_ERROR_STOP=1',
|
|
`--dbname=service=${options.pgService}`,
|
|
`--command=${databaseSql(auditIds)}`,
|
|
]);
|
|
const snapshot = jsonOutput(result, 'PostgreSQL evidence query');
|
|
const migrations = snapshot.migrationIds;
|
|
if (
|
|
snapshot.currentRole !== 'ql3_package_manager' ||
|
|
snapshot.serverVersionNumber !== 180004 ||
|
|
!Array.isArray(migrations) ||
|
|
migrations.length !== 25 ||
|
|
migrations.some(
|
|
(migrationId, index) =>
|
|
!new RegExp(`^pg-${String(index + 1).padStart(4, '0')}-`).test(
|
|
migrationId,
|
|
),
|
|
) ||
|
|
migrations[24] !== 'pg-0025-plugin-package-materialized-revisions' ||
|
|
snapshot.controlCoreCapability !== 24 ||
|
|
snapshot.tableCount !== 38 ||
|
|
snapshot.auditRows !== 3 ||
|
|
!snapshot.ledger ||
|
|
snapshot.ledger.generation !== exercise.identity.keysetGenerations[2] ||
|
|
snapshot.ledger.issuer !== exercise.identity.issuer ||
|
|
snapshot.ledger.audience !== exercise.identity.audience ||
|
|
!Number.isSafeInteger(snapshot.ledger.revokedKeyCount) ||
|
|
snapshot.ledger.revokedKeyCount < 1 ||
|
|
snapshot.ledger.revokedKeyCount > 64
|
|
) {
|
|
fail('PostgreSQL manager-role evidence does not match the v24 contract');
|
|
}
|
|
return Object.freeze(snapshot);
|
|
}
|
|
|
|
async function readBoundedResponse(response, label) {
|
|
if (!response || response.status !== 200) {
|
|
fail(`${label} did not return HTTP 200`);
|
|
}
|
|
const contentLength = response.headers?.get?.('content-length');
|
|
if (
|
|
contentLength !== null &&
|
|
contentLength !== undefined &&
|
|
(!/^(?:0|[1-9][0-9]*)$/.test(contentLength) ||
|
|
Number(contentLength) > MAX_OIDC_DOCUMENT_BYTES)
|
|
) {
|
|
fail(`${label} Content-Length is invalid`);
|
|
}
|
|
const chunks = [];
|
|
let length = 0;
|
|
if (
|
|
response.body &&
|
|
typeof response.body[Symbol.asyncIterator] === 'function'
|
|
) {
|
|
for await (const value of response.body) {
|
|
const chunk = Buffer.from(value);
|
|
length += chunk.length;
|
|
if (length > MAX_OIDC_DOCUMENT_BYTES) {
|
|
fail(`${label} exceeds 1 MiB`);
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
} else {
|
|
const chunk = Buffer.from(await response.arrayBuffer());
|
|
length = chunk.length;
|
|
chunks.push(chunk);
|
|
}
|
|
const bytes = Buffer.concat(chunks, length);
|
|
if (bytes.length < 2 || bytes.length > MAX_OIDC_DOCUMENT_BYTES) {
|
|
fail(`${label} size is invalid`);
|
|
}
|
|
let document;
|
|
try {
|
|
document = JSON.parse(bytes.toString('utf8'));
|
|
} catch {
|
|
fail(`${label} is not valid JSON`);
|
|
}
|
|
return Object.freeze({ bytes, document });
|
|
}
|
|
|
|
function externalHttpsUrl(value, label) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
fail(`${label} URL is invalid`);
|
|
}
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
if (
|
|
parsed.protocol !== 'https:' ||
|
|
parsed.username !== '' ||
|
|
parsed.password !== '' ||
|
|
parsed.hash !== '' ||
|
|
parsed.toString() !== value ||
|
|
net.isIP(hostname) !== 0 ||
|
|
hostname === 'localhost' ||
|
|
hostname.endsWith('.localhost') ||
|
|
hostname.endsWith('.local') ||
|
|
hostname.endsWith('.test') ||
|
|
hostname.endsWith('.invalid') ||
|
|
hostname.endsWith('.example')
|
|
) {
|
|
fail(`${label} must be a canonical external HTTPS URL`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function collectOidcSnapshot(identity, fetchImpl = globalThis.fetch) {
|
|
if (typeof fetchImpl !== 'function') fail('fetch is unavailable');
|
|
externalHttpsUrl(identity.issuer, 'OIDC issuer');
|
|
const discoveryUrl = externalHttpsUrl(
|
|
`${identity.issuer}${
|
|
identity.issuer.endsWith('/') ? '' : '/'
|
|
}.well-known/openid-configuration`,
|
|
'OIDC discovery',
|
|
).toString();
|
|
const request = (url) =>
|
|
fetchImpl(url, {
|
|
redirect: 'error',
|
|
signal: AbortSignal.timeout(10_000),
|
|
headers: { accept: 'application/json' },
|
|
});
|
|
const discovery = await readBoundedResponse(
|
|
await request(discoveryUrl),
|
|
'OIDC discovery',
|
|
);
|
|
if (
|
|
discovery.document?.issuer !== identity.issuer ||
|
|
typeof discovery.document?.jwks_uri !== 'string'
|
|
) {
|
|
fail('OIDC discovery does not bind the exercised issuer');
|
|
}
|
|
const jwksUrl = externalHttpsUrl(discovery.document.jwks_uri, 'OIDC JWKS');
|
|
const jwks = await readBoundedResponse(
|
|
await request(jwksUrl.toString()),
|
|
'OIDC JWKS',
|
|
);
|
|
if (!Array.isArray(jwks.document?.keys) || jwks.document.keys.length < 1) {
|
|
fail('OIDC JWKS contains no keys');
|
|
}
|
|
return Object.freeze({
|
|
discoveryDocumentSha256: rawDigest(discovery.bytes),
|
|
jwksSha256: rawDigest(jwks.bytes),
|
|
});
|
|
}
|
|
|
|
function buildLiveEvidenceReport({
|
|
kubernetes,
|
|
database,
|
|
oidc,
|
|
exercise,
|
|
observedAt,
|
|
}) {
|
|
const report = {
|
|
schemaVersion: 1,
|
|
fixture: REPORT_FIXTURE,
|
|
observedAt,
|
|
platform: {
|
|
kubernetesVersion: kubernetes.kubernetesVersion,
|
|
architecture: kubernetes.architecture,
|
|
managementImageId: kubernetes.managementImageId,
|
|
postgresVersionNumber: database.serverVersionNumber,
|
|
postgresImageId: kubernetes.postgresImageId,
|
|
cniName: kubernetes.cniName,
|
|
cniVersion: kubernetes.cniVersion,
|
|
controlPlaneNodes: kubernetes.controlPlaneNodes,
|
|
workerNodes: kubernetes.workerNodes,
|
|
},
|
|
deployment: {
|
|
namespace: NAMESPACE,
|
|
service: MANAGEMENT_NAME,
|
|
replicas: kubernetes.replicas,
|
|
readyReplicas: kubernetes.readyReplicas,
|
|
podIdentitySha256: kubernetes.podIdentitySha256,
|
|
nodeIdentitySha256: kubernetes.nodeIdentitySha256,
|
|
serviceAccount: kubernetes.serviceAccount,
|
|
automountServiceAccountToken: kubernetes.automountServiceAccountToken,
|
|
databaseRole: database.currentRole,
|
|
migrationCount: database.migrationIds.length,
|
|
controlCoreCapability: database.controlCoreCapability,
|
|
tableCount: database.tableCount,
|
|
},
|
|
identity: {
|
|
providerKind: 'external_oidc',
|
|
issuer: exercise.identity.issuer,
|
|
discoveryDocumentSha256: oidc.discoveryDocumentSha256,
|
|
jwksSha256: oidc.jwksSha256,
|
|
audience: exercise.identity.audience,
|
|
requesterSubjectSha256: digest(
|
|
'qinglong3.management-subject.v1',
|
|
exercise.identity.requesterSubject,
|
|
),
|
|
reviewerSubjectSha256: digest(
|
|
'qinglong3.management-subject.v1',
|
|
exercise.identity.reviewerSubject,
|
|
),
|
|
requesterAssurance: exercise.identity.requesterAssurance,
|
|
reviewerAssurance: exercise.identity.reviewerAssurance,
|
|
keysetGenerations: [...exercise.identity.keysetGenerations],
|
|
finalLedgerGeneration: database.ledger.generation,
|
|
finalRevokedKeyCount: database.ledger.revokedKeyCount,
|
|
},
|
|
ceremony: {
|
|
requesterProposeAccepted: true,
|
|
requesterSelfDecisionRejected: true,
|
|
reviewerDecisionAccepted: true,
|
|
requesterAndReviewerDistinct: true,
|
|
inspectionAuthorized: true,
|
|
durableAuditObserved: database.auditRows === 3,
|
|
},
|
|
isolation: {
|
|
labelledClientAllowed: true,
|
|
unlabelledClientDenied: true,
|
|
wrongPortDenied: true,
|
|
kubernetesApiEgressDenied: true,
|
|
publicInternetEgressDenied: true,
|
|
postgresEgressAllowed: true,
|
|
managerSecretReadDenied: kubernetes.managerSecretReadDenied,
|
|
managerExecutorMutationDenied: kubernetes.managerExecutorMutationDenied,
|
|
},
|
|
rotation: {
|
|
overlapOldAssertionAccepted: true,
|
|
newAssertionAccepted: true,
|
|
revokedOldAssertionRejected: true,
|
|
previousTlsSerialSha256: digest(
|
|
'qinglong3.management-tls-serial.v1',
|
|
exercise.rotation.previousTlsSerial,
|
|
),
|
|
currentTlsSerialSha256: digest(
|
|
'qinglong3.management-tls-serial.v1',
|
|
exercise.rotation.currentTlsSerial,
|
|
),
|
|
previousTlsSecretVersionSha256: digest(
|
|
'qinglong3.management-tls-resource-version.v1',
|
|
exercise.rotation.previousTlsSecretResourceVersion,
|
|
),
|
|
currentTlsSecretVersionSha256: digest(
|
|
'qinglong3.management-tls-resource-version.v1',
|
|
exercise.rotation.currentTlsSecretResourceVersion,
|
|
),
|
|
allReplicasReadyThroughout: true,
|
|
tls13BeforeAndAfter: true,
|
|
},
|
|
gates: {
|
|
externalIdentity: true,
|
|
separationOfDuty: true,
|
|
twoReplicaAvailability: true,
|
|
networkPolicy: kubernetes.boundedNetworkPolicy,
|
|
keysetRotation: true,
|
|
tlsRotation: true,
|
|
leastPrivilege:
|
|
kubernetes.managerSecretReadDenied &&
|
|
kubernetes.managerExecutorMutationDenied,
|
|
schema: true,
|
|
passed: true,
|
|
},
|
|
};
|
|
const audit = validatePluginPackageManagementLiveEvidence(report);
|
|
if (!audit.compatible) {
|
|
fail(
|
|
`assembled evidence was rejected: ${audit.findings
|
|
.map(({ code }) => code)
|
|
.join(', ')}`,
|
|
);
|
|
}
|
|
return Object.freeze(report);
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const allowed = new Set([
|
|
'kubeconfig',
|
|
'context',
|
|
'cni-daemonset',
|
|
'cni-container',
|
|
'cni-name',
|
|
'exercise',
|
|
'pg-service-file',
|
|
'pg-service',
|
|
'output',
|
|
]);
|
|
const values = new Map();
|
|
for (const argument of argv) {
|
|
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
|
|
if (!match || !allowed.has(match[1]) || values.has(match[1])) {
|
|
fail(`unknown or duplicate argument ${argument}`);
|
|
}
|
|
values.set(match[1], match[2]);
|
|
}
|
|
if (values.size !== allowed.size) {
|
|
fail(`required arguments are: ${[...allowed].join(', ')}`);
|
|
}
|
|
const context = token(values.get('context'), 'Kubernetes context', 253);
|
|
const cniContainer = token(values.get('cni-container'), 'CNI container', 63);
|
|
const cniName = token(values.get('cni-name'), 'CNI name', 64);
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(cniName)) {
|
|
fail('CNI name is invalid');
|
|
}
|
|
const pgService = token(values.get('pg-service'), 'PostgreSQL service', 63);
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pgService)) {
|
|
fail('PostgreSQL service name is invalid');
|
|
}
|
|
const output = values.get('output');
|
|
if (!path.isAbsolute(output) || fs.existsSync(output)) {
|
|
fail('output must be an unused absolute path');
|
|
}
|
|
const outputParent = fs.realpathSync(path.dirname(output));
|
|
if (path.join(outputParent, path.basename(output)) !== output) {
|
|
fail('output parent must be canonical');
|
|
}
|
|
return Object.freeze({
|
|
kubeconfig: canonicalPrivatePath(
|
|
values.get('kubeconfig'),
|
|
'Kubernetes config',
|
|
),
|
|
context,
|
|
cniDaemonSet: parseCniReference(values.get('cni-daemonset')),
|
|
cniContainer,
|
|
cniName,
|
|
exercisePath: canonicalPrivatePath(
|
|
values.get('exercise'),
|
|
'exercise evidence',
|
|
),
|
|
pgServiceFile: canonicalPrivatePath(
|
|
values.get('pg-service-file'),
|
|
'PostgreSQL service file',
|
|
),
|
|
pgService,
|
|
output,
|
|
});
|
|
}
|
|
|
|
function commandRunner(binary, baseArgs = [], environment = process.env) {
|
|
return (args) => {
|
|
const result = spawnSync(binary, [...baseArgs, ...args], {
|
|
encoding: 'utf8',
|
|
env: environment,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
timeout: 30_000,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
if (result.error) {
|
|
fail(`${path.basename(binary)} failed: ${result.error.message}`);
|
|
}
|
|
return Object.freeze({
|
|
status: result.status,
|
|
stdout: result.stdout ?? '',
|
|
stderr: result.stderr ?? '',
|
|
});
|
|
};
|
|
}
|
|
|
|
function writeNoReplace(filePath, report) {
|
|
const descriptor = fs.openSync(filePath, 'wx', 0o600);
|
|
try {
|
|
fs.writeFileSync(descriptor, `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const exercise = validateExercise(
|
|
readPrivateJson(options.exercisePath, 'exercise evidence'),
|
|
);
|
|
const kubectl = commandRunner(process.env.QL3_KUBECTL_BIN || 'kubectl', [
|
|
'--kubeconfig',
|
|
options.kubeconfig,
|
|
'--context',
|
|
options.context,
|
|
]);
|
|
const psql = commandRunner(process.env.QL3_PSQL_BIN || 'psql', [], {
|
|
...process.env,
|
|
PGSERVICEFILE: options.pgServiceFile,
|
|
});
|
|
const kubernetes = collectKubernetesSnapshot(options, kubectl);
|
|
const database = collectDatabaseSnapshot(exercise, options, psql);
|
|
const oidc = await collectOidcSnapshot(exercise.identity);
|
|
const report = buildLiveEvidenceReport({
|
|
kubernetes,
|
|
database,
|
|
oidc,
|
|
exercise,
|
|
observedAt: new Date().toISOString(),
|
|
});
|
|
writeNoReplace(options.output, report);
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
fixture: REPORT_FIXTURE,
|
|
output: options.output,
|
|
compatible: true,
|
|
})}\n`,
|
|
);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`${
|
|
error instanceof Error ? error.message : 'unknown collection error'
|
|
}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
PluginPackageManagementLiveEvidenceCollectionError,
|
|
buildLiveEvidenceReport,
|
|
collectDatabaseSnapshot,
|
|
collectKubernetesSnapshot,
|
|
collectOidcSnapshot,
|
|
parseArguments,
|
|
policyIsBounded,
|
|
validateExercise,
|
|
};
|