Skip to main content

New research: Apex vs Claude Code Security vs Codex Security on the same codebase.

Creating a Security Exposure Agent

Build a web exposure analysis agent with Cloudflare Workers. Follow the scans, evidence and false positives that show why HTTP 200 is not proof of exposure.

Jimmy Vo 18 min read
AI agents Cloudflare Exposure Management MCP Attack Surface

At Cantina, the team is constantly experimenting and shipping security agents to address security problems that lack determinism. As organizations adopt AI to augment their security capabilities, security agents can live in many different places. After speaking with many security practitioners at BlackHat, I learned that not every security team is using security agents. To be fair, AI-powered agents should be treated with the proper due diligence and skepticism because using AI in security teams is just one element in a comprehensive security program that is made up of people, process, and technology.

As a kinesthetic learner, I learn by doing and applying nebulous concepts that help build a proper mental model. What better way to learn about AI agents than by building a simple exposure analysis agent? This write-up will be more relevant to people who have not built sophisticated agents and orchestration systems.

Prerequisites:

  • Cloudflare Account (Free Tier is sufficient)
  • A Cloudflare zone for the hostname that fronts the agent
  • Cloudflare Zero Trust, for the Access application protecting that hostname
  • Wrangler, which is pinned in the example repository and installed by npm ci
  • Node.js 22
  • Example Source Code

Vibing

As we enter the age of vibecoding, anyone can fire off a short prompt to create a web application and publish it to a platform like Vercel or Replit. This capability has enabled many non-engineering teams to quickly solve problems without having to find engineering resources to develop custom applications. This presents an unintended consequence where well-intentioned employees may unknowingly expose web applications that are not intended for public consumption.

The first questions that need to be answered are: “What are all my Vercel projects and how can I compile an array of Vercel URLs?”

This can be done using the Vercel REST API by enumerating every team in a Vercel account, listing every project, and grabbing all domains per project. Depending on your organization, this can produce a large list of hosts. Fortunately, Vercel has a set of Protect Deployments features that can address most exposure issues by project. The project deployment settings are returned in a payload when performing a GET request to https://api.vercel.com/v10/projects therefore it may not be necessary to check every host for potential exposure. This enables filtering of hosts that lack deployment protection, which makes the potential exposure list much smaller.

{
  "id": "prj_abc123xyz",
  "name": "my-secure-project",
  "slug": "my-secure-project",
  "teamId": "team_xyz789",
  "accountId": "usr_123456",
  "createdAt": 1609459200000,
  "updatedAt": 1640995200000,
  "framework": "nextjs",
  "productionDeploymentsFastPath": true,
  "latestDeployments": [],
  "ssoProtection": {
    "deploymentType": "prod_deployment_urls_and_all_previews"
  },
  "passwordProtection": {
    "deploymentType": "prod_deployment_urls_and_all_previews",
    "password": "hashed_password_value"
  },
  "trustedIps": {
    "deploymentType": "prod_deployment_urls_and_all_previews",
    "protectionMode": "additional",
    "addresses": [
      {
        "value": "203.0.113.42",
        "note": "Corporate office"
      },
      {
        "value": "203.0.113.0/24",
        "note": "VPN range"
      }
    ]
  },
  "passport": {
    "deploymentType": "all"
  },
  "trustedSources": {
    "deploymentType": "prod_deployment_urls_and_all_previews"
  },
  "protectionBypass": [
    {
      "scope": "automation"
    }
  ]
}

If you enforce deployment protection on every Vercel project by default and there are no volunteers to check every single vercel site that lacks deploy protection on a regular cadence, this could be a use case for automation.

The concept

At Cantina, we like building agents to help perform meaningful security work. For no other reason than I like Cloudflare, I decided to build an “exposure agent” using a combination of Cloudflare Workers, D1, R2, Workflows, Durable Objects, Browser Run and Cloudflare Access.

Product Who can use it
Workers + Access Any Access identity that also exists in principals
D1 read lists; scan inserts scan rows; admin mutates scope and triage
R2 read via get_evidence; writes happen inside TargetWorkflow
Workflows scan starts/polls TargetWorkflow; cron starts sweep and expire
Durable Objects Same read / scan / admin checks as MCP
Workers AI Only from TargetWorkflow (scan or cron), not an MCP tool
Browser Run TargetWorkflow (scan or cron); TriageAgent browse (read)

