Skip to content

feat(caido): add Caido plugin for Titus secret scanning#291

Draft
anushkavirgaonkar wants to merge 2 commits into
mainfrom
anushkavirgaonkar/lab-1364-caido-plugin-for-titus
Draft

feat(caido): add Caido plugin for Titus secret scanning#291
anushkavirgaonkar wants to merge 2 commits into
mainfrom
anushkavirgaonkar/lab-1364-caido-plugin-for-titus

Conversation

@anushkavirgaonkar

Copy link
Copy Markdown
Contributor

Summary

  • Adds a Caido proxy plugin (caido/) that scans HTTP traffic for secrets using the Titus detection engine, mirroring the Burp Suite extension architecture
  • Backend spawns titus serve subprocess and communicates via NDJSON, intercepts HTTP responses, filters non-scannable content, deduplicates findings, and reports via Caido's findings API
  • Frontend provides a sidebar tab with stats dashboard, findings table, and settings toggles (passive scan, request scanning, scope filtering)

Closes LAB-1364

Architecture

Same subprocess approach as the Burp extension -- spawns titus serve once, reuses for all scans. No rules ported to JS; full 444+ rule engine via the native binary.

Caido Backend ──NDJSON──► titus serve (444+ rules, Hyperscan)
     │
     │ sdk.api (RPC)
     ▼
Caido Frontend (stats, findings table, settings)

Files

Path Description
caido/packages/backend/src/index.ts Plugin entry: HTTP handler, findings API
caido/packages/backend/src/scanner.ts TitusScanner: subprocess management
caido/packages/backend/src/filter.ts FastPathFilter: content-type/extension filtering
caido/packages/backend/src/dedup.ts DedupCache: finding deduplication
caido/packages/backend/src/types.ts NDJSON protocol types
caido/packages/frontend/src/index.ts UI: stats, findings table, settings
caido/packages/frontend/src/style.css Dark theme CSS

Test plan

  • Install titus binary, run pnpm install && pnpm build in caido/
  • Load plugin_package.zip in Caido Settings > Plugins
  • Verify Titus sidebar tab appears with "Running" status
  • Browse a site with known exposed secrets and verify findings appear
  • Toggle passive scan off/on and verify behavior
  • Test scope-only filtering with Caido scope configuration

🤖 Generated with Claude Code

Add a Caido proxy plugin that scans HTTP traffic for secrets using the
Titus detection engine via its `titus serve` NDJSON subprocess protocol,
mirroring the architecture of the existing Burp Suite extension.

Backend: spawns titus serve, intercepts responses via onInterceptResponse,
filters non-scannable content, deduplicates findings, reports via Caido
findings API. Frontend: sidebar tab with stats dashboard, findings table,
and settings toggles for passive/request scanning and scope filtering.

Closes: LAB-1364

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@anushkavirgaonkar anushkavirgaonkar marked this pull request as draft June 22, 2026 14:03

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review

Critical Issues

  • caido/packages/frontend/src/index.ts:11 imports API from titus-backend, but caido/packages/frontend/package.json:10 does not declare titus-backend as a dependency, and caido/packages/backend/package.json:1 exposes no main/types/exports pointing at src/index.ts or generated declarations. The frontend package is therefore not resolvable in a normal pnpm workspace build/typecheck.
  • caido/packages/backend/src/scanner.ts:184 stores every in-flight request in the same pendingResolve/pendingReject fields, while caido/packages/backend/src/index.ts:210 can run multiple intercepted responses concurrently and call scanner.scan() at index.ts:242. A second scan overwrites the first promise before titus serve responds, causing hung scans and response/result mis-association under normal proxy traffic. The wrapper needs serialization or request correlation.

Security

  • caido/packages/backend/src/dedup.ts:85 uses a 32-bit non-cryptographic hash as the only dedupe key for ruleId:secretContent. Collisions can suppress distinct findings, and the cache is explicitly sized up to 50k entries, where accidental collisions are realistic. Use a collision-resistant digest or the full composite key.

Suggestions

None.


Reviewed by Codex (gpt-5.5)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Review

