mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
354 lines
11 KiB
JavaScript
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;
|
|
}
|
|
}
|