v1.11.7: secret-file deny list for codebase tools

Ports continue.dev's DEFAULT_SECURITY_IGNORE_FILETYPES + ignored-dir lists
into apps/server/src/services/secret_guard.ts plus a small BooCode
additions block (id_rsa*, *credentials*, .netrc, *.kdbx). Tiny glob-to-
regex matcher; no new prod dep.

view_file hard-refuses via SecretBlockedError. list_dir / grep /
find_files filter their results and surface a pathguard_note string
field with the hidden count — never list the offending paths back.

Named secret_guard.ts (not safety/pathGuard.ts) to avoid collision with
the existing path_guard.ts which already exports a pathGuard() function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:55:50 +00:00
parent 85037f000d
commit 863452ae07
3 changed files with 467 additions and 11 deletions

View File

@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest';
import {
isSecretPath,
filterSecretEntries,
SecretBlockedError,
DEFAULT_SECURITY_IGNORE_FILETYPES,
} from '../secret_guard.js';
// ---- env / config patterns -------------------------------------------------
describe('isSecretPath — env / config files', () => {
it('matches .env (literal via .env*)', () => {
expect(isSecretPath('.env')).toBe(true);
});
it('matches .env.local (via .env*)', () => {
expect(isSecretPath('.env.local')).toBe(true);
});
it('matches .env.production.local (via .env*)', () => {
expect(isSecretPath('.env.production.local')).toBe(true);
});
it('matches .envrc (via .env*, common direnv config holding secrets)', () => {
expect(isSecretPath('.envrc')).toBe(true);
});
it('matches nested .env (apps/server/.env via basename test)', () => {
expect(isSecretPath('apps/server/.env')).toBe(true);
});
it('case-insensitive: .ENV matches .env*', () => {
expect(isSecretPath('.ENV')).toBe(true);
});
});
// ---- SSH / cert / key patterns --------------------------------------------
describe('isSecretPath — SSH / certs / keys', () => {
it('matches id_rsa (continue.dev literal)', () => {
expect(isSecretPath('id_rsa')).toBe(true);
});
it('matches id_rsa.pub (BooCode addition id_rsa*)', () => {
// continue.dev's literal id_rsa wouldn't match this; BooCode broadens
// because .pub files leak hostnames/usernames and authorized_keys hints.
expect(isSecretPath('id_rsa.pub')).toBe(true);
});
it('matches cert.pem (*.pem)', () => {
expect(isSecretPath('cert.pem')).toBe(true);
});
it('matches private.key (*.key)', () => {
expect(isSecretPath('private.key')).toBe(true);
});
});
// ---- credential patterns ---------------------------------------------------
describe('isSecretPath — credential files (BooCode additions)', () => {
it('matches credentials.json (BooCode *credentials*)', () => {
expect(isSecretPath('credentials.json')).toBe(true);
});
it('matches aws_credentials (BooCode *credentials* — substring match)', () => {
// continue.dev has no `credentials*` pattern. BooCode adds `*credentials*`
// to catch the common `aws_credentials`, `gcp-credentials.yml`, etc.
expect(isSecretPath('aws_credentials')).toBe(true);
});
it('matches .netrc (BooCode addition)', () => {
expect(isSecretPath('.netrc')).toBe(true);
});
it('matches keystore.kdbx (BooCode addition *.kdbx)', () => {
expect(isSecretPath('keystore.kdbx')).toBe(true);
});
});
// ---- directory patterns ----------------------------------------------------
describe('isSecretPath — directory segments (trailing-slash patterns)', () => {
it('matches files under .aws/ via segment test', () => {
expect(isSecretPath('home/user/.aws/credentials')).toBe(true);
});
it('matches files under .ssh/', () => {
expect(isSecretPath('home/user/.ssh/known_hosts')).toBe(true);
});
it('matches files inside any path segment named secrets/', () => {
expect(isSecretPath('apps/server/secrets/api.key')).toBe(true);
});
});
// ---- negatives -------------------------------------------------------------
describe('isSecretPath — negatives', () => {
it('package.json is allowed', () => {
expect(isSecretPath('package.json')).toBe(false);
});
it('README.md is allowed', () => {
expect(isSecretPath('README.md')).toBe(false);
});
it('Login.tsx is allowed (substring "login" doesn\'t trigger anything)', () => {
expect(isSecretPath('src/components/Login.tsx')).toBe(false);
});
it('empty string returns false (defensive)', () => {
expect(isSecretPath('')).toBe(false);
});
it('a directory NAMED "credentials" alone does NOT trigger — only file basenames do', () => {
// Worth pinning: BooCode's `*credentials*` is a basename pattern (no
// trailing `/`), so it tests the leaf filename only. A directory
// literally called "credentials" containing innocuous files (e.g.
// Login.tsx) is fine. This is a deliberate trade-off vs. continue.dev's
// dir-pattern approach — adding `credentials/` as a dir pattern would
// block legitimate code like `src/auth/credentials/Login.tsx`.
expect(isSecretPath('src/auth/credentials/Login.tsx')).toBe(false);
// ...but a file INSIDE that dir whose name includes "credentials" still
// blocks via the basename match:
expect(isSecretPath('src/auth/credentials/credentials.ts')).toBe(true);
});
});
// ---- filterSecretEntries (listing-tools helper) ----------------------------
describe('filterSecretEntries', () => {
it('removes secret entries and reports the count via note string', () => {
const entries = [
{ path: 'src/index.ts' },
{ path: '.env' },
{ path: 'README.md' },
{ path: 'id_rsa' },
{ path: 'apps/server/package.json' },
];
const result = filterSecretEntries(entries, (e) => e.path);
expect(result.kept.map((e) => e.path)).toEqual([
'src/index.ts',
'README.md',
'apps/server/package.json',
]);
expect(result.hidden).toBe(2);
expect(result.note).toBe('[pathGuard: 2 entries hidden by secret-file filter]');
});
it('returns undefined note when nothing was filtered', () => {
const result = filterSecretEntries(
[{ path: 'a.ts' }, { path: 'b.ts' }],
(e) => e.path,
);
expect(result.kept).toHaveLength(2);
expect(result.hidden).toBe(0);
expect(result.note).toBeUndefined();
});
it('uses singular "entry" for a 1-hit filter (cosmetic but worth pinning)', () => {
const result = filterSecretEntries(
[{ path: 'index.ts' }, { path: '.env' }],
(e) => e.path,
);
expect(result.note).toBe('[pathGuard: 1 entry hidden by secret-file filter]');
});
});
// ---- SecretBlockedError ----------------------------------------------------
describe('SecretBlockedError', () => {
it('carries the offending path on .path and in the message', () => {
const err = new SecretBlockedError('apps/server/.env');
expect(err.name).toBe('SecretBlockedError');
expect(err.path).toBe('apps/server/.env');
expect(err.message).toContain('apps/server/.env');
expect(err.message).toContain('pathGuard');
});
});
// ---- contract sanity check -------------------------------------------------
describe('DEFAULT_SECURITY_IGNORE_FILETYPES', () => {
it('exports at least 40 patterns (continue.dev base) and is non-empty', () => {
expect(DEFAULT_SECURITY_IGNORE_FILETYPES.length).toBeGreaterThanOrEqual(40);
});
it('includes all the headline continue.dev entries we tested above', () => {
// Spot-check that the list still carries the patterns whose behavior
// the tests depend on. Catches an accidental list edit that would
// silently degrade coverage.
const set = new Set(DEFAULT_SECURITY_IGNORE_FILETYPES);
for (const pat of ['*.env', '.env*', '*.pem', '*.key', 'id_rsa', '.aws/', '.ssh/']) {
expect(set.has(pat), `missing pattern: ${pat}`).toBe(true);
}
});
});