mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
#!/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']);
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const counts = { admin: 0, control: 0, 'control-ai': 0, local: 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,
|
||||
};
|
||||
Reference in New Issue
Block a user