mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
336 lines
9.2 KiB
JavaScript
336 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { TextDecoder } = require('node:util');
|
|
|
|
const FIXTURE = 'qinglong/image-os-vulnerability-exceptions@v1';
|
|
const IMAGES = Object.freeze([
|
|
'admin',
|
|
'control',
|
|
'control-ai',
|
|
'local',
|
|
'local-console',
|
|
'local-operator',
|
|
'worker',
|
|
]);
|
|
const MAX_POLICY_BYTES = 256 * 1024;
|
|
const MAX_EXCEPTIONS = 128;
|
|
const MAX_EXCEPTION_DAYS = 30;
|
|
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
const CVE_PATTERN = /^CVE-[0-9]{4}-[0-9]{4,}$/;
|
|
const OWNER_PATTERN = /^[a-z0-9][a-z0-9._/-]{1,127}$/;
|
|
const TICKET_PATTERN = /^[A-Z][A-Z0-9]{1,15}-[1-9][0-9]{0,9}$/;
|
|
const PURL_PATTERN =
|
|
/^pkg:(?:apk|deb|rpm)\/[A-Za-z0-9._~%+-]+\/[A-Za-z0-9._~%+-]+@[A-Za-z0-9._~%+:-]+$/;
|
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
|
const POLICY_PATH = 'deploy/containers/ql3-os-vulnerability-exceptions.json';
|
|
|
|
class ImageOsVulnerabilityPolicyError extends Error {
|
|
constructor(message) {
|
|
super(`QingLong image OS vulnerability policy failed: ${message}`);
|
|
this.name = 'ImageOsVulnerabilityPolicyError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new ImageOsVulnerabilityPolicyError(message);
|
|
}
|
|
|
|
function exactKeys(value, keys) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value).sort()) ===
|
|
JSON.stringify([...keys].sort())
|
|
);
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return (
|
|
Array.isArray(values) &&
|
|
values.length > 0 &&
|
|
new Set(values).size === values.length &&
|
|
JSON.stringify(values) === JSON.stringify([...values].sort())
|
|
);
|
|
}
|
|
|
|
function boundedText(value, minimum, maximum) {
|
|
return (
|
|
typeof value === 'string' &&
|
|
value.length >= minimum &&
|
|
value.length <= maximum &&
|
|
!CONTROL_PATTERN.test(value)
|
|
);
|
|
}
|
|
|
|
function utcDay(value) {
|
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
return null;
|
|
}
|
|
const milliseconds = Date.parse(`${value}T00:00:00.000Z`);
|
|
return Number.isFinite(milliseconds) &&
|
|
new Date(milliseconds).toISOString().slice(0, 10) === value
|
|
? milliseconds
|
|
: null;
|
|
}
|
|
|
|
function auditImageOsVulnerabilityPolicy(
|
|
policy,
|
|
dependencies = { now: Date.now },
|
|
) {
|
|
if (
|
|
!exactKeys(dependencies, ['now']) ||
|
|
typeof dependencies.now !== 'function'
|
|
) {
|
|
fail('clock is invalid');
|
|
}
|
|
const nowMs = dependencies.now();
|
|
if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail('clock is invalid');
|
|
const todayMs = Date.parse(new Date(nowMs).toISOString().slice(0, 10));
|
|
const findings = [];
|
|
const add = (code, id = null) => findings.push(Object.freeze({ code, id }));
|
|
if (
|
|
!exactKeys(policy, ['schemaVersion', 'fixture', 'exceptions']) ||
|
|
policy?.schemaVersion !== 1 ||
|
|
policy?.fixture !== FIXTURE ||
|
|
!Array.isArray(policy?.exceptions) ||
|
|
policy.exceptions.length > MAX_EXCEPTIONS
|
|
) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_POLICY_SHAPE');
|
|
return Object.freeze({
|
|
compatible: false,
|
|
findings: Object.freeze(findings),
|
|
exceptionCount: 0,
|
|
imageExceptionCounts: Object.freeze({
|
|
admin: 0,
|
|
control: 0,
|
|
'control-ai': 0,
|
|
local: 0,
|
|
'local-console': 0,
|
|
'local-operator': 0,
|
|
worker: 0,
|
|
}),
|
|
});
|
|
}
|
|
|
|
const counts = {
|
|
admin: 0,
|
|
control: 0,
|
|
'control-ai': 0,
|
|
local: 0,
|
|
'local-console': 0,
|
|
'local-operator': 0,
|
|
worker: 0,
|
|
};
|
|
const seen = new Set();
|
|
let previousId = '';
|
|
for (const exception of policy.exceptions) {
|
|
const id = exception?.id ?? null;
|
|
if (
|
|
!exactKeys(exception, [
|
|
'id',
|
|
'images',
|
|
'purls',
|
|
'owner',
|
|
'ticket',
|
|
'expiresOn',
|
|
'rationale',
|
|
]) ||
|
|
!CVE_PATTERN.test(id ?? '') ||
|
|
seen.has(id) ||
|
|
id <= previousId
|
|
) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID', id);
|
|
continue;
|
|
}
|
|
seen.add(id);
|
|
previousId = id;
|
|
if (
|
|
!uniqueSorted(exception.images) ||
|
|
exception.images.some((image) => !IMAGES.includes(image))
|
|
) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_IMAGES', id);
|
|
} else {
|
|
for (const image of exception.images) counts[image] += 1;
|
|
}
|
|
if (
|
|
!uniqueSorted(exception.purls) ||
|
|
exception.purls.some((purl) => !PURL_PATTERN.test(purl))
|
|
) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_PURLS', id);
|
|
}
|
|
if (!OWNER_PATTERN.test(exception.owner ?? '')) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_OWNER', id);
|
|
}
|
|
if (!TICKET_PATTERN.test(exception.ticket ?? '')) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_TICKET', id);
|
|
}
|
|
if (!boundedText(exception.rationale, 20, 512)) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_RATIONALE', id);
|
|
}
|
|
const expiryMs = utcDay(exception.expiresOn);
|
|
if (
|
|
expiryMs === null ||
|
|
expiryMs <= todayMs ||
|
|
expiryMs - todayMs > MAX_EXCEPTION_DAYS * 24 * 60 * 60 * 1000
|
|
) {
|
|
add('QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_EXPIRY', id);
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
compatible: findings.length === 0,
|
|
findings: Object.freeze(findings),
|
|
exceptionCount: policy.exceptions.length,
|
|
imageExceptionCounts: Object.freeze(counts),
|
|
});
|
|
}
|
|
|
|
function readPolicy(root = DEFAULT_ROOT) {
|
|
const filePath = path.join(path.resolve(root), POLICY_PATH);
|
|
const stat = fs.lstatSync(filePath);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 2 ||
|
|
stat.size > MAX_POLICY_BYTES ||
|
|
fs.realpathSync(filePath) !== filePath
|
|
) {
|
|
fail('policy must be one canonical bounded regular file');
|
|
}
|
|
let bytes;
|
|
try {
|
|
bytes = fs.readFileSync(filePath);
|
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
} catch (error) {
|
|
if (error instanceof ImageOsVulnerabilityPolicyError) throw error;
|
|
fail('policy must contain UTF-8 JSON');
|
|
} finally {
|
|
if (bytes) bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function renderTrivyIgnore(policy, image, dependencies = { now: Date.now }) {
|
|
if (!IMAGES.includes(image)) fail('image is invalid');
|
|
const audit = auditImageOsVulnerabilityPolicy(policy, dependencies);
|
|
if (!audit.compatible) fail('policy is incompatible');
|
|
const selected = policy.exceptions.filter((entry) =>
|
|
entry.images.includes(image),
|
|
);
|
|
const lines = ['vulnerabilities:'];
|
|
if (selected.length === 0) lines.push(' []');
|
|
for (const exception of selected) {
|
|
lines.push(` - id: ${JSON.stringify(exception.id)}`);
|
|
lines.push(' purls:');
|
|
for (const purl of exception.purls) {
|
|
lines.push(` - ${JSON.stringify(purl)}`);
|
|
}
|
|
lines.push(` expired_at: ${exception.expiresOn}`);
|
|
lines.push(
|
|
` statement: ${JSON.stringify(
|
|
`owner=${exception.owner}; ticket=${exception.ticket}; rationale=${exception.rationale}`,
|
|
)}`,
|
|
);
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function writeNoReplace(filePath, value) {
|
|
if (
|
|
typeof filePath !== 'string' ||
|
|
!path.isAbsolute(filePath) ||
|
|
CONTROL_PATTERN.test(filePath) ||
|
|
fs.existsSync(filePath) ||
|
|
fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)
|
|
) {
|
|
fail('output path must be unused in one canonical directory');
|
|
}
|
|
let descriptor = -1;
|
|
const bytes = Buffer.from(value, 'utf8');
|
|
try {
|
|
descriptor = fs.openSync(
|
|
filePath,
|
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
0o600,
|
|
);
|
|
fs.writeFileSync(descriptor, bytes);
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
bytes.fill(0);
|
|
if (descriptor >= 0) fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
if (argv.length === 0) return Object.freeze({ mode: 'audit' });
|
|
const values = {};
|
|
for (const argument of argv) {
|
|
if (argument === '--') continue;
|
|
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
|
|
if (!match || Object.hasOwn(values, match[1]))
|
|
fail('arguments are invalid');
|
|
values[match[1]] = match[2];
|
|
}
|
|
if (
|
|
JSON.stringify(Object.keys(values).sort()) !==
|
|
JSON.stringify(['image', 'output'])
|
|
) {
|
|
fail('arguments are invalid');
|
|
}
|
|
return Object.freeze({
|
|
mode: 'render',
|
|
image: values.image,
|
|
output: values.output,
|
|
});
|
|
}
|
|
|
|
function runCli(argv, root = DEFAULT_ROOT, dependencies = { now: Date.now }) {
|
|
const options = parseArguments(argv);
|
|
const policy = readPolicy(root);
|
|
const audit = auditImageOsVulnerabilityPolicy(policy, dependencies);
|
|
if (!audit.compatible) fail('policy is incompatible');
|
|
if (options.mode === 'render') {
|
|
writeNoReplace(
|
|
options.output,
|
|
renderTrivyIgnore(policy, options.image, dependencies),
|
|
);
|
|
}
|
|
const report = {
|
|
schemaVersion: 1,
|
|
fixture: FIXTURE,
|
|
compatible: true,
|
|
exceptionCount: audit.exceptionCount,
|
|
imageExceptionCounts: audit.imageExceptionCounts,
|
|
};
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
return report;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
runCli(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? error.message : 'policy audit failed'}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
FIXTURE,
|
|
IMAGES,
|
|
MAX_EXCEPTION_DAYS,
|
|
ImageOsVulnerabilityPolicyError,
|
|
auditImageOsVulnerabilityPolicy,
|
|
parseArguments,
|
|
readPolicy,
|
|
renderTrivyIgnore,
|
|
runCli,
|
|
writeNoReplace,
|
|
};
|