The agent needs two triggers that align with continuous and adhoc exposure checks of potentially exposed assets. The agent can be triggered through MCP by another agent or through a built-in scheduled Worker job, which is essentially a cron job that looks for active hosts and triggers the exposure scan.

Scheduled                            On demand
nightly cron → sweep active hosts    MCP client → Access → MCP tools
                │                              │
                └──────────────┬───────────────┘

        ┌────────────────────────────────────────────────┐
        │ each in-scope host                             │
        │                                                │
        │   Scan ──► Detectors ┄ no hits ┄► Workers AI    │
        │     └───► Screenshot                           │
        └────────────────────────────────────────────────┘
                     │                    │
                     ▼                    ▼
              Findings · D1        Screenshots · R2

Scope

In an ideal scenario, the exposure check should be defined ahead of time or at least auditable. One approach is to use a D1 table to store targets. This would be the workflow:

MCP add_target / put_targets


   D1 targets table  (host PK, status, expires_at, auth ref)


 assertInScope(url)  ← TargetWorkflow, MCP probes, TriageAgent

        ├── ok → normalized host, proceed with GET/HEAD / browser
        └── throw → no outbound request

This check is done in exposure-agent-walkthrough/src/core/scope.ts, where assertInScope matches the requested hostname against the targets allowlist before any outbound request. The allowlist is what bounds SSRF here rather than the Worker itself however the check is on the hostname only, so an in-scope host that resolves to internal space would still be fetched because the resolved IP is never enforced. On Cloudflare’s egress that stays a minor risk since there is no metadata endpoint to reach. These targets are managed in a targets table in D1.

host status added_by_principal authorization_ref added_at expires_at notes
app.example.com active test-admin-key user-request-2026-08-18 1787058175819 1787662975819 User-requested one-off check

The scan

In a typical security exposure check, a list of hosts will likely need scanning. The D1 database will act as an audit log and capture state using the scans table. For all valid scan targets, the worker should resolve DNS over HTTPS, GET the root without following redirects, and only on an HTTP 2xx ask Browser Run for HTML, markdown, and a screenshot in R2.

Cloudflare’s Browser Run (formerly Browser Rendering) provides a programmatic way to use a headless Chrome browser which is perfect for the exposure analysis use case. This is accomplished in /src/workflows/target.ts.

const render: RenderedPage = await step.do("render-browser", async () => {
  if (probe.status < 200 || probe.status >= 300 || !this.env.BROWSER) {
    return { markdown: "", html: "", screenshotKey: null };
  }
  await assertInScope(probe.url, this.env.DB);
  try {
    const [contentResponse, markdownResponse, screenshotResponse] = await Promise.all([
      this.env.BROWSER.quickAction("content", { url: probe.url }),
      this.env.BROWSER.quickAction("markdown", { url: probe.url }),
      this.env.BROWSER.quickAction("screenshot", { url: probe.url }),
    ]);
    // ... store the screenshot in R2, return html + markdown + screenshotKey
  } catch {
    return { markdown: "", html: "", screenshotKey: null };
  }
});

In addition to host classification, each site is also screenshotted using Cloudflare’s Browser Run and saved to an R2 bucket. These screenshots are not used in the exposure agent, however it is trivial to extend this functionality.

Detectors

In order to keep costs low, deterministic logic should be applied before deferring to an AI model for inference. Deterministic logic is used for probing sensitive paths and detectors. This agent uses a concept called detectors to deterministically figure out what exposure characteristics exist for a host. The detectors are written as pure TypeScript functions that take DetectorContext (the HTTP probe, optional render, DNS, and path results the workflow already collected) and return FindingInput[]. The agent includes these four detectors which use local constants such as regex tables and fingerprint lists.

Module detector id Reads Typical fire condition
no-auth-gate.ts no-auth-gate probe, render 2xx app body, no 401/403 / WWW-Authenticate / Access / login redirect / password form
unauth-admin.ts unauth-admin probe, render Same auth exits, then regex/header fingerprints (Grafana, Jenkins, …)
leaked-artifact.ts leaked-artifact paths, also root probe for directory listing Path probe 200 plus content that matches git HEAD, .env, swagger, actuator, etc.
dev-staging-origin.ts dev-staging-origin host, probe, doh, paths (/robots.txt) Staging-ish name, bare IP, missing noindex, private IPs in DNS

