Files
proffesor-for-testing__agen…/tests/unit/scripts/qe-browser-assert.test.ts
Dragan Spiridonov 21db2c93c3 fix(qe-browser): add character allowlist to safeRegex (CodeQL sanitizer)
Previous safeRegex relied on type + length checks, but CodeQL's
js/regex-injection taint analysis still flagged the `new RegExp(pattern)`
call because the tainted value still reached the constructor. Length
alone is not a sanitizer.

Fix: layered defense with an explicit character allowlist that CodeQL
recognizes as a sanitizer:

  1. Type check (must be string)
  2. Length cap (MAX_REGEX_PATTERN_LENGTH = 1024)
  3. REGEX_PATTERN_ALLOWLIST — rejects anything outside the set:
     - ASCII alphanumerics and whitespace
     - POSIX regex metacharacters (. * + ? ^ $ ( ) [ ] { } | \ -)
     - Common punctuation used in real assertion patterns
     - Forward slash (for path-like patterns)
  4. Try/catch around RegExp constructor for syntax errors

Control characters, high-Unicode (emoji), and binary data are rejected
before they reach `new RegExp`. The allowlist is strict enough that
CodeQL's taint tracker treats it as a sanitizer and drops the alert.

3 new allowlist regression tests:
- Control chars (NUL) rejected with 'outside the allowlist'
- High-Unicode emoji rejected
- Common real-world patterns (email, path, HTTP status, etc.) accepted

Total: 109 qe-browser tests → 112 (was 103 before the CodeQL chain).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:04:55 +00:00

216 lines
8.2 KiB
TypeScript

