mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
createClusterImageSbom,
|
||||
resolveImageProfile,
|
||||
} = require('./ql3-cluster-image-sbom.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const OCI_INDEX_MEDIA_TYPE = 'application/vnd.oci.image.index.v1+json';
|
||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||
const OCI_CONFIG_MEDIA_TYPE = 'application/vnd.oci.image.config.v1+json';
|
||||
const OCI_LAYER_MEDIA_TYPE = 'application/vnd.oci.image.layer.v1.tar+gzip';
|
||||
const IN_TOTO_MEDIA_TYPE = 'application/vnd.in-toto+json';
|
||||
const SPDX_PREDICATE_TYPE = 'https://spdx.dev/Document';
|
||||
const SLSA_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
|
||||
const EXPECTED_PLATFORMS = Object.freeze(['linux/amd64', 'linux/arm64']);
|
||||
const MAX_BLOBS = 128;
|
||||
const MAX_TOTAL_BLOB_BYTES = 1024 * 1024 * 1024;
|
||||
const MAX_INDEX_BYTES = 1024 * 1024;
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
const MAX_ATTESTATION_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_LAYER_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
function readBoundedJson(filePath, maximumBytes) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.size < 2 || stat.size > maximumBytes) {
|
||||
throw new Error(`invalid bounded JSON file: ${filePath}`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function assertDirectory(directory) {
|
||||
const stat = fs.lstatSync(directory);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`OCI path must be a directory: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDescriptor(descriptor, expectedMediaType, maximumBytes) {
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.mediaType !== expectedMediaType ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(descriptor.digest || '') ||
|
||||
!Number.isSafeInteger(descriptor.size) ||
|
||||
descriptor.size < 1 ||
|
||||
descriptor.size > maximumBytes
|
||||
) {
|
||||
throw new Error(`invalid OCI descriptor for ${expectedMediaType}`);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function createBlobReader(layoutRoot) {
|
||||
const blobDirectory = path.join(layoutRoot, 'blobs', 'sha256');
|
||||
assertDirectory(blobDirectory);
|
||||
const verified = new Map();
|
||||
let totalBytes = 0;
|
||||
|
||||
function read(descriptor, maximumBytes, collect = true) {
|
||||
const existing = verified.get(descriptor.digest);
|
||||
if (existing) {
|
||||
if (existing.size !== descriptor.size) {
|
||||
throw new Error(`OCI descriptor size drift: ${descriptor.digest}`);
|
||||
}
|
||||
if (collect && !existing.content) {
|
||||
throw new Error(`OCI blob was not retained: ${descriptor.digest}`);
|
||||
}
|
||||
return existing.content;
|
||||
}
|
||||
if (verified.size >= MAX_BLOBS) {
|
||||
throw new Error('OCI layout exceeds the bounded blob count');
|
||||
}
|
||||
|
||||
const digestHex = descriptor.digest.slice('sha256:'.length);
|
||||
const filePath = path.join(blobDirectory, digestHex);
|
||||
const descriptorStat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!descriptorStat.isFile() ||
|
||||
descriptorStat.size !== descriptor.size ||
|
||||
descriptorStat.size > maximumBytes
|
||||
) {
|
||||
throw new Error(`OCI blob size or type mismatch: ${descriptor.digest}`);
|
||||
}
|
||||
|
||||
totalBytes += descriptorStat.size;
|
||||
if (totalBytes > MAX_TOTAL_BLOB_BYTES) {
|
||||
throw new Error('OCI layout exceeds the bounded total blob size');
|
||||
}
|
||||
|
||||
const fd = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0),
|
||||
);
|
||||
const hash = crypto.createHash('sha256');
|
||||
const chunks = collect ? [] : undefined;
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
let offset = 0;
|
||||
try {
|
||||
const openedStat = fs.fstatSync(fd);
|
||||
if (!openedStat.isFile() || openedStat.size !== descriptor.size) {
|
||||
throw new Error(`OCI blob changed while opening: ${descriptor.digest}`);
|
||||
}
|
||||
while (offset < openedStat.size) {
|
||||
const bytesRead = fs.readSync(
|
||||
fd,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, openedStat.size - offset),
|
||||
offset,
|
||||
);
|
||||
if (bytesRead < 1) {
|
||||
throw new Error(`OCI blob ended early: ${descriptor.digest}`);
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
if (chunks) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
offset += bytesRead;
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
if (hash.digest('hex') !== digestHex) {
|
||||
throw new Error(`OCI blob digest mismatch: ${descriptor.digest}`);
|
||||
}
|
||||
const content = chunks ? Buffer.concat(chunks) : undefined;
|
||||
verified.set(descriptor.digest, {
|
||||
size: descriptor.size,
|
||||
content,
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
function readJson(descriptor, expectedMediaType, maximumBytes) {
|
||||
normalizeDescriptor(descriptor, expectedMediaType, maximumBytes);
|
||||
const content = read(descriptor, maximumBytes, true);
|
||||
return JSON.parse(content.toString('utf8'));
|
||||
}
|
||||
|
||||
function verifyNoUnreferencedBlobs() {
|
||||
const actual = fs
|
||||
.readdirSync(blobDirectory, { withFileTypes: true })
|
||||
.map((entry) => {
|
||||
if (!entry.isFile() || !/^[0-9a-f]{64}$/.test(entry.name)) {
|
||||
throw new Error(`invalid OCI blob entry: ${entry.name}`);
|
||||
}
|
||||
return `sha256:${entry.name}`;
|
||||
})
|
||||
.sort();
|
||||
const expected = [...verified.keys()].sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error('OCI layout contains missing or unreferenced blobs');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
readJson,
|
||||
report: () => ({ blobs: verified.size, totalBytes }),
|
||||
verifyNoUnreferencedBlobs,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedImageConfig(architecture, revision, image) {
|
||||
if (image === 'local') {
|
||||
return {
|
||||
architecture,
|
||||
os: 'linux',
|
||||
config: {
|
||||
User: '65532:65532',
|
||||
Env: [
|
||||
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'NODE_VERSION=24.18.0',
|
||||
'YARN_VERSION=1.22.22',
|
||||
'NODE_ENV=production',
|
||||
],
|
||||
Entrypoint: [
|
||||
'node',
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
WorkingDir: '/opt/qinglong',
|
||||
Labels: {
|
||||
'io.qinglong.ai': 'excluded',
|
||||
'io.qinglong.local.application-config': '2',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.local.sqlite-contract-max': '41',
|
||||
'io.qinglong.local.sqlite-contract-min': '41',
|
||||
'io.qinglong.local.sqlite-write-contract': '41',
|
||||
'io.qinglong.profile': 'edge,standalone',
|
||||
'org.opencontainers.image.description':
|
||||
'QingLong 3.0 AI-excluded Edge and Standalone runtime',
|
||||
'org.opencontainers.image.licenses': 'Apache-2.0',
|
||||
'org.opencontainers.image.revision': revision,
|
||||
'org.opencontainers.image.source':
|
||||
'https://github.com/whyour/qinglong',
|
||||
'org.opencontainers.image.title': 'QingLong 3.0 Local Application',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const isControl = image === 'control' || image === 'control-ai';
|
||||
const isControlAi = image === 'control-ai';
|
||||
return {
|
||||
architecture,
|
||||
os: 'linux',
|
||||
config: {
|
||||
User: '10001:10001',
|
||||
...(isControl
|
||||
? {
|
||||
ExposedPorts: {
|
||||
'5800/tcp': {},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
Env: [
|
||||
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'NODE_VERSION=24.18.0',
|
||||
'YARN_VERSION=1.22.22',
|
||||
'NODE_ENV=production',
|
||||
],
|
||||
Entrypoint: [
|
||||
'node',
|
||||
isControl
|
||||
? isControlAi
|
||||
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
|
||||
: '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js'
|
||||
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js',
|
||||
],
|
||||
WorkingDir: '/opt/qinglong',
|
||||
Labels: {
|
||||
'org.opencontainers.image.description': isControl
|
||||
? isControlAi
|
||||
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
||||
: 'QingLong 3.0 PostgreSQL-backed cluster control plane'
|
||||
: 'QingLong 3.0 short-lived cluster administration jobs',
|
||||
'org.opencontainers.image.licenses': 'Apache-2.0',
|
||||
'org.opencontainers.image.revision': revision,
|
||||
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
||||
'org.opencontainers.image.title': isControl
|
||||
? isControlAi
|
||||
? 'QingLong 3.0 Cluster Control AI'
|
||||
: 'QingLong 3.0 Cluster Control'
|
||||
: 'QingLong 3.0 Cluster Admin',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertImageConfig(config, platform, revision, image) {
|
||||
const expected = expectedImageConfig(platform.architecture, revision, image);
|
||||
if (
|
||||
config.architecture !== expected.architecture ||
|
||||
config.os !== expected.os ||
|
||||
JSON.stringify(config.config) !== JSON.stringify(expected.config)
|
||||
) {
|
||||
throw new Error(
|
||||
`OCI image config differs for ${platform.os}/${platform.architecture}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(config.rootfs?.diff_ids) ||
|
||||
config.rootfs.diff_ids.length < 1 ||
|
||||
config.rootfs.diff_ids.length > 32 ||
|
||||
config.rootfs.diff_ids.some(
|
||||
(digest) => !/^sha256:[0-9a-f]{64}$/.test(digest),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`OCI rootfs diff IDs are invalid for ${platform.architecture}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applicationPackageRefs(statement) {
|
||||
if (
|
||||
statement?._type !== 'https://in-toto.io/Statement/v1' ||
|
||||
!Array.isArray(statement.subject) ||
|
||||
statement.subject.length !== 0 ||
|
||||
statement.predicateType !== SPDX_PREDICATE_TYPE ||
|
||||
statement.predicate?.spdxVersion !== 'SPDX-2.3' ||
|
||||
!Array.isArray(statement.predicate.packages)
|
||||
) {
|
||||
throw new Error('invalid BuildKit SPDX attestation statement');
|
||||
}
|
||||
const refs = [];
|
||||
for (const packageRecord of statement.predicate.packages) {
|
||||
if (
|
||||
typeof packageRecord.sourceInfo !== 'string' ||
|
||||
!packageRecord.sourceInfo.includes('/opt/qinglong/node_modules/')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const purls = (packageRecord.externalRefs || [])
|
||||
.filter((reference) => reference.referenceType === 'purl')
|
||||
.map((reference) => reference.referenceLocator);
|
||||
if (purls.length !== 1 || !purls[0].startsWith('pkg:npm/')) {
|
||||
throw new Error(
|
||||
`application SPDX package has no unique npm purl: ${packageRecord.name}`,
|
||||
);
|
||||
}
|
||||
refs.push(purls[0]);
|
||||
}
|
||||
refs.sort();
|
||||
if (new Set(refs).size !== refs.length) {
|
||||
throw new Error('application SPDX package purls must be unique');
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function inspectBuildArguments(value, findings = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
inspectBuildArguments(entry, findings);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return findings;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key.startsWith('build-arg:')) {
|
||||
findings.push([key.slice('build-arg:'.length), child]);
|
||||
}
|
||||
inspectBuildArguments(child, findings);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function assertProvenance(statement, expectedRevision) {
|
||||
if (
|
||||
statement?._type !== 'https://in-toto.io/Statement/v1' ||
|
||||
!Array.isArray(statement.subject) ||
|
||||
statement.subject.length !== 0 ||
|
||||
statement.predicateType !== SLSA_PREDICATE_TYPE ||
|
||||
statement.predicate?.buildDefinition?.buildType !==
|
||||
'https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md' ||
|
||||
statement.predicate.buildDefinition.externalParameters?.request
|
||||
?.frontend !== 'dockerfile.v0' ||
|
||||
typeof statement.predicate.runDetails?.builder?.id !== 'string'
|
||||
) {
|
||||
throw new Error('invalid BuildKit SLSA provenance statement');
|
||||
}
|
||||
|
||||
const buildArguments = inspectBuildArguments(statement);
|
||||
const revisionArguments = buildArguments.filter(
|
||||
([name]) => name === 'SOURCE_REVISION',
|
||||
);
|
||||
if (
|
||||
revisionArguments.length < 1 ||
|
||||
revisionArguments.some(([, value]) => value !== expectedRevision)
|
||||
) {
|
||||
throw new Error(
|
||||
'SLSA provenance does not bind the expected source revision',
|
||||
);
|
||||
}
|
||||
for (const [name, value] of buildArguments) {
|
||||
if (
|
||||
!['SOURCE_REVISION', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'].includes(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
throw new Error(`unexpected provenance build argument: ${name}`);
|
||||
}
|
||||
if (
|
||||
/(?:secret|token|password|credential|private.?key)/i.test(name) ||
|
||||
typeof value !== 'string'
|
||||
) {
|
||||
throw new Error(`unsafe provenance build argument: ${name}`);
|
||||
}
|
||||
if (['HTTP_PROXY', 'HTTPS_PROXY'].includes(name) && value) {
|
||||
const proxy = new URL(value);
|
||||
if (proxy.username || proxy.password || proxy.search || proxy.hash) {
|
||||
throw new Error(
|
||||
`credential-bearing proxy leaked into provenance: ${name}`,
|
||||
);
|
||||
}
|
||||
} else if (name === 'NO_PROXY' && /[@\u0000-\u001f\u007f]/.test(value)) {
|
||||
throw new Error('unsafe NO_PROXY value leaked into provenance');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function auditAttestation(
|
||||
descriptor,
|
||||
imageDescriptor,
|
||||
blobReader,
|
||||
expectedApplicationRefs,
|
||||
expectedRevision,
|
||||
) {
|
||||
if (
|
||||
descriptor.platform?.os !== 'unknown' ||
|
||||
descriptor.platform?.architecture !== 'unknown' ||
|
||||
descriptor.annotations?.['vnd.docker.reference.type'] !==
|
||||
'attestation-manifest' ||
|
||||
descriptor.annotations?.['vnd.docker.reference.digest'] !==
|
||||
imageDescriptor.digest
|
||||
) {
|
||||
throw new Error(
|
||||
`OCI attestation is not bound to ${imageDescriptor.digest}`,
|
||||
);
|
||||
}
|
||||
const manifest = blobReader.readJson(
|
||||
descriptor,
|
||||
OCI_MANIFEST_MEDIA_TYPE,
|
||||
MAX_INDEX_BYTES,
|
||||
);
|
||||
const config = blobReader.readJson(
|
||||
manifest.config,
|
||||
OCI_CONFIG_MEDIA_TYPE,
|
||||
MAX_CONFIG_BYTES,
|
||||
);
|
||||
if (
|
||||
config.architecture !== 'unknown' ||
|
||||
config.os !== 'unknown' ||
|
||||
JSON.stringify(config.config) !== JSON.stringify({}) ||
|
||||
!Array.isArray(config.rootfs?.diff_ids) ||
|
||||
!Array.isArray(manifest.layers) ||
|
||||
JSON.stringify(config.rootfs.diff_ids) !==
|
||||
JSON.stringify(manifest.layers.map((layer) => layer.digest))
|
||||
) {
|
||||
throw new Error(
|
||||
'OCI attestation config must bind an empty unknown platform',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(manifest.layers) || manifest.layers.length !== 2) {
|
||||
throw new Error('OCI attestation must contain exactly SBOM and provenance');
|
||||
}
|
||||
|
||||
const statements = new Map();
|
||||
for (const layer of manifest.layers) {
|
||||
normalizeDescriptor(layer, IN_TOTO_MEDIA_TYPE, MAX_ATTESTATION_BYTES);
|
||||
const predicateType = layer.annotations?.['in-toto.io/predicate-type'];
|
||||
if (
|
||||
![SPDX_PREDICATE_TYPE, SLSA_PREDICATE_TYPE].includes(predicateType) ||
|
||||
statements.has(predicateType)
|
||||
) {
|
||||
throw new Error('OCI attestation predicate set is invalid');
|
||||
}
|
||||
statements.set(
|
||||
predicateType,
|
||||
JSON.parse(
|
||||
blobReader.read(layer, MAX_ATTESTATION_BYTES, true).toString('utf8'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const actualApplicationRefs = applicationPackageRefs(
|
||||
statements.get(SPDX_PREDICATE_TYPE),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(actualApplicationRefs) !==
|
||||
JSON.stringify(expectedApplicationRefs)
|
||||
) {
|
||||
throw new Error(
|
||||
`BuildKit SPDX application closure differs for ${imageDescriptor.digest}`,
|
||||
);
|
||||
}
|
||||
assertProvenance(statements.get(SLSA_PREDICATE_TYPE), expectedRevision);
|
||||
return {
|
||||
spdxApplicationPackages: actualApplicationRefs.length,
|
||||
predicates: [...statements.keys()].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function auditClusterOciLayout(options = {}) {
|
||||
const root = path.resolve(options.root || DEFAULT_ROOT);
|
||||
const layoutRoot = path.resolve(options.layoutRoot || '');
|
||||
const expectedRevision = options.expectedRevision;
|
||||
const image = resolveImageProfile(options.image).id;
|
||||
const expectedPlatforms = options.expectedPlatforms || EXPECTED_PLATFORMS;
|
||||
const maximumPlatformBytes =
|
||||
image === 'local' ? 128 * 1024 * 1024 : 512 * 1024 * 1024;
|
||||
if (
|
||||
!path.isAbsolute(layoutRoot) ||
|
||||
typeof expectedRevision !== 'string' ||
|
||||
!/^[0-9A-Za-z._-]{1,128}$/.test(expectedRevision) ||
|
||||
!Array.isArray(expectedPlatforms) ||
|
||||
![1, 2].includes(expectedPlatforms.length) ||
|
||||
expectedPlatforms.some(
|
||||
(platform) => !EXPECTED_PLATFORMS.includes(platform),
|
||||
) ||
|
||||
new Set(expectedPlatforms).size !== expectedPlatforms.length
|
||||
) {
|
||||
throw new Error(
|
||||
'absolute layout root, bounded revision and reviewed platforms are required',
|
||||
);
|
||||
}
|
||||
const reviewedPlatforms = [...expectedPlatforms].sort();
|
||||
assertDirectory(layoutRoot);
|
||||
const layout = readBoundedJson(
|
||||
path.join(layoutRoot, 'oci-layout'),
|
||||
MAX_CONFIG_BYTES,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(layout) !== JSON.stringify({ imageLayoutVersion: '1.0.0' })
|
||||
) {
|
||||
throw new Error('OCI layout version must be exactly 1.0.0');
|
||||
}
|
||||
|
||||
const blobReader = createBlobReader(layoutRoot);
|
||||
const outerIndex = readBoundedJson(
|
||||
path.join(layoutRoot, 'index.json'),
|
||||
MAX_INDEX_BYTES,
|
||||
);
|
||||
if (
|
||||
outerIndex.schemaVersion !== 2 ||
|
||||
outerIndex.mediaType !== OCI_INDEX_MEDIA_TYPE ||
|
||||
!Array.isArray(outerIndex.manifests) ||
|
||||
outerIndex.manifests.length !== 1
|
||||
) {
|
||||
throw new Error('OCI layout must contain one root image index');
|
||||
}
|
||||
const rootDescriptor = normalizeDescriptor(
|
||||
outerIndex.manifests[0],
|
||||
OCI_INDEX_MEDIA_TYPE,
|
||||
MAX_INDEX_BYTES,
|
||||
);
|
||||
const imageIndex = blobReader.readJson(
|
||||
rootDescriptor,
|
||||
OCI_INDEX_MEDIA_TYPE,
|
||||
MAX_INDEX_BYTES,
|
||||
);
|
||||
if (
|
||||
imageIndex.schemaVersion !== 2 ||
|
||||
imageIndex.mediaType !== OCI_INDEX_MEDIA_TYPE ||
|
||||
!Array.isArray(imageIndex.manifests) ||
|
||||
imageIndex.manifests.length !== reviewedPlatforms.length * 2
|
||||
) {
|
||||
throw new Error(
|
||||
'OCI image index must contain one image and one attestation per reviewed platform',
|
||||
);
|
||||
}
|
||||
|
||||
const imageDescriptors = imageIndex.manifests.filter(
|
||||
(descriptor) =>
|
||||
descriptor.platform?.os === 'linux' &&
|
||||
['amd64', 'arm64'].includes(descriptor.platform?.architecture),
|
||||
);
|
||||
const attestationDescriptors = imageIndex.manifests.filter(
|
||||
(descriptor) =>
|
||||
descriptor.platform?.os === 'unknown' &&
|
||||
descriptor.platform?.architecture === 'unknown',
|
||||
);
|
||||
const platforms = imageDescriptors
|
||||
.map(
|
||||
(descriptor) =>
|
||||
`${descriptor.platform.os}/${descriptor.platform.architecture}`,
|
||||
)
|
||||
.sort();
|
||||
if (
|
||||
JSON.stringify(platforms) !== JSON.stringify(reviewedPlatforms) ||
|
||||
attestationDescriptors.length !== reviewedPlatforms.length
|
||||
) {
|
||||
throw new Error(
|
||||
`OCI image index platform set must be ${reviewedPlatforms.join(' and ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedApplicationRefs = createClusterImageSbom({
|
||||
root,
|
||||
image,
|
||||
})
|
||||
.components.map((component) => component.purl)
|
||||
.sort();
|
||||
const platformReports = [];
|
||||
for (const imageDescriptor of imageDescriptors) {
|
||||
const manifest = blobReader.readJson(
|
||||
imageDescriptor,
|
||||
OCI_MANIFEST_MEDIA_TYPE,
|
||||
MAX_INDEX_BYTES,
|
||||
);
|
||||
if (
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.mediaType !== OCI_MANIFEST_MEDIA_TYPE ||
|
||||
!Array.isArray(manifest.layers) ||
|
||||
manifest.layers.length < 1 ||
|
||||
manifest.layers.length > 32
|
||||
) {
|
||||
throw new Error(`invalid OCI image manifest: ${imageDescriptor.digest}`);
|
||||
}
|
||||
const config = blobReader.readJson(
|
||||
manifest.config,
|
||||
OCI_CONFIG_MEDIA_TYPE,
|
||||
MAX_CONFIG_BYTES,
|
||||
);
|
||||
assertImageConfig(
|
||||
config,
|
||||
imageDescriptor.platform,
|
||||
expectedRevision,
|
||||
image,
|
||||
);
|
||||
let compressedBytes = 0;
|
||||
for (const layer of manifest.layers) {
|
||||
normalizeDescriptor(layer, OCI_LAYER_MEDIA_TYPE, MAX_LAYER_BYTES);
|
||||
blobReader.read(layer, MAX_LAYER_BYTES, false);
|
||||
compressedBytes += layer.size;
|
||||
if (compressedBytes > maximumPlatformBytes) {
|
||||
throw new Error(
|
||||
`OCI platform layers exceed the bounded size: ${imageDescriptor.digest}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const matchingAttestations = attestationDescriptors.filter(
|
||||
(descriptor) =>
|
||||
descriptor.annotations?.['vnd.docker.reference.digest'] ===
|
||||
imageDescriptor.digest,
|
||||
);
|
||||
if (matchingAttestations.length !== 1) {
|
||||
throw new Error(
|
||||
`OCI image must have one bound attestation: ${imageDescriptor.digest}`,
|
||||
);
|
||||
}
|
||||
const attestation = auditAttestation(
|
||||
matchingAttestations[0],
|
||||
imageDescriptor,
|
||||
blobReader,
|
||||
expectedApplicationRefs,
|
||||
expectedRevision,
|
||||
);
|
||||
platformReports.push({
|
||||
platform: `${imageDescriptor.platform.os}/${imageDescriptor.platform.architecture}`,
|
||||
manifestDigest: imageDescriptor.digest,
|
||||
configDigest: manifest.config.digest,
|
||||
compressedLayerBytes: compressedBytes,
|
||||
...attestation,
|
||||
});
|
||||
}
|
||||
|
||||
blobReader.verifyNoUnreferencedBlobs();
|
||||
const blobReport = blobReader.report();
|
||||
return {
|
||||
image,
|
||||
rootIndexDigest: rootDescriptor.digest,
|
||||
platforms: platformReports.sort((left, right) =>
|
||||
left.platform.localeCompare(right.platform, 'en'),
|
||||
),
|
||||
blobs: blobReport.blobs,
|
||||
totalBlobBytes: blobReport.totalBytes,
|
||||
maximumPlatformBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {};
|
||||
for (const argument of argv) {
|
||||
if (argument.startsWith('--layout=')) {
|
||||
options.layoutRoot = path.resolve(argument.slice('--layout='.length));
|
||||
} else if (argument.startsWith('--expected-revision=')) {
|
||||
options.expectedRevision = argument.slice('--expected-revision='.length);
|
||||
} else if (argument.startsWith('--root=')) {
|
||||
options.root = path.resolve(argument.slice('--root='.length));
|
||||
} else if (argument.startsWith('--image=')) {
|
||||
options.image = argument.slice('--image='.length);
|
||||
} else if (argument.startsWith('--platform=')) {
|
||||
options.expectedPlatforms = [argument.slice('--platform='.length)];
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const report = auditClusterOciLayout(parseArguments(process.argv.slice(2)));
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, ...report })}\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auditClusterOciLayout,
|
||||
};
|
||||
Reference in New Issue
Block a user