When the detectors find nothing at all, the analysis is delegated to Workers AI which runs inference using a defined model. Cloudflare’s Workers AI enables a simple way to serve a small language model, where the model and inference occur on Cloudflare’s infrastructure. The Cloudflare worker has an AI binding which allows classification, for example:

if (this.env.AI && probe.status === 200 && probe.body.length > 200 && findingsList.length === 0) {
  try {
    const aiResp: any = await this.env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
      messages: [{ role: "user", content: `Classify whether this anonymously rendered application is an exposed internal service. Return JSON with isExposed, confidence, and reason.\nHost: ${host}\nRendered markdown:\n${(render.markdown || "").slice(0, 5000)}` }],
      response_format: { type: "json_object" },
    });
    // ... push onto findingsList if isExposed && confidence > 0.8
  } catch {
    // AI is advisory; deterministic scanning and persistence still complete.
  }
}

This example exposure agent just uses meta/llama-3.3-70b-instruct-fp8-fast for demonstration purposes. For a production agent, benchmarks and evals would be done to find the most cost-effective model.

The question put to the model is deliberately narrow, since it only has to decide whether the rendered markdown belongs to an internal service that anyone can reach. The rendered markdown is content the scanned host controls however, so a hostile page can try to steer the classifier, which is bounded to a false positive because the model only runs when the detectors found nothing and can add a finding but never suppress one or write to D1.

  • A yes with confidence above 0.8 is stored as a no-auth-gate finding, and that 0.8 is the model’s own number rather than a calibrated probability.
  • An AI miss or “no” does not fail the scan.

Findings

After a target has gone through the analysis using detectors or LLM inference, findings are generated for each host in order to determine potential exposures. A 200 on a host’s /admin is not enough, since admin pages could require authentication. A finding is recorded only when the detector sees no auth wall, login form, or Cloudflare Access. These findings are written to a D1 findings table.

Field Value
id blog.example.com:no-auth-gate:exposed-application-with-sensitive-keywords-blog-example-com
target_host blog.example.com
detector no-auth-gate
severity critical
state open
title Exposed Application with Sensitive Keywords (blog.example.com)
description The host blog.example.com returned HTTP status 200 and served application content without any authentication gate (no 401/403, no WWW-Authenticate header, no login redirect, and no Cloudflare Access protection).
evidence status 200; hasPasswordForm false; sensitiveKeywordsFound true; content-type text/html; Hugo 0.140.0 personal blog
r2_screenshot_key screenshots/blog.example.com/1787022070526-xb5bck.png
first_seen_at 1786806913505 (2026-08-15T15:15:13Z)
last_seen_at 1787022072538 (2026-08-18T03:01:12Z)
resolved_at NULL

The full response headers and HTML snippet are omitted from the evidence cell so the table stays readable. This row is a public Hugo blog, not an internal app: no-auth-gate treated a 200 with a keyword hit as proof.

Lifecycle

Those findings are then diffed against D1 by a stable host:detector:title id: new ones open, still-present ones update, open ones missing from a completed scan become fixed, and a later rediscovery reopens fixed. Analyst accepted_risk / false_positive dispositions stay put. A failed scan, including a blown path probe, leaves existing findings unchanged, so you only close a ticket after completed and fixed.

detector fires
new finding id


    open ── missing from a completed scan ──► fixed
      │                                         │
      │ ◄──────────── rediscovered ─────────────┘

      ├── triage_finding ──► accepted_risk
      └── triage_finding ──► false_positive

Orchestration

The exposure agent uses Cloudflare’s Workflows as an execution plane, which provides the durable, multi-step execution that is required by the agent. The following Workflows are used in the example agent.

Workflow File What it owns
TargetWorkflow src/workflows/target.ts One host, one scan: mark-running, assert-scope, resolve-dns, probe-root, render-browser, probe-paths, classify-findings, persist-findings
SweepWorkflow src/workflows/sweep.ts Nightly fan-out over active targets, batched 100 at a time via createBatch
ExpireWorkflow src/workflows/expire.ts Retires targets whose expires_at has passed