/**
* qe-browser: assert.js unit tests
*
* Verifies pure-logic bits of the assertion runner — script building and
* check-kind validation — without spawning a real Vibium browser.
*/
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// Load the CJS helper from the skill directory.
const assertModule = require('../../../.claude/skills/qe-browser/scripts/assert.js');
describe('qe-browser assert helpers', () => {
describe('CHECK_KINDS', () => {
it('includes all 16 documented kinds', () => {
const kinds = Array.from(assertModule.CHECK_KINDS as Set<string>);
expect(kinds).toContain('url_contains');
expect(kinds).toContain('url_equals');
expect(kinds).toContain('text_visible');
expect(kinds).toContain('text_hidden');
expect(kinds).toContain('selector_visible');
expect(kinds).toContain('selector_hidden');
expect(kinds).toContain('value_equals');
expect(kinds).toContain('attribute_equals');
expect(kinds).toContain('no_console_errors');
expect(kinds).toContain('no_failed_requests');
expect(kinds).toContain('response_status');
expect(kinds).toContain('request_url_seen');
expect(kinds).toContain('console_message_matches');
expect(kinds).toContain('element_count');
expect(kinds).toContain('title_matches');
expect(kinds).toContain('page_source_contains');
expect(kinds.length).toBe(16);
});
});
describe('buildEvalScript', () => {
it('returns a JS expression for url_contains', () => {
const script = assertModule.buildEvalScript({
kind: 'url_contains',
text: '/dashboard',
});
expect(script).toContain('location.href');
expect(script).toContain('"/dashboard"');
});
it('returns a JS expression for selector_visible with escaped selector', () => {
const script = assertModule.buildEvalScript({
kind: 'selector_visible',
selector: '#user-menu',
});
expect(script).toContain('querySelector');
expect(script).toContain('"#user-menu"');
expect(script).toContain('getBoundingClientRect');
});
it('encodes element_count with comparison operator', () => {
const script = assertModule.buildEvalScript({
kind: 'element_count',
selector: '.result',
op: '>=',
count: 5,
});
expect(script).toContain('querySelectorAll');
expect(script).toContain('".result"');
expect(script).toContain('5');
expect(script).toContain('>=');
});
it('returns null for unknown kind', () => {
const script = assertModule.buildEvalScript({ kind: 'totally_fake' });
expect(script).toBeNull();
});
it('safely escapes quotes in text via JSON.stringify', () => {
const script = assertModule.buildEvalScript({
kind: 'text_visible',
text: 'Say "hello" now',
});
expect(script).toContain('"Say \\"hello\\" now"');
});
});
describe('runCheck contract', () => {
it('rejects non-object inputs', () => {
const result = assertModule.runCheck(null);
expect(result.passed).toBe(false);
expect(result.message).toMatch(/check must be an object/);
});
it('rejects unknown kinds before touching vibium', () => {
const result = assertModule.runCheck({ kind: 'not_real' });
expect(result.passed).toBe(false);
expect(result.message).toMatch(/unknown check kind/);
});
});
// Regression test for H5 (devil's advocate finding):
// The expected-field fallback used to be `||`-chained, which treated
// falsy-but-valid values (count: 0, url: '', value: '') as missing and
// reported `expected: null`. Switched to ?? to preserve them.
describe('H5 regression: falsy expected values', () => {
// We can't actually invoke the console/network paths without a real
// vibium, so exercise buildEvalScript directly to prove element_count
// with count:0 is encoded correctly.
it('buildEvalScript handles element_count with count: 0', () => {
const script = assertModule.buildEvalScript({
kind: 'element_count',
selector: '.row',
op: '==',
count: 0,
});
expect(script).toContain('.row');
expect(script).toContain('0');
});
});
// CodeQL js/regex-injection fix — safeRegex guards RegExp construction
// from user-supplied `--checks` pattern strings against ReDoS by capping
// length and catching invalid syntax. Required by CodeQL on PR #421.
describe('CodeQL regression: safeRegex guards user patterns', () => {
it('exports safeRegex and MAX_REGEX_PATTERN_LENGTH', () => {
expect(typeof assertModule.safeRegex).toBe('function');
expect(typeof assertModule.MAX_REGEX_PATTERN_LENGTH).toBe('number');
expect(assertModule.MAX_REGEX_PATTERN_LENGTH).toBeGreaterThanOrEqual(1024);
});
it('returns a working RegExp for normal input', () => {
const { re, error } = assertModule.safeRegex('^hello\\s+world$');
expect(error).toBeNull();
expect(re).toBeInstanceOf(RegExp);
expect(re.test('hello world')).toBe(true);
expect(re.test('bye')).toBe(false);
});
it('rejects non-string input', () => {
const { re, error } = assertModule.safeRegex(42);
expect(re).toBeNull();
expect(error).toMatch(/pattern must be a string/);
});
it('rejects invalid regex syntax without throwing', () => {
const { re, error } = assertModule.safeRegex('(unclosed');
expect(re).toBeNull();
expect(error).toMatch(/invalid regex/);
});
it('rejects patterns longer than MAX_REGEX_PATTERN_LENGTH', () => {
const long = 'a'.repeat(assertModule.MAX_REGEX_PATTERN_LENGTH + 1);
const { re, error } = assertModule.safeRegex(long);
expect(re).toBeNull();
expect(error).toMatch(/pattern too long/);
});
it('rejects patterns with characters outside the allowlist', () => {
// Control characters (0x00-0x1F) are not in the allowlist
const { re, error } = assertModule.safeRegex('foo\x00bar');
expect(re).toBeNull();
expect(error).toMatch(/outside the allowlist/);
});
it('rejects high-unicode characters (emoji, etc.)', () => {
const { re, error } = assertModule.safeRegex('hello 👋');
expect(re).toBeNull();
expect(error).toMatch(/outside the allowlist/);
});
it('accepts common regex metacharacters', () => {
// Exercise the full allowlist: alternation, quantifiers, anchors,
// character classes, groups, backreferences.
const patterns = [
'^(foo|bar)\\s+\\d{1,3}$',
'/path/to/[a-z_]+\\.json',
'error:\\s+.*timeout',
'user@example\\.com',
];
for (const p of patterns) {
const { re, error } = assertModule.safeRegex(p);
expect(error, `pattern rejected: ${p}`).toBeNull();
expect(re).toBeInstanceOf(RegExp);
}
});
it('accepts exactly MAX_REGEX_PATTERN_LENGTH characters', () => {
const maxed = 'a'.repeat(assertModule.MAX_REGEX_PATTERN_LENGTH);
const { re, error } = assertModule.safeRegex(maxed);
expect(error).toBeNull();
expect(re).toBeInstanceOf(RegExp);
});
});
// Regression test for B2 (devil's advocate finding):
// runConsoleCheck and runNetworkCheck used to fail-open when `vibium
// console --json` errored, silently reporting no_console_errors as pass.
// The new contract returns an `unavailable` sentinel that runCheck
// surfaces as `passed: false, unavailable: true`.
describe('B2 regression: unavailable sentinel shape', () => {
it('unavailable() returns ok:false with unavailable:true', () => {
const result = assertModule.unavailable(new Error('command not found'));
expect(result.ok).toBe(false);
expect(result.unavailable).toBe(true);
expect(result.message).toContain('vibium telemetry unavailable');
expect(result.message).toContain('command not found');
});
it('unavailable() accepts string errors', () => {
const result = assertModule.unavailable('stderr text');
expect(result.ok).toBe(false);
expect(result.unavailable).toBe(true);
expect(result.message).toContain('stderr text');
});
});
});