mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
21db2c93c3
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>
216 lines
8.2 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|