These actions are separated from the exposure agent worker because Cloudflare Workflows provide this overall reliability:

  1. Steps are checkpointed, so a scan that dies during probe-paths resumes there instead of re-probing the root and re-rendering the browser, which already wrote a screenshot to R2.
  2. Retries are idempotent on the scan row: mark-running uses ON CONFLICT(id) DO UPDATE SET status = 'running', error = NULL (target.ts:30-34).
  3. A hundred hosts do not fit in one Worker invocation. createBatch gives each host its own instance with its own lifetime and retry behaviour.
  4. Failure is per host. One host timing out does not fail the other ninety-nine, and its findings stay untouched.

The nightly trigger is defined in wrangler.jsonc as "0 3 * * *", and the scheduled handler in src/index.ts creates ExpireWorkflow before SweepWorkflow. Expiry is enforced in two places, since the sweep’s own query only selects targets whose expires_at is still in the future. Running the expiry first is what keeps the stored status honest, so a lapsed target is not still reported as active by list_targets. SweepWorkflow also checks a pause flag in the settings table before it fetches a single target, which means the kill switch takes effect before any outbound request is made.

In addition to the root request, the workflow probes a fixed list of ten paths defined as PROBE_PATHS in src/workflows/target.ts, covering /.git/HEAD, /.env, /swagger.json, /openapi.json, /api-docs, /actuator/health, /actuator/env, /server-status, /.DS_Store and /robots.txt. Every one of those paths is passed through assertInScope before the request, which keeps the probe set inside the same scope boundary as the root. This example agent does no crawling and no URL discovery, so the probe set stays fixed at whatever was configured.

Access and roles

The following tool calls are exposed in the exposure agent MCP server which can be found in exposure-agent-walkthrough/src/mcp/tools.ts with a simple RBAC model. This allows agents to perform adhoc scans or review previous scans which can be very beneficial for this use case.

Tool Min role Description
list_targets read List all target assets registered in scope with their attestation metadata and expiry
list_findings read List exposure findings filtered by host, detector, severity, state, or timestamp
get_finding read Get full details, evidence, and screenshot keys for a specific finding
get_evidence read Read a stored evidence object for a finding
browse_target read One-shot render and inspect an in-scope URL using Browser Run or HTTP probe
scan_target scan Launch a scan workflow against an in-scope target host
get_scan_status scan Poll the status and progress of a scan instance
put_targets admin Atomically replace the whole set of scope targets. Missing active targets are retired.
add_target admin Add or reactivate a single target host in the scope registry
remove_target admin Retire a target from active scope without deleting finding history
triage_finding admin Update the state of a finding (e.g. mark fixed, accepted_risk, false_positive)

Every request to /mcp and /agents requires a Cloudflare Access assertion, which the worker verifies against the team’s JWKS, audience, and expiry before any tool runs. A service token client ID is not accepted as a bearer token on its own, since automation presents the client ID and secret to Cloudflare Access and the worker trusts the signed assertion that comes back. The example exposure agent provides three available roles: read, scan, admin, which are defined in the principals table in the D1 database. These are manually added for the sake of simplicity.

In addition to the MCP server, the exposure agent exposes a TriageAgent which is a Durable Object that another agent can call directly at /agents. A Durable Object is essentially a Worker with a stable identity and its own storage, where every request for the same object id is routed to the same instance instead of whichever worker happens to be free. This is done in exposure-agent-walkthrough/src/agent/triage.ts using the agents SDK, where each method is marked callable and runs the same permission check as its equivalent MCP tool.

Listing targets, listing findings, and browsing a URL require read, launching a scan requires scan, and changing a finding’s state requires admin. The protocol is different however the authorization model is identical. This example does not use the Durable Object’s own storage and reads everything from D1.

Agent interaction

The exposure agent has a /mcp endpoint which is intended to be protected by Cloudflare’s Access, so authentication is done by the CF-Access-Client-Id / CF-Access-Client-Secret service-token pair. Giving the agent an MCP endpoint allows coding harnesses like Claude Code to combine skills you already have with the exposure agent’s tool calls. Adding the exposure agent is as simple as:

claude mcp add --transport http exposure-agent https://<YOUR_SECURITY_AGENT_HOST>/mcp \
  -H "CF-Access-Client-Id: <CLIENT_ID>" \
  -H "CF-Access-Client-Secret: <CLIENT_SECRET>"

The service token’s Client ID has to exist in principals with the role the harness actually needs, which for a skill that reads findings and starts adhoc scans is scan and never admin.

