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>
199 lines
6.9 KiB
TypeScript
199 lines
6.9 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|