diff --git a/src/lock-publisher.mjs b/src/lock-publisher.mjs index d01c68d..004bcd7 100644 --- a/src/lock-publisher.mjs +++ b/src/lock-publisher.mjs @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID, randomInt } from 'node:crypto'; import { SimplePool } from 'nostr-tools/pool'; import { getPublishTimeoutMs, diff --git a/src/services/governance/index.js b/src/services/governance/index.js index 4d498da..9aa6778 100644 --- a/src/services/governance/index.js +++ b/src/services/governance/index.js @@ -220,8 +220,9 @@ export async function applyProposal(id) { // Git Commit (optional) try { - // We use execFileSync for git operations to avoid command injection - // Ensure we are in repo root? cwd is repo root. + // We use execFileSync for git operations to avoid command injection. + // Explicitly using the array form ensures arguments are passed directly to the spawned process + // without shell interpretation, preventing command injection attacks via 'meta.target' or 'commitMsg'. execFileSync('git', ['add', meta.target]); const commitMsg = `feat(prompts): apply proposal ${id} by ${meta.author}`; execFileSync('git', ['commit', '-m', commitMsg]); diff --git a/test/governance-security-impl.mjs b/test/governance-security-impl.mjs new file mode 100644 index 0000000..acdc109 --- /dev/null +++ b/test/governance-security-impl.mjs @@ -0,0 +1,111 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Calculate paths relative to this file +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const governanceServicePath = path.resolve(repoRoot, 'src/services/governance/index.js'); + +async function runTest() { + // Create temp dir + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-gov-test-')); + console.log(`Using temp dir: ${tempDir}`); + + // Setup git repo + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: tempDir, stdio: 'ignore' }); + + // Create required directory structure + await fs.mkdir(path.join(tempDir, 'src/prompts/daily'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'src/prompts/weekly'), { recursive: true }); + // Proposals dir is created automatically by ensureDirs() but allowlist check needs prompts dir + + // Create a target file + const targetFile = 'src/prompts/daily/test-agent.md'; + const absoluteTarget = path.join(tempDir, targetFile); + await fs.writeFile(absoluteTarget, 'Original Content'); + + // Commit initial state so we have a HEAD + execSync(`git add ${targetFile}`, { cwd: tempDir }); + execSync('git commit -m "Initial commit"', { cwd: tempDir }); + + // Switch cwd to tempDir + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + console.log(`Changed cwd to: ${process.cwd()}`); + + // Import governance service dynamically so it picks up the new cwd + const governance = await import(governanceServicePath); + + // Malicious payload + const maliciousPayload = 'attacker"; touch /tmp/pwned_check; echo "'; + // const proposalId = `test-proposal-${Date.now()}`; // unused + + // Manually create proposal metadata and files because createProposal also uses git diff + // We can use createProposal from the service, but we need to pass the malicious agent name there. + // Let's try using createProposal first. + + const proposalContent = `Shared contract (required):\nRequired startup + artifacts + memory + issue capture\n\nNew Content`; + + console.log('Creating proposal...'); + const { id } = await governance.createProposal({ + agent: maliciousPayload, + target: targetFile, + newContent: proposalContent, + reason: 'Testing security' + }); + + console.log(`Proposal created with ID: ${id}`); + + // Verify metadata has the malicious author + const metaPath = path.join(tempDir, 'src/proposals', id, 'meta.json'); + const meta = JSON.parse(await fs.readFile(metaPath, 'utf8')); + if (meta.author !== maliciousPayload) { + throw new Error('Meta author does not match malicious payload'); + } + + // Apply proposal + console.log('Applying proposal...'); + await governance.applyProposal(id); + console.log('Proposal applied.'); + + // Verification 1: Check if /tmp/pwned_check exists + try { + await fs.access('/tmp/pwned_check'); + console.error('VULNERABILITY DETECTED: /tmp/pwned_check exists!'); + process.exit(1); + } catch { + console.log('Security check passed: /tmp/pwned_check does not exist.'); + } + + // Verification 2: Check git log for commit message + const log = execSync('git log -1 --pretty=%B', { cwd: tempDir }).toString(); + console.log(`Commit message: ${log}`); + + if (!log.includes(maliciousPayload)) { + console.error('Verification failed: Commit message does not contain the payload literally.'); + process.exit(1); + } + + // Check if the commit message looks "broken" (e.g. if newlines were injected) + // Here we just want to ensure the whole thing is in the message. + console.log('Verification passed: Commit message contains payload.'); + + } finally { + // Cleanup + process.chdir(originalCwd); + await fs.rm(tempDir, { recursive: true, force: true }); + // Also remove /tmp/pwned_check if it exists (it shouldn't) + try { await fs.unlink('/tmp/pwned_check'); } catch { /* ignore */ } + } +} + +runTest().catch(err => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/test/governance-security.test.mjs b/test/governance-security.test.mjs new file mode 100644 index 0000000..cefa3c1 --- /dev/null +++ b/test/governance-security.test.mjs @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const implPath = path.join(__dirname, 'governance-security-impl.mjs'); + +test('Governance Service Security: Command Injection Prevention', (_t) => { + // We run the implementation in a separate process to ensure process.cwd() isolation + // and to verify that the dynamic import picks up the correct paths. + try { + const output = execFileSync(process.execPath, [implPath], { encoding: 'utf8' }); + // If we reach here, the process exited with code 0 (success) + // We can optionally check the output for specific messages if needed, + // but the implementation script asserts internally. + assert.ok(output.includes('Verification passed: Commit message contains payload.'), 'Output should confirm verification passed'); + } catch (err) { + console.error('Test implementation failed.'); + if (err.stdout) console.error('STDOUT:', err.stdout); + if (err.stderr) console.error('STDERR:', err.stderr); + assert.fail(`Security test failed with exit code ${err.status}: ${err.message}`); + } +});