The same endpoint can be registered in Clarion, the security platform we build at Cantina, as a custom MCP server that its triage agents load as tools. Two things differ from the harness case. Clarion resolves upstream auth to a single Authorization: Bearer value, either a static key or an OAuth 2.1 token set it refreshes server side, so the Access service-token pair does not carry over and the Worker’s auth module would need to accept one of those instead. A workspace admin also approves each tool’s input schema before an agent can load the server, and outbound arguments are filtered to the keys in that approved schema, so a tool that starts advertising new parameters is held back until someone approves the change.

Clarion’s Add MCP Server form, registering the exposure agent endpoint with API key or bearer token authentication Egress IPs in this screenshot are replaced with RFC 5737 documentation addresses. Read the real ones from your own workspace.

Closing the loop

Exposure findings and their current state are stored in the findings table, which at any point looks like this:

Host Detector Severity State Why it sits there
blog.example.com no-auth-gate critical open 200 with app content, no auth gate, sensitive-keyword hit. Never triaged, so it stays open. It is a public Hugo blog.
blog.example.com leaked-artifact low open /.DS_Store returns 200. A deterministic path probe, so there was nothing to interpret.
app.example.com no-auth-gate high false_positive Chromium render shows a public marketing page with a Log in link in the header. The app sits behind that login.
team-meet.vercel.app no-auth-gate critical false_positive Root serves a “Sign in with Google” form in the initial HTML. The page is the gate.

No finding has reached fixed or accepted_risk yet. fixed requires a completed scan where the finding no longer appears, and nothing has been remediated.

An external system integrates against those states. One example loop:

  1. Your ticketing automation asks the agent for open findings it has not seen yet, calling list_findings with a watermark from its last successful run.
  2. It uses finding.id as its deduplication key, so the same exposure never opens a second ticket.
  3. It maps the finding’s severity and the host’s owner onto whatever queue and priority scheme you already run.
  4. Someone either fixes the exposure or decides it is acceptable and records that decision instead.
  5. The automation starts a rescan and polls get_scan_status until it reports completed.
  6. Only then does it look at the finding again, and it closes the ticket only if the state has moved to fixed.

Step 6 waits on step 5 because a failed scan leaves existing findings unchanged, so fixed only means the exposure is gone if the scan is actually completed.

None of that loop needs much privilege. Pulling findings and kicking off a rescan are read and scan work, and only step 4, where a human records a disposition, needs admin. Keeping the ticketing integration on its own read or scan identity means it cannot add a host to scope.

The agent promises the other system two things: a finding’s state, and an id that does not change between scans. Where the ticket lives, who owns it, and when it was closed all stay in the vendor system.

On the receiving end, that division is visible in the ticket itself, where the state and the id came from the scanner and everything else was added after.

A Clarion issue opened from an exposure sweep, escalated as a confirmed threat with the owner resolved from deploy metadata This issue came from Clarion’s own Vercel sweep rather than the agent in this post, however the handoff is the same. The owner resolution, escalation, and Slack digest all happen after the finding arrives.

Summary

Run against a small set of hosts, the agent produced four findings. One of them, a .DS_Store served at 200, was unambiguous, low severity, and needed no rendering or inference to confirm. Two were false positives that failed the same way: a public landing page with a login form on it is a gate, and no-auth-gate has no way to see that. The fourth is a public blog nobody has bothered to triage. The two critical findings were the wrong ones, and the cheapest deterministic check was the only one that was right.

Several limits are worth naming before anyone points this at real infrastructure. Browser Run may load cross-host subresources or follow client-side navigation after the initial scoped URL, so it collects evidence and does not enforce the scope boundary. The artifact detector probes a fixed path list with no URL discovery, so it finds what you thought to ask for and nothing else. AI classification can fail or disagree with the detectors, which is why it stays advisory and never controls persistence. R2 evidence needs a retention policy before any of this goes to production. Cloudflare egress is external, but a target can treat Cloudflare address space differently from a normal browser, so the absence of a finding is weaker evidence than it looks.

This walkthrough is intended to build something concrete that addresses a real risk. My preference is to learn by building. The hard part was never giving an agent compute, since reachability is easy to test. Most of the work went into deciding what counts as authorization, what counts as proof, what should happen when a step fails, and how a verdict reaches a queue that someone already operates. That is where most of the code in the repository ended up.