112 lines
4.4 KiB
JavaScript
112 lines
4.4 KiB
JavaScript
// Guard rules for shell commands, focused on inline interpreter scripts.
|
|
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { shellViolation, inlineScriptViolation, pageText, pageShellResult } from '../index.js'
|
|
|
|
const ALLOW = [
|
|
// harmless computation one-liners
|
|
`node -e "console.log(1+1)"`,
|
|
`node -p "2**10"`,
|
|
`node --eval 'console.log([1,2,3].map(x => x * 2))'`,
|
|
`node --print="JSON.stringify({a: 1})"`,
|
|
`node -pe "Math.max(3, 7)"`,
|
|
`python3 -c "print(2**10)"`,
|
|
`python3 -c 'print(sum(range(100)))'`,
|
|
`ruby -e 'puts [1,2,3].sum'`,
|
|
`perl -e 'print 6*7, "\\n"'`,
|
|
`node -e 'console.log(JSON.parse(\`{"a":1}\`).a)'`,
|
|
// running files / tools is a normal command, not an inline script
|
|
'node --test test/',
|
|
'node build.mjs',
|
|
'node --check index.js',
|
|
'python3 manage.py migrate',
|
|
'node --version',
|
|
'python3 --version',
|
|
// unrelated commands
|
|
'git status',
|
|
'npm test 2>&1 | tail -5',
|
|
// reading / searching through the shell is allowed (output is paged by the bash wrapper)
|
|
'cat README.md',
|
|
'head -50 index.js',
|
|
'sed -n 10,20p index.js',
|
|
'grep -rn foo .',
|
|
'find . -name "*.js"',
|
|
'ls -R lib',
|
|
`awk '{print $1}' f.txt`,
|
|
]
|
|
|
|
const BLOCK = [
|
|
// the reported gap
|
|
`node --input-type=module --eval "import fs from 'node:fs'; fs.readFileSync('a')"`,
|
|
`node --input-type=module -e "import('node:fs').then(m => m.writeFileSync('a', 'b'))"`,
|
|
// direct file / process access
|
|
`node -e "require('fs').writeFileSync('a', 'b')"`,
|
|
`node -e "require('child_process').execSync('cat f')"`,
|
|
`node -p "process.mainModule.require('fs')"`,
|
|
`node -e "fs['write'+'File']('a','b')"`,
|
|
`node -e "globalThis['req'+'uire']('fs')"`,
|
|
`node -e "this.constructor.constructor('return process')()"`,
|
|
`node -e "eval('requ'+'ire')('fs')"`,
|
|
`node -e "new Function('return process')()"`,
|
|
`node -e "fetch('http://x')"`,
|
|
`python3 -c "open('f','w').write('x')"`,
|
|
`python3 -c "import os; os.remove('f')"`,
|
|
`python3 -c "__import__('os').system('ls')"`,
|
|
`python3 -c "print(().__class__.__bases__[0].__subclasses__())"`,
|
|
`ruby -e "File.write('x', 1)"`,
|
|
'ruby -e "puts `ls`"',
|
|
`perl -e 'open(F, ">x")'`,
|
|
`deno eval "Deno.writeTextFileSync('x','y')"`,
|
|
`bun -e "Bun.write('x','y')"`,
|
|
`php -r "file_put_contents('x','y');"`,
|
|
// scripts we cannot inspect
|
|
'echo "require(1)" | node',
|
|
'cat script.js | node -',
|
|
'node - < script.js',
|
|
'python3 - < s.py',
|
|
// wrapped forms
|
|
`env FOO=1 node -e "require('fs')"`,
|
|
`sudo node -e "require('fs')"`,
|
|
`/usr/bin/node -e "require('fs')"`,
|
|
`ls && node -e "require('fs')"`,
|
|
// shell writes stay blocked
|
|
`awk '{print > "out.txt"}' f.txt`,
|
|
`awk 'BEGIN{system("rm x")}'`,
|
|
'find . -name "*.tmp" -delete',
|
|
'sed -i s/a/b/ f.txt',
|
|
'echo x > f.txt',
|
|
]
|
|
|
|
test('harmless inline one-liners and normal commands are allowed', () => {
|
|
for (const c of ALLOW) assert.equal(shellViolation(c), null, `should allow: ${c}`)
|
|
})
|
|
|
|
test('file/process/network-capable or uninspectable inline scripts are blocked', () => {
|
|
for (const c of BLOCK) assert.ok(inlineScriptViolation(c) || shellViolation(c), `should block: ${c}`)
|
|
})
|
|
|
|
test('denial message points at the right tools', () => {
|
|
const m = shellViolation(`node -e "require('fs').readFileSync('a')"`)
|
|
assert.match(m, /read\/grep\/edit/)
|
|
assert.match(m, /computation-only one-liners are fine/)
|
|
})
|
|
|
|
test('pageText caps output at pageLines and names the next read', () => {
|
|
const text = Array.from({ length: 450 }, (_, i) => `l${i + 1}`).join('\n') + '\n'
|
|
let saved
|
|
const p = pageText(text, 200, (t) => { saved = t; return '/tmp/x.txt' })
|
|
assert.equal(saved, text)
|
|
assert.equal(p.text.split('\n').length, 201)
|
|
assert.match(p.text, /\[output lines 1-200 of 450\. Read lines 201-400: read file_path="\/tmp\/x\.txt" offset=201/)
|
|
assert.equal(pageText('a\nb\n', 200, () => { throw new Error('no save') }), null)
|
|
})
|
|
|
|
test('pageShellResult pages stdout and leaves short streams alone', () => {
|
|
const long = Array.from({ length: 250 }, (_, i) => String(i)).join('\n')
|
|
const v = pageShellResult({ kind: 'foreground', stdout: { text: long, truncated: false }, stderr: { text: 'e', truncated: false } }, 200, () => '/tmp/full.txt')
|
|
assert.equal(v.stdout.spillPath, '/tmp/full.txt')
|
|
assert.match(v.stdout.text, /stdout lines 1-200 of 250/)
|
|
assert.equal(v.stderr.text, 'e')
|
|
assert.deepEqual(pageShellResult({ kind: 'background', jobId: 'j' }, 200), { kind: 'background', jobId: 'j' })
|
|
})
|