fix(llm-security): B1 pathguard regex — match multi-segment .env.*.*

The previous ENV regex `/[\\/]\.env\.[a-z]+$/` only matched a single
lowercase segment after `.env`. Multi-segment and mixed-case variants
such as `.env.production.local.backup`, `.env.stage-1.local`, and
`.env.CI.secret` slipped past the hook. Replaced with
`/[\\/]\.env(\.[A-Za-z0-9._-]+)*$/` which matches `.env` plus any
number of dot-separated alphanumeric/dot/hyphen/underscore segments.
`.envrc` (direnv config, no dot separator) is still allowed.

Addresses critical review 2026-04-20 §2 B1 (HIGH).

Tests: 7 added (6 new multi-segment BLOCK cases + 1 .envrc ALLOW).
All 1494 tests pass.
This commit is contained in:
Kjell Tore Guttormsen 2026-04-19 23:59:38 +02:00
commit 751f1199c8
2 changed files with 51 additions and 4 deletions

View file

@ -17,11 +17,15 @@ import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
// Sensitive path patterns — 8 categories
// ---------------------------------------------------------------------------
/** Category 1: Environment files */
/** Category 1: Environment files
* Matches `.env` and any multi-segment suffix after it, e.g.
* `.env.local`, `.env.production.local.backup`, `.env.stage-1.local`,
* `.env.CI.secret`. Does NOT match `.envrc` (direnv) no dot separator.
* v7.1.0 B1 fix: previous regex `/[\\/]\.env\.[a-z]+$/` only matched a
* single lowercase segment after `.env`.
*/
const ENV_PATTERNS = [
/[\\/]\.env$/,
/[\\/]\.env\.[a-z]+$/, // .env.local, .env.production, etc.
/[\\/]\.env\.local$/,
/[\\/]\.env(\.[A-Za-z0-9._-]+)*$/,
];
/** Category 2: SSH directory */

View file

@ -36,6 +36,43 @@ describe('pre-write-pathguard — BLOCK cases', () => {
assert.match(result.stderr, /PATH GUARD/);
});
// B1 regression — multi-segment .env.*.*.* must be blocked (v7.1.0)
it('blocks a write to .env.production.local.backup (multi-segment env)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.production.local.backup'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .env.dev.local.old (multi-segment env)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.dev.local.old'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .env.prod.local.bak (multi-segment env)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.prod.local.bak'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .env.stage-1.local (hyphen + digit in segment)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.stage-1.local'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .env.CI.secret (uppercase segment)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.CI.secret'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .env.A.B.C.D (many short uppercase segments)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.env.A.B.C.D'));
assert.equal(result.code, 2);
assert.match(result.stderr, /PATH GUARD/);
});
it('blocks a write to .ssh/id_rsa (SSH directory)', async () => {
const result = await runHook(SCRIPT, writePayload('/home/user/.ssh/id_rsa'));
assert.equal(result.code, 2);
@ -117,6 +154,12 @@ describe('pre-write-pathguard — ALLOW cases', () => {
assert.equal(result.code, 0);
});
// B1 negative — direnv's .envrc (no dot-suffix) must not be blocked (v7.1.0)
it('allows a write to .envrc (direnv config, not a dotenv file)', async () => {
const result = await runHook(SCRIPT, writePayload('/project/.envrc'));
assert.equal(result.code, 0);
});
it('allows a write when file_path is empty', async () => {
const result = await runHook(SCRIPT, { tool_name: 'Write', tool_input: { file_path: '', content: 'x' } });
assert.equal(result.code, 0);