Skip to content
Back to field notes
ProcessJune 6, 20266 min read

How to read a security scan result and triage findings

A scan report is a list, not a verdict, and a solo founder has to decide which findings block the launch and which can wait. This note shows how to read and triage one.

TriageProcessSeverity

A scan result is a worklist, not a grade

You ran a scan against your app's public URL and got back a list of findings, each with a severity label and some explanation. The risk now is not the findings themselves. It is mis-reading them: shipping past a real launch blocker because the report was long and everything blurred together, or burning your launch window on a low-severity hardening item while a high-severity one sits two rows down.

The first thing to understand about how to read security scan results is what the report is and is not. It is an observation: from outside, with no login, here is what the scanner could reach and what it looked like. It is not a grade on your whole app, and a finding is not a confirmed breach. Your job in triage is to sort each line into one of three buckets: fix before you share the URL, fix soon after, or dismiss with a reason you can defend.

Two distinctions do most of the sorting work, so keep them in mind as you go down the list.

  • Observable versus possible. The scanner saw something reachable (a key string in the bundle, a 200 from a backup file, a missing header). That is observable. What an attacker could then do with it is the possible part, and it is what severity is trying to estimate.
  • Severity versus reproducibility. A finding can be high severity and still be one you cannot immediately reproduce by hand. Those are not contradictions, and the section below handles them separately.

What severity ratings actually mean

Severity is an estimate of impact and exposure combined, not a countdown timer and not a precise score. A high-severity finding means something sensitive is reachable from the public surface with little or no effort. A low-severity one usually means a defense-in-depth control is missing but nothing sensitive is exposed on its own yet. Read the label as a starting sort order, then apply your own judgment, because the scanner does not know your app's context.

A useful way to read each finding is to ask three questions in order: what is exposed, who can reach it, and what does reaching it let them do. A live secret key answers all three badly at once, which is why it lands at the top.

One caveat the label cannot capture: a medium finding can stack into a high one. A wildcard CORS header is medium on its own, but combined with a cookie that lacks SameSite it becomes a real cross-origin path. Read findings together, not only in isolation.

  • High, treat as a launch blocker: a secret-shaped string in the client bundle that grants real power, for example sk_live_ for Stripe, a Supabase service_role JWT, sk-ant- for Anthropic, or sk-proj- for OpenAI. A reachable build or deploy artifact, for example GET /.env returning 200, a public /.git/HEAD, or a database backup downloadable without auth. A table or storage bucket returning data to an anonymous caller. These are exposures, not theoretical, and the URL spreading makes them worse.
  • Medium, usually fix soon but rarely a hard blocker by itself: missing or weak security headers, a permissive or wildcard CORS header, a session cookie without Secure, HttpOnly, or SameSite, source maps served in production. These widen what an attacker can do or learn; they are real, but most are hardening rather than an open door on their own.
  • Low or informational: a verbose server banner, a robots.txt that names paths, an analytics script flagged for review. Worth a note, not worth holding the launch.

Launch blockers versus hardening tasks

The single decision your triage has to produce is this: does this finding block sharing the URL, or can it ship and get fixed in the next deploy? Severity is the first input, but the deciding test is sharper. A launch blocker is something that exposes data, credentials, or money to anyone who loads the page, right now, with no further weakness needed. A hardening task makes the app more resistant but is not, by itself, an open door.

Run each finding through that test rather than trusting the label alone.

The honest part: this sort is about the public surface only. A clean scan does not tell you whether a logged-in user can read another user's data, whether a webhook verifies its signature, or whether an admin route checks authorization and not just authentication. Those are real launch blockers too, but a public scan does not see them. For where that line sits, see public scan vs code review before launch.

  • Blocks launch: a live secret key in the bundle (rotate it, because a published key must be assumed compromised, then move the call server-side). A 200 from /.env, /.git/HEAD, or a backup file (remove it from the webroot and confirm the path now 404s). A non-public table or bucket returning rows or files to the anonymous caller (close it; this is an authorization gap, not a login problem). These three patterns are the hard pass or fail gates.
  • Fix soon, ship if needed: missing security headers, source maps in production, cookie flags, CORS tightening. Schedule these into the first post-launch deploy and verify the fix took effect.
  • Note and move on: informational items and anything the scanner explicitly marks as low and context-dependent.

Handling a finding you cannot reproduce or think is wrong

Two cases trip people up most: a high-severity finding you cannot reproduce by hand, and a finding you are sure is a false positive. Handle them differently.

If you cannot reproduce it, do not dismiss it yet. The scanner ran from a clean session with no cookies, no extensions, and no logged-in state, and it may have hit a different cache layer, region, or deploy than your browser. Reproduce it the same way the scanner did: open a private window, or use curl with the verbose flag against the exact path and method the report names, and read the real status code and response. If a fresh clean session confirms the exposure, it is real regardless of what your normal browser shows. If repeated clean attempts cannot reach it, downgrade it to watch rather than deleting the line, and note why.

If you believe it is a false positive, the standard is the same: a reason you can defend, not a feeling. A string that matches a key pattern but is a public publishable key (pk_ or a Supabase anon key meant to ship) is a genuine false positive, and you record that the value is public by design. A backup-looking path that returns your normal 404 page with a 200 status is worth a closer look, not an automatic dismissal. Write the reason next to the finding so the next person, including future you, does not re-litigate it.

A scanner reports what it observed, not your intent. Only you know that pk_ is meant to be public and service_role is not. Triage is where that context gets applied. Dismissing a finding without recording why is how a real exposure gets waved through on the second pass.

Where VibeCodeGuard fits, and what triage cannot prove

VibeCodeGuard's Launch Check produces exactly the kind of severity-rated, public-surface findings this note is about. It scans your live URL from a clean external session and reports what it could reach: missing or weak headers, source maps, exposed build and deploy artifacts, secret-shaped strings in the client bundle, publicly readable buckets and endpoints, and open or wildcard CORS, each with a severity and fix direction. The point of this article is that the report is the start of the work, not the end. The scan finds and rates; you triage and decide.

What triage on a scan result cannot prove is the half of your app the scan never touched. A clean or fully-triaged report says the public surface looked sound from outside at that moment. It does not confirm that your authorization logic is correct, that entitlements are enforced server-side, or that a later deploy will not silently re-open something you just closed.

Next step: take your most recent report, put every finding into one of the three buckets above, and confirm that none of the launch-blocker patterns (a live key, a reachable artifact, an open table or bucket) is sitting unaddressed. Then make the scan a repeatable step before each deploy rather than a one-time launch ritual, so a future change cannot quietly undo today's fixes; see building a repeatable pre-launch security habit. Run a focused Launch Check against the public URL before you share it.

A triaged scan is a snapshot of one surface at one moment. It does not certify the app as secure, and it does not replace code review of your auth and business logic. Treat a high-severity public-surface finding as a hard launch blocker, and treat a clean result as one good signal among several.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.