mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-25 14:00:53 +00:00
abc00b8804
Bind guest WebSocket sessions to a single target peer, enforce grant lifecycle and atomic usage, add the guest issuer scope, and document the password-less security boundary.\n\nRefs #440\n\nThanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
1152 lines
43 KiB
JavaScript
1152 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* BetterDesk local-only diagnostic gate.
|
|
*
|
|
* This file intentionally uses Node.js built-ins only. It is called by local
|
|
* Git hooks and is never referenced by GitHub Actions. A non-zero exit code
|
|
* means that a commit or push must not proceed.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawn, spawnSync } = require('child_process');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const WEB_ROOT = path.join(ROOT, 'web-nodejs');
|
|
const SDK_NODE_ROOT = path.join(ROOT, 'sdks', 'nodejs');
|
|
const SDK_PYTHON_ROOT = path.join(ROOT, 'sdks', 'python');
|
|
const REPORT_DIR = process.env.BETTERDESK_LOCAL_GATE_REPORT_DIR
|
|
? path.resolve(process.env.BETTERDESK_LOCAL_GATE_REPORT_DIR)
|
|
: path.join(ROOT, '.local-test-results');
|
|
const MAX_CAPTURE_BYTES = 512 * 1024;
|
|
const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
|
|
const SECURITY_TIMEOUT_MS = 20 * 60 * 1000;
|
|
const POSTGRES_PASSWORD = 'BetterDeskLocalGateOnly2026';
|
|
|
|
const COMPOSE_FILES = [
|
|
'docker-compose.yml',
|
|
'docker-compose.single.yml',
|
|
'docker-compose.quick.yml',
|
|
'docker-compose.quick.single.yml',
|
|
'docker-compose.quick.macvlan.yml',
|
|
'docker-compose.quick.single.macvlan.yml',
|
|
];
|
|
|
|
const BASH_INSTALLER_FILES = [
|
|
'install.sh',
|
|
'betterdesk.sh',
|
|
'betterdesk-docker.sh',
|
|
'scripts/test-native-installer.sh',
|
|
'scripts/test-betterdesk-node-install.sh',
|
|
'scripts/test-postgresql-hba.sh',
|
|
'scripts/test-docker-hardening.sh',
|
|
'scripts/local-gate.sh',
|
|
];
|
|
|
|
const OUT_OF_SCOPE = [
|
|
{
|
|
id: 'native-host-lifecycle',
|
|
area: 'Installer lifecycle on the host',
|
|
reason: 'Requires a disposable Linux/Windows VM and can change services or permissions.',
|
|
},
|
|
{
|
|
id: 'external-rustdesk-client',
|
|
area: 'External RustDesk client and physical devices',
|
|
reason: 'Requires a real client/device pair and is not reproducible from this checkout alone.',
|
|
},
|
|
{
|
|
id: 'missing-desktop-trees',
|
|
area: 'Desktop/agent trees absent from this checkout',
|
|
reason: 'The documented desktop and agent source directories are not tracked here.',
|
|
},
|
|
{
|
|
id: 'platform-specific-linux-tests',
|
|
area: 'Linux-only tests on a Windows host',
|
|
reason: 'Systemd/privileged Linux checks cannot run natively on Windows without changing a host or VM.',
|
|
},
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
dryRun: false,
|
|
preflightOnly: false,
|
|
reportDir: REPORT_DIR,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--dry-run') {
|
|
options.dryRun = true;
|
|
} else if (arg === '--preflight-only') {
|
|
options.preflightOnly = true;
|
|
} else if (arg === '--report-dir') {
|
|
options.reportDir = path.resolve(argv[++index] || '');
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: node scripts/local-gate.js [options]
|
|
|
|
Runs the complete local-only BetterDesk diagnostic gate.
|
|
|
|
Options:
|
|
--dry-run Print the planned command categories without running them
|
|
--preflight-only Validate local tools and versions without running application tests
|
|
--report-dir DIR Write JSON/Markdown reports to DIR
|
|
--help Show this help
|
|
|
|
The gate fails closed: missing required tools, skipped required tests and failed
|
|
checks return a non-zero exit code.`);
|
|
}
|
|
|
|
function isWindows() {
|
|
return process.platform === 'win32';
|
|
}
|
|
|
|
function appendOutput(current, chunk) {
|
|
const next = current + String(chunk);
|
|
if (next.length <= MAX_CAPTURE_BYTES) return next;
|
|
return `${next.slice(0, MAX_CAPTURE_BYTES)}\n...[output truncated]`;
|
|
}
|
|
|
|
function redactSensitive(value) {
|
|
return String(value)
|
|
.replace(
|
|
/(postgres(?:ql)?:\/\/[^:]+:)[^@/\s]+(@)/giu,
|
|
'$1[REDACTED]$2',
|
|
)
|
|
.replace(
|
|
/((?:["']?(?:password|passwd|token|secret|api[_-]?key|private[_-]?key)["']?\s*[:=]\s*["']?))[^"',}\s]+/giu,
|
|
'$1[REDACTED]',
|
|
)
|
|
.replace(
|
|
/(authorization\s*:\s*bearer\s+)[^\s]+/giu,
|
|
'$1[REDACTED]',
|
|
);
|
|
}
|
|
|
|
function formatCommand(command, args = []) {
|
|
const rendered = [command, ...args].map((value) => {
|
|
const text = String(value);
|
|
if (/[\s"]/u.test(text)) return `"${text.replace(/"/g, '\\"')}"`;
|
|
return text;
|
|
}).join(' ');
|
|
return rendered.replace(
|
|
/(postgres(?:ql)?:\/\/[^:]+:)([^@]+)(@)/gi,
|
|
'$1[REDACTED]$3',
|
|
);
|
|
}
|
|
|
|
function quoteCmdArg(value) {
|
|
const text = String(value);
|
|
if (/^[\w./:=+-]+$/u.test(text)) return text;
|
|
return `"${text.replace(/(["^&|<>])/gu, '^$1')}"`;
|
|
}
|
|
|
|
function processInvocation(command, args = []) {
|
|
if (isWindows() && /\.cmd$/iu.test(command)) {
|
|
return {
|
|
command: 'cmd.exe',
|
|
args: ['/d', '/s', '/c', [command, ...args].map(quoteCmdArg).join(' ')],
|
|
};
|
|
}
|
|
return { command, args };
|
|
}
|
|
|
|
function commandCandidates(name) {
|
|
const candidates = [name];
|
|
if (!isWindows()) return candidates;
|
|
if (!name.endsWith('.exe') && !name.endsWith('.cmd')) {
|
|
candidates.unshift(`${name}.exe`, `${name}.cmd`);
|
|
}
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
function preferredCommand(name) {
|
|
if (!isWindows() || name !== 'bash') return null;
|
|
const gitInstallRoots = [
|
|
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'Git') : '',
|
|
process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'], 'Git') : '',
|
|
'C:\\Program Files\\Git',
|
|
].filter(Boolean);
|
|
for (const root of [...new Set(gitInstallRoots)]) {
|
|
const candidate = path.join(root, 'bin', 'bash.exe');
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCommand(name) {
|
|
const preferred = preferredCommand(name);
|
|
if (preferred) return preferred;
|
|
const locator = isWindows() ? 'where.exe' : 'which';
|
|
for (const candidate of commandCandidates(name)) {
|
|
const result = spawnSync(locator, [candidate], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
if (!result.error && result.status === 0) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function executable(name) {
|
|
return resolveCommand(name);
|
|
}
|
|
|
|
function runProcess(command, args = [], options = {}) {
|
|
return new Promise((resolve) => {
|
|
const invocation = processInvocation(command, args);
|
|
const child = spawn(invocation.command, invocation.args, {
|
|
cwd: options.cwd || ROOT,
|
|
env: { ...process.env, ...(options.env || {}) },
|
|
windowsHide: true,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
let settled = false;
|
|
const timeoutMs = options.timeoutMs || COMMAND_TIMEOUT_MS;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
if (isWindows() && child.pid) {
|
|
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
|
|
windowsHide: true,
|
|
stdio: 'ignore',
|
|
});
|
|
} else {
|
|
child.kill('SIGTERM');
|
|
}
|
|
}, timeoutMs);
|
|
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({
|
|
command,
|
|
args,
|
|
stdout,
|
|
stderr,
|
|
timedOut,
|
|
...result,
|
|
});
|
|
};
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
const safeChunk = redactSensitive(chunk);
|
|
stdout = appendOutput(stdout, safeChunk);
|
|
process.stdout.write(safeChunk);
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
const safeChunk = redactSensitive(chunk);
|
|
stderr = appendOutput(stderr, safeChunk);
|
|
process.stderr.write(safeChunk);
|
|
});
|
|
child.once('error', (error) => finish({ code: null, signal: null, error }));
|
|
child.once('close', (code, signal) => finish({ code, signal }));
|
|
});
|
|
}
|
|
|
|
function containsUnexpectedSkip(text) {
|
|
const patterns = [
|
|
/\bSKIP(?:PED)?\s*:/iu,
|
|
/(?:^|\s)[1-9]\d*\s+skipped\b/iu,
|
|
/\btests?\s+skipped\b/iu,
|
|
/\bno tests? to run\b/iu,
|
|
];
|
|
return patterns.some((pattern) => pattern.test(text));
|
|
}
|
|
|
|
function resultStatus(result, { allowSkip = false, expectedSkip = false } = {}) {
|
|
if (result.error?.code === 'ENOENT') return 'BLOCKED';
|
|
if (result.error) return 'FAIL';
|
|
if (result.timedOut) return 'FAIL';
|
|
if (result.code !== 0) return 'FAIL';
|
|
if (!allowSkip && containsUnexpectedSkip(`${result.stdout}\n${result.stderr}`)) {
|
|
if (expectedSkip) return 'OUT_OF_SCOPE';
|
|
return 'BLOCKED';
|
|
}
|
|
return 'PASS';
|
|
}
|
|
|
|
async function runCheck(report, id, label, command, args = [], options = {}) {
|
|
const commandText = formatCommand(command, args);
|
|
console.log(`\n=== ${label} ===\n$ ${commandText}`);
|
|
const startedAt = new Date().toISOString();
|
|
const started = Date.now();
|
|
const result = await runProcess(command, args, options);
|
|
const status = resultStatus(result, options);
|
|
const detail = result.error?.code === 'ENOENT'
|
|
? `Required command not found: ${command}`
|
|
: result.timedOut
|
|
? `Timed out after ${options.timeoutMs || COMMAND_TIMEOUT_MS} ms`
|
|
: result.error?.message || (result.code === 0 ? 'Completed successfully' : `Exit code ${result.code}`);
|
|
const record = {
|
|
id,
|
|
label,
|
|
status,
|
|
required: options.required !== false && status !== 'OUT_OF_SCOPE',
|
|
command: commandText,
|
|
cwd: options.cwd || ROOT,
|
|
startedAt,
|
|
durationMs: Date.now() - started,
|
|
exitCode: result.code,
|
|
signal: result.signal,
|
|
detail,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
report.results.push(record);
|
|
console.log(`[${status}] ${label} (${record.durationMs} ms): ${detail}`);
|
|
return record;
|
|
}
|
|
|
|
function addInternalResult(report, id, label, status, detail, extra = {}) {
|
|
const record = {
|
|
id,
|
|
label,
|
|
status,
|
|
required: extra.required !== false,
|
|
command: extra.command || '',
|
|
cwd: extra.cwd || ROOT,
|
|
startedAt: new Date().toISOString(),
|
|
durationMs: extra.durationMs || 0,
|
|
exitCode: extra.exitCode ?? null,
|
|
signal: null,
|
|
detail,
|
|
stdout: extra.stdout || '',
|
|
stderr: extra.stderr || '',
|
|
};
|
|
report.results.push(record);
|
|
console.log(`[${status}] ${label}: ${detail}`);
|
|
return record;
|
|
}
|
|
|
|
function getRepoState() {
|
|
const branchResult = spawnSync('git', ['branch', '--show-current'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
const shaResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
branch: String(branchResult.stdout || '').trim() || 'unknown',
|
|
sha: String(shaResult.stdout || '').trim() || 'unknown',
|
|
dirty: Boolean(spawnSync('git', ['status', '--porcelain'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
}).stdout),
|
|
};
|
|
}
|
|
|
|
function parseVersion(value) {
|
|
const match = String(value).match(/(\d+)\.(\d+)(?:\.(\d+))?/u);
|
|
if (!match) return null;
|
|
return {
|
|
major: Number(match[1]),
|
|
minor: Number(match[2]),
|
|
patch: Number(match[3] || 0),
|
|
};
|
|
}
|
|
|
|
function atLeastVersion(actual, required) {
|
|
if (!actual) return false;
|
|
if (actual.major !== required.major) return actual.major > required.major;
|
|
if (actual.minor !== required.minor) return actual.minor > required.minor;
|
|
return actual.patch >= required.patch;
|
|
}
|
|
|
|
function listFiles(root, predicate, output = []) {
|
|
if (!fs.existsSync(root)) return output;
|
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
if ([
|
|
'.git',
|
|
'.cargo-cache',
|
|
'.cache',
|
|
'.local-test-results',
|
|
'.venv',
|
|
'__pycache__',
|
|
'build',
|
|
'dist',
|
|
'node_modules',
|
|
'target',
|
|
].includes(entry.name)) {
|
|
continue;
|
|
}
|
|
if (entry.name.endsWith('_old')) continue;
|
|
const fullPath = path.join(root, entry.name);
|
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
listFiles(fullPath, predicate, output);
|
|
} else if (entry.isFile() && predicate(fullPath)) {
|
|
output.push(fullPath);
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function discoverSecurityManifests() {
|
|
return {
|
|
pythonRequirements: listFiles(ROOT, (file) => /(^|[\\/])requirements[^\\/]*\.txt$/iu.test(file)),
|
|
pythonProjects: listFiles(ROOT, (file) => path.basename(file).toLowerCase() === 'pyproject.toml'),
|
|
rustProjects: listFiles(ROOT, (file) => path.basename(file) === 'Cargo.toml'),
|
|
};
|
|
}
|
|
|
|
function extractPyprojectDependencies(file) {
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
const dependencies = [];
|
|
const block = text.match(/dependencies\s*=\s*\[([\s\S]*?)\]/u)?.[1] || '';
|
|
for (const line of block.split(/\r?\n/u)) {
|
|
const match = line.match(/["']([^"']+)["']/u);
|
|
if (match) dependencies.push(match[1]);
|
|
}
|
|
return dependencies;
|
|
}
|
|
|
|
function findPowerShell() {
|
|
return executable('pwsh') || executable('powershell');
|
|
}
|
|
|
|
function findPython() {
|
|
return executable('python') || executable('python3');
|
|
}
|
|
|
|
function checkDockerCompose(docker) {
|
|
const result = spawnSync(docker, ['compose', 'version'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 15_000,
|
|
});
|
|
return !result.error && result.status === 0
|
|
? String(result.stdout || result.stderr).trim()
|
|
: '';
|
|
}
|
|
|
|
function checkDockerDaemon(docker) {
|
|
const result = spawnSync(docker, ['info'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
timeout: 20_000,
|
|
});
|
|
return !result.error && result.status === 0;
|
|
}
|
|
|
|
function commandVersion(command, args = ['--version']) {
|
|
const invocation = processInvocation(command, args);
|
|
const result = spawnSync(invocation.command, invocation.args, {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 15_000,
|
|
});
|
|
if (result.error || result.status !== 0) return '';
|
|
return String(result.stdout || result.stderr).trim();
|
|
}
|
|
|
|
function preflight(report) {
|
|
const tools = {};
|
|
const missing = [];
|
|
const manifests = discoverSecurityManifests();
|
|
|
|
const requiredTools = [
|
|
['node', executable('node')],
|
|
['npm', executable('npm')],
|
|
['go', executable('go')],
|
|
['govulncheck', executable('govulncheck')],
|
|
['docker', executable('docker')],
|
|
['gitleaks', executable('gitleaks')],
|
|
['rg', executable('rg')],
|
|
['bash', executable('bash')],
|
|
['shellcheck', executable('shellcheck')],
|
|
['trivy', executable('trivy')],
|
|
['powershell', findPowerShell()],
|
|
];
|
|
|
|
if (manifests.pythonRequirements.length || manifests.pythonProjects.length) {
|
|
requiredTools.push(['python', findPython()], ['pip-audit', executable('pip-audit')]);
|
|
}
|
|
if (manifests.rustProjects.length) {
|
|
requiredTools.push(['cargo', executable('cargo')], ['cargo-audit', executable('cargo-audit')]);
|
|
}
|
|
|
|
for (const [name, command] of requiredTools) {
|
|
if (!command) {
|
|
missing.push(name);
|
|
addInternalResult(report, `preflight-${name}`, `Required tool: ${name}`, 'BLOCKED', 'Command not found');
|
|
} else {
|
|
tools[name] = command;
|
|
addInternalResult(
|
|
report,
|
|
`preflight-${name}`,
|
|
`Required tool: ${name}`,
|
|
'PASS',
|
|
commandVersion(command, name === 'go' ? ['version'] : ['--version']),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tools.docker) {
|
|
const composeVersion = checkDockerCompose(tools.docker);
|
|
if (!composeVersion) {
|
|
missing.push('docker compose');
|
|
addInternalResult(report, 'preflight-docker-compose', 'Docker Compose plugin', 'BLOCKED', 'docker compose version failed');
|
|
} else {
|
|
addInternalResult(report, 'preflight-docker-compose', 'Docker Compose plugin', 'PASS', composeVersion);
|
|
}
|
|
if (!checkDockerDaemon(tools.docker)) {
|
|
missing.push('Docker daemon');
|
|
addInternalResult(report, 'preflight-docker-daemon', 'Docker daemon', 'BLOCKED', 'docker info failed');
|
|
} else {
|
|
addInternalResult(report, 'preflight-docker-daemon', 'Docker daemon', 'PASS', 'Docker daemon is available');
|
|
}
|
|
}
|
|
|
|
if (tools.node) {
|
|
const nodeVersion = parseVersion(process.version);
|
|
const nodeOk = atLeastVersion(nodeVersion, { major: 22, minor: 0, patch: 0 });
|
|
if (!nodeOk) {
|
|
missing.push('Node.js >= 22');
|
|
addInternalResult(report, 'preflight-node-version', 'Node.js version', 'BLOCKED', `${process.version}; required >= 22.0.0`);
|
|
} else {
|
|
addInternalResult(report, 'preflight-node-version', 'Node.js version', 'PASS', process.version);
|
|
}
|
|
}
|
|
|
|
if (tools.go) {
|
|
const goVersion = parseVersion(commandVersion(tools.go, ['version']));
|
|
if (!atLeastVersion(goVersion, { major: 1, minor: 25, patch: 0 })) {
|
|
missing.push('Go >= 1.25');
|
|
addInternalResult(report, 'preflight-go-version', 'Go version', 'BLOCKED', `${goVersion ? JSON.stringify(goVersion) : 'unknown'}; required >= 1.25`);
|
|
} else {
|
|
addInternalResult(report, 'preflight-go-version', 'Go version', 'PASS', JSON.stringify(goVersion));
|
|
}
|
|
}
|
|
|
|
if (missing.length) {
|
|
addInternalResult(
|
|
report,
|
|
'preflight-summary',
|
|
'Required local environment',
|
|
'BLOCKED',
|
|
`Missing or unavailable: ${[...new Set(missing)].join(', ')}`,
|
|
);
|
|
} else {
|
|
addInternalResult(report, 'preflight-summary', 'Required local environment', 'PASS', 'All required tools and services are available');
|
|
}
|
|
|
|
return { ok: missing.length === 0, tools, manifests };
|
|
}
|
|
|
|
function bashCommand(tools, script, args = []) {
|
|
return { command: tools.bash, args: [script, ...args] };
|
|
}
|
|
|
|
function npmCommand(tools) {
|
|
return tools.npm;
|
|
}
|
|
|
|
function pythonCommand(tools) {
|
|
return tools.python;
|
|
}
|
|
|
|
function powershellCommand(tools) {
|
|
return tools.powershell;
|
|
}
|
|
|
|
async function runNodeChecks(report, tools) {
|
|
await runCheck(report, 'node-root-install', 'Install root Node dependencies', npmCommand(tools), [
|
|
'ci', '--no-audit', '--no-fund',
|
|
], { cwd: ROOT });
|
|
await runCheck(report, 'node-console-install', 'Install console Node dependencies', npmCommand(tools), [
|
|
'ci', '--no-audit', '--no-fund',
|
|
], { cwd: WEB_ROOT });
|
|
await runCheck(report, 'node-sdk-install', 'Install Node SDK dependencies', npmCommand(tools), [
|
|
'ci', '--no-audit', '--no-fund',
|
|
], { cwd: SDK_NODE_ROOT });
|
|
|
|
for (const [id, cwd, label] of [
|
|
['npm-audit-root', ROOT, 'Audit root npm dependencies'],
|
|
['npm-audit-console', WEB_ROOT, 'Audit console npm dependencies'],
|
|
['npm-audit-sdk', SDK_NODE_ROOT, 'Audit Node SDK dependencies'],
|
|
]) {
|
|
await runCheck(report, id, label, npmCommand(tools), [
|
|
'audit', '--audit-level=moderate',
|
|
], { cwd, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
}
|
|
|
|
await runCheck(report, 'frontend-syntax', 'Check browser JavaScript syntax', npmCommand(tools), [
|
|
'run', 'check:frontend',
|
|
], { cwd: WEB_ROOT });
|
|
await runCheck(report, 'protocol-artifacts', 'Verify protocol schema artifacts', npmCommand(tools), [
|
|
'run', 'protocols:check',
|
|
], { cwd: WEB_ROOT });
|
|
await runCheck(report, 'i18n-parity', 'Verify all locale keys and translations', npmCommand(tools), [
|
|
'run', 'i18n:check',
|
|
], { cwd: ROOT });
|
|
await runCheck(report, 'provenance-policy', 'Verify clean-room provenance policy', npmCommand(tools), [
|
|
'run', 'check:provenance',
|
|
], { cwd: WEB_ROOT });
|
|
}
|
|
|
|
async function runGoChecks(report, tools, postgresDsn = '') {
|
|
const goEnv = postgresDsn ? { BETTERDESK_TEST_POSTGRES_DSN: postgresDsn } : {};
|
|
await runCheck(report, 'go-vet', 'Go static analysis', tools.go, ['vet', './...'], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
env: goEnv,
|
|
});
|
|
await runCheck(report, 'go-tests-race', 'Go tests with race detector', tools.go, [
|
|
'test', '-race', '-count=1', './...',
|
|
], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
env: goEnv,
|
|
});
|
|
await runCheck(report, 'go-vulnerability-scan', 'Go dependency vulnerability scan', tools.govulncheck, [
|
|
'./...',
|
|
], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: SECURITY_TIMEOUT_MS,
|
|
});
|
|
|
|
const binaryPath = path.join(os.tmpdir(), `betterdesk-server-local-gate-${process.pid}${isWindows() ? '.exe' : ''}`);
|
|
try {
|
|
await runCheck(report, 'go-build', 'Build BetterDesk server binary', tools.go, [
|
|
'build', '-o', binaryPath, '.',
|
|
], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
});
|
|
if (fs.existsSync(binaryPath)) {
|
|
await runCheck(report, 'go-binary-help', 'Verify BetterDesk server binary help', binaryPath, [
|
|
'--help',
|
|
], { cwd: path.join(ROOT, 'betterdesk-server'), timeoutMs: 15_000 });
|
|
} else {
|
|
addInternalResult(report, 'go-binary-help', 'Verify BetterDesk server binary help', 'BLOCKED', 'Build did not create the expected binary');
|
|
}
|
|
} finally {
|
|
try {
|
|
fs.rmSync(binaryPath, { force: true });
|
|
} catch {
|
|
// The command result is more useful than a cleanup warning.
|
|
}
|
|
}
|
|
|
|
for (const [goos, goarch] of [['linux', 'amd64'], ['linux', 'arm64'], ['windows', 'amd64']]) {
|
|
const output = path.join(os.tmpdir(), `betterdesk-server-${goos}-${goarch}-${process.pid}${goos === 'windows' ? '.exe' : ''}`);
|
|
try {
|
|
await runCheck(report, `go-cross-${goos}-${goarch}`, `Cross-compile Go server for ${goos}/${goarch}`, tools.go, [
|
|
'build', '-o', output, '.',
|
|
], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
env: { GOOS: goos, GOARCH: goarch },
|
|
});
|
|
} finally {
|
|
try {
|
|
fs.rmSync(output, { force: true });
|
|
} catch {
|
|
// Keep the original command result.
|
|
}
|
|
}
|
|
}
|
|
|
|
if (postgresDsn) {
|
|
await runCheck(report, 'go-postgres-tests', 'Go PostgreSQL integration tests', tools.go, [
|
|
'test', './db/...', '-run', 'Postgres', '-count=1',
|
|
], {
|
|
cwd: path.join(ROOT, 'betterdesk-server'),
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
env: { BETTERDESK_TEST_POSTGRES_DSN: postgresDsn },
|
|
});
|
|
} else {
|
|
addInternalResult(report, 'go-postgres-tests', 'Go PostgreSQL integration tests', 'BLOCKED', 'No temporary PostgreSQL DSN was created');
|
|
}
|
|
}
|
|
|
|
async function runPythonChecks(report, tools, manifests) {
|
|
const python = pythonCommand(tools);
|
|
await runCheck(report, 'python-compile', 'Compile Python bridges and SDK', python, [
|
|
'-m', 'compileall', '-q', path.join(ROOT, 'bridges'), SDK_PYTHON_ROOT,
|
|
], { timeoutMs: COMMAND_TIMEOUT_MS });
|
|
await runCheck(report, 'python-sdk-tests', 'Run Python SDK tests', python, [
|
|
'-m', 'unittest', 'discover', '-s', path.join(SDK_PYTHON_ROOT, 'tests'), '-p', 'test_*.py',
|
|
], { cwd: ROOT, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
|
|
for (const requirements of manifests.pythonRequirements) {
|
|
await runCheck(report, `pip-audit-${path.basename(path.dirname(requirements))}`, `Audit Python requirements ${path.relative(ROOT, requirements)}`, tools['pip-audit'], [
|
|
'-r', requirements,
|
|
], { cwd: ROOT, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
}
|
|
|
|
for (const project of manifests.pythonProjects) {
|
|
const dependencies = extractPyprojectDependencies(project);
|
|
if (!dependencies.length) {
|
|
addInternalResult(report, `pip-audit-project-${path.basename(path.dirname(project))}`, `Audit Python project ${path.relative(ROOT, project)}`, 'BLOCKED', 'No project dependencies could be extracted');
|
|
continue;
|
|
}
|
|
const tempFile = path.join(os.tmpdir(), `betterdesk-python-deps-${process.pid}.txt`);
|
|
fs.writeFileSync(tempFile, `${dependencies.join('\n')}\n`, 'utf8');
|
|
try {
|
|
await runCheck(report, `pip-audit-project-${path.basename(path.dirname(project))}`, `Audit Python project ${path.relative(ROOT, project)}`, tools['pip-audit'], [
|
|
'-r', tempFile,
|
|
], { cwd: ROOT, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
} finally {
|
|
fs.rmSync(tempFile, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runRustChecks(report, tools, manifests) {
|
|
const projectDirectories = [...new Set(
|
|
manifests.rustProjects.map((file) => path.dirname(file)),
|
|
)];
|
|
for (const projectDirectory of projectDirectories) {
|
|
const relativeDirectory = path.relative(ROOT, projectDirectory);
|
|
await runCheck(report, `cargo-audit-${relativeDirectory}`, `Audit Rust dependencies in ${relativeDirectory}`, tools.cargo, [
|
|
'audit',
|
|
], { cwd: projectDirectory, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
await runCheck(report, `cargo-test-${relativeDirectory}`, `Run Rust tests in ${relativeDirectory}`, tools.cargo, [
|
|
'test', '--locked',
|
|
], { cwd: projectDirectory, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
}
|
|
}
|
|
|
|
async function runInstallerChecks(report, tools) {
|
|
const discoveredShellFiles = listFiles(ROOT, (file) => file.toLowerCase().endsWith('.sh'))
|
|
.map((file) => path.relative(ROOT, file).split(path.sep).join('/'));
|
|
const files = [...new Set([...BASH_INSTALLER_FILES, ...discoveredShellFiles])].sort();
|
|
await runCheck(report, 'bash-syntax', 'Parse Bash installers and local wrappers', tools.bash, [
|
|
'-n', ...files,
|
|
], { timeoutMs: 60_000 });
|
|
await runCheck(report, 'shellcheck', 'Run ShellCheck on installers and wrappers', tools.shellcheck, [
|
|
'--severity=error', ...files,
|
|
], { timeoutMs: 120_000 });
|
|
|
|
for (const [id, script, label] of [
|
|
['installer-node-recovery', 'scripts/test-betterdesk-node-install.sh', 'Test Node installer recovery'],
|
|
['installer-native-recovery', 'scripts/test-native-installer.sh', 'Test native installer recovery'],
|
|
['installer-postgres-hba', 'scripts/test-postgresql-hba.sh', 'Test PostgreSQL HBA setup'],
|
|
]) {
|
|
const invocation = bashCommand(tools, script);
|
|
await runCheck(report, id, label, invocation.command, invocation.args, { timeoutMs: 120_000 });
|
|
}
|
|
|
|
for (const [id, script, label] of [
|
|
['installer-help', 'install.sh', 'Check install.sh help path'],
|
|
['betterdesk-help', 'betterdesk.sh', 'Check betterdesk.sh help path'],
|
|
['docker-installer-help', 'betterdesk-docker.sh', 'Check betterdesk-docker.sh help path'],
|
|
]) {
|
|
const invocation = bashCommand(tools, script, ['--help']);
|
|
await runCheck(report, id, label, invocation.command, invocation.args, { timeoutMs: 60_000 });
|
|
}
|
|
|
|
const powershellCheck = path.join(ROOT, 'scripts', 'check-powershell-syntax.ps1');
|
|
await runCheck(report, 'powershell-syntax', 'Parse PowerShell installers', powershellCommand(tools), [
|
|
'-NoProfile', '-NonInteractive', '-File', powershellCheck,
|
|
], { timeoutMs: 60_000 });
|
|
}
|
|
|
|
async function runSecurityChecks(report, tools) {
|
|
await runCheck(report, 'gitleaks', 'Scan repository for secrets with gitleaks', tools.gitleaks, [
|
|
'detect', '--source', '.', '--config', '.gitleaks.toml',
|
|
'--exclude', '.local-test-results/**',
|
|
'--exclude', '**/node_modules/**',
|
|
'--exclude', '**/*_old/**',
|
|
'--exclude', '.cargo-cache/**',
|
|
], { cwd: ROOT, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
|
|
const sensitive = bashCommand(tools, 'scripts/check-no-sensitive-paths.sh');
|
|
await runCheck(report, 'sensitive-paths', 'Scan for operator-specific sensitive paths', sensitive.command, sensitive.args, {
|
|
cwd: ROOT,
|
|
timeoutMs: 120_000,
|
|
});
|
|
|
|
await runCheck(report, 'trivy-filesystem', 'Scan source tree for vulnerabilities, secrets and misconfiguration', tools.trivy, [
|
|
'fs',
|
|
'--scanners', 'vuln,secret,misconfig',
|
|
'--severity', 'HIGH,CRITICAL',
|
|
'--exit-code', '1',
|
|
'--skip-dirs', '.git',
|
|
'--skip-dirs', 'node_modules',
|
|
'--skip-dirs', '.local-test-results',
|
|
'--skip-dirs', '*_old',
|
|
'--skip-dirs', '.cargo-cache',
|
|
'.',
|
|
], { cwd: ROOT, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
}
|
|
|
|
async function runComposeConfigChecks(report, tools) {
|
|
for (const composeFile of COMPOSE_FILES) {
|
|
const fullPath = path.join(ROOT, composeFile);
|
|
if (!fs.existsSync(fullPath)) {
|
|
addInternalResult(report, `compose-config-${composeFile}`, `Validate ${composeFile}`, 'BLOCKED', 'Compose file is missing');
|
|
continue;
|
|
}
|
|
await runCheck(report, `compose-config-${composeFile}`, `Validate ${composeFile}`, tools.docker, [
|
|
'compose', '-f', fullPath, 'config', '--quiet',
|
|
], {
|
|
cwd: ROOT,
|
|
env: {
|
|
PG_PASSWORD: POSTGRES_PASSWORD,
|
|
MACVLAN_IPV4: '192.0.2.51',
|
|
},
|
|
timeoutMs: 120_000,
|
|
});
|
|
}
|
|
|
|
const singleBuild = await runCheck(report, 'docker-build-single', 'Build local single-container image', tools.docker, [
|
|
'compose', '-f', path.join(ROOT, 'docker-compose.single.yml'), 'build', 'betterdesk',
|
|
], { cwd: ROOT, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
const splitBuild = await runCheck(report, 'docker-build-split', 'Build local split server and console images', tools.docker, [
|
|
'compose', '-f', path.join(ROOT, 'docker-compose.yml'), 'build', 'server', 'console',
|
|
], { cwd: ROOT, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
return {
|
|
singleBuilt: singleBuild.status === 'PASS',
|
|
splitBuilt: splitBuild.status === 'PASS',
|
|
};
|
|
}
|
|
|
|
async function startPostgres(report, tools) {
|
|
const name = `betterdesk-local-gate-postgres-${process.pid}-${Date.now()}`;
|
|
const startResult = await runCheck(report, 'postgres-start', 'Start isolated PostgreSQL container', tools.docker, [
|
|
'run', '--detach',
|
|
'--name', name,
|
|
'--label', 'com.betterdesk.local-gate=true',
|
|
'--publish', '127.0.0.1::5432',
|
|
'--env', 'POSTGRES_USER=betterdesk',
|
|
'--env', 'POSTGRES_DB=betterdesk',
|
|
'--env', 'POSTGRES_PASSWORD',
|
|
'postgres:16-alpine',
|
|
], {
|
|
cwd: ROOT,
|
|
env: { POSTGRES_PASSWORD },
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
});
|
|
if (startResult.status !== 'PASS') {
|
|
return { name, dsn: '', containerDsn: '' };
|
|
}
|
|
|
|
const deadline = Date.now() + 120_000;
|
|
let ready = false;
|
|
while (Date.now() < deadline) {
|
|
const probe = await runProcess(tools.docker, [
|
|
'exec', name, 'pg_isready', '-U', 'betterdesk', '-d', 'betterdesk',
|
|
], { timeoutMs: 10_000 });
|
|
if (probe.code === 0) {
|
|
ready = true;
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
}
|
|
if (!ready) {
|
|
addInternalResult(report, 'postgres-ready', 'Wait for isolated PostgreSQL', 'FAIL', 'pg_isready did not succeed within 120 seconds');
|
|
return { name, dsn: '', containerDsn: '' };
|
|
}
|
|
const portResult = spawnSync(tools.docker, ['port', name, '5432/tcp'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
const port = String(portResult.stdout || '').match(/:(\d+)\s*$/m)?.[1];
|
|
if (!port) {
|
|
addInternalResult(report, 'postgres-ready', 'Wait for isolated PostgreSQL', 'FAIL', 'Could not determine the mapped PostgreSQL port');
|
|
return { name, dsn: '', containerDsn: '' };
|
|
}
|
|
const hostDsn = `postgres://betterdesk:${POSTGRES_PASSWORD}@127.0.0.1:${port}/betterdesk?sslmode=disable`;
|
|
const containerDsn = `postgres://betterdesk:${POSTGRES_PASSWORD}@host.docker.internal:${port}/betterdesk?sslmode=disable`;
|
|
addInternalResult(report, 'postgres-ready', 'Wait for isolated PostgreSQL', 'PASS', `PostgreSQL is ready on an ephemeral local port ${port}`);
|
|
return { name, dsn: hostDsn, containerDsn };
|
|
}
|
|
|
|
async function runDockerRuntimeChecks(report, tools, postgres, builds) {
|
|
if (!builds.singleBuilt) {
|
|
addInternalResult(
|
|
report,
|
|
'docker-runtime-prerequisite',
|
|
'Local Docker runtime prerequisite',
|
|
'BLOCKED',
|
|
'The single-container image build failed; runtime checks will not use a stale local image.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const smoke = path.join(ROOT, 'scripts', 'local-docker-smoke.js');
|
|
await runCheck(report, 'docker-smoke-sqlite', 'Smoke-test local Docker image with SQLite', process.execPath, [
|
|
smoke,
|
|
'--database-type', 'sqlite',
|
|
], { cwd: ROOT, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
|
|
if (postgres?.containerDsn) {
|
|
await runCheck(report, 'docker-smoke-postgres', 'Smoke-test local Docker image with PostgreSQL', process.execPath, [
|
|
smoke,
|
|
'--database-type', 'postgres',
|
|
'--database-url', postgres.containerDsn,
|
|
], { cwd: ROOT, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
} else {
|
|
addInternalResult(report, 'docker-smoke-postgres', 'Smoke-test local Docker image with PostgreSQL', 'BLOCKED', 'PostgreSQL container was not ready');
|
|
}
|
|
|
|
for (const image of ['betterdesk:local', 'betterdesk-server:local', 'betterdesk-console:local']) {
|
|
await runCheck(report, `trivy-image-${image.replace(/[^a-z0-9]+/giu, '-')}`, `Scan local image ${image}`, tools.trivy, [
|
|
'image',
|
|
'--scanners', 'vuln,secret,misconfig',
|
|
'--severity', 'HIGH,CRITICAL',
|
|
'--exit-code', '1',
|
|
image,
|
|
], { cwd: ROOT, timeoutMs: SECURITY_TIMEOUT_MS });
|
|
}
|
|
|
|
const hardening = bashCommand(tools, 'scripts/test-docker-hardening.sh');
|
|
await runCheck(report, 'docker-hardening', 'Test hardened Docker bind mounts and restart behavior', hardening.command, hardening.args, {
|
|
cwd: ROOT,
|
|
env: { BETTERDESK_HARDENING_IMAGE: 'betterdesk:local' },
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
});
|
|
}
|
|
|
|
async function runFullNodeTests(report, tools, builds) {
|
|
if (!builds.splitBuilt) {
|
|
addInternalResult(
|
|
report,
|
|
'console-docker-integration-prerequisite',
|
|
'Console Docker integration prerequisite',
|
|
'BLOCKED',
|
|
'The split server/console image build failed; integration tests will not use stale local images.',
|
|
);
|
|
await runCheck(report, 'console-tests-unit-only', 'Run console tests without unavailable Docker integration', npmCommand(tools), [
|
|
'run', 'test:ci',
|
|
], {
|
|
cwd: WEB_ROOT,
|
|
env: { RUN_DOCKER_INTEGRATION: '0' },
|
|
expectedSkip: true,
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
});
|
|
return;
|
|
}
|
|
await runCheck(report, 'console-tests', 'Run full Node.js console test suite including Docker integration', npmCommand(tools), [
|
|
'run', 'test:ci',
|
|
], {
|
|
cwd: WEB_ROOT,
|
|
env: {
|
|
RUN_DOCKER_INTEGRATION: '1',
|
|
BETTERDESK_SERVER_IMAGE: 'betterdesk-server:local',
|
|
BETTERDESK_CONSOLE_IMAGE: 'betterdesk-console:local',
|
|
},
|
|
expectedSkip: true,
|
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
});
|
|
await runCheck(report, 'node-sdk-tests', 'Run Node.js SDK tests', npmCommand(tools), [
|
|
'test',
|
|
], { cwd: SDK_NODE_ROOT, timeoutMs: 120_000 });
|
|
}
|
|
|
|
async function runVersionAndDiffChecks(report, tools) {
|
|
await runCheck(report, 'version-parity', 'Verify repository version parity', npmCommand(tools), [
|
|
'scripts/bump-version.js', '--verify',
|
|
], { cwd: ROOT, timeoutMs: 60_000 });
|
|
await runCheck(report, 'git-diff-check', 'Check whitespace and patch errors', 'git', [
|
|
'diff', '--check',
|
|
], { cwd: ROOT, timeoutMs: 60_000 });
|
|
}
|
|
|
|
function writeReports(report, reportDir) {
|
|
fs.mkdirSync(reportDir, { recursive: true });
|
|
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
const jsonPath = path.join(reportDir, `local-gate-${stamp}.json`);
|
|
const markdownPath = path.join(reportDir, `local-gate-${stamp}.md`);
|
|
const latestJson = path.join(reportDir, 'latest.json');
|
|
const latestMarkdown = path.join(reportDir, 'latest.md');
|
|
const json = JSON.stringify(report, null, 2) + '\n';
|
|
const lines = [
|
|
`# BetterDesk local gate`,
|
|
``,
|
|
`- Overall: **${report.overall}**`,
|
|
`- Branch: \`${report.repository.branch}\``,
|
|
`- Commit: \`${report.repository.sha}\``,
|
|
`- Host: \`${report.environment.platform} ${report.environment.release}\``,
|
|
`- Started: ${report.startedAt}`,
|
|
`- Finished: ${report.finishedAt || 'in progress'}`,
|
|
``,
|
|
`## Checks`,
|
|
``,
|
|
...report.results.map((result) => (
|
|
`- **${result.status}** \`${result.id}\` — ${result.label}: ${result.detail}`
|
|
)),
|
|
``,
|
|
`## Explicit boundaries`,
|
|
``,
|
|
...report.outOfScope.map((entry) => `- **OUT_OF_SCOPE** ${entry.area}: ${entry.reason}`),
|
|
``,
|
|
`The report is local-only. It does not prove external client, physical-device, or disposable-host lifecycle behavior.`,
|
|
``,
|
|
];
|
|
const markdown = lines.join('\n');
|
|
fs.writeFileSync(jsonPath, json, 'utf8');
|
|
fs.writeFileSync(markdownPath, markdown, 'utf8');
|
|
fs.writeFileSync(latestJson, json, 'utf8');
|
|
fs.writeFileSync(latestMarkdown, markdown, 'utf8');
|
|
return { jsonPath, markdownPath, latestJson, latestMarkdown };
|
|
}
|
|
|
|
function calculateOverall(report) {
|
|
const required = report.results.filter((result) => result.required !== false);
|
|
if (required.some((result) => result.status === 'FAIL')) return 'FAIL';
|
|
if (required.some((result) => result.status === 'BLOCKED')) return 'BLOCKED';
|
|
return 'PASS';
|
|
}
|
|
|
|
function printSummary(report, paths) {
|
|
const counts = report.results.reduce((accumulator, result) => {
|
|
accumulator[result.status] = (accumulator[result.status] || 0) + 1;
|
|
return accumulator;
|
|
}, {});
|
|
console.log(`\nLOCAL GATE: ${report.overall}`);
|
|
console.log(Object.entries(counts).map(([status, count]) => `${status}=${count}`).join(' '));
|
|
console.log(`JSON report: ${paths.latestJson}`);
|
|
console.log(`Markdown report: ${paths.latestMarkdown}`);
|
|
}
|
|
|
|
async function run(options = {}) {
|
|
const report = {
|
|
schemaVersion: 1,
|
|
localOnly: true,
|
|
startedAt: new Date().toISOString(),
|
|
finishedAt: null,
|
|
repository: getRepoState(),
|
|
environment: {
|
|
platform: process.platform,
|
|
release: os.release(),
|
|
arch: process.arch,
|
|
node: process.version,
|
|
},
|
|
results: [],
|
|
outOfScope: OUT_OF_SCOPE,
|
|
overall: 'BLOCKED',
|
|
};
|
|
let postgres = null;
|
|
|
|
if (options.dryRun) {
|
|
console.log('Dry run: full local gate categories:');
|
|
console.log('- preflight and version checks');
|
|
console.log('- npm install/audit, frontend/protocol/i18n/provenance and Jest');
|
|
console.log('- Go vet/tests/race/vulnerability scan/build/cross-build');
|
|
console.log('- Python compile/tests/pip-audit');
|
|
console.log('- Rust tests and cargo-audit when Rust manifests are present');
|
|
console.log('- gitleaks, sensitive path scan and Trivy');
|
|
console.log('- Bash/ShellCheck/PowerShell installer checks');
|
|
console.log('- Compose validation, local image builds, PostgreSQL and runtime smoke');
|
|
return { ...report, overall: 'PASS', dryRun: true };
|
|
}
|
|
|
|
try {
|
|
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
addInternalResult(
|
|
report,
|
|
'local-only-guard',
|
|
'Local-only execution guard',
|
|
'BLOCKED',
|
|
'The local gate is intentionally disabled inside GitHub Actions.',
|
|
);
|
|
} else {
|
|
const environment = preflight(report);
|
|
if (options.preflightOnly) {
|
|
if (!environment.ok) {
|
|
// The final status is calculated in finally.
|
|
}
|
|
} else if (environment.ok) {
|
|
const { tools, manifests } = environment;
|
|
await runVersionAndDiffChecks(report, tools);
|
|
await runNodeChecks(report, tools);
|
|
await runPythonChecks(report, tools, manifests);
|
|
await runRustChecks(report, tools, manifests);
|
|
await runInstallerChecks(report, tools);
|
|
await runSecurityChecks(report, tools);
|
|
const dockerBuilds = await runComposeConfigChecks(report, tools);
|
|
|
|
postgres = await startPostgres(report, tools);
|
|
await runGoChecks(report, tools, postgres.dsn);
|
|
await runFullNodeTests(report, tools, dockerBuilds);
|
|
await runDockerRuntimeChecks(report, tools, postgres, dockerBuilds);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
addInternalResult(report, 'local-gate-fatal', 'Local gate orchestration', 'FAIL', error.message);
|
|
} finally {
|
|
if (postgres?.name) {
|
|
const docker = executable('docker');
|
|
if (docker) {
|
|
spawnSync(docker, ['rm', '--force', postgres.name], {
|
|
cwd: ROOT,
|
|
windowsHide: true,
|
|
stdio: 'ignore',
|
|
});
|
|
}
|
|
}
|
|
report.finishedAt = new Date().toISOString();
|
|
report.overall = calculateOverall(report);
|
|
}
|
|
|
|
const paths = writeReports(report, options.reportDir || REPORT_DIR);
|
|
printSummary(report, paths);
|
|
return report;
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
const report = await run(options);
|
|
if (report.overall !== 'PASS') process.exitCode = 1;
|
|
} catch (error) {
|
|
console.error(`LOCAL GATE FAILED: ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) main();
|
|
|
|
module.exports = {
|
|
ROOT,
|
|
REPORT_DIR,
|
|
COMPOSE_FILES,
|
|
OUT_OF_SCOPE,
|
|
parseArgs,
|
|
redactSensitive,
|
|
formatCommand,
|
|
processInvocation,
|
|
containsUnexpectedSkip,
|
|
resultStatus,
|
|
parseVersion,
|
|
atLeastVersion,
|
|
discoverSecurityManifests,
|
|
extractPyprojectDependencies,
|
|
calculateOverall,
|
|
run,
|
|
};
|