mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:17:47 +08:00
729 lines
23 KiB
JavaScript
729 lines
23 KiB
JavaScript
#!/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 { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
|
|
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
|
const QL3_VERSION = readReleaseIdentity(DEFAULT_ROOT).version;
|
|
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 === 'worker') {
|
|
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/worker-runtime/dist/process/workerProcessCli.js',
|
|
],
|
|
WorkingDir: '/opt/qinglong',
|
|
Labels: {
|
|
'io.qinglong.profile': 'worker',
|
|
'io.qinglong.worker.capacity-profiles': 'edge,node',
|
|
'org.opencontainers.image.description':
|
|
'QingLong 3.0 headless Remote Worker 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 Worker',
|
|
'org.opencontainers.image.version': QL3_VERSION,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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': QL3_VERSION,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
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/product-cli/cli.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 cluster operations and bounded Copilot surfaces',
|
|
'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',
|
|
'org.opencontainers.image.version': QL3_VERSION,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|