From 751f1199c80cdd9df4a32daff63f24e3cac286d8 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 19 Apr 2026 23:59:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(llm-security):=20B1=20pathguard=20regex=20?= =?UTF-8?q?=E2=80=94=20match=20multi-segment=20.env.*.*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/scripts/pre-write-pathguard.mjs | 12 ++++-- .../tests/hooks/pre-write-pathguard.test.mjs | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs index 15f0d3a..205d2e2 100644 --- a/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs +++ b/plugins/llm-security/hooks/scripts/pre-write-pathguard.mjs @@ -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 */ diff --git a/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs b/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs index 7f9b716..aec4bd9 100644 --- a/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs +++ b/plugins/llm-security/tests/hooks/pre-write-pathguard.test.mjs @@ -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);