Critical issues

  • Concurrent scans corrupt/lose responses (scanner.ts). The NDJSON protocol is strictly serial (one request → one response line, no request IDs), and the Java reference enforces this by making scan()/scanBatch()/close() synchronized. The TS port has a single pendingResolve/pendingReject pair with no serialization, but sdk.events.onInterceptResponse fires concurrently and await scanner.scan(...) yields. With two responses in flight: call B overwrites call A's pendingResolve (A's promise hangs forever / leaks), then titus's first reply resolves B with A's matches (wrong URL/secret correlation), and the second reply arrives with no pending handler and is silently dropped. Under any real proxy traffic this mis-attributes and loses findings. Needs a request queue/mutex so only one sendRequest is outstanding at a time.
  • In-flight scans hang on process death (scanner.ts). The exit and error handlers (and the close path) never call pendingReject. If titus serve dies mid-scan, the awaiting promise never settles — and there is no per-request timeout, so a stuck subprocess wedges the handler indefinitely. The Java version returns when readLine() yields null; the TS port has no equivalent.
  • Weak 32-bit dedup hash silently drops distinct secrets (dedup.ts). computeKey is a 32-bit string hash (the comment even says "in production you'd use SHA-256"). With MAX_CACHE_SIZE = 50_000, birthday collisions are likely (~30%+), and a collision maps two different secrets to the same key, so the second is treated as a duplicate and never reported. For a secret scanner, dropping real findings is a correctness bug — use a full-width hash (e.g. SHA-256) or add more discriminating data to the key.

Security

  • Subprocess spawn of titus serve is local-only (searches ~/.titus, ~/bin, /usr/local/bin, then bare titus on PATH) with no new network calls; reasonable for this tool. Note the bare-titus PATH fallback executes whatever titus resolves to in the plugin's environment — fine in practice but worth awareness.
  • Findings (full secretContent) are held in memory and rendered into the UI; the frontend escapes via escapeHtml before innerHTML, so no obvious XSS from scanned content.

Test coverage

  • ~1000 lines of new backend TS (scanner.ts, dedup.ts, filter.ts) ship with no tests. At minimum cover the dedup key/eviction logic and NDJSON request/response correlation — the concurrency bug above would be caught by a test issuing two overlapping scan() calls.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini Review

Critical Issues

  • Concurrency Race Condition in TitusScanner: sendRequest indiscriminately overwrites this.pendingResolve and this.pendingReject without queuing. Because Caido invokes onInterceptResponse concurrently for parallel HTTP requests, simultaneous scans will overwrite each other's pending promises. When responses sequentially arrive from titus serve, they will resolve the last requested promise with the first response's data, causing mismatched findings and leaving earlier promises to hang indefinitely (memory leak).
  • High Probability Deduplication Hash Collisions: In DedupCache.computeKey, the deduplication key is calculated using a 32-bit integer DJB2-like hash (hash |= 0). With a max cache size of 50,000, the Birthday Paradox guarantees an extremely high likelihood of collisions (~25% at capacity). A collision will silently merge two distinct secrets under the same record, incorrectly dropping valid findings. Use the string value (combined) directly as the Map key instead.
  • Initialization Timeout Hang on Startup Failure: In TitusScanner.initialize, the process.on("exit") handler correctly updates boolean state but fails to reject the pending promise if the binary exits immediately (e.g., missing shared libraries or execute permissions). This forces the user to wait for the entire 30-second timeout before seeing an initialization error.

Security

  • No security concerns flagged.

Suggestions

  • Prefer NamedGroups over Snippet.Matching: extractMatchContent currently prefers positional Groups and then falls back to Snippet.Matching. Since positional Groups are deprecated in Titus in favor of NamedGroups (per pkg/types/match.go), this function will frequently skip to Snippet.Matching. Snippets often include surrounding context (e.g., api_key = "foo" instead of just foo), degrading UI presentation and deduping effectiveness. Prioritize extracting from NamedGroups before falling back to snippets.
  • Non-Serializable Set in API Payload: FindingRecord includes urls: Set<string>. When returned to the frontend via the IPC layer (sdk.api.register("getFindings", ...)), Set objects cannot be natively serialized to JSON without a custom replacer and will arrive as empty objects ({}). Since the frontend only reads the singular .url property, consider removing .urls from the exported interface or converting it to an array before transmission.
  • Use Buffer for Base64 Decoding: The decodeBase64 function relies on atob(), which converts a base64 string into a binary string rather than properly decoding UTF-8 bytes. Since the backend executes in a Node.js context (inferred from child_process and fs imports), prefer Buffer.from(b64, 'base64').toString('utf-8') to correctly decode UTF-8 secret values and prevent mojibake.

