Files
waggle-os/packages/core/tests/file-store-path-safety.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

176 lines
8.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { LocalFileStore, LinkedDirStore, isSensitiveFilePath } from '../src/file-store.js';
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsguard-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
describe('isSensitiveFilePath', () => {
it('flags SSH/GPG/cloud secret directories and key basenames', () => {
for (const p of ['.ssh/id_rsa', '.ssh/config', '.aws/credentials', '.gnupg/secring.gpg',
'id_rsa', 'id_ed25519', '.netrc', '.pgpass', '.git-credentials', 'credentials.json',
'project/nested/.ssh/known_hosts']) {
expect(isSensitiveFilePath(p), p).toBe(true);
}
});
it('flags dotenv files but NOT their checked-in templates', () => {
expect(isSensitiveFilePath('.env')).toBe(true);
expect(isSensitiveFilePath('config/.env.production')).toBe(true);
expect(isSensitiveFilePath('.env.example')).toBe(false);
expect(isSensitiveFilePath('.env.template')).toBe(false);
});
it('is case-insensitive and path-separator agnostic', () => {
expect(isSensitiveFilePath('.SSH/ID_RSA')).toBe(true);
expect(isSensitiveFilePath('.ssh\\id_rsa')).toBe(true);
});
it('does NOT flag ordinary files (incl. public keys)', () => {
for (const p of ['', 'readme.md', 'src/index.ts', 'config.json', 'id_rsa.pub', '.environment', 'data/credentials-form.tsx']) {
expect(isSensitiveFilePath(p), p).toBe(false);
}
});
it('defeats Windows ADS + trailing dot/space normalization tricks', () => {
for (const p of ['id_rsa::$DATA', '.env::$DATA', 'id_rsa.', '.env ', '.NPMRC ']) {
expect(isSensitiveFilePath(p), p).toBe(true);
}
});
it('flags backup copies of secrets but not ordinary backups', () => {
expect(isSensitiveFilePath('credentials.bak')).toBe(true);
expect(isSensitiveFilePath('id_rsa.old')).toBe(true);
expect(isSensitiveFilePath('.npmrc.backup')).toBe(true);
expect(isSensitiveFilePath('readme.bak')).toBe(false);
});
it('flags extended secret classes (pem keys, authorized_keys, cloud, terraform)', () => {
for (const p of ['deploy/secret.pem', 'authorized_keys', 'known_hosts', '.azure/accessTokens.json',
'.terraform/x', 'service-account.json', 'terraform.tfstate', 'infra/terraform.tfstate.backup']) {
expect(isSensitiveFilePath(p), p).toBe(true);
}
});
});
describe('resolveSafe containment (via LocalFileStore)', () => {
const store = () => new LocalFileStore(tmp, 'ws1'); // root = tmp/workspaces/ws1/files
it('reads/writes a normal in-root path', async () => {
const s = store();
await s.writeFile('notes/todo.txt', 'hi');
expect((await s.readFile('notes/todo.txt')).toString()).toBe('hi');
});
it('denies a classic ../ escape', async () => {
await expect(store().readFile('../../../etc/passwd')).rejects.toThrow(/traversal denied/i);
});
it('denies a SIBLING-prefix escape (the startsWith bug)', async () => {
// root is .../ws1/files; this resolves to a sibling .../ws1/files-evil which a
// bare startsWith(root) check WRONGLY admitted. Segment-boundary check rejects it.
await expect(store().writeFile('../files-evil/loot.txt', 'x')).rejects.toThrow(/traversal denied/i);
});
});
describe('LinkedDirStore sensitive-file deny (external folder)', () => {
function seedLinked(): string {
const dir = path.join(tmp, 'project');
fs.mkdirSync(path.join(dir, '.ssh'), { recursive: true });
fs.mkdirSync(path.join(dir, '.aws'), { recursive: true });
fs.writeFileSync(path.join(dir, '.ssh', 'id_rsa'), 'PRIVATE KEY');
fs.writeFileSync(path.join(dir, '.aws', 'credentials'), '[default]\naws_secret=xxx');
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
fs.writeFileSync(path.join(dir, '.env.example'), 'SECRET=');
fs.writeFileSync(path.join(dir, 'README.md'), 'hello');
fs.writeFileSync(path.join(dir, 'credentials.json'), '{"token":"x"}');
fs.writeFileSync(path.join(dir, 'data.json'), '{"ok":true}');
return dir;
}
it('denies reading SSH keys, cloud creds, and .env', async () => {
const s = new LinkedDirStore(seedLinked());
await expect(s.readFile('.ssh/id_rsa')).rejects.toThrow(/sensitive file denied/i);
await expect(s.readFile('.aws/credentials')).rejects.toThrow(/sensitive file denied/i);
await expect(s.readFile('.env')).rejects.toThrow(/sensitive file denied/i);
});
it('allows a normal file and the .env template', async () => {
const s = new LinkedDirStore(seedLinked());
expect((await s.readFile('README.md')).toString()).toBe('hello');
expect((await s.readFile('.env.example')).toString()).toBe('SECRET=');
});
it('denies writing/clobbering and moving a secret file', async () => {
const s = new LinkedDirStore(seedLinked());
await expect(s.writeFile('.ssh/authorized_keys', 'attacker-key')).rejects.toThrow(/sensitive file denied/i);
await expect(s.moveFile('README.md', '.env')).rejects.toThrow(/sensitive file denied/i);
});
it('searchFiles never discloses a non-dot secret (credentials.json)', async () => {
const s = new LinkedDirStore(seedLinked());
const names = (await s.searchFiles('*.json')).map(f => f.name);
expect(names).toContain('data.json');
expect(names).not.toContain('credentials.json');
});
it('listFiles hides non-dot secrets (credentials.json) but keeps normal files', async () => {
const s = new LinkedDirStore(seedLinked());
const names = (await s.listFiles()).map(f => f.name);
expect(names).toContain('README.md');
expect(names).not.toContain('credentials.json');
});
it('searchFiles cannot escape the root via a ../ glob pattern', async () => {
const outside = path.join(tmp, 'outside'); fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'loot.txt'), 'x');
const s = new LinkedDirStore(seedLinked());
const results = await s.searchFiles('../**/*');
expect(results.every(f => !f.path.includes('..'))).toBe(true);
expect(results.map(f => f.name)).not.toContain('loot.txt');
});
it('still denies a ../ escape out of the linked root', async () => {
const s = new LinkedDirStore(seedLinked());
await expect(s.readFile('../../secret.txt')).rejects.toThrow(/traversal denied/i);
});
});
describe('LinkedDirStore symlink containment (the CRITICAL escape)', () => {
// Symlink creation can fail without privilege (esp. Windows file symlinks) — the
// fix still applies; these tests self-skip when the env cannot create the link.
function trySymlink(target: string, link: string, type: 'junction' | 'file' | 'dir'): boolean {
try { fs.symlinkSync(target, link, type); return true; } catch { return false; }
}
it('denies reading through a junction/symlink that escapes the root', async () => {
const outside = path.join(tmp, 'outside'); fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'secret.txt'), 'TOPSECRET');
const root = path.join(tmp, 'proj'); fs.mkdirSync(root, { recursive: true });
if (!trySymlink(outside, path.join(root, 'escape'), 'junction')) return;
const s = new LinkedDirStore(root);
await expect(s.readFile('escape/secret.txt')).rejects.toThrow(/traversal denied/i);
});
it('still allows a symlink that stays inside the root (monorepo-style link)', async () => {
const root = path.join(tmp, 'proj2'); fs.mkdirSync(path.join(root, 'real'), { recursive: true });
fs.writeFileSync(path.join(root, 'real', 'data.txt'), 'OK');
if (!trySymlink(path.join(root, 'real'), path.join(root, 'alias'), 'junction')) return;
const s = new LinkedDirStore(root);
expect((await s.readFile('alias/data.txt')).toString()).toBe('OK');
});
it('denies a symlink that launders an in-root secret past a benign name', async () => {
const root = path.join(tmp, 'proj3'); fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, '.env'), 'SECRET=1');
if (!trySymlink(path.join(root, '.env'), path.join(root, 'notes'), 'file')) return;
const s = new LinkedDirStore(root);
await expect(s.readFile('notes')).rejects.toThrow(/sensitive file denied/i);
});
});
describe('LocalFileStore is sandboxed — no sensitive-file deny', () => {
it('allows a .env in the virtual workspace (the agent\'s own scratch)', async () => {
const s = new LocalFileStore(tmp, 'ws2');
await s.writeFile('.env', 'LOCAL=1');
expect((await s.readFile('.env')).toString()).toBe('LOCAL=1');
});
});