fix(portal): prepare approved resource mappings safely
This commit is contained in:
1 parent
36d417a422
commit
81492125f4
11 files changed
+427
-153
No files matched your search
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { rsyncArguments, sshTarget } from './deploy.mjs';
|
||||
import { mappingsFor, approvedMapping } from './portal-production.mjs';
|
||||
|
||||
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repositoryRoot = path.resolve(toolRoot, '..');
|
||||
@@ -19,14 +20,18 @@ function run(command, args, { quiet = false } = {}) {
|
||||
if (!quiet && result.stdout) process.stdout.write(result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
function runBytes(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: toolRoot });
|
||||
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr?.length ? `: ${result.stderr.toString().trim()}` : ''}`);
|
||||
return result.stdout;
|
||||
}
|
||||
function safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
|
||||
function roleAndName(record) {
|
||||
if (!['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) fail('Every installed resource must have an approved internal classification');
|
||||
const role = record.classification === 'STAFF-INTERNAL' ? 'staff' : 'management';
|
||||
function roleAndName(record, mapping) {
|
||||
const role = mapping?.role;
|
||||
if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
|
||||
const prefix = `/${role}-resources/`;
|
||||
if (!record.destinationImplemented || !record.destinationVerified || !record.destinationAccessControlled || !record.rewriteApproved || typeof record.deliveryPath !== 'string' || !record.deliveryPath.startsWith(prefix)) fail('Resource destination is not deployment-approved');
|
||||
const name = record.deliveryPath.slice(prefix.length);
|
||||
if (!safeName(name) || record.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
|
||||
const name = mapping.deliveryPath.slice(prefix.length);
|
||||
if (!safeName(name) || mapping.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
|
||||
return { role, name };
|
||||
}
|
||||
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
|
||||
@@ -38,20 +43,22 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
|
||||
if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
|
||||
const seen = new Set(), files = [];
|
||||
for (const record of manifest.resources) {
|
||||
if (!['STAFF-INTERNAL','MANAGEMENT-INTERNAL'].includes(record.classification)) continue;
|
||||
const { role, name } = roleAndName(record);
|
||||
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
|
||||
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
|
||||
seen.add(`${role}/${name}`);
|
||||
const source = path.join(privateRoot, 'hubfs', record.localFile);
|
||||
const stat = await fs.lstat(source).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
|
||||
const bytes = await fs.readFile(source);
|
||||
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
|
||||
const destination = path.join(staging, role);
|
||||
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
|
||||
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
|
||||
for (const mapping of mappingsFor(record)) {
|
||||
if (!['staff', 'management'].includes(mapping.role)) continue;
|
||||
const { role, name } = roleAndName(record, mapping);
|
||||
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
|
||||
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
|
||||
seen.add(`${role}/${name}`);
|
||||
const source = path.join(privateRoot, 'hubfs', record.localFile);
|
||||
const stat = await fs.lstat(source).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
|
||||
const bytes = await fs.readFile(source);
|
||||
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
|
||||
const destination = path.join(staging, role);
|
||||
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
|
||||
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
|
||||
}
|
||||
}
|
||||
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
|
||||
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
|
||||
@@ -86,6 +93,13 @@ async function main() {
|
||||
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
|
||||
const [ssh, ...sshOptions] = sshTarget(host, identity);
|
||||
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
|
||||
const reviewedInstaller = runBytes('git', ['-C', repositoryRoot, 'show', `${sha}:www/tools/portal-resource-install.sh`]);
|
||||
const localInstaller = await fs.readFile(path.join(toolRoot, 'tools', 'portal-resource-install.sh'));
|
||||
if (!reviewedInstaller.equals(localInstaller)) fail('Local resource installer does not match the reviewed source commit');
|
||||
const installerHash = hash(reviewedInstaller);
|
||||
const installerFile = path.join(temporary, 'portal-resource-install.sh');
|
||||
await fs.writeFile(installerFile, reviewedInstaller, { mode: 0o600 });
|
||||
const remoteInstaller = `${remoteStage}/portal-resource-install.sh`;
|
||||
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
|
||||
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
|
||||
for (const role of ['staff','management']) {
|
||||
@@ -94,7 +108,9 @@ async function main() {
|
||||
run('rsync', args);
|
||||
}
|
||||
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
|
||||
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]);
|
||||
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, installerFile, `${host}:${remoteInstaller}`]);
|
||||
run(ssh, [...sshOptions, host, `test "$(sha256sum ${remoteInstaller} | awk '{print $1}')" = ${installerHash}`]);
|
||||
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${remoteInstaller} ${remoteStage} ${remotePlan}`]);
|
||||
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user