Reviewed by Gemini (gemini-3.1-pro-preview)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef93a9f191

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +227 to +230
const bodyStr =
typeof responseBody === "string"
? responseBody
: new TextDecoder().decode(responseBody as ArrayBuffer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decode Caido Body objects with toText

For any intercepted response with a body, response.getBody() returns Caido's Body object rather than a string or ArrayBuffer. This branch therefore calls TextDecoder.decode() on the Body object, throws, and the catch logs a scan error before scanner.scan is ever reached; the request-body path below has the same issue. Use responseBody.toText()/requestBody.toText() before filtering and scanning.

Useful? React with 👍 / 👎.

Comment on lines +233 to +234
const contentType = response.getHeader("content-type") ?? undefined;
if (!filter.shouldScan(contentType, bodyStr.length)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize header arrays before content filtering

When a response has a Content-Type, Caido returns header values as an array, so passing that value into FastPathFilter.shouldScan makes contentType.toLowerCase() throw instead of filtering/scanning the response. After the body conversion is fixed, most normal HTTP responses will still fail here unless this uses a single header value (or joins the values) before calling the filter.

Useful? React with 👍 / 👎.

Comment thread caido/packages/backend/src/scanner.ts Outdated
Comment on lines +184 to +186
return new Promise<TitusResponse>((resolve, reject) => {
this.pendingResolve = resolve;
this.pendingReject = reject;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize scanner requests instead of overwriting callbacks

Under normal proxy traffic, two onInterceptResponse callbacks can call scanner.scan() before Titus answers the first request. Each call overwrites this single pendingResolve/pendingReject, so the first promise is left hanging and the first Titus response is delivered to the later scan (misattributing or dropping findings). Queue requests or reject/await while one is in flight before writing another NDJSON request.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a complete Caido plugin workspace (caido/) implementing passive HTTP secret scanning via the Titus detection engine. The backend spawns a titus serve subprocess and communicates over NDJSON to scan intercepted response (and optionally request) bodies. A FastPathFilter gates candidates by content-type, extension, and size; a DedupCache keys findings by rule ID plus secret content. The backend exposes a frontend API for stats, findings, and settings toggles. The frontend registers a Caido navigation page rendering a stats bar, toggle controls, and a findings table. Build tooling uses pnpm workspaces, Vite library mode, and JSZip packaging scripts.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch anushkavirgaonkar/lab-1364-caido-plugin-for-titus

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
caido/README.md (2)

65-65: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Add language specifier to fenced code block.

Line 65 uses a bare code fence; it should specify bash for syntax highlighting and clarity.

Fix fenced code block
-```
+```bash
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/README.md` at line 65, The fenced code block at line 65 in
caido/README.md is missing a language specifier. Change the bare opening code
fence from ``` to ```bash to enable proper syntax highlighting and clarify that
the code block contains bash commands.

124-124: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Remove redundant adverb "Only" from the Scope Only setting description.

The phrase "Only scan in-scope targets" repeats the "Only" concept from the setting name. Simplify to "Scan in-scope targets only" or "Scope filtered."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/README.md` at line 124, The description for the "Scope Only" setting
contains a redundant use of the word "Only" by stating "Only scan in-scope
targets", which repeats the concept already present in the setting name. Locate
the row with the "Scope Only" setting in the table and modify the description
text from "Only scan in-scope targets" to either "Scan in-scope targets only" or
"Scope filtered" to eliminate the redundancy while maintaining clarity.
caido/packages/backend/src/scanner.ts (1)

84-86: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Drain child stderr to avoid subprocess backpressure stalls.

You pipe stderr (Line 85) but never read it. If Titus emits enough stderr output, the child can block.

Suggested fix
       try {
         this.process = spawn(titusPath, ["serve"], {
           stdio: ["pipe", "pipe", "pipe"],
         });
       } catch (err) {
@@
       this.process.on("error", (err) => {
         clearTimeout(timeout);
         reject(new Error(`Titus process error: ${err.message}`));
       });
+
+      this.process.stderr?.on("data", (chunk: Buffer) => {
+        // Keep pipe drained; optionally forward to plugin logs if desired.
+        void chunk;
+      });

Also applies to: 97-100, 143-144

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/backend/src/scanner.ts` around lines 84 - 86, The spawn call
for Titus is piping stderr but never reading from it, which can cause the child
process to block when its stderr buffer fills up. After spawning the process
with this.process = spawn(titusPath, ["serve"], {...}), add code to drain the
stderr stream by either piping it to process.stderr or attaching a data listener
to consume the output. Apply this same fix to the other two spawn calls
mentioned in the comment (around lines 97-100 and 143-144) to prevent
backpressure stalls in all child processes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@caido/packages/backend/src/dedup.ts`:
- Around line 85-95: The computeKey method currently uses a simple 32-bit hash
algorithm that is prone to collisions, which can cause unrelated findings to be
merged and detections to be suppressed. Replace this weak hashing approach with
a collision-resistant cryptographic hash function such as SHA-256. Update the
computeKey method to use Node.js's crypto module (or similar) to generate a
secure hash of the combined ruleId and secretContent string, ensuring the output
is deterministic and collision-resistant for proper deduplication.

In `@caido/packages/backend/src/index.ts`:
- Around line 246-255: The request body scanning in the `if (scanRequests)`
block does not apply `FastPathFilter` to check content-type and enforce max-size
constraints before scanning. This allows large or binary uploads to be scanned
unnecessarily, degrading proxy performance. Apply the same `FastPathFilter`
logic that is used for response body scanning to filter the request body based
on content-type and max-size before calling `scanner.scan()` on the request
string. Ensure the filter check happens before the length check and scanner
invocation.

In `@caido/packages/backend/src/scanner.ts`:
- Around line 179-197: The sendRequest method has two issues: first, storing a
single pendingResolve and pendingReject causes concurrent requests to overwrite
each other's handlers, misrouting responses and leaving promises hanging.
Replace the single pendingResolve/pendingReject pattern with a Map or queue that
uses request IDs or tracking tokens to store each request's resolve and reject
handlers separately. Second, the method lacks any timeout mechanism, so if the
Titus process stops responding, promises hang indefinitely. Add a timeout
promise using Promise.race that rejects after a configurable duration (e.g., 30
seconds), ensuring each request in the sendRequest method automatically fails if
no response is received within the time limit.

In `@caido/packages/frontend/src/index.ts`:
- Around line 70-75: The toggle handlers in the createToggle function calls are
performing fire-and-forget backend mutations without error handling. When
sdk.backend.setPassiveScan(enabled) or similar calls fail, the UI checkbox state
becomes out of sync with the actual backend state. Modify each toggle handler
(the callback functions passed to createToggle for "Passive Scan", and the other
toggles at the specified locations) to properly handle promise rejections from
the backend calls, either by reverting the checkbox state on failure or
displaying an error notification to the user so the UI state remains consistent
with the backend.
- Around line 205-228: The current implementation renders the entire sorted
findings array into the table every time refreshFindings is called, which causes
performance degradation with large datasets. Instead of mapping over all
findings in the sorted array and setting tbody.innerHTML with all rows at once,
limit the number of findings that are rendered by slicing the sorted array to
only include a manageable subset (such as the first 50 or 100 findings). This
pagination approach in the refreshFindings function will prevent full-table
re-renders and maintain UI responsiveness even when dealing with large finding
sets.
- Around line 97-100: The clearBtn click event listener currently calls
clearFindings() and refreshFindings() but does not refresh the statistics after
clearing findings, leaving stats stale until the next periodic update. Locate
the function responsible for refreshing statistics (likely something similar to
refreshFindings) and call it immediately after refreshFindings(sdk) in the
clearBtn event listener to ensure both the findings table and statistics totals
are updated immediately after clearing.

---

Nitpick comments:
In `@caido/packages/backend/src/scanner.ts`:
- Around line 84-86: The spawn call for Titus is piping stderr but never reading
from it, which can cause the child process to block when its stderr buffer fills
up. After spawning the process with this.process = spawn(titusPath, ["serve"],
{...}), add code to drain the stderr stream by either piping it to
process.stderr or attaching a data listener to consume the output. Apply this
same fix to the other two spawn calls mentioned in the comment (around lines
97-100 and 143-144) to prevent backpressure stalls in all child processes.

In `@caido/README.md`:
- Line 65: The fenced code block at line 65 in caido/README.md is missing a
language specifier. Change the bare opening code fence from ``` to ```bash to
enable proper syntax highlighting and clarify that the code block contains bash
commands.
- Line 124: The description for the "Scope Only" setting contains a redundant
use of the word "Only" by stating "Only scan in-scope targets", which repeats
the concept already present in the setting name. Locate the row with the "Scope
Only" setting in the table and modify the description text from "Only scan
in-scope targets" to either "Scan in-scope targets only" or "Scope filtered" to
eliminate the redundancy while maintaining clarity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6bcd8fc0-ecdd-46bd-866f-8a4858f5e528

📥 Commits

Reviewing files that changed from the base of the PR and between 180718d and ef93a9f.

📒 Files selected for processing (20)
  • caido/README.md
  • caido/manifest.json
  • caido/package.json
  • caido/packages/backend/package.json
  • caido/packages/backend/src/dedup.ts
  • caido/packages/backend/src/filter.ts
  • caido/packages/backend/src/index.ts
  • caido/packages/backend/src/scanner.ts
  • caido/packages/backend/src/types.ts
  • caido/packages/backend/tsconfig.json
  • caido/packages/backend/vite.config.ts
  • caido/packages/frontend/package.json
  • caido/packages/frontend/src/index.ts
  • caido/packages/frontend/src/style.css
  • caido/packages/frontend/tsconfig.json
  • caido/packages/frontend/vite.config.ts
  • caido/pnpm-workspace.yaml
  • caido/scripts/clean.js
  • caido/scripts/pack.js
  • caido/tsconfig.json

Comment on lines +85 to +95
private computeKey(secretContent: string, ruleId: string): string {
// Simple hash - in production you'd use SHA-256
let hash = 0;
const combined = `${ruleId}:${secretContent}`;
for (let i = 0; i < combined.length; i++) {
const chr = combined.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return hash.toString(36);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a collision-resistant dedup key.

Line 85 uses a 32-bit hash for ruleId + secretContent. Collisions can merge unrelated findings and suppress detections.

Suggested fix
+import { createHash } from "crypto";
 import type { FindingRecord } from "./types.js";
@@
   private computeKey(secretContent: string, ruleId: string): string {
-    // Simple hash - in production you'd use SHA-256
-    let hash = 0;
-    const combined = `${ruleId}:${secretContent}`;
-    for (let i = 0; i < combined.length; i++) {
-      const chr = combined.charCodeAt(i);
-      hash = (hash << 5) - hash + chr;
-      hash |= 0;
-    }
-    return hash.toString(36);
+    return createHash("sha256")
+      .update(ruleId)
+      .update("\0")
+      .update(secretContent)
+      .digest("hex");
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private computeKey(secretContent: string, ruleId: string): string {
// Simple hash - in production you'd use SHA-256
let hash = 0;
const combined = `${ruleId}:${secretContent}`;
for (let i = 0; i < combined.length; i++) {
const chr = combined.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return hash.toString(36);
}
import { createHash } from "crypto";
import type { FindingRecord } from "./types.js";
private computeKey(secretContent: string, ruleId: string): string {
return createHash("sha256")
.update(ruleId)
.update("\0")
.update(secretContent)
.digest("hex");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/backend/src/dedup.ts` around lines 85 - 95, The computeKey
method currently uses a simple 32-bit hash algorithm that is prone to
collisions, which can cause unrelated findings to be merged and detections to be
suppressed. Replace this weak hashing approach with a collision-resistant
cryptographic hash function such as SHA-256. Update the computeKey method to use
Node.js's crypto module (or similar) to generate a secure hash of the combined
ruleId and secretContent string, ensuring the output is deterministic and
collision-resistant for proper deduplication.

Comment on lines +246 to +255
if (scanRequests) {
const requestBody = request.getBody();
if (requestBody) {
const reqStr =
typeof requestBody === "string"
? requestBody
: new TextDecoder().decode(requestBody as ArrayBuffer);
if (reqStr.length > 10) {
requestMatches = await scanner.scan(reqStr, `${url} [request]`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply fast-path filtering to request-body scanning too.

At Line 246+, request scanning bypasses FastPathFilter (content-type and max-size). When enabled, large or binary uploads can be scanned and degrade proxy responsiveness.

Suggested fix
       let requestMatches: TitusMatch[] = [];
       if (scanRequests) {
         const requestBody = request.getBody();
         if (requestBody) {
-          const reqStr =
-            typeof requestBody === "string"
-              ? requestBody
-              : new TextDecoder().decode(requestBody as ArrayBuffer);
-          if (reqStr.length > 10) {
-            requestMatches = await scanner.scan(reqStr, `${url} [request]`);
-          }
+          const requestContentType =
+            request.getHeader("content-type") ?? undefined;
+          const requestLength =
+            typeof requestBody === "string"
+              ? requestBody.length
+              : (requestBody as ArrayBuffer).byteLength;
+
+          if (filter.shouldScan(requestContentType, requestLength)) {
+            const reqStr =
+              typeof requestBody === "string"
+                ? requestBody
+                : new TextDecoder().decode(requestBody as ArrayBuffer);
+            requestMatches = await scanner.scan(reqStr, `${url} [request]`);
+          }
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (scanRequests) {
const requestBody = request.getBody();
if (requestBody) {
const reqStr =
typeof requestBody === "string"
? requestBody
: new TextDecoder().decode(requestBody as ArrayBuffer);
if (reqStr.length > 10) {
requestMatches = await scanner.scan(reqStr, `${url} [request]`);
}
if (scanRequests) {
const requestBody = request.getBody();
if (requestBody) {
const requestContentType =
request.getHeader("content-type") ?? undefined;
const requestLength =
typeof requestBody === "string"
? requestBody.length
: (requestBody as ArrayBuffer).byteLength;
if (filter.shouldScan(requestContentType, requestLength)) {
const reqStr =
typeof requestBody === "string"
? requestBody
: new TextDecoder().decode(requestBody as ArrayBuffer);
requestMatches = await scanner.scan(reqStr, `${url} [request]`);
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/backend/src/index.ts` around lines 246 - 255, The request body
scanning in the `if (scanRequests)` block does not apply `FastPathFilter` to
check content-type and enforce max-size constraints before scanning. This allows
large or binary uploads to be scanned unnecessarily, degrading proxy
performance. Apply the same `FastPathFilter` logic that is used for response
body scanning to filter the request body based on content-type and max-size
before calling `scanner.scan()` on the request string. Ensure the filter check
happens before the length check and scanner invocation.

Comment on lines +179 to +197
private async sendRequest(request: TitusRequest): Promise<TitusResponse> {
if (!this.initialized || !this.process?.stdin) {
throw new Error("Scanner not initialized");
}

return new Promise<TitusResponse>((resolve, reject) => {
this.pendingResolve = resolve;
this.pendingReject = reject;

const json = JSON.stringify(request) + "\n";
this.process!.stdin!.write(json, "utf-8", (err) => {
if (err) {
this.pendingResolve = null;
this.pendingReject = null;
reject(new Error(`Failed to write to titus: ${err.message}`));
}
});
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize request/response handling and enforce a request timeout.

Line 185 overwrites pendingResolve/pendingReject per call. With concurrent scans (reachable from onInterceptResponse), responses can be misrouted and promises can hang. Also, this path has no timeout if Titus stops responding.

Suggested fix
+const REQUEST_TIMEOUT_MS = 15_000;
+
 export class TitusScanner {
   private process: ChildProcess | null = null;
@@
   private pendingResolve: ((resp: TitusResponse) => void) | null = null;
   private pendingReject: ((err: Error) => void) | null = null;
+  private requestChain: Promise<void> = Promise.resolve();
@@
   private async sendRequest(request: TitusRequest): Promise<TitusResponse> {
     if (!this.initialized || !this.process?.stdin) {
       throw new Error("Scanner not initialized");
     }
-
-    return new Promise<TitusResponse>((resolve, reject) => {
-      this.pendingResolve = resolve;
-      this.pendingReject = reject;
+    const run = () =>
+      new Promise<TitusResponse>((resolve, reject) => {
+        if (this.pendingResolve || this.pendingReject) {
+          reject(new Error("Scanner request already in flight"));
+          return;
+        }
+
+        const timeout = setTimeout(() => {
+          this.pendingResolve = null;
+          this.pendingReject = null;
+          reject(new Error("Timeout waiting for titus response"));
+        }, REQUEST_TIMEOUT_MS);
+
+        this.pendingResolve = (resp) => {
+          clearTimeout(timeout);
+          resolve(resp);
+        };
+        this.pendingReject = (err) => {
+          clearTimeout(timeout);
+          reject(err);
+        };
 
-      const json = JSON.stringify(request) + "\n";
-      this.process!.stdin!.write(json, "utf-8", (err) => {
-        if (err) {
-          this.pendingResolve = null;
-          this.pendingReject = null;
-          reject(new Error(`Failed to write to titus: ${err.message}`));
-        }
-      });
-    });
+        const json = JSON.stringify(request) + "\n";
+        this.process!.stdin!.write(json, "utf-8", (err) => {
+          if (err) {
+            clearTimeout(timeout);
+            this.pendingResolve = null;
+            this.pendingReject = null;
+            reject(new Error(`Failed to write to titus: ${err.message}`));
+          }
+        });
+      });
+
+    const queued = this.requestChain.then(run, run);
+    this.requestChain = queued.then(() => undefined, () => undefined);
+    return queued;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/backend/src/scanner.ts` around lines 179 - 197, The
sendRequest method has two issues: first, storing a single pendingResolve and
pendingReject causes concurrent requests to overwrite each other's handlers,
misrouting responses and leaving promises hanging. Replace the single
pendingResolve/pendingReject pattern with a Map or queue that uses request IDs
or tracking tokens to store each request's resolve and reject handlers
separately. Second, the method lacks any timeout mechanism, so if the Titus
process stops responding, promises hang indefinitely. Add a timeout promise
using Promise.race that rejects after a configurable duration (e.g., 30
seconds), ensuring each request in the sendRequest method automatically fails if
no response is received within the time limit.

Comment on lines +70 to +75
const passiveToggle = createToggle(
"Passive Scan",
"titus-passive",
true,
(enabled) => sdk.backend.setPassiveScan(enabled)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle backend mutation failures in toggle handlers.

At Line 154, toggle updates are fire-and-forget. If a backend call fails, the checkbox remains flipped and the UI state diverges from backend state.

Suggested fix
 function createToggle(
   label: string,
   id: string,
   defaultChecked: boolean,
-  onChange: (enabled: boolean) => void
+  onChange: (enabled: boolean) => void | Promise<void>
 ): HTMLElement {
@@
-  input.addEventListener("change", () => onChange(input.checked));
+  input.addEventListener("change", async () => {
+    const next = input.checked;
+    try {
+      await Promise.resolve(onChange(next));
+    } catch {
+      input.checked = !next;
+    }
+  });

Also applies to: 78-83, 86-91, 154-154

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/frontend/src/index.ts` around lines 70 - 75, The toggle
handlers in the createToggle function calls are performing fire-and-forget
backend mutations without error handling. When
sdk.backend.setPassiveScan(enabled) or similar calls fail, the UI checkbox state
becomes out of sync with the actual backend state. Modify each toggle handler
(the callback functions passed to createToggle for "Passive Scan", and the other
toggles at the specified locations) to properly handle promise rejections from
the backend calls, either by reverting the checkbox state on failure or
displaying an error notification to the user so the UI state remains consistent
with the backend.

Comment on lines +97 to +100
clearBtn.addEventListener("click", async () => {
await sdk.backend.clearFindings();
refreshFindings(sdk);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refresh stats immediately after clearing findings.

Line 99 refreshes only the table. clearFindings also resets totals, so stats remain stale until the next periodic refresh.

Suggested fix
   clearBtn.addEventListener("click", async () => {
     await sdk.backend.clearFindings();
-    refreshFindings(sdk);
+    await Promise.all([refreshStats(sdk), refreshFindings(sdk)]);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/frontend/src/index.ts` around lines 97 - 100, The clearBtn
click event listener currently calls clearFindings() and refreshFindings() but
does not refresh the statistics after clearing findings, leaving stats stale
until the next periodic update. Locate the function responsible for refreshing
statistics (likely something similar to refreshFindings) and call it immediately
after refreshFindings(sdk) in the clearBtn event listener to ensure both the
findings table and statistics totals are updated immediately after clearing.

Comment thread caido/packages/frontend/src/index.ts Outdated
Comment on lines +205 to +228
const sorted = [...findings].sort(
(a, b) =>
new Date(b.firstSeen).getTime() - new Date(a.firstSeen).getTime()
);

tbody.innerHTML = sorted
.map((f) => {
const severity = getSeverityFromRuleId(f.ruleId);
const color = SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.info;
const time = new Date(f.firstSeen).toLocaleTimeString();

return `
<tr>
<td><span class="titus-severity" style="background:${color}">${severity.toUpperCase()}</span></td>
<td class="titus-rule" title="${escapeHtml(f.ruleId)}">${escapeHtml(f.ruleName)}</td>
<td class="titus-secret"><code>${escapeHtml(f.secretPreview)}</code></td>
<td>${escapeHtml(f.host)}</td>
<td class="titus-url" title="${escapeHtml(f.url)}">${escapeHtml(truncate(f.url, 60))}</td>
<td>${f.occurrenceCount}</td>
<td>${time}</td>
</tr>
`;
})
.join("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid full-table re-render on every newFinding event.

Line 296 triggers a full findings refresh per event, and Lines 205-228 sort/rebuild the entire table each time. With large finding sets, this will degrade UI responsiveness.

Suggested direction
+let findingsRefreshTimer: number | undefined;
+
+function scheduleFindingsRefresh(sdk: SDK): void {
+  if (findingsRefreshTimer) return;
+  findingsRefreshTimer = window.setTimeout(async () => {
+    findingsRefreshTimer = undefined;
+    await refreshFindings(sdk);
+  }, 250);
+}
@@
-  sdk.backend.onEvent("newFinding" as any, () => {
-    refreshStats(sdk);
-    refreshFindings(sdk);
-  });
+  sdk.backend.onEvent("newFinding" as any, () => {
+    refreshStats(sdk);
+    scheduleFindingsRefresh(sdk);
+  });

Then add pagination/row limits in refreshFindings to avoid rendering all rows at once.

Also applies to: 296-299

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 209-227: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: tbody.innerHTML = sorted
.map((f) => {
const severity = getSeverityFromRuleId(f.ruleId);
const color = SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.info;
const time = new Date(f.firstSeen).toLocaleTimeString();

    return `
    <tr>
      <td><span class="titus-severity" style="background:${color}">${severity.toUpperCase()}</span></td>
      <td class="titus-rule" title="${escapeHtml(f.ruleId)}">${escapeHtml(f.ruleName)}</td>
      <td class="titus-secret"><code>${escapeHtml(f.secretPreview)}</code></td>
      <td>${escapeHtml(f.host)}</td>
      <td class="titus-url" title="${escapeHtml(f.url)}">${escapeHtml(truncate(f.url, 60))}</td>
      <td>${f.occurrenceCount}</td>
      <td>${time}</td>
    </tr>
  `;
  })
  .join("")

Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(dom-content-modification)


[warning] 209-227: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: tbody.innerHTML = sorted
.map((f) => {
const severity = getSeverityFromRuleId(f.ruleId);
const color = SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.info;
const time = new Date(f.firstSeen).toLocaleTimeString();

    return `
    <tr>
      <td><span class="titus-severity" style="background:${color}">${severity.toUpperCase()}</span></td>
      <td class="titus-rule" title="${escapeHtml(f.ruleId)}">${escapeHtml(f.ruleName)}</td>
      <td class="titus-secret"><code>${escapeHtml(f.secretPreview)}</code></td>
      <td>${escapeHtml(f.host)}</td>
      <td class="titus-url" title="${escapeHtml(f.url)}">${escapeHtml(truncate(f.url, 60))}</td>
      <td>${f.occurrenceCount}</td>
      <td>${time}</td>
    </tr>
  `;
  })
  .join("")

Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(unsafe-html-content-assignment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@caido/packages/frontend/src/index.ts` around lines 205 - 228, The current
implementation renders the entire sorted findings array into the table every
time refreshFindings is called, which causes performance degradation with large
datasets. Instead of mapping over all findings in the sorted array and setting
tbody.innerHTML with all rows at once, limit the number of findings that are
rendered by slicing the sorted array to only include a manageable subset (such
as the first 50 or 100 findings). This pagination approach in the
refreshFindings function will prevent full-table re-renders and maintain UI
responsiveness even when dealing with large finding sets.

…ialization

- Serialize scanner requests with queue to prevent concurrent response corruption
- Add per-request timeout and reject pending promises on process death
- Replace weak 32-bit dedup hash with full composite string key
- Use Buffer.from for base64 decoding, prefer NamedGroups over Groups
- Apply FastPathFilter to request body scanning
- Fix Set<string> -> string[] for IPC serialization of finding URLs
- Add backend package exports and frontend workspace dependency
- Refresh stats on clear, limit table render to 100 rows
- Drain child process stderr to prevent backpressure stalls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant