From 173de01bdbe0dd1392d1e74b715db11a47433331 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 10:48:41 +0800 Subject: [PATCH] test: harden profile artifact path boundaries --- .../src/adapters/orchestrator.mjs | 20 ++++-- .../src/storage/profile-artifact.mjs | 7 ++ .../test/profile-artifact.test.mjs | 64 +++++++++++++++++++ .../test/watchdog-chain.integration.test.mjs | 27 ++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/plugins/reporting-governance/src/adapters/orchestrator.mjs b/plugins/reporting-governance/src/adapters/orchestrator.mjs index 454c245..d537b8d 100644 --- a/plugins/reporting-governance/src/adapters/orchestrator.mjs +++ b/plugins/reporting-governance/src/adapters/orchestrator.mjs @@ -1,7 +1,7 @@ import path from 'node:path'; import { ensureSuccess, parseJsonStdout, runNodeScript } from './_script-runner.mjs'; import { createRuntimeBinding, resolveScriptPath } from './runtime-binding.mjs'; -import { loadDeploymentProfileArtifact, createDeploymentBindingContract } from '../storage/profile-artifact.mjs'; +import { loadDeploymentProfileArtifact, createDeploymentBindingContract, assertUseTimePathWithinRepoRoot } from '../storage/profile-artifact.mjs'; export function runOrchestratorAdapter({ scriptPath = null, @@ -44,13 +44,25 @@ export function runOrchestratorAdapter({ const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding })); const resolvedSupervisorScript = path.resolve(supervisorScript ?? resolveScriptPath('bridgeSupervisor', { runtimeBinding: binding })); + const resolvedQueueDir = queueDir + ? path.resolve(queueDir) + : deploymentBinding?.artifactRoots?.queueItems + ? assertUseTimePathWithinRepoRoot(deploymentBinding.artifactRoots.queueItems, 'orchestrator adapter queueDir', { repoRootOverride, allowMissingLeaf: true }) + : null; + const resolvedSpoolDir = spoolDir + ? path.resolve(spoolDir) + : null; + const resolvedReceiptDir = receiptDir + ? path.resolve(receiptDir) + : null; + const args = []; if (state) args.push('--state', path.resolve(state)); if (evidenceDir) args.push('--evidence-dir', path.resolve(evidenceDir)); if (eventDir) args.push('--event-dir', path.resolve(eventDir)); - if (queueDir) args.push('--queue-dir', path.resolve(queueDir)); - if (spoolDir) args.push('--spool-dir', path.resolve(spoolDir)); - if (receiptDir) args.push('--receipt-dir', path.resolve(receiptDir)); + if (resolvedQueueDir) args.push('--queue-dir', resolvedQueueDir); + if (resolvedSpoolDir) args.push('--spool-dir', resolvedSpoolDir); + if (resolvedReceiptDir) args.push('--receipt-dir', resolvedReceiptDir); if (resolvedWatchdogScript) args.push('--watchdog-script', resolvedWatchdogScript); if (resolvedDispatcherScript) args.push('--dispatcher-script', resolvedDispatcherScript); if (resolvedSupervisorScript) args.push('--supervisor-script', resolvedSupervisorScript); diff --git a/plugins/reporting-governance/src/storage/profile-artifact.mjs b/plugins/reporting-governance/src/storage/profile-artifact.mjs index 5298656..0b34cdb 100644 --- a/plugins/reporting-governance/src/storage/profile-artifact.mjs +++ b/plugins/reporting-governance/src/storage/profile-artifact.mjs @@ -53,6 +53,13 @@ function assertPathWithinRealRoot(candidatePath, label, { root, allowMissingLeaf } } +export function assertUseTimePathWithinRepoRoot(candidatePath, label, { repoRootOverride, allowMissingLeaf = false } = {}) { + const root = path.resolve(repoRootOverride ?? repoRoot); + const resolvedPath = path.resolve(candidatePath); + assertPathWithinRealRoot(resolvedPath, label, { root, allowMissingLeaf }); + return resolvedPath; +} + function assertRelativePathWithinRoot(relativePath, label, { root, allowMissingLeaf = false }) { const normalizedPath = assertNonEmptyString(relativePath, label); if (path.isAbsolute(normalizedPath)) { diff --git a/plugins/reporting-governance/test/profile-artifact.test.mjs b/plugins/reporting-governance/test/profile-artifact.test.mjs index 6b9c5ec..1df3c82 100644 --- a/plugins/reporting-governance/test/profile-artifact.test.mjs +++ b/plugins/reporting-governance/test/profile-artifact.test.mjs @@ -8,6 +8,7 @@ import { loadDeploymentProfileArtifact, createDeploymentBindingContract, validateDeploymentProfileArtifact, + assertUseTimePathWithinRepoRoot, } from '../src/storage/profile-artifact.mjs'; import { createRuntimeBinding } from '../src/adapters/index.mjs'; @@ -259,3 +260,66 @@ test('deployment profile artifact validation rejects artifact_roots symlink esca /spec\.bindings\.artifact_roots\.queueItems must stay within repo root: symlink resolution escapes realpath boundary/ ); }); + + +test('deployment profile artifact validation rejects entrypoint and scripts symlink escapes after realpath resolution', async (t) => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-profile-artifact-scripts-')); + t.after(() => fs.rmSync(sandbox, { recursive: true, force: true })); + + const fakeRepoRoot = path.join(sandbox, 'repo'); + const outsideRoot = path.join(sandbox, 'outside'); + fs.mkdirSync(fakeRepoRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'scripts-link'), 'dir'); + fs.writeFileSync(path.join(fakeRepoRoot, 'safe-watchdog.mjs'), `export default true;\n`); + fs.writeFileSync(path.join(outsideRoot, 'entry.mjs'), `export default true;\n`); + fs.writeFileSync(path.join(fakeRepoRoot, 'entry.mjs'), `export default true;\n`); + fs.writeFileSync(path.join(outsideRoot, 'watchdog.mjs'), `export default true;\n`); + + assert.throws( + () => validateDeploymentProfileArtifact(createArtifact({ + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + bindings: { + entrypoint: 'scripts-link/entry.mjs', + scripts: { watchdog: 'safe-watchdog.mjs' }, + artifact_roots: { queueItems: 'state/operator-notify-queue' }, + }, + }, + }), { repoRootOverride: fakeRepoRoot }), + /spec\.bindings\.entrypoint must stay within repo root: symlink resolution escapes realpath boundary/ + ); + + assert.throws( + () => validateDeploymentProfileArtifact(createArtifact({ + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + bindings: { + entrypoint: 'entry.mjs', + scripts: { watchdog: 'scripts-link/watchdog.mjs' }, + artifact_roots: { queueItems: 'state/operator-notify-queue' }, + }, + }, + }), { repoRootOverride: fakeRepoRoot }), + /spec\.bindings\.scripts\.watchdog must stay within repo root: symlink resolution escapes realpath boundary/ + ); +}); + +test('use-time repo-root boundary check rejects symlink escapes for missing artifact leaf', async (t) => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-use-time-boundary-')); + t.after(() => fs.rmSync(sandbox, { recursive: true, force: true })); + + const fakeRepoRoot = path.join(sandbox, 'repo'); + const outsideRoot = path.join(sandbox, 'outside'); + fs.mkdirSync(fakeRepoRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'queue-link'), 'dir'); + + assert.throws( + () => assertUseTimePathWithinRepoRoot(path.join(fakeRepoRoot, 'queue-link', 'pending'), 'orchestrator adapter queueDir', { + repoRootOverride: fakeRepoRoot, + allowMissingLeaf: true, + }), + /orchestrator adapter queueDir must stay within repo root: symlink resolution escapes realpath boundary/ + ); +}); diff --git a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs index 06fab24..cbf92b9 100644 --- a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs +++ b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs @@ -149,3 +149,30 @@ test('orchestrator adapter can bootstrap from profile artifact loader path', () fs.rmSync(root, { recursive: true, force: true }); } }); + + +test('orchestrator adapter can use artifact_roots.queueItems as the default queueDir at execution time', () => { + const root = createFixtureRoot(); + try { + mkdirs(root, ['evidence', 'events', 'spool', 'receipts']); + const statePath = writeState(root); + + const result = runOrchestratorAdapter({ + profileId: 'strict-manager-mode', + repoRootOverride: path.resolve(packageRoot, '..', '..'), + state: statePath, + evidenceDir: path.join(root, 'evidence'), + eventDir: path.join(root, 'events'), + spoolDir: path.join(root, 'spool'), + receiptDir: path.join(root, 'receipts'), + writeState: true, + dryRun: true, + now: '2026-05-07T08:20:00.000Z', + }); + + assert.equal(result.ok, true); + assert.equal(result.result.dispatcher.dispatchedCount >= 0, true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});