Files
qinglong/scripts/ql3-local-image-audit.cjs
T

354 lines
11 KiB
JavaScript

#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const IMAGE_DIRECTORY = 'deploy/containers/ql3-local-application';
const NODE_IMAGE =
'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d';
const BUILD_DEPENDENCIES = Object.freeze({
croner: '7.0.8',
'drizzle-orm': '1.0.0-rc.4',
semver: '7.7.4',
});
const BUILD_DEV_DEPENDENCIES = Object.freeze({
'@types/node': '24.13.3',
typescript: '5.9.3',
});
const RUNTIME_DEPENDENCIES = Object.freeze({
croner: '7.0.8',
semver: '7.7.4',
});
const BUILD_PACKAGES = Object.freeze([
'ql3-ai',
'ql3-local-admin',
'ql3-local-application',
'ql3-local-command-file',
'ql3-local-execution',
'ql3-local-process',
'ql3-local-secret',
'ql3-local-sqlite',
'ql3-runtime-core',
]);
const RUNTIME_PACKAGES = Object.freeze(
BUILD_PACKAGES.filter((name) => name !== 'ql3-ai'),
);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function sameJson(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function sortedObject(value) {
return Object.fromEntries(
Object.entries(value ?? {}).sort(([a], [b]) => a.localeCompare(b)),
);
}
function addFinding(findings, code, detail) {
findings.push(Object.freeze({ code, ...(detail ? { detail } : {}) }));
}
function auditManifest(manifest, runtime, findings) {
const expectedName = '@qinglong/local-application-image';
if (
manifest.name !== expectedName ||
manifest.version !== '3.0.0-alpha.0' ||
manifest.private !== true ||
manifest.license !== 'Apache-2.0' ||
manifest.engines?.node !== '>=24.18.0 <25'
) {
addFinding(
findings,
runtime ? 'RUNTIME_MANIFEST_IDENTITY' : 'BUILD_MANIFEST_IDENTITY',
);
}
const expectedDependencies = runtime
? RUNTIME_DEPENDENCIES
: BUILD_DEPENDENCIES;
if (
!sameJson(
sortedObject(manifest.dependencies),
sortedObject(expectedDependencies),
)
) {
addFinding(
findings,
runtime ? 'RUNTIME_DEPENDENCY_DRIFT' : 'BUILD_DEPENDENCY_DRIFT',
);
}
const expectedDevDependencies = runtime ? {} : BUILD_DEV_DEPENDENCIES;
if (
!sameJson(
sortedObject(manifest.devDependencies),
sortedObject(expectedDevDependencies),
)
) {
addFinding(
findings,
runtime ? 'RUNTIME_DEV_DEPENDENCY_PRESENT' : 'BUILD_DEV_DEPENDENCY_DRIFT',
);
}
for (const section of [
'optionalDependencies',
'peerDependencies',
'bundledDependencies',
]) {
if (manifest[section] !== undefined) {
addFinding(findings, 'UNREVIEWED_MANIFEST_SECTION', section);
}
}
}
function auditLock(manifest, lock, runtime, findings) {
if (
lock.lockfileVersion !== 3 ||
lock.requires !== true ||
!lock.packages ||
typeof lock.packages !== 'object'
) {
addFinding(findings, runtime ? 'RUNTIME_LOCK_SHAPE' : 'BUILD_LOCK_SHAPE');
return;
}
const root = lock.packages[''];
if (
!root ||
root.name !== manifest.name ||
root.version !== manifest.version ||
root.license !== manifest.license ||
root.engines?.node !== manifest.engines.node ||
!sameJson(
sortedObject(root.dependencies),
sortedObject(manifest.dependencies),
) ||
!sameJson(
sortedObject(root.devDependencies),
sortedObject(manifest.devDependencies),
)
) {
addFinding(
findings,
runtime ? 'RUNTIME_LOCK_ROOT_DRIFT' : 'BUILD_LOCK_ROOT_DRIFT',
);
}
for (const [packagePath, entry] of Object.entries(lock.packages)) {
if (packagePath === '') continue;
if (
typeof entry.version !== 'string' ||
typeof entry.integrity !== 'string' ||
!entry.integrity.startsWith('sha512-') ||
typeof entry.resolved !== 'string' ||
!entry.resolved.startsWith('https://registry.npmjs.org/') ||
entry.hasInstallScript === true ||
entry.link === true
) {
addFinding(findings, 'LOCKED_PACKAGE_UNSAFE', packagePath);
}
}
if (runtime) {
const expectedPaths = ['', 'node_modules/croner', 'node_modules/semver'];
if (!sameJson(Object.keys(lock.packages).sort(), expectedPaths.sort())) {
addFinding(findings, 'RUNTIME_LOCK_CLOSURE_DRIFT');
}
}
}
function captures(contents, pattern) {
return [...contents.matchAll(pattern)].map((match) => match[1]);
}
function counts(values) {
const result = {};
for (const value of values) result[value] = (result[value] ?? 0) + 1;
return result;
}
function auditDockerfile(contents, findings) {
const exactBasePattern = new RegExp(
`^FROM ${NODE_IMAGE.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
)} AS (?:dependency-manifest|runtime)$`,
'gm',
);
if ([...contents.matchAll(exactBasePattern)].length !== 2) {
addFinding(findings, 'BASE_IMAGE_NOT_EXACTLY_PINNED');
}
if (/(?:^|\n)\s*ARG\s+NODE_IMAGE\b/.test(contents)) {
addFinding(findings, 'BASE_IMAGE_OVERRIDE_AUTHORITY');
}
if (
!contents.includes('RUN npm ci --ignore-scripts --no-audit --no-fund') ||
!contents.includes(
'RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund',
)
) {
addFinding(findings, 'NPM_CI_CONTRACT_DRIFT');
}
if (/\b(?:apt-get|apt|curl|wget)\b|ADD\s+https?:/i.test(contents)) {
addFinding(findings, 'UNREVIEWED_BUILD_NETWORK_OR_OS_PACKAGE');
}
if (/^(?:EXPOSE|HEALTHCHECK)\b/gm.test(contents)) {
addFinding(findings, 'UNREVIEWED_RUNTIME_SURFACE');
}
const buildCopies = captures(
contents,
/^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gm,
).sort();
if (!sameJson(buildCopies, [...BUILD_PACKAGES].sort())) {
addFinding(findings, 'BUILD_PACKAGE_CLOSURE_DRIFT');
}
const runtimeCopyCounts = counts(
captures(
contents,
/^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm,
),
);
if (
!sameJson(
sortedObject(runtimeCopyCounts),
sortedObject(
Object.fromEntries(RUNTIME_PACKAGES.map((name) => [name, 2])),
),
)
) {
addFinding(findings, 'RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT');
}
if (contents.includes('COPY --from=workspace /workspace/packages/ql3-ai/')) {
addFinding(findings, 'AI_PRESENT_IN_RUNTIME_STAGE');
}
if (
!contents.includes(
'RUN rm -rf node_modules/.bin \\\n' +
' && node /tmp/ql3-prune-runtime-artifact.cjs node_modules/@qinglong \\\n' +
' @qinglong/local-application \\\n' +
' @qinglong/local-application/process \\\n' +
' @qinglong/local-application/plugin-package-recovery-catalog \\\n' +
' --exclude=@qinglong/ai \\\n' +
' && rm /tmp/ql3-prune-runtime-artifact.cjs',
) ||
!contents.includes(
'COPY scripts/ql3-prune-runtime-artifact.cjs /tmp/ql3-prune-runtime-artifact.cjs',
)
) {
addFinding(findings, 'RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED');
}
if (
!contents.includes('USER 65532:65532') ||
!contents.includes(
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"]',
) ||
!contents.includes('io.qinglong.ai="excluded"') ||
!contents.includes('io.qinglong.profile="edge,standalone"') ||
!contents.includes('io.qinglong.local.application-config="2"') ||
!contents.includes('io.qinglong.local.sqlite-contract-min="44"') ||
!contents.includes('io.qinglong.local.sqlite-contract-max="44"') ||
!contents.includes('io.qinglong.local.sqlite-write-contract="44"') ||
!contents.includes('io.qinglong.local.compose-selection="1"')
) {
addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT');
}
}
function auditWorkflow(contents, findings) {
const match = /\n local-image:\n([\s\S]*?)(?=\n [a-z0-9-]+:\n)/.exec(
contents,
);
if (!match) {
addFinding(findings, 'LOCAL_IMAGE_CI_JOB_MISSING');
return;
}
const job = match[1];
const required = [
'runner: ubuntu-24.04\n node_arch: x64\n image_arch: amd64',
'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64',
'pnpm audit:local-image:ql3',
'docker build',
'--file deploy/containers/ql3-local-application/Dockerfile',
'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"',
'io.qinglong.local.application-config',
'io.qinglong.local.sqlite-contract-min',
'io.qinglong.local.sqlite-contract-max',
'io.qinglong.local.sqlite-write-contract',
'io.qinglong.local.compose-selection',
'--read-only',
'--network none',
'--cap-drop ALL',
'--security-opt no-new-privileges',
'--memory=128m',
'--pids-limit=64',
'scripts/ql3-local-image-inventory.cjs',
'--inventory-root=/opt/qinglong/node_modules',
'node ../../scripts/ql3-build-package-closure.cjs',
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge',
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone',
];
for (const value of required) {
if (!job.includes(value)) {
addFinding(findings, 'LOCAL_IMAGE_CI_CONTRACT_DRIFT', value);
}
}
}
function auditLocalImageContract(root) {
const imageRoot = path.join(root, IMAGE_DIRECTORY);
const buildManifest = readJson(path.join(imageRoot, 'package.json'));
const buildLock = readJson(path.join(imageRoot, 'package-lock.json'));
const runtimeRoot = path.join(imageRoot, 'runtime-dependencies');
const runtimeManifest = readJson(path.join(runtimeRoot, 'package.json'));
const runtimeLock = readJson(path.join(runtimeRoot, 'package-lock.json'));
const dockerfile = fs.readFileSync(
path.join(imageRoot, 'Dockerfile'),
'utf8',
);
const workflow = fs.readFileSync(
path.join(root, '.github/workflows/ql3-ci.yml'),
'utf8',
);
const findings = [];
auditManifest(buildManifest, false, findings);
auditManifest(runtimeManifest, true, findings);
auditLock(buildManifest, buildLock, false, findings);
auditLock(runtimeManifest, runtimeLock, true, findings);
auditDockerfile(dockerfile, findings);
auditWorkflow(workflow, findings);
return Object.freeze({
schemaVersion: 1,
image: 'local-application',
nodeImage: NODE_IMAGE,
runtimePackages: Object.freeze(
[
...RUNTIME_PACKAGES.map((name) => `@qinglong/${name.slice(4)}`),
...Object.keys(RUNTIME_DEPENDENCIES),
].sort(),
),
findings: Object.freeze(findings),
compatible: findings.length === 0,
});
}
module.exports = {
auditLocalImageContract,
};
if (require.main === module) {
try {
const report = auditLocalImageContract(path.resolve(__dirname, '..'));
process.stdout.write(`${JSON.stringify(report)}\n`);
if (!report.compatible) process.exitCode = 1;
} catch (error) {
process.stderr.write(
`QingLong local image audit failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
}
}