fix(cicd): harden PaC source artifact verification
This commit is contained in:
@@ -6,6 +6,7 @@ const provenanceKeys = {
|
||||
configRef: "unidesk.ai/owning-config-ref",
|
||||
effectiveConfigSha256: "unidesk.ai/effective-config-sha256",
|
||||
renderer: "unidesk.ai/source-artifact-renderer",
|
||||
mode: "unidesk.ai/source-artifact-mode",
|
||||
};
|
||||
|
||||
const sourceCommitKeys = [
|
||||
@@ -16,14 +17,59 @@ const sourceCommitKeys = [
|
||||
"hwlab.pikastech.local/source-commit",
|
||||
];
|
||||
|
||||
const knownDriftPathKeys = new Set([
|
||||
"accessModes", "annotations", "apiVersion", "args", "command", "computeResources", "default", "description", "dnsPolicy", "env", "envFrom",
|
||||
"executionMode", "finally", "fsGroup", "generateName", "hostNetwork", "image", "kind", "labels", "length", "metadata", "mode", "mountPath", "name", "namespace", "onError", "operator",
|
||||
"optional", "params", "pipeline", "pipelineRef", "pipelineSpec", "podTemplate", "readinessProbe", "readOnly", "requests", "resources",
|
||||
"results", "retries", "runAfter", "script", "secret", "secretName", "securityContext", "serviceAccountName", "sidecars", "spec", "steps",
|
||||
"storage", "subPath", "taskRunTemplate", "taskSpec", "tasks", "timeout", "timeouts", "type", "value", "values", "volumeClaimTemplate",
|
||||
"volumeMounts", "volumes", "when", "workspaces", "workingDir", "wrapper",
|
||||
]);
|
||||
|
||||
function record(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function compact(value) {
|
||||
const raw = JSON.stringify(value);
|
||||
if (raw === undefined) return String(value);
|
||||
return raw.length <= 160 ? raw : `${raw.slice(0, 157)}...`;
|
||||
function stableValue(value) {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
||||
}
|
||||
if (value === undefined) return { __type: "undefined" };
|
||||
if (typeof value === "bigint") return { __type: "bigint", value: String(value) };
|
||||
return value;
|
||||
}
|
||||
|
||||
function valueEvidence(value) {
|
||||
const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
||||
const serialized = JSON.stringify(stableValue(value));
|
||||
const text = serialized === undefined ? String(value) : serialized;
|
||||
const length = typeof value === "string"
|
||||
? Buffer.byteLength(value, "utf8")
|
||||
: Array.isArray(value)
|
||||
? value.length
|
||||
: value !== null && typeof value === "object"
|
||||
? Object.keys(value).length
|
||||
: Buffer.byteLength(text, "utf8");
|
||||
const sha256 = createHash("sha256").update(text).digest("hex");
|
||||
return `type=${type},length=${length},sha256=${sha256}`;
|
||||
}
|
||||
|
||||
function safeRuntimeReason(reason) {
|
||||
const text = typeof reason === "string" ? reason : String(reason);
|
||||
const fixed = new Set([
|
||||
"source-commit-not-specified",
|
||||
"source-commit-run-not-found",
|
||||
"source-commit-identity-conflict",
|
||||
"pipeline-get-not-found",
|
||||
"pipelinerun-list-not-found",
|
||||
"pipelinerun-list-invalid-shape",
|
||||
]);
|
||||
if (fixed.has(text)) return text;
|
||||
if (/^source-commit-run-conflict:\d+$/u.test(text)) return text;
|
||||
if (/^(?:pipeline-get|pipelinerun-list)-command-failed:(?:unknown|\d+):type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(text)) return text;
|
||||
if (/^runtime-observer-input-error:type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(text)) return text;
|
||||
return `runtime-observer-reason:${valueEvidence(text)}`;
|
||||
}
|
||||
|
||||
function unavailable(reason, sourceCommit = null, candidateCount = 0) {
|
||||
@@ -36,7 +82,7 @@ function unavailable(reason, sourceCommit = null, candidateCount = 0) {
|
||||
configRef: null,
|
||||
effectiveConfigSha256: null,
|
||||
sourceCommit,
|
||||
reason,
|
||||
reason: safeRuntimeReason(reason),
|
||||
candidateCount,
|
||||
};
|
||||
}
|
||||
@@ -82,8 +128,8 @@ export function canonicalPacPipelineSha256(value) {
|
||||
function firstCanonicalDrift(expected, actual, path = "$") {
|
||||
if (Object.is(expected, actual)) return null;
|
||||
if (Array.isArray(expected) || Array.isArray(actual)) {
|
||||
if (!Array.isArray(expected) || !Array.isArray(actual)) return { path, expected: compact(expected), actual: compact(actual) };
|
||||
if (expected.length !== actual.length) return { path: `${path}.length`, expected: String(expected.length), actual: String(actual.length) };
|
||||
if (!Array.isArray(expected) || !Array.isArray(actual)) return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) };
|
||||
if (expected.length !== actual.length) return { path: `${path}.length`, expected: valueEvidence(expected.length), actual: valueEvidence(actual.length) };
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
const drift = firstCanonicalDrift(expected[index], actual[index], `${path}[${index}]`);
|
||||
if (drift !== null) return drift;
|
||||
@@ -93,18 +139,21 @@ function firstCanonicalDrift(expected, actual, path = "$") {
|
||||
const expectedRecord = expected !== null && typeof expected === "object";
|
||||
const actualRecord = actual !== null && typeof actual === "object";
|
||||
if (expectedRecord || actualRecord) {
|
||||
if (!expectedRecord || !actualRecord) return { path, expected: compact(expected), actual: compact(actual) };
|
||||
if (!expectedRecord || !actualRecord) return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) };
|
||||
const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort();
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(expected, key) || !Object.prototype.hasOwnProperty.call(actual, key)) {
|
||||
return { path: `${path}.${key}`, expected: compact(expected[key]), actual: compact(actual[key]) };
|
||||
const expectedOwnsKey = Object.prototype.hasOwnProperty.call(expected, key);
|
||||
const actualOwnsKey = Object.prototype.hasOwnProperty.call(actual, key);
|
||||
const childPath = knownDriftPathKeys.has(key) ? `${path}.${key}` : `${path}.[key:${valueEvidence(key)}]`;
|
||||
if (!expectedOwnsKey || !actualOwnsKey) {
|
||||
return { path: childPath, expected: valueEvidence(expected[key]), actual: valueEvidence(actual[key]) };
|
||||
}
|
||||
const drift = firstCanonicalDrift(expected[key], actual[key], `${path}.${key}`);
|
||||
const drift = firstCanonicalDrift(expected[key], actual[key], childPath);
|
||||
if (drift !== null) return drift;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { path, expected: compact(expected), actual: compact(actual) };
|
||||
return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) };
|
||||
}
|
||||
|
||||
function firstDrift(expected, actual) {
|
||||
@@ -119,10 +168,11 @@ function kubectlJson(args, label) {
|
||||
};
|
||||
} catch (error) {
|
||||
const stderr = typeof error?.stderr === "string" ? error.stderr : Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : "";
|
||||
const message = stderr.trim().split(/\r?\n/u)[0] || String(error?.message ?? "command failed");
|
||||
const stdout = typeof error?.stdout === "string" ? error.stdout : Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : "";
|
||||
const message = stderr.trim().split(/\r?\n/u)[0] || stdout.trim().split(/\r?\n/u)[0] || String(error?.message ?? "command failed");
|
||||
if (/\bNotFound\b|\bnot found\b/u.test(message)) return { kind: "not-found", reason: `${label}-not-found` };
|
||||
const status = Number.isInteger(error?.status) ? error.status : "unknown";
|
||||
return { kind: "unavailable", reason: `${label}-command-failed:${status}:${compact(message)}` };
|
||||
return { kind: "unavailable", reason: `${label}-command-failed:${status}:${valueEvidence(message)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +182,7 @@ function manifestProvenance(manifest) {
|
||||
configRef: typeof annotations[provenanceKeys.configRef] === "string" ? annotations[provenanceKeys.configRef] : null,
|
||||
effectiveConfigSha256: typeof annotations[provenanceKeys.effectiveConfigSha256] === "string" ? annotations[provenanceKeys.effectiveConfigSha256] : null,
|
||||
renderer: typeof annotations[provenanceKeys.renderer] === "string" ? annotations[provenanceKeys.renderer] : null,
|
||||
mode: typeof annotations[provenanceKeys.mode] === "string" ? annotations[provenanceKeys.mode] : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,20 +195,68 @@ function manifestSourceCommits(manifest) {
|
||||
.map((value) => value.toLowerCase()))];
|
||||
}
|
||||
|
||||
function observedItem(input, manifest, spec, sourceCommit = null, candidateCount = 0) {
|
||||
function safeManifestName(value) {
|
||||
return typeof value === "string"
|
||||
&& value.length <= 253
|
||||
&& /^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function safeAlignedConfigRef(observed, expected) {
|
||||
return observed === expected
|
||||
&& typeof expected === "string"
|
||||
&& expected.length <= 512
|
||||
&& /^[A-Za-z0-9_./-]+#[A-Za-z0-9_.-]+$/u.test(expected)
|
||||
? expected
|
||||
: null;
|
||||
}
|
||||
|
||||
function safeAlignedSha256(observed, expected) {
|
||||
return observed === expected && typeof expected === "string" && /^sha256:[0-9a-f]{64}$/u.test(expected) ? expected : null;
|
||||
}
|
||||
|
||||
function pipelineRunWrapper(manifest) {
|
||||
const spec = record(manifest?.spec);
|
||||
const hasPipelineSpec = Object.prototype.hasOwnProperty.call(spec, "pipelineSpec");
|
||||
const hasPipelineRef = Object.prototype.hasOwnProperty.call(spec, "pipelineRef");
|
||||
const observedMode = manifestProvenance(manifest).mode;
|
||||
const mode = observedMode === "embedded-pipeline-spec" || observedMode === "remote-pipeline-annotation" ? observedMode : "invalid";
|
||||
const executionMode = hasPipelineSpec && !hasPipelineRef ? "pipeline-spec" : "invalid";
|
||||
const wrapperSpec = { ...spec };
|
||||
delete wrapperSpec.pipelineSpec;
|
||||
delete wrapperSpec.pipelineRef;
|
||||
return { mode, executionMode, spec: wrapperSpec };
|
||||
}
|
||||
|
||||
function observedItem(input, manifest, spec, sourceCommit = null, candidateCount = 0, wrapper = null) {
|
||||
const observedProvenance = manifestProvenance(manifest);
|
||||
const provenanceAligned = observedProvenance.configRef === input.provenance.configRef
|
||||
&& observedProvenance.effectiveConfigSha256 === input.provenance.effectiveConfigSha256
|
||||
&& observedProvenance.renderer === input.provenance.renderer;
|
||||
const drift = firstDrift(input.desiredSpec, spec);
|
||||
&& observedProvenance.renderer === input.provenance.renderer
|
||||
&& observedProvenance.mode === input.provenance.mode;
|
||||
const comparePipelineSpec = wrapper === null || wrapper.executionMode === "pipeline-spec";
|
||||
const specDrift = comparePipelineSpec ? firstDrift(input.desiredSpec, spec) : null;
|
||||
const wrapperDrift = wrapper === null || input.desiredWrapper === null
|
||||
? null
|
||||
: firstDrift(input.desiredWrapper, wrapper);
|
||||
const drift = specDrift ?? wrapperDrift;
|
||||
return {
|
||||
status: drift === null ? "aligned" : "drifted",
|
||||
name: typeof record(manifest.metadata).name === "string" ? record(manifest.metadata).name : null,
|
||||
canonicalSha256: canonicalPacPipelineSha256(spec),
|
||||
name: safeManifestName(record(manifest.metadata).name),
|
||||
canonicalSha256: canonicalPacPipelineSha256(
|
||||
wrapper === null
|
||||
? spec
|
||||
: wrapper.executionMode === "pipeline-spec"
|
||||
? { pipelineSpec: spec, wrapper }
|
||||
: { wrapper },
|
||||
),
|
||||
firstDrift: drift,
|
||||
provenanceAligned,
|
||||
configRef: observedProvenance.configRef,
|
||||
effectiveConfigSha256: observedProvenance.effectiveConfigSha256,
|
||||
configRef: safeAlignedConfigRef(observedProvenance.configRef, input.provenance.configRef),
|
||||
effectiveConfigSha256: safeAlignedSha256(observedProvenance.effectiveConfigSha256, input.provenance.effectiveConfigSha256),
|
||||
renderer: observedProvenance.renderer === input.provenance.renderer ? input.provenance.renderer : null,
|
||||
mode: observedProvenance.mode === input.provenance.mode ? input.provenance.mode : null,
|
||||
sourceCommit,
|
||||
reason: null,
|
||||
candidateCount,
|
||||
@@ -205,11 +304,23 @@ export function observePacSourceArtifactRuntime(input, readKubectlJson = kubectl
|
||||
const selected = selectPipelineRunBySourceCommit(items, input.pipelineRunPrefix, input.sourceCommit);
|
||||
if (selected.kind === "ok") {
|
||||
const signatures = selected.candidates.map((run) => JSON.stringify({
|
||||
canonicalSha256: canonicalPacPipelineSha256(record(record(run.spec).pipelineSpec)),
|
||||
canonicalSha256: (() => {
|
||||
const wrapper = pipelineRunWrapper(run);
|
||||
return canonicalPacPipelineSha256(wrapper.executionMode === "pipeline-spec"
|
||||
? { pipelineSpec: record(record(run.spec).pipelineSpec), wrapper }
|
||||
: { wrapper });
|
||||
})(),
|
||||
provenance: manifestProvenance(run),
|
||||
}));
|
||||
embedded = new Set(signatures).size === 1
|
||||
? observedItem(input, selected.run, record(record(selected.run.spec).pipelineSpec), input.sourceCommit, selected.candidates.length)
|
||||
? observedItem(
|
||||
input,
|
||||
selected.run,
|
||||
record(record(selected.run.spec).pipelineSpec),
|
||||
input.sourceCommit,
|
||||
selected.candidates.length,
|
||||
pipelineRunWrapper(selected.run),
|
||||
)
|
||||
: unavailable(`source-commit-run-conflict:${selected.candidates.length}`, input.sourceCommit, selected.candidates.length);
|
||||
} else {
|
||||
embedded = selected.kind === "missing"
|
||||
@@ -222,17 +333,16 @@ export function observePacSourceArtifactRuntime(input, readKubectlJson = kubectl
|
||||
return { live, embedded };
|
||||
}
|
||||
|
||||
if (typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string" || typeof process.env.PAC_SOURCE_ARTIFACT_INPUT === "string") {
|
||||
if (typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string") {
|
||||
try {
|
||||
const inputText = typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string"
|
||||
? readFileSync(process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE, "utf8")
|
||||
: Buffer.from(process.env.PAC_SOURCE_ARTIFACT_INPUT, "base64").toString("utf8");
|
||||
const inputText = readFileSync(process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE, "utf8");
|
||||
const input = JSON.parse(inputText);
|
||||
process.stdout.write(JSON.stringify(observePacSourceArtifactRuntime(input)));
|
||||
} catch (error) {
|
||||
const evidence = valueEvidence(String(error?.message ?? error));
|
||||
process.stdout.write(JSON.stringify({
|
||||
live: unavailable(`runtime-observer-input-error:${compact(String(error?.message ?? error))}`),
|
||||
embedded: unavailable(`runtime-observer-input-error:${compact(String(error?.message ?? error))}`),
|
||||
live: unavailable(`runtime-observer-input-error:${evidence}`),
|
||||
embedded: unavailable(`runtime-observer-input-error:${evidence}`),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user