DEV Community

Bcrypto
Bcrypto

Posted on

My security scanner cried wolf on Vercel and Linear. Here's the bug.

I built a scanner that checks a live site for exposed secrets and files β€”
leaked API keys in JS bundles, a reachable .env, a published .git/config.
Before showing it to anyone, I ran it across a batch of well-known sites as a
sanity check.

It reported two CRITICAL findings on vercel.com. And two on linear.app.

Both companies employ people who do security for a living. My first thought was
the honest one: it's not them, it's my scanner. This is the story of why it was
wrong, because the failure mode is one every "does this file exist" check walks
into.

What it claimed

πŸ”΄ CRITICAL β€” Sensitive file exposed: /.git/config
πŸ”΄ CRITICAL β€” Sensitive file exposed: /.DS_Store

If true, that's a real leak: a served .git/config can expose repository
internals. So I checked by hand:

curl -s https://vercel.com/.git/config | head -c 200
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html><html><head><title>Vercel</title>...
Enter fullscreen mode Exit fullscreen mode

That's not a git config. That's the homepage. Served with 200 OK, for a
path that does not exist.

The bug

Single-page apps route on the client. Ask the server for /.git/config,
/.env, /anything-at-all, and it can't know that's not a real app route β€” so
it returns 200 and the app shell, letting the client-side router sort it out.

My probe was, in effect:

const res = await fetch(origin + '/.git/config');
if (res.status === 200) {
  // treat as exposed
}
Enter fullscreen mode Exit fullscreen mode

On a SPA, status === 200 is true for every path. So the scanner reported
.git/config exposed on every well-built SPA on the internet β€” which is to say,
precisely the sites whose engineers would notice and never trust it again.

Then it got worse. Looking at the older code, the "is this real" guard had this:

const looksReal =
  (path === '/.git/config' && /\[core\]/.test(body)) ||
  (path.endsWith('.json') && /[{[]/.test(body.trim())) ||
  (path === '/.git/config' ) ||   // <-- unconditionally true
  ...
Enter fullscreen mode Exit fullscreen mode

There's a second /.git/config clause with no body check. Any 200 response for
that path matched. The one file whose content I most needed to verify was the
one I'd accidentally waved through.

The fix

A 200 proves the server answered. It proves nothing about what it answered.
So verify the content is actually the file, and treat any HTML response as the
catch-all it almost always is.

const SENSITIVE_FILES = [
  { path: '/.env',        sig: (b) => /^\s*(?:#|[A-Z][A-Z0-9_]*\s*=)/m.test(b) },
  { path: '/.git/config', sig: (b) => /\[core\]/i.test(b) },
  { path: '/.DS_Store',   sig: (b) => b.includes('Bud1') }, // binary magic bytes
  // ...
];

for (const { path: p, sig } of SENSITIVE_FILES) {
  const r = await safeFetch(origin + p);
  if (r.status !== 200) continue;
  const ctype = (r.headers.get('content-type') || '').toLowerCase();
  if (ctype.includes('text/html')) continue;          // SPA/404 catch-all
  const body = (await r.text()).slice(0, 800);
  if (/^\s*<!doctype html|^\s*<html/i.test(body)) continue; // mislabeled catch-all
  if (!sig(body)) continue;                            // 200, but not the real file
  report('critical', `Sensitive file exposed: ${p}`);
}
Enter fullscreen mode Exit fullscreen mode

Three gates now: not HTML by content-type, not HTML by sniffing the body (some
servers mislabel), and a signature that matches the real file β€” [core] for a
git config, the Bud1 magic string for a .DS_Store, KEY=value for a
.env. A real exposed file passes all three; a SPA shell fails the first.

Proof it works both ways

The part I actually care about β€” a fix that stops false positives by also
suppressing real ones is worthless. So I tested both directions:

  • vercel.com, linear.app, github.com, stripe.com: 0 criticals (was 2 each on the first two)
  • A local server serving a genuine .env with SECRET_KEY=...: still caught

Then I locked it in with tests, because this is the highest-consequence logic in
the whole tool:

t('/.git/config rejects an HTML app shell', () =>
  assert.strictEqual(sig(SPA_SHELL), false));
t('/.git/config accepts a real git config', () =>
  assert.ok(sig('[core]\n\trepositoryformatversion = 0\n')));
Enter fullscreen mode Exit fullscreen mode

The lesson

A scanner's credibility is entirely downside. Miss a real issue and you're no
worse than not scanning. Cry wolf on a well-run site once and everyone who saw
it stops believing every finding you'll ever produce. For a security tool the
false positive isn't a smaller bug than the false negative β€” in reputation terms
it's the larger one.

"HTTP 200 means the file exists" is an assumption almost everything on the web
quietly breaks. I'm glad I pointed the thing at Vercel before I pointed it at a
front page.


This was in Preflight, a free scanner for
exactly these exposures. It grades itself, and after this fix it earns the A.

Top comments (1)

Collapse
 
peterbuildssecure profile image
Peter

Good example of why a finding needs evidence about the returned object, not merely the requested path and status code.

I’d tighten the .env detector one step further. A single uppercase KEY=value line can also appear in documentation, build output, shell examples or plaintext error pages. Instead of treating one match as conclusive, score multiple independent signals: several assignment-shaped lines, names associated with credentials, low HTML/Markdown likelihood, response size, and whether the same body is returned for a random nonexistent path.

That last comparison is useful beyond SPAs. Some servers return a branded fallback with text/plain, so content type and HTML sniffing can both pass even though the response is still a soft 404. Fetch a high-entropy control path and compare normalized body hashes, length and redirect chain against the sensitive-path response.

I’d also retain the Vercel and Linear cases as permanent negative fixtures, plus fixtures for mislabeled HTML, plaintext soft-404s and documentation containing fake environment variables. The regression suite should prove both that real files remain detectable and that each known fallback class stays suppressed.