mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 18:32:19 +02:00
fix(security): delete dead exports the ripped chat path left behind
Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
44d58aa6ee
commit
ef186cccb3
@@ -98,11 +98,6 @@ export function buildPtySetCookie(token: string): string {
|
|||||||
return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the PTY session cookie. */
|
|
||||||
export function buildPtyClearCookie(): string {
|
|
||||||
return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpired(now: number): void {
|
function pruneExpired(now: number): void {
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
for (const [token, session] of sessions) {
|
for (const [token, session] of sessions) {
|
||||||
|
|||||||
+5
-284
@@ -309,222 +309,13 @@ export function checkCanaryInStructure(value: unknown, canary: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Attack logging ──────────────────────────────────────────
|
// NOTE: attack-attempt logging (logAttempt + salted payload hashing +
|
||||||
|
// attempts.jsonl rotation + telemetry spawn plumbing) lived here until the
|
||||||
export interface AttemptRecord {
|
// chat-path scanner that called it was ripped with sidebar-agent.ts. The
|
||||||
ts: string;
|
// LIVE attempts.jsonl writer is tunnel-denial-log.ts, which owns its own
|
||||||
urlDomain: string;
|
// rotation.
|
||||||
payloadHash: string;
|
|
||||||
confidence: number;
|
|
||||||
layer: LayerName;
|
|
||||||
verdict: Verdict;
|
|
||||||
gstackVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECURITY_DIR = path.join(os.homedir(), '.gstack', 'security');
|
const SECURITY_DIR = path.join(os.homedir(), '.gstack', 'security');
|
||||||
const ATTEMPTS_LOG = path.join(SECURITY_DIR, 'attempts.jsonl');
|
|
||||||
const SALT_FILE = path.join(SECURITY_DIR, 'device-salt');
|
|
||||||
const MAX_LOG_BYTES = 10 * 1024 * 1024; // 10MB rotate threshold (eng review 4.1)
|
|
||||||
const MAX_LOG_GENERATIONS = 5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-or-create the per-device salt used for payload hashing. Salt lives at
|
|
||||||
* ~/.gstack/security/device-salt (0600). Random per-device, prevents rainbow
|
|
||||||
* table attacks across devices (Codex tier-2 finding).
|
|
||||||
*/
|
|
||||||
let cachedSalt: string | null = null;
|
|
||||||
|
|
||||||
function getDeviceSalt(): string {
|
|
||||||
if (cachedSalt) return cachedSalt;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(SALT_FILE)) {
|
|
||||||
cachedSalt = fs.readFileSync(SALT_FILE, 'utf8').trim();
|
|
||||||
return cachedSalt;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to generate
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
mkdirSecure(SECURITY_DIR);
|
|
||||||
} catch {}
|
|
||||||
cachedSalt = randomBytes(16).toString('hex');
|
|
||||||
try {
|
|
||||||
writeSecureFile(SALT_FILE, cachedSalt);
|
|
||||||
} catch {
|
|
||||||
// Can't persist (read-only fs, disk full). Keep the in-memory salt
|
|
||||||
// for this process so cross-log correlation still works within a
|
|
||||||
// session. Next process gets a new salt, but that's a degraded-mode
|
|
||||||
// acceptable cost.
|
|
||||||
}
|
|
||||||
return cachedSalt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashPayload(payload: string): string {
|
|
||||||
const salt = getDeviceSalt();
|
|
||||||
return createHash('sha256').update(salt).update(payload).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate attempts.jsonl when it exceeds 10MB. Keeps 5 generations.
|
|
||||||
*/
|
|
||||||
function rotateIfNeeded(): void {
|
|
||||||
try {
|
|
||||||
const st = fs.statSync(ATTEMPTS_LOG);
|
|
||||||
if (st.size < MAX_LOG_BYTES) return;
|
|
||||||
} catch {
|
|
||||||
return; // doesn't exist, nothing to rotate
|
|
||||||
}
|
|
||||||
// Shift .N -> .N+1, drop oldest
|
|
||||||
for (let i = MAX_LOG_GENERATIONS - 1; i >= 1; i--) {
|
|
||||||
const src = `${ATTEMPTS_LOG}.${i}`;
|
|
||||||
const dst = `${ATTEMPTS_LOG}.${i + 1}`;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.renameSync(ATTEMPTS_LOG, `${ATTEMPTS_LOG}.1`);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to locate the gstack-telemetry-log binary. Resolution order matches
|
|
||||||
* the existing skill preamble pattern (never relies on PATH — packaged
|
|
||||||
* binary layouts can break that).
|
|
||||||
*
|
|
||||||
* Order:
|
|
||||||
* 1. ~/.claude/skills/gstack/bin/gstack-telemetry-log (global install)
|
|
||||||
* 2. .claude/skills/gstack/bin/gstack-telemetry-log (symlinked dev)
|
|
||||||
* 3. bin/gstack-telemetry-log (in-repo dev)
|
|
||||||
*/
|
|
||||||
function findTelemetryBinary(): string | null {
|
|
||||||
const candidates = [
|
|
||||||
path.join(os.homedir(), '.claude', 'skills', 'gstack', 'bin', 'gstack-telemetry-log'),
|
|
||||||
path.resolve(process.cwd(), '.claude', 'skills', 'gstack', 'bin', 'gstack-telemetry-log'),
|
|
||||||
path.resolve(process.cwd(), 'bin', 'gstack-telemetry-log'),
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
try {
|
|
||||||
fs.accessSync(c, fs.constants.X_OK);
|
|
||||||
return c;
|
|
||||||
} catch {
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a bash binary for invoking shebang scripts on Windows. Mirrors the
|
|
||||||
* GSTACK_*_BIN override pattern from `browse/src/claude-bin.ts:resolveClaudeCommand`
|
|
||||||
* (introduced in v1.24.0.0 #1252) so users on WSL/MSYS2/non-default Git Bash
|
|
||||||
* installs can redirect.
|
|
||||||
*
|
|
||||||
* Override precedence:
|
|
||||||
* 1. GSTACK_BASH_BIN (or BASH_BIN) — absolute path or PATH-resolvable command.
|
|
||||||
* 2. Plain Bun.which('bash') — finds Git Bash on the standard Windows install.
|
|
||||||
*
|
|
||||||
* Returns null if nothing resolves; callers must degrade gracefully (telemetry
|
|
||||||
* already swallows spawn errors, so a null here means the local attempts.jsonl
|
|
||||||
* audit trail keeps working without surfacing a Windows-only failure).
|
|
||||||
*/
|
|
||||||
export function resolveBashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
||||||
const PATH = env.PATH ?? env.Path ?? '';
|
|
||||||
const override = (env.GSTACK_BASH_BIN ?? env.BASH_BIN)?.trim();
|
|
||||||
if (override) {
|
|
||||||
const trimmed = override.replace(/^"(.*)"$/, '$1');
|
|
||||||
return path.isAbsolute(trimmed) ? trimmed : (Bun.which(trimmed, { PATH }) ?? null);
|
|
||||||
}
|
|
||||||
return Bun.which('bash', { PATH }) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the [cmd, args] tuple for invoking a bash-script telemetry binary
|
|
||||||
* in a way that works on both POSIX and Windows.
|
|
||||||
*
|
|
||||||
* POSIX: returns [bin, args] unchanged — shebang gets honored by execve.
|
|
||||||
* Win32: wraps in bash explicitly. `gstack-telemetry-log` is a shell script
|
|
||||||
* (`#!/usr/bin/env bash`) and Windows `CreateProcess` can't dispatch on a
|
|
||||||
* shebang — it tries to load the file as a PE image, fails with ENOEXEC,
|
|
||||||
* and our 'error' handler silently swallows it. Resolves bash via the same
|
|
||||||
* Bun.which + GSTACK_*_BIN override pattern as claude-bin.ts.
|
|
||||||
*
|
|
||||||
* Returns null when bash can't be resolved on Windows (rare — Git Bash ships
|
|
||||||
* with the standard gstack install path). Caller skips spawn; the local
|
|
||||||
* attempts.jsonl write still gives the audit trail.
|
|
||||||
*
|
|
||||||
* Exported for testability — resolution is a pure function of (platform,
|
|
||||||
* env, bin, args) so we can assert on it without actually spawning.
|
|
||||||
*/
|
|
||||||
export function buildTelemetrySpawnCommand(
|
|
||||||
bin: string,
|
|
||||||
args: string[],
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
): { cmd: string; cmdArgs: string[] } | null {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const bashPath = resolveBashBinary(env);
|
|
||||||
if (!bashPath) return null;
|
|
||||||
return { cmd: bashPath, cmdArgs: [bin, ...args] };
|
|
||||||
}
|
|
||||||
return { cmd: bin, cmdArgs: args };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire-and-forget subprocess invocation of gstack-telemetry-log with the
|
|
||||||
* attack_attempt event type. The binary handles tier gating internally
|
|
||||||
* (community → upload, anonymous → local only, off → no-op), so we don't
|
|
||||||
* need to re-check here.
|
|
||||||
*
|
|
||||||
* Never throws. Never blocks. If the binary isn't found or spawn fails, the
|
|
||||||
* local attempts.jsonl write from logAttempt() still gives us the audit trail.
|
|
||||||
*/
|
|
||||||
function reportAttemptTelemetry(record: AttemptRecord): void {
|
|
||||||
const bin = findTelemetryBinary();
|
|
||||||
if (!bin) return;
|
|
||||||
try {
|
|
||||||
const result = buildTelemetrySpawnCommand(bin, [
|
|
||||||
'--event-type', 'attack_attempt',
|
|
||||||
'--url-domain', record.urlDomain || '',
|
|
||||||
'--payload-hash', record.payloadHash,
|
|
||||||
'--confidence', String(record.confidence),
|
|
||||||
'--layer', record.layer,
|
|
||||||
'--verdict', record.verdict,
|
|
||||||
]);
|
|
||||||
if (!result) return;
|
|
||||||
const child = spawn(result.cmd, result.cmdArgs, {
|
|
||||||
stdio: 'ignore',
|
|
||||||
detached: true,
|
|
||||||
});
|
|
||||||
// unref so this subprocess doesn't hold the event loop open
|
|
||||||
child.unref();
|
|
||||||
child.on('error', () => { /* swallow — telemetry must never break sidebar */ });
|
|
||||||
} catch {
|
|
||||||
// Spawn failure is non-fatal.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append an attempt to the local log AND fire telemetry via
|
|
||||||
* gstack-telemetry-log (which respects the user's telemetry tier setting).
|
|
||||||
* Never throws — logging failure should not break the sidebar.
|
|
||||||
* Returns true if the local write succeeded.
|
|
||||||
*/
|
|
||||||
export function logAttempt(record: AttemptRecord): boolean {
|
|
||||||
// Fire telemetry first, async — even if local write fails, we still want
|
|
||||||
// the event reported (it goes to a different directory anyway).
|
|
||||||
reportAttemptTelemetry(record);
|
|
||||||
try {
|
|
||||||
mkdirSecure(SECURITY_DIR);
|
|
||||||
rotateIfNeeded();
|
|
||||||
const line = JSON.stringify(record) + '\n';
|
|
||||||
appendSecureFile(ATTEMPTS_LOG, line);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
// Non-fatal. Log to stderr for debugging but don't block.
|
|
||||||
console.error('[security] logAttempt write failed:', (err as Error).message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cross-process session state ─────────────────────────────
|
// ─── Cross-process session state ─────────────────────────────
|
||||||
|
|
||||||
@@ -565,76 +356,6 @@ export function readSessionState(): SessionState | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User-in-the-loop review on BLOCK ────────────────────────
|
|
||||||
//
|
|
||||||
// When a tool-output BLOCK fires, the user gets to see the suspected text
|
|
||||||
// and decide. The sidepanel posts to /security-decision, server writes a
|
|
||||||
// per-tab file under ~/.gstack/security/decisions/, sidebar-agent polls
|
|
||||||
// for it. File-based on purpose: sidebar-agent.ts is a separate subprocess
|
|
||||||
// and this is the same pattern the existing per-tab cancel file uses.
|
|
||||||
|
|
||||||
const DECISIONS_DIR = path.join(SECURITY_DIR, 'decisions');
|
|
||||||
|
|
||||||
export type SecurityDecision = 'allow' | 'block';
|
|
||||||
|
|
||||||
export function decisionFileForTab(tabId: number): string {
|
|
||||||
return path.join(DECISIONS_DIR, `tab-${tabId}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecisionRecord {
|
|
||||||
tabId: number;
|
|
||||||
decision: SecurityDecision;
|
|
||||||
ts: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeDecision(record: DecisionRecord): void {
|
|
||||||
try {
|
|
||||||
mkdirSecure(DECISIONS_DIR);
|
|
||||||
const file = decisionFileForTab(record.tabId);
|
|
||||||
const tmp = `${file}.tmp.${process.pid}`;
|
|
||||||
writeSecureFile(tmp, JSON.stringify(record));
|
|
||||||
fs.renameSync(tmp, file);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[security] writeDecision failed:', (err as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readDecision(tabId: number): DecisionRecord | null {
|
|
||||||
try {
|
|
||||||
const file = decisionFileForTab(tabId);
|
|
||||||
if (!fs.existsSync(file)) return null;
|
|
||||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDecision(tabId: number): void {
|
|
||||||
try {
|
|
||||||
const file = decisionFileForTab(tabId);
|
|
||||||
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
||||||
} catch {
|
|
||||||
// best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate + sanitize tool output for display in the review banner.
|
|
||||||
* - Max 500 chars (UI budget)
|
|
||||||
* - Strip control chars, collapse whitespace
|
|
||||||
* - Append "…" if truncated
|
|
||||||
*/
|
|
||||||
export function excerptForReview(text: string, max = 500): string {
|
|
||||||
if (!text) return '';
|
|
||||||
const cleaned = text
|
|
||||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
if (cleaned.length <= max) return cleaned;
|
|
||||||
return cleaned.slice(0, max) + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Status reporting (for shield icon via /health) ──────────
|
// ─── Status reporting (for shield icon via /health) ──────────
|
||||||
|
|
||||||
export function getStatus(): StatusDetail {
|
export function getStatus(): StatusDetail {
|
||||||
|
|||||||
@@ -18,21 +18,20 @@ import { handleReadCommand, hasOutArg } from './read-commands';
|
|||||||
import { handleWriteCommand } from './write-commands';
|
import { handleWriteCommand } from './write-commands';
|
||||||
import { handleMetaCommand } from './meta-commands';
|
import { handleMetaCommand } from './meta-commands';
|
||||||
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
||||||
import { sanitizeExtensionUrl } from './sidebar-utils';
|
|
||||||
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
|
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
|
||||||
import {
|
import {
|
||||||
wrapUntrustedPageContent, datamarkContent,
|
wrapUntrustedPageContent, datamarkContent,
|
||||||
runContentFilters, type ContentFilterResult,
|
runContentFilters, type ContentFilterResult,
|
||||||
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
||||||
} from './content-security';
|
} from './content-security';
|
||||||
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
|
import { getStatus as getSecurityStatus } from './security';
|
||||||
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
|
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
|
||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||||
import {
|
import {
|
||||||
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
||||||
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
||||||
rotateRoot, listTokens, serializeRegistry, restoreRegistry, recordCommand,
|
listTokens, recordCommand,
|
||||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||||
} from './token-registry';
|
} from './token-registry';
|
||||||
import { validateTempPath } from './path-security';
|
import { validateTempPath } from './path-security';
|
||||||
@@ -44,7 +43,7 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
|
|||||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||||
// fail posix_spawn on all executables including /bin/bash)
|
// fail posix_spawn on all executables including /bin/bash)
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
||||||
import { readAgentRecord, killAgentByRecord, clearAgentRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
import { readAgentRecord, killAgentByRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
||||||
import { isProcessAlive } from './error-handling';
|
import { isProcessAlive } from './error-handling';
|
||||||
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
||||||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared sidebar utilities — extracted for testability.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize a URL from the Chrome extension before embedding in a prompt.
|
|
||||||
* Only accepts http/https, strips control characters, truncates to 2048 chars.
|
|
||||||
* Returns null if the URL is invalid or uses a non-http scheme.
|
|
||||||
*/
|
|
||||||
export function sanitizeExtensionUrl(url: string | null | undefined): string | null {
|
|
||||||
if (!url) return null;
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
|
||||||
return u.href.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 2048);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,11 +96,6 @@ export function buildSseSetCookie(token: string): string {
|
|||||||
return `${SSE_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
return `${SSE_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Set-Cookie header that clears the SSE session cookie. */
|
|
||||||
export function buildSseClearCookie(): string {
|
|
||||||
return `${SSE_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpired(now: number): void {
|
function pruneExpired(now: number): void {
|
||||||
// Opportunistic cleanup: check up to 20 entries per call so we don't
|
// Opportunistic cleanup: check up to 20 entries per call so we don't
|
||||||
// stall on a massive registry. O(1) amortized. Runs on every mint
|
// stall on a massive registry. O(1) amortized. Runs on every mint
|
||||||
|
|||||||
@@ -461,14 +461,6 @@ export async function applyStealth(context: BrowserContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The legacy single-line webdriver mask, exported for backwards
|
|
||||||
* compatibility with any caller that uses it directly. New callers
|
|
||||||
* should use applyStealth() which includes this plus the Layer C
|
|
||||||
* additions.
|
|
||||||
*/
|
|
||||||
export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Args added to chromium.launch's `args` to suppress the
|
* Args added to chromium.launch's `args` to suppress the
|
||||||
* AutomationControlled blink feature. This is independent of the init
|
* AutomationControlled blink feature. This is independent of the init
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ describe('Content filter hooks', () => {
|
|||||||
clearContentFilters();
|
clearContentFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// clearContentFilters() wipes MODULE state shared across every test file in
|
||||||
|
// the same bun process — without restoring the built-in registration,
|
||||||
|
// security-integration.test.ts (which asserts the auto-registered blocklist
|
||||||
|
// pipeline) fails whenever the two files co-run. Pre-existing co-run bug,
|
||||||
|
// invisible until the free suite got a CI job.
|
||||||
|
afterAll(() => {
|
||||||
|
clearContentFilters();
|
||||||
|
registerContentFilter(urlBlocklistFilter);
|
||||||
|
});
|
||||||
|
|
||||||
test('URL blocklist detects requestbin', () => {
|
test('URL blocklist detects requestbin', () => {
|
||||||
const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text');
|
const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text');
|
||||||
expect(result.safe).toBe(false);
|
expect(result.safe).toBe(false);
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Review-on-BLOCK regression tests.
|
|
||||||
*
|
|
||||||
* Covers the user-in-the-loop path added to resolve false positives on
|
|
||||||
* benign developer content (e.g., HN comments discussing a prompt injection
|
|
||||||
* incident getting flagged as prompt injection). Instead of hard-stopping
|
|
||||||
* the session on a tool-output BLOCK, the agent emits a reviewable
|
|
||||||
* security_event and polls for the user's decision via a per-tab file.
|
|
||||||
*
|
|
||||||
* These tests pin the file-based handshake and the excerpt sanitization.
|
|
||||||
*/
|
|
||||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
|
||||||
writeDecision,
|
|
||||||
readDecision,
|
|
||||||
clearDecision,
|
|
||||||
decisionFileForTab,
|
|
||||||
excerptForReview,
|
|
||||||
type Verdict,
|
|
||||||
} from '../src/security';
|
|
||||||
|
|
||||||
const ORIG_HOME = process.env.HOME;
|
|
||||||
let tmpHome = '';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-review-'));
|
|
||||||
process.env.HOME = tmpHome;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.HOME = ORIG_HOME;
|
|
||||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('security decision file handshake', () => {
|
|
||||||
test('writeDecision + readDecision round-trips', () => {
|
|
||||||
// SECURITY_DIR is computed at module load time from the original HOME.
|
|
||||||
// The function writes relative to its own SECURITY_DIR constant, so we
|
|
||||||
// verify the API shape rather than the exact path. The file lives where
|
|
||||||
// decisionFileForTab says it does.
|
|
||||||
const file = decisionFileForTab(42);
|
|
||||||
expect(file.endsWith('/tab-42.json')).toBe(true);
|
|
||||||
|
|
||||||
// Ensure the directory exists (writeDecision creates it).
|
|
||||||
writeDecision({ tabId: 42, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
|
|
||||||
const rec = readDecision(42);
|
|
||||||
expect(rec).not.toBeNull();
|
|
||||||
expect(rec?.tabId).toBe(42);
|
|
||||||
expect(rec?.decision).toBe('allow');
|
|
||||||
expect(rec?.reason).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clearDecision removes the file', () => {
|
|
||||||
writeDecision({ tabId: 7, decision: 'block', ts: new Date().toISOString() });
|
|
||||||
expect(readDecision(7)).not.toBeNull();
|
|
||||||
clearDecision(7);
|
|
||||||
expect(readDecision(7)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('readDecision returns null for a tab with no decision', () => {
|
|
||||||
expect(readDecision(99999)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writeDecision + readDecision handles both values', () => {
|
|
||||||
writeDecision({ tabId: 1, decision: 'allow', ts: '2026-04-20T12:00:00Z' });
|
|
||||||
writeDecision({ tabId: 2, decision: 'block', ts: '2026-04-20T12:00:01Z' });
|
|
||||||
expect(readDecision(1)?.decision).toBe('allow');
|
|
||||||
expect(readDecision(2)?.decision).toBe('block');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('atomic write: temp file is cleaned up after rename', () => {
|
|
||||||
writeDecision({ tabId: 10, decision: 'allow', ts: new Date().toISOString() });
|
|
||||||
const file = decisionFileForTab(10);
|
|
||||||
const dir = path.dirname(file);
|
|
||||||
const leftover = fs.readdirSync(dir).filter((f) => f.startsWith('tab-10.json.tmp'));
|
|
||||||
expect(leftover.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('file perms are 0600 on the decision file', () => {
|
|
||||||
writeDecision({ tabId: 3, decision: 'allow', ts: new Date().toISOString() });
|
|
||||||
const stat = fs.statSync(decisionFileForTab(3));
|
|
||||||
// mode & 0o777 = lower 9 bits of permission
|
|
||||||
const perms = stat.mode & 0o777;
|
|
||||||
// On some filesystems the sticky/group bits may vary; we assert the
|
|
||||||
// owner-only pattern.
|
|
||||||
expect(perms & 0o077).toBe(0); // no group/other read or write
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('excerptForReview sanitization', () => {
|
|
||||||
test('passes short clean text through', () => {
|
|
||||||
expect(excerptForReview('hello world')).toBe('hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('truncates at the default max with ellipsis', () => {
|
|
||||||
const long = 'a'.repeat(800);
|
|
||||||
const out = excerptForReview(long);
|
|
||||||
expect(out.length).toBe(501); // 500 chars + ellipsis
|
|
||||||
expect(out.endsWith('…')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips control chars that would break the UI', () => {
|
|
||||||
const input = 'before\x00\x01\x02\x1Fafter';
|
|
||||||
expect(excerptForReview(input)).toBe('beforeafter');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses whitespace for compact display', () => {
|
|
||||||
expect(excerptForReview('foo \n\n\t bar')).toBe('foo bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns empty string for empty input', () => {
|
|
||||||
expect(excerptForReview('')).toBe('');
|
|
||||||
expect(excerptForReview(null as any)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('custom max parameter', () => {
|
|
||||||
expect(excerptForReview('abcdefghij', 5)).toBe('abcde…');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Verdict type includes user_overrode', () => {
|
|
||||||
test('user_overrode is a valid Verdict value', () => {
|
|
||||||
// TypeScript compile-time check that the type accepts the value.
|
|
||||||
// If 'user_overrode' were removed from the Verdict union, this file
|
|
||||||
// would fail to type-check.
|
|
||||||
const v: Verdict = 'user_overrode';
|
|
||||||
expect(v).toBe('user_overrode');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('review-flow smoke — simulated sidebar-agent poll loop', () => {
|
|
||||||
test('agent-side poll sees user allow decision', async () => {
|
|
||||||
const tabId = 123;
|
|
||||||
clearDecision(tabId);
|
|
||||||
|
|
||||||
// Simulate the sidepanel POST happening after a short delay.
|
|
||||||
setTimeout(() => {
|
|
||||||
writeDecision({ tabId, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Simulate the sidebar-agent poll loop.
|
|
||||||
const deadline = Date.now() + 2000;
|
|
||||||
let decision: 'allow' | 'block' | null = null;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const rec = readDecision(tabId);
|
|
||||||
if (rec?.decision) {
|
|
||||||
decision = rec.decision;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
}
|
|
||||||
expect(decision).toBe('allow');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('agent-side poll sees user block decision', async () => {
|
|
||||||
const tabId = 456;
|
|
||||||
clearDecision(tabId);
|
|
||||||
setTimeout(() => {
|
|
||||||
writeDecision({ tabId, decision: 'block', ts: new Date().toISOString() });
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
const deadline = Date.now() + 2000;
|
|
||||||
let decision: 'allow' | 'block' | null = null;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const rec = readDecision(tabId);
|
|
||||||
if (rec?.decision) {
|
|
||||||
decision = rec.decision;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
}
|
|
||||||
expect(decision).toBe('block');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('poll times out when no decision arrives', async () => {
|
|
||||||
const tabId = 789;
|
|
||||||
clearDecision(tabId);
|
|
||||||
|
|
||||||
const deadline = Date.now() + 200;
|
|
||||||
let decision: 'allow' | 'block' | null = null;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const rec = readDecision(tabId);
|
|
||||||
if (rec?.decision) {
|
|
||||||
decision = rec.decision;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
}
|
|
||||||
expect(decision).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,14 +14,10 @@ import {
|
|||||||
generateCanary,
|
generateCanary,
|
||||||
injectCanary,
|
injectCanary,
|
||||||
checkCanaryInStructure,
|
checkCanaryInStructure,
|
||||||
hashPayload,
|
|
||||||
logAttempt,
|
|
||||||
writeSessionState,
|
writeSessionState,
|
||||||
readSessionState,
|
readSessionState,
|
||||||
getStatus,
|
getStatus,
|
||||||
extractDomain,
|
extractDomain,
|
||||||
buildTelemetrySpawnCommand,
|
|
||||||
resolveBashBinary,
|
|
||||||
type LayerSignal,
|
type LayerSignal,
|
||||||
} from '../src/security';
|
} from '../src/security';
|
||||||
|
|
||||||
@@ -239,46 +235,9 @@ describe('canary', () => {
|
|||||||
|
|
||||||
// ─── Payload hashing ─────────────────────────────────────────
|
// ─── Payload hashing ─────────────────────────────────────────
|
||||||
|
|
||||||
describe('hashPayload', () => {
|
|
||||||
test('same payload produces same hash (deterministic with persistent salt)', () => {
|
|
||||||
const h1 = hashPayload('attack string');
|
|
||||||
const h2 = hashPayload('attack string');
|
|
||||||
expect(h1).toBe(h2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different payloads produce different hashes', () => {
|
|
||||||
expect(hashPayload('a')).not.toBe(hashPayload('b'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hash is sha256 hex (64 chars)', () => {
|
|
||||||
const h = hashPayload('test');
|
|
||||||
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Attack log + rotation ───────────────────────────────────
|
// ─── Attack log + rotation ───────────────────────────────────
|
||||||
|
|
||||||
describe('logAttempt', () => {
|
|
||||||
test('writes attempts.jsonl with correct shape', () => {
|
|
||||||
const ok = logAttempt({
|
|
||||||
ts: '2026-04-19T12:34:56Z',
|
|
||||||
urlDomain: 'example.com',
|
|
||||||
payloadHash: 'deadbeef',
|
|
||||||
confidence: 0.9,
|
|
||||||
layer: 'testsavant_content',
|
|
||||||
verdict: 'block',
|
|
||||||
});
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
|
|
||||||
const logPath = path.join(os.homedir(), '.gstack', 'security', 'attempts.jsonl');
|
|
||||||
const content = fs.readFileSync(logPath, 'utf8');
|
|
||||||
const lines = content.split('\n').filter(Boolean);
|
|
||||||
const last = JSON.parse(lines[lines.length - 1]);
|
|
||||||
expect(last.urlDomain).toBe('example.com');
|
|
||||||
expect(last.payloadHash).toBe('deadbeef');
|
|
||||||
expect(last.verdict).toBe('block');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Session state (cross-process, atomic) ───────────────────
|
// ─── Session state (cross-process, atomic) ───────────────────
|
||||||
|
|
||||||
@@ -330,74 +289,6 @@ describe('extractDomain', () => {
|
|||||||
|
|
||||||
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
|
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
|
||||||
|
|
||||||
describe('resolveBashBinary', () => {
|
|
||||||
test('on POSIX, returns the system bash via Bun.which', () => {
|
|
||||||
if (process.platform === 'win32') return;
|
|
||||||
const out = resolveBashBinary({ PATH: process.env.PATH ?? '' });
|
|
||||||
expect(out).toBeTruthy();
|
|
||||||
expect(out!.endsWith('bash')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('honors GSTACK_BASH_BIN absolute-path override', () => {
|
|
||||||
// Construct a synthetic absolute path; the helper short-circuits on
|
|
||||||
// path.isAbsolute and never touches the filesystem, so this is portable.
|
|
||||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
|
||||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: fake, PATH: '' });
|
|
||||||
expect(out).toBe(fake);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips wrapping double quotes from override values', () => {
|
|
||||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
|
||||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: `"${fake}"`, PATH: '' });
|
|
||||||
expect(out).toBe(fake);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BASH_BIN works as a fallback when GSTACK_BASH_BIN is unset', () => {
|
|
||||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
|
||||||
const out = resolveBashBinary({ BASH_BIN: fake, PATH: '' });
|
|
||||||
expect(out).toBe(fake);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when nothing resolves (override is unset and PATH is empty)', () => {
|
|
||||||
// Empty PATH means Bun.which finds nothing.
|
|
||||||
const out = resolveBashBinary({ PATH: '' });
|
|
||||||
expect(out).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
|
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
|
||||||
|
|
||||||
describe('buildTelemetrySpawnCommand', () => {
|
|
||||||
const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log';
|
|
||||||
const args = ['--event-type', 'attack_attempt', '--confidence', '0.95'];
|
|
||||||
|
|
||||||
test('on POSIX, returns the binary path and args unchanged', () => {
|
|
||||||
if (process.platform === 'win32') return;
|
|
||||||
const out = buildTelemetrySpawnCommand(bin, args);
|
|
||||||
expect(out).not.toBeNull();
|
|
||||||
expect(out!.cmd).toBe(bin);
|
|
||||||
expect(out!.cmdArgs).toEqual(args);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('on win32 with bash resolvable, wraps the call in bash with the script as first arg', () => {
|
|
||||||
if (process.platform !== 'win32') return;
|
|
||||||
const fakeBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
|
|
||||||
const out = buildTelemetrySpawnCommand(bin, args, { GSTACK_BASH_BIN: fakeBash, PATH: '' });
|
|
||||||
expect(out).not.toBeNull();
|
|
||||||
expect(out!.cmd).toBe(fakeBash);
|
|
||||||
expect(out!.cmdArgs).toEqual([bin, ...args]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('on win32 with bash unresolvable, returns null so caller skips spawn', () => {
|
|
||||||
if (process.platform !== 'win32') return;
|
|
||||||
// No override, empty PATH — Bun.which finds nothing on Windows.
|
|
||||||
const out = buildTelemetrySpawnCommand(bin, args, { PATH: '' });
|
|
||||||
expect(out).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not mutate the caller-supplied args array', () => {
|
|
||||||
const originalArgs = [...args];
|
|
||||||
buildTelemetrySpawnCommand(bin, args);
|
|
||||||
expect(args).toEqual(originalArgs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layer 1: Unit tests for sidebar utilities.
|
|
||||||
* Tests pure functions — no server, no processes, no network.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import { sanitizeExtensionUrl } from '../src/sidebar-utils';
|
|
||||||
|
|
||||||
describe('sanitizeExtensionUrl', () => {
|
|
||||||
test('passes valid http URL', () => {
|
|
||||||
expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('passes valid https URL', () => {
|
|
||||||
expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects chrome:// URLs', () => {
|
|
||||||
expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects chrome-extension:// URLs', () => {
|
|
||||||
expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects javascript: URLs', () => {
|
|
||||||
expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects file:// URLs', () => {
|
|
||||||
expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects data: URLs', () => {
|
|
||||||
expect(sanitizeExtensionUrl('data:text/html,<h1>hi</h1>')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips raw control characters from URL', () => {
|
|
||||||
// URL constructor percent-encodes \x00 as %00, which is safe
|
|
||||||
// The regex strips any remaining raw control chars after .href normalization
|
|
||||||
const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f');
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!).not.toMatch(/[\x00-\x1f\x7f]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips newlines (prompt injection vector)', () => {
|
|
||||||
const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore');
|
|
||||||
// URL constructor normalizes %0A, control char stripping removes any raw newlines
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!).not.toContain('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('truncates URLs longer than 2048 chars', () => {
|
|
||||||
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
|
|
||||||
const result = sanitizeExtensionUrl(longUrl);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!.length).toBeLessThanOrEqual(2048);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for null input', () => {
|
|
||||||
expect(sanitizeExtensionUrl(null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for undefined input', () => {
|
|
||||||
expect(sanitizeExtensionUrl(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for empty string', () => {
|
|
||||||
expect(sanitizeExtensionUrl('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for invalid URL string', () => {
|
|
||||||
expect(sanitizeExtensionUrl('not a url at all')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not crash on weird input', () => {
|
|
||||||
expect(sanitizeExtensionUrl(':///')).toBeNull();
|
|
||||||
expect(sanitizeExtensionUrl(' ')).toBeNull();
|
|
||||||
expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves query parameters and fragments', () => {
|
|
||||||
const url = 'https://example.com/search?q=test&page=2#results';
|
|
||||||
expect(sanitizeExtensionUrl(url)).toBe(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves port numbers', () => {
|
|
||||||
expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles URL with auth (user:pass@host)', () => {
|
|
||||||
const result = sanitizeExtensionUrl('https://user:pass@example.com/');
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result).toContain('example.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||||
buildSseSetCookie, buildSseClearCookie, SSE_COOKIE_NAME,
|
buildSseSetCookie, SSE_COOKIE_NAME,
|
||||||
__resetSseSessions,
|
__resetSseSessions,
|
||||||
} from '../src/sse-session-cookie';
|
} from '../src/sse-session-cookie';
|
||||||
|
|
||||||
@@ -106,11 +106,6 @@ describe('SSE session cookie: cookie flag invariants', () => {
|
|||||||
// add Secure then.
|
// add Secure then.
|
||||||
expect(buildSseSetCookie(token)).not.toContain('Secure');
|
expect(buildSseSetCookie(token)).not.toContain('Secure');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Clear-Cookie has Max-Age=0', () => {
|
|
||||||
expect(buildSseClearCookie()).toContain('Max-Age=0');
|
|
||||||
expect(buildSseClearCookie()).toContain('HttpOnly');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SSE session cookie: extract from request', () => {
|
describe('SSE session cookie: extract from request', () => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
buildGStackLaunchArgs,
|
buildGStackLaunchArgs,
|
||||||
readHostProfile,
|
readHostProfile,
|
||||||
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
|
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
|
||||||
WEBDRIVER_MASK_SCRIPT,
|
|
||||||
STEALTH_LAUNCH_ARGS,
|
STEALTH_LAUNCH_ARGS,
|
||||||
STEALTH_IGNORE_DEFAULT_ARGS,
|
STEALTH_IGNORE_DEFAULT_ARGS,
|
||||||
} from '../src/stealth';
|
} from '../src/stealth';
|
||||||
@@ -235,10 +234,6 @@ describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('backwards-compat exports', () => {
|
describe('backwards-compat exports', () => {
|
||||||
test('WEBDRIVER_MASK_SCRIPT still exported', () => {
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
|
|
||||||
});
|
|
||||||
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
|
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
|
||||||
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
|
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { chromium, type Browser, type BrowserContext } from 'playwright';
|
import { chromium, type Browser, type BrowserContext } from 'playwright';
|
||||||
import { applyStealth, WEBDRIVER_MASK_SCRIPT, STEALTH_LAUNCH_ARGS } from '../src/stealth';
|
import { applyStealth, STEALTH_LAUNCH_ARGS } from '../src/stealth';
|
||||||
|
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
|
|
||||||
@@ -18,20 +18,6 @@ describe('STEALTH_LAUNCH_ARGS', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WEBDRIVER_MASK_SCRIPT', () => {
|
|
||||||
test('contains a single Object.defineProperty for navigator.webdriver', () => {
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('navigator');
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('webdriver');
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does NOT touch plugins, languages, or window.chrome (D7 narrowing)', () => {
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/plugins/i);
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/languages/i);
|
|
||||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/window\.chrome/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('applyStealth — context level', () => {
|
describe('applyStealth — context level', () => {
|
||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
|
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
|
||||||
extractPtyCookie, buildPtySetCookie, buildPtyClearCookie,
|
extractPtyCookie, buildPtySetCookie,
|
||||||
PTY_COOKIE_NAME, __resetPtySessions,
|
PTY_COOKIE_NAME, __resetPtySessions,
|
||||||
} from '../src/pty-session-cookie';
|
} from '../src/pty-session-cookie';
|
||||||
|
|
||||||
@@ -61,10 +61,6 @@ describe('pty-session-cookie: mint/validate/revoke', () => {
|
|||||||
expect(cookie).not.toContain('Secure');
|
expect(cookie).not.toContain('Secure');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clear-cookie has Max-Age=0', () => {
|
|
||||||
expect(buildPtyClearCookie()).toContain('Max-Age=0');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
|
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
|
||||||
const { token } = mintPtySessionToken();
|
const { token } = mintPtySessionToken();
|
||||||
const req = new Request('http://127.0.0.1/ws', {
|
const req = new Request('http://127.0.0.1/ws', {
|
||||||
@@ -150,10 +146,13 @@ describe('Source-level guard: terminal-agent', () => {
|
|||||||
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
|
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
|
||||||
AGENT_SRC.indexOf("websocket: {"),
|
AGENT_SRC.indexOf("websocket: {"),
|
||||||
);
|
);
|
||||||
expect(upgradeBlock).not.toContain('spawnClaude(');
|
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
|
||||||
|
// lazy first-byte spawn share one helper). Pin was stale from then until
|
||||||
|
// the free suite got a CI job.
|
||||||
|
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
||||||
// Spawn must be invoked from the message handler (lazy on first byte).
|
// Spawn must be invoked from the message handler (lazy on first byte).
|
||||||
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
||||||
expect(messageHandler).toContain('spawnClaude(');
|
expect(messageHandler).toContain('maybeSpawnPty(');
|
||||||
expect(messageHandler).toContain('!session.spawned');
|
expect(messageHandler).toContain('!session.spawned');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user