From 8086da354b775f8f08a0fe29dd4e14fc0fd10e48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:29:54 +0000 Subject: [PATCH 1/6] test: add coverage for squid ACL generation security modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive Jest unit tests for three security-critical modules that had near-zero coverage: - src/squid/acl-generator.ts (5.66% → ~90%+) Covers all branches of generateAclSections: both/http/https-only plain domains and wildcard patterns, blocked domain plain and wildcard ACLs, protocol-prefix/trailing-slash stripping, empty/undefined blocked domain inputs. - src/squid/access-rules.ts (2.27% → ~90%+) Covers all branches of generateAccessRules/generateDenyRule/ allow rules for HTTP/HTTPS-only configs, blocked rules integration, output ordering, trailing newline. - src/squid/domain-acl.ts (14.28% → ~85%+) Covers assertSafeForSquidConfig injection prevention (whitespace, quotes, semicolons, backtick, hash, null byte), formatDomainForSquid leading-dot canonicalisation, and parseDomainConfig domain grouping, subdomain deduplication, wildcard pattern coverage filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/squid/access-rules.test.ts | 201 +++++++++++++++++++++++++ src/squid/acl-generator.test.ts | 250 ++++++++++++++++++++++++++++++++ src/squid/domain-acl.test.ts | 167 +++++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 src/squid/access-rules.test.ts create mode 100644 src/squid/acl-generator.test.ts create mode 100644 src/squid/domain-acl.test.ts diff --git a/src/squid/access-rules.test.ts b/src/squid/access-rules.test.ts new file mode 100644 index 000000000..4e7bbdfb1 --- /dev/null +++ b/src/squid/access-rules.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for src/squid/access-rules.ts – generateAccessRules. + * + * Covers every branch in generateProtocolRules, generateDenyRule, and + * generateAccessRulesSection: + * + * generateDenyRule + * - both domains + patterns → deny !allowed_domains !allowed_domains_regex + * - only both-protocol plain domains → deny !allowed_domains + * - only both-protocol patterns → deny !allowed_domains_regex + * - only HTTP-only or HTTPS-only (no "both") → deny all + * - completely empty config → deny all + * + * generateProtocolRules + * - HTTP-only plain domains only → !CONNECT allowed_http_only rule + * - HTTP-only patterns only → !CONNECT allowed_http_only_regex rule + * - HTTP-only domains + patterns → both !CONNECT rules + * - HTTPS-only plain domains only → CONNECT allowed_https_only rule + * - HTTPS-only patterns only → CONNECT allowed_https_only_regex rule + * - HTTPS-only domains + patterns → both CONNECT rules + * - no protocol-specific domains → empty array + * + * generateAccessRulesSection + * - blocked rules present → section with header included in output + * - protocol rules present → section with header included in output + * - both absent → empty string + */ +import { generateAccessRules } from './access-rules'; +import { parseDomainConfig } from './domain-acl'; + +describe('generateAccessRules', () => { + // ── denyRule generation ───────────────────────────────────────────────────── + + describe('denyRule', () => { + it('returns "deny all" for completely empty domain config', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny !allowed_domains" when only both-protocol plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains'); + }); + + it('returns "deny !allowed_domains_regex" when only both-protocol patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['*.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains_regex'); + }); + + it('returns "deny !allowed_domains !allowed_domains_regex" when both-protocol domains AND patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', '*.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny !allowed_domains !allowed_domains_regex'); + }); + + it('returns "deny all" when only HTTP-only plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTPS-only plain domains are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTP-only patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + + it('returns "deny all" when only HTTPS-only patterns are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { denyRule } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(denyRule).toBe('http_access deny all'); + }); + }); + + // ── HTTP-only protocol rules ──────────────────────────────────────────────── + + describe('HTTP-only protocol rules', () => { + it('generates "allow !CONNECT allowed_http_only" for plain HTTP-only domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only'); + }); + + it('generates "allow !CONNECT allowed_http_only_regex" for HTTP-only patterns', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only_regex'); + }); + + it('generates both !CONNECT rules when HTTP-only domains and patterns coexist', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([ + 'http://metrics.example.com', + 'http://*.metrics2.example.com', + ]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only'); + expect(accessRulesSection).toContain('http_access allow !CONNECT allowed_http_only_regex'); + }); + }); + + // ── HTTPS-only protocol rules ─────────────────────────────────────────────── + + describe('HTTPS-only protocol rules', () => { + it('generates "allow CONNECT allowed_https_only" for plain HTTPS-only domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only'); + }); + + it('generates "allow CONNECT allowed_https_only_regex" for HTTPS-only patterns', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only_regex'); + }); + + it('generates both CONNECT rules when HTTPS-only domains and patterns coexist', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([ + 'https://secure.example.com', + 'https://*.secure2.example.com', + ]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only'); + expect(accessRulesSection).toContain('http_access allow CONNECT allowed_https_only_regex'); + }); + }); + + // ── accessRulesSection structure ──────────────────────────────────────────── + + describe('accessRulesSection', () => { + it('is empty string when there are no protocol-specific rules and no blocked rules', () => { + // Only both-protocol domains → no protocol rules, no blocked rules + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toBe(''); + }); + + it('is empty string for a fully empty config with no blocked rules', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toBe(''); + }); + + it('includes blocked rules section header when blocked access rules are provided', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const blockedAccessRules = ['http_access deny blocked_domains']; + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, blockedAccessRules); + + expect(accessRulesSection).toContain('# Deny requests to blocked domains (blocklist takes precedence)'); + expect(accessRulesSection).toContain('http_access deny blocked_domains'); + }); + + it('includes protocol rules section header when protocol-specific rules are present', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection).toContain('# Protocol-specific domain access rules'); + }); + + it('positions blocked rules before protocol-specific rules', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const blockedAccessRules = ['http_access deny blocked_domains']; + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, blockedAccessRules); + + const blockedHeaderPos = accessRulesSection.indexOf('# Deny requests to blocked domains'); + const protocolHeaderPos = accessRulesSection.indexOf('# Protocol-specific domain access rules'); + expect(blockedHeaderPos).toBeLessThan(protocolHeaderPos); + }); + + it('ends with a trailing newline when non-empty', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { accessRulesSection } = generateAccessRules(domainsByProto, patternsByProto, []); + + expect(accessRulesSection.endsWith('\n')).toBe(true); + }); + }); +}); diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts new file mode 100644 index 000000000..805e51a71 --- /dev/null +++ b/src/squid/acl-generator.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for src/squid/acl-generator.ts – generateAclSections. + * + * Covers every branch in generateDomainAcls and generateBlockedDomainAcls: + * - both-protocol plain domains → allowed_domains ACL + * - both-protocol wildcard patterns → allowed_domains_regex ACL + * - HTTP-only plain domains → allowed_http_only ACL + * - HTTP-only wildcard patterns → allowed_http_only_regex ACL + * - HTTPS-only plain domains → allowed_https_only ACL + * - HTTPS-only wildcard patterns → allowed_https_only_regex ACL + * - Blocked plain domains → blocked_domains ACL + deny rule + * - Blocked wildcard patterns → blocked_domains_regex ACL + deny rule + * - Protocol-prefix / trailing-slash stripping for blocked domains + * - Empty / undefined blocked domains → no blocked config + */ +import { generateAclSections } from './acl-generator'; +import { parseDomainConfig } from './domain-acl'; + +describe('generateAclSections', () => { + // ── Empty config ──────────────────────────────────────────────────────────── + + describe('empty domain config', () => { + it('returns empty aclLines and empty blockedDomainConfig', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const result = generateAclSections(domainsByProto, patternsByProto); + + expect(result.aclLines).toEqual([]); + expect(result.blockedDomainConfig.aclLines).toEqual([]); + expect(result.blockedDomainConfig.accessRules).toEqual([]); + }); + }); + + // ── Both-protocol plain domains ───────────────────────────────────────────── + + describe('both-protocol plain domains', () => { + it('generates allowed_domains ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for allowed domains (HTTP and HTTPS)'); + expect(aclLines.some(l => l.startsWith('acl allowed_domains dstdomain') && l.includes('github.com'))).toBe(true); + }); + + it('generates one ACL entry per domain', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'npmjs.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const domainAcls = aclLines.filter(l => l.startsWith('acl allowed_domains dstdomain')); + expect(domainAcls).toHaveLength(2); + }); + + it('uses formatDomainForSquid (leading dot) for domain values', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const aclLine = aclLines.find(l => l.startsWith('acl allowed_domains dstdomain')); + expect(aclLine).toMatch(/\.github\.com/); + }); + }); + + // ── Both-protocol wildcard patterns ───────────────────────────────────────── + + describe('both-protocol wildcard patterns', () => { + it('generates allowed_domains_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['*.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for allowed domain patterns (HTTP and HTTPS)'); + expect(aclLines.some(l => l.startsWith('acl allowed_domains_regex dstdom_regex -i'))).toBe(true); + }); + + it('inserts a blank separator before the regex ACL section when plain domains precede it', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', '*.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const patternHeaderIdx = aclLines.indexOf('# ACL definitions for allowed domain patterns (HTTP and HTTPS)'); + expect(patternHeaderIdx).toBeGreaterThan(0); + expect(aclLines[patternHeaderIdx - 1]).toBe(''); + }); + }); + + // ── HTTP-only plain domains ───────────────────────────────────────────────── + + describe('HTTP-only plain domains', () => { + it('generates allowed_http_only ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTP-only domains'); + expect( + aclLines.some(l => l.startsWith('acl allowed_http_only dstdomain') && l.includes('metrics.example.com')) + ).toBe(true); + }); + + it('inserts blank separator before the HTTP-only section', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'http://metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const headerIdx = aclLines.indexOf('# ACL definitions for HTTP-only domains'); + expect(headerIdx).toBeGreaterThan(0); + expect(aclLines[headerIdx - 1]).toBe(''); + }); + }); + + // ── HTTP-only wildcard patterns ───────────────────────────────────────────── + + describe('HTTP-only wildcard patterns', () => { + it('generates allowed_http_only_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTP-only domain patterns'); + expect(aclLines.some(l => l.startsWith('acl allowed_http_only_regex dstdom_regex -i'))).toBe(true); + }); + }); + + // ── HTTPS-only plain domains ──────────────────────────────────────────────── + + describe('HTTPS-only plain domains', () => { + it('generates allowed_https_only ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTPS-only domains'); + expect( + aclLines.some(l => l.startsWith('acl allowed_https_only dstdomain') && l.includes('secure.example.com')) + ).toBe(true); + }); + + it('inserts blank separator before the HTTPS-only section', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com', 'https://secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + const headerIdx = aclLines.indexOf('# ACL definitions for HTTPS-only domains'); + expect(headerIdx).toBeGreaterThan(0); + expect(aclLines[headerIdx - 1]).toBe(''); + }); + }); + + // ── HTTPS-only wildcard patterns ──────────────────────────────────────────── + + describe('HTTPS-only wildcard patterns', () => { + it('generates allowed_https_only_regex ACL with section header', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + const { aclLines } = generateAclSections(domainsByProto, patternsByProto); + + expect(aclLines).toContain('# ACL definitions for HTTPS-only domain patterns'); + expect(aclLines.some(l => l.startsWith('acl allowed_https_only_regex dstdom_regex -i'))).toBe(true); + }); + }); + + // ── Blocked domains – plain ───────────────────────────────────────────────── + + describe('blocked plain domains', () => { + it('generates blocked_domains ACL and http_access deny rule', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com']); + + expect(blockedDomainConfig.aclLines).toContain('# ACL definitions for blocked domains'); + expect( + blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains dstdomain') && l.includes('evil.com')) + ).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + }); + + it('strips https:// prefix from blocked domains before generating the ACL', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['https://evil.com']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('https://'); + }); + + it('strips http:// prefix from blocked domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['http://evil.com']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('http://'); + }); + + it('strips trailing slash from blocked domains', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com/']); + + const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + expect(aclLine).toBeDefined(); + expect(aclLine).not.toContain('/'); + }); + + it('works on a stand-alone allowlist (no allowed domains)', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com']); + + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + }); + }); + + // ── Blocked domains – wildcard patterns ──────────────────────────────────── + + describe('blocked wildcard patterns', () => { + it('generates blocked_domains_regex ACL and http_access deny rule', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['*.evil.com']); + + expect(blockedDomainConfig.aclLines).toContain('# ACL definitions for blocked domain patterns (wildcard)'); + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains_regex dstdom_regex -i'))).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains_regex'); + }); + }); + + // ── Blocked domains – mixed plain + wildcard ──────────────────────────────── + + describe('blocked mixed plain and wildcard', () => { + it('generates both blocked_domains and blocked_domains_regex ACLs', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, [ + 'evil.com', + '*.malware.net', + ]); + + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains dstdomain'))).toBe(true); + expect(blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains_regex dstdom_regex -i'))).toBe(true); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); + expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains_regex'); + }); + }); + + // ── Empty / undefined blocked domains ────────────────────────────────────── + + describe('no blocked domains', () => { + it('returns empty blocked config when blockedDomains is an empty array', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, []); + + expect(blockedDomainConfig.aclLines).toEqual([]); + expect(blockedDomainConfig.accessRules).toEqual([]); + }); + + it('returns empty blocked config when blockedDomains is undefined', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); + const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, undefined); + + expect(blockedDomainConfig.aclLines).toEqual([]); + expect(blockedDomainConfig.accessRules).toEqual([]); + }); + }); +}); diff --git a/src/squid/domain-acl.test.ts b/src/squid/domain-acl.test.ts new file mode 100644 index 000000000..98d9b82e1 --- /dev/null +++ b/src/squid/domain-acl.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for src/squid/domain-acl.ts. + * + * Covers all public functions: + * assertSafeForSquidConfig – injection-prevention defence-in-depth + * formatDomainForSquid – leading-dot canonicalisation + * parseDomainConfig – domain parsing, grouping, deduplication + */ +import { assertSafeForSquidConfig, formatDomainForSquid, parseDomainConfig } from './domain-acl'; + +// ── assertSafeForSquidConfig ──────────────────────────────────────────────── + +describe('assertSafeForSquidConfig', () => { + it('returns safe domain strings unchanged', () => { + expect(assertSafeForSquidConfig('github.com')).toBe('github.com'); + expect(assertSafeForSquidConfig('.github.com')).toBe('.github.com'); + expect(assertSafeForSquidConfig('api.internal-service.example.com')).toBe('api.internal-service.example.com'); + }); + + it('throws for strings containing whitespace', () => { + expect(() => assertSafeForSquidConfig('github.com evil.com')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com\tevil.com')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com\nevil.com')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a double quote', () => { + expect(() => assertSafeForSquidConfig('"github.com"')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a single quote', () => { + expect(() => assertSafeForSquidConfig("github.com'")).toThrow(/SECURITY/); + }); + + it('throws for strings containing a semicolon', () => { + expect(() => assertSafeForSquidConfig('github.com;http_access allow all')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a backtick', () => { + expect(() => assertSafeForSquidConfig('github.com`rm -rf /`')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a hash / comment character', () => { + expect(() => assertSafeForSquidConfig('github.com # allow all')).toThrow(/SECURITY/); + }); + + it('throws for strings containing a null byte', () => { + expect(() => assertSafeForSquidConfig('github.com\x00evil')).toThrow(/SECURITY/); + }); +}); + +// ── formatDomainForSquid ─────────────────────────────────────────────────── + +describe('formatDomainForSquid', () => { + it('prepends a dot to a plain domain', () => { + expect(formatDomainForSquid('github.com')).toBe('.github.com'); + }); + + it('leaves a domain that already starts with a dot unchanged', () => { + expect(formatDomainForSquid('.github.com')).toBe('.github.com'); + }); + + it('handles multi-level subdomains', () => { + expect(formatDomainForSquid('api.internal.example.com')).toBe('.api.internal.example.com'); + }); + + it('throws for domains containing dangerous characters', () => { + expect(() => formatDomainForSquid('evil.com;http_access allow all')).toThrow(/SECURITY/); + }); +}); + +// ── parseDomainConfig ────────────────────────────────────────────────────── + +describe('parseDomainConfig', () => { + describe('empty input', () => { + it('returns empty domain groups for an empty array', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig([]); + + expect(domainsByProto.both).toEqual([]); + expect(domainsByProto.http).toEqual([]); + expect(domainsByProto.https).toEqual([]); + expect(patternsByProto.both).toEqual([]); + expect(patternsByProto.http).toEqual([]); + expect(patternsByProto.https).toEqual([]); + }); + }); + + describe('protocol grouping', () => { + it('places a plain domain with no protocol prefix in domainsByProto.both', () => { + const { domainsByProto } = parseDomainConfig(['github.com']); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.http).toHaveLength(0); + expect(domainsByProto.https).toHaveLength(0); + }); + + it('places an http:// prefixed domain in domainsByProto.http', () => { + const { domainsByProto } = parseDomainConfig(['http://metrics.internal.com']); + expect(domainsByProto.http).toContain('metrics.internal.com'); + expect(domainsByProto.both).toHaveLength(0); + expect(domainsByProto.https).toHaveLength(0); + }); + + it('places an https:// prefixed domain in domainsByProto.https', () => { + const { domainsByProto } = parseDomainConfig(['https://secure.example.com']); + expect(domainsByProto.https).toContain('secure.example.com'); + expect(domainsByProto.both).toHaveLength(0); + expect(domainsByProto.http).toHaveLength(0); + }); + }); + + describe('wildcard patterns', () => { + it('places a wildcard domain in patternsByProto.both', () => { + const { patternsByProto, domainsByProto } = parseDomainConfig(['*.example.com']); + expect(patternsByProto.both).toHaveLength(1); + expect(domainsByProto.both).toHaveLength(0); + expect(patternsByProto.both[0]).toHaveProperty('regex'); + }); + + it('places an http:// wildcard in patternsByProto.http', () => { + const { patternsByProto } = parseDomainConfig(['http://*.metrics.example.com']); + expect(patternsByProto.http).toHaveLength(1); + expect(patternsByProto.http[0]).toHaveProperty('regex'); + }); + + it('places an https:// wildcard in patternsByProto.https', () => { + const { patternsByProto } = parseDomainConfig(['https://*.secure.example.com']); + expect(patternsByProto.https).toHaveLength(1); + expect(patternsByProto.https[0]).toHaveProperty('regex'); + }); + }); + + describe('subdomain deduplication', () => { + it('removes a subdomain when its parent domain is also in the list', () => { + const { domainsByProto } = parseDomainConfig(['api.github.com', 'github.com']); + // api.github.com is redundant: github.com already covers it + expect(domainsByProto.both).not.toContain('api.github.com'); + expect(domainsByProto.both).toContain('github.com'); + }); + + it('keeps both domains when they share no parent-child relationship', () => { + const { domainsByProto } = parseDomainConfig(['github.com', 'npmjs.com']); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.both).toContain('npmjs.com'); + }); + }); + + describe('wildcard pattern coverage deduplication', () => { + it('removes a plain subdomain already matched by a wildcard pattern', () => { + const { domainsByProto, patternsByProto } = parseDomainConfig(['api.github.com', '*.github.com']); + // api.github.com is covered by *.github.com — should be filtered out + expect(domainsByProto.both).not.toContain('api.github.com'); + expect(patternsByProto.both).toHaveLength(1); + }); + }); + + describe('mixed protocol domains', () => { + it('correctly groups multiple domains with different protocols', () => { + const { domainsByProto } = parseDomainConfig([ + 'github.com', + 'http://metrics.internal.com', + 'https://secure.example.com', + ]); + expect(domainsByProto.both).toContain('github.com'); + expect(domainsByProto.http).toContain('metrics.internal.com'); + expect(domainsByProto.https).toContain('secure.example.com'); + }); + }); +}); From 5ecaef39262f69444929967a6c939bfdf74a8ea7 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 15 Jun 2026 07:59:41 -0700 Subject: [PATCH 2/6] fix: remove whitespace from injection test inputs to isolate character checks Semicolon, backtick, and hash tests now use inputs without whitespace so they specifically test each character's rejection rather than accidentally passing due to the whitespace check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/squid/domain-acl.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/squid/domain-acl.test.ts b/src/squid/domain-acl.test.ts index 98d9b82e1..01123b683 100644 --- a/src/squid/domain-acl.test.ts +++ b/src/squid/domain-acl.test.ts @@ -32,15 +32,15 @@ describe('assertSafeForSquidConfig', () => { }); it('throws for strings containing a semicolon', () => { - expect(() => assertSafeForSquidConfig('github.com;http_access allow all')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com;evil')).toThrow(/SECURITY/); }); it('throws for strings containing a backtick', () => { - expect(() => assertSafeForSquidConfig('github.com`rm -rf /`')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com`evil`')).toThrow(/SECURITY/); }); it('throws for strings containing a hash / comment character', () => { - expect(() => assertSafeForSquidConfig('github.com # allow all')).toThrow(/SECURITY/); + expect(() => assertSafeForSquidConfig('github.com#evil')).toThrow(/SECURITY/); }); it('throws for strings containing a null byte', () => { From 345afbe34e33480076aca1e4001d98aac09a31f1 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 15 Jun 2026 08:12:46 -0700 Subject: [PATCH 3/6] Potential fix for pull request finding 'CodeQL / Missing regular expression anchor' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/squid/acl-generator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts index 805e51a71..412d137ad 100644 --- a/src/squid/acl-generator.test.ts +++ b/src/squid/acl-generator.test.ts @@ -54,7 +54,7 @@ describe('generateAclSections', () => { const { aclLines } = generateAclSections(domainsByProto, patternsByProto); const aclLine = aclLines.find(l => l.startsWith('acl allowed_domains dstdomain')); - expect(aclLine).toMatch(/\.github\.com/); + expect(aclLine).toMatch(/^acl allowed_domains dstdomain \.github\.com$/); }); }); From 8bec2f0d01bb1cbfec9065df7c2f78bb978b4e5e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 15 Jun 2026 08:13:01 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/squid/acl-generator.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts index 412d137ad..cfca376a2 100644 --- a/src/squid/acl-generator.test.ts +++ b/src/squid/acl-generator.test.ts @@ -88,7 +88,11 @@ describe('generateAclSections', () => { expect(aclLines).toContain('# ACL definitions for HTTP-only domains'); expect( - aclLines.some(l => l.startsWith('acl allowed_http_only dstdomain') && l.includes('metrics.example.com')) + aclLines.some( + l => + l.startsWith('acl allowed_http_only dstdomain') && + /\b\.metrics\.example\.com\b/.test(l) + ) ).toBe(true); }); From e8d0c3a07993003ca59478b3474b5029e43474f1 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 15 Jun 2026 08:13:21 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/squid/acl-generator.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts index cfca376a2..0af4e252e 100644 --- a/src/squid/acl-generator.test.ts +++ b/src/squid/acl-generator.test.ts @@ -162,7 +162,11 @@ describe('generateAclSections', () => { expect(blockedDomainConfig.aclLines).toContain('# ACL definitions for blocked domains'); expect( - blockedDomainConfig.aclLines.some(l => l.startsWith('acl blocked_domains dstdomain') && l.includes('evil.com')) + blockedDomainConfig.aclLines.some(l => { + if (!l.startsWith('acl blocked_domains dstdomain')) return false; + const tokens = l.trim().split(/\s+/); + return tokens.includes('evil.com'); + }) ).toBe(true); expect(blockedDomainConfig.accessRules).toContain('http_access deny blocked_domains'); }); From b2e32a30ce90afbbd561e2b2dfcd8bdf18cae479 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 15 Jun 2026 08:13:40 -0700 Subject: [PATCH 6/6] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/squid/acl-generator.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/squid/acl-generator.test.ts b/src/squid/acl-generator.test.ts index 0af4e252e..9571687ef 100644 --- a/src/squid/acl-generator.test.ts +++ b/src/squid/acl-generator.test.ts @@ -175,7 +175,7 @@ describe('generateAclSections', () => { const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['https://evil.com']); - const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); expect(aclLine).toBeDefined(); expect(aclLine).not.toContain('https://'); }); @@ -184,7 +184,7 @@ describe('generateAclSections', () => { const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['http://evil.com']); - const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); expect(aclLine).toBeDefined(); expect(aclLine).not.toContain('http://'); }); @@ -193,7 +193,7 @@ describe('generateAclSections', () => { const { domainsByProto, patternsByProto } = parseDomainConfig(['github.com']); const { blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, ['evil.com/']); - const aclLine = blockedDomainConfig.aclLines.find(l => l.includes('evil.com')); + const aclLine = blockedDomainConfig.aclLines.find(l => l.split(/\s+/).includes('evil.com')); expect(aclLine).toBeDefined(); expect(aclLine).not.toContain('/'); });