88 lines
5.0 KiB
JavaScript
88 lines
5.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { inspectInterpreter, recordInterpreterStart } from '../src/interpreter.mjs';
|
|
|
|
function fixture(change = {}) {
|
|
const binary = Buffer.from('\x7fELFbody');
|
|
return { realpath: () => '/usr/bin/python3.14',
|
|
stat: path => ({ uid: 0, mode: 0o100755, dev: 1, ino: 10, isFile: () => path.endsWith('3.14'), isDirectory: () => !path.endsWith('3.14') }),
|
|
readFile: path => path === '/etc/passwd' ? 'root:x:0:0::/root:/bin/sh\nuser:x:1000:1000::/home/user:/bin/sh\n' : binary,
|
|
exec: () => '[3,14,0]', ...change };
|
|
}
|
|
test('system Python is a resolved root-owned ELF, never a shim or home interpreter', () => {
|
|
const record = inspectInterpreter({ io: fixture() });
|
|
assert.equal(record.path, '/usr/bin/python3.14');
|
|
assert.deepEqual(record.version, [3,14,0]);
|
|
assert.match(record.sha256, /^[a-f0-9]{64}$/);
|
|
const bad = [
|
|
{ realpath: () => '/home/user/python3' },
|
|
{ realpath: () => '/root/python3' },
|
|
{ realpath: () => '/srv/user/python3', readFile: p => p === '/etc/passwd' ? 'u:x:1000:1000::/srv/user:/bin/sh' : Buffer.from('\x7fELF') },
|
|
{ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('#!/bin/sh\n') },
|
|
{ stat: () => ({ uid: 1000, mode: 0o100755, isFile: () => true, isDirectory: () => true }) },
|
|
{ stat: () => ({ uid: 0, mode: 0o100775, isFile: () => true, isDirectory: () => true }) },
|
|
{ stat: () => { throw Error('missing'); } },
|
|
{ exec: () => '[3,9,9]' },
|
|
{ exec: () => 'unexpected' },
|
|
];
|
|
for (const f of bad) assert.throws(() => inspectInterpreter({ io: fixture(f) }), /interpreter-unsafe/);
|
|
});
|
|
test('real system interpreter is available and safely inspected', () => {
|
|
const actual = inspectInterpreter();
|
|
assert.ok(actual.path.startsWith('/usr/'));
|
|
assert.ok(actual.version[0] >= 3);
|
|
});
|
|
test('restart receipt appends actual replacement identity and refuses unsafe files', t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'g196-int-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const first = inspectInterpreter({ io: fixture() });
|
|
recordInterpreterStart(root, first);
|
|
const file = join(root, 'peercred-starts.jsonl');
|
|
const old = readFileSync(file, 'utf8');
|
|
const second = inspectInterpreter({ io: fixture({ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('\x7fELFnew') }) });
|
|
recordInterpreterStart(root, second);
|
|
const now = readFileSync(file, 'utf8');
|
|
assert.ok(now.startsWith(old));
|
|
assert.notEqual(JSON.parse(old).sha256, JSON.parse(now.trim().split('\n')[1]).sha256);
|
|
chmodSync(file, 0o644);
|
|
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
|
|
rmSync(file);
|
|
writeFileSync(join(root,'target'), 'untouched');
|
|
symlinkSync(join(root,'target'),file);
|
|
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
|
|
assert.equal(readFileSync(join(root,'target'),'utf8'),'untouched');
|
|
});
|
|
|
|
test('activation gate compares fresh startup receipt to the current interpreter identity',async t=>{
|
|
const { verifyInterpreterReceipt }=await import('../src/interpreter.mjs');
|
|
const root=mkdtempSync(join(tmpdir(),'g196-int-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
const first=inspectInterpreter({io:fixture()}),since=Date.now()-1;
|
|
recordInterpreterStart(root,first);
|
|
assert.equal(verifyInterpreterReceipt(root,first,since),true);
|
|
assert.throws(()=>verifyInterpreterReceipt(root,{...first,sha256:'b'.repeat(64)},since),/interpreter-receipt-mismatch/);
|
|
assert.throws(()=>verifyInterpreterReceipt(root,first,Date.now()+1000),/interpreter-receipt-mismatch/);
|
|
});
|
|
|
|
test('non-regular startup receipt refuses promptly without waiting on a FIFO peer',async t=>{
|
|
const {execFileSync,spawnSync}=await import('node:child_process');
|
|
const root=mkdtempSync(join(tmpdir(),'g196-fifo-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
execFileSync('mkfifo',[join(root,'peercred-starts.jsonl')]);
|
|
const url=new URL('../src/interpreter.mjs',import.meta.url).href;
|
|
const child=spawnSync(process.execPath,['--input-type=module','-e',`import {recordInterpreterStart} from ${JSON.stringify(url)};try{recordInterpreterStart(${JSON.stringify(root)},{});process.exit(2)}catch(e){process.exit(e.message==='interpreter-receipt-unsafe'?0:3)}`],{timeout:1000});
|
|
assert.equal(child.status,0,'unsafe FIFO must refuse without blocking startup');
|
|
});
|
|
|
|
//641: scheduler delay is lack of probe evidence, not evidence of an unsafe executable.
|
|
test('interpreter probe timeout refuses distinctly without approving the interpreter', () => {
|
|
let calls = 0;
|
|
assert.throws(() => inspectInterpreter({ io: fixture({ exec: (_path, _args, options) => {
|
|
calls++;
|
|
assert.equal(options.timeout, 1000);
|
|
throw Object.assign(Error('controlled timeout'), { code: 'ETIMEDOUT' });
|
|
} }) }), { message: 'interpreter-probe-timeout' });
|
|
assert.equal(calls, 1);
|
|
});
|