Skip to content

feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) - #137

Open
ssowonny wants to merge 1 commit into
mainfrom
bea-111-ph-idea-strip-secrets-out-of-a-file-before-it-goes-public
Open

feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111)#137
ssowonny wants to merge 1 commit into
mainfrom
bea-111-ph-idea-strip-secrets-out-of-a-file-before-it-goes-public

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Sharing a file that holds an AWS-shaped key used to be one silent click and a public URL. Now it asks first.
  • The gate is one if in handleShareCreate: first 1 MiB, six anchored rules, 409 with rule + line number — never the matched string, in the body or the log.
  • Both callers carry the override, so a false positive is a question and not a wall: bdrive share --force, and Share anyway in the browser.
  • A file that already has a live link mints without scanning — its content is already public, so withholding the URL protects nobody.
  • Known gap, deliberate: the check runs at mint time only. A key written into an already-shared file after minting is never caught, and every user-facing string says so.

Closes BEA-111.

Before After
Share deploy.md (holds an AKIA… key) before after
One click: minted, copied to the clipboard, zero questions The rule and the line, and the limit stated in the same breath

Share anyway lands in exactly the dialog you'd have got anyway, and a clean file never sees any of this:

confirmed mobile
After Share anyway — the same Public link dialog The same dialog at 390px

And the CLI:

$ bdrive share deploy.md
Error: deploy.md looks like it contains credentials (checked at the moment you shared it):
  line 3    aws_access_key_id
Nothing was shared. Re-run with --force if that is intentional.

The one rule that cannot bend

The matched bytes never leave scanSecrets — not into the 409 body, not into a
log line, not into a metric label. It returns rule ids and line numbers and
nothing else, the same rule reads.go already keeps for actor identity, and a
409 body is the easiest place in this codebase to leak a secret by accident.
TestShareSecretNeverEchoed redirects log.SetOutput to a buffer and greps
both the response body and that buffer for the planted string, so the
tempting log.Printf("found %s") fails the suite rather than shipping.

Where the gate sits

Between the synced-path check and Shares.Create, using the Source.Open the
volume already has. One handler, one choke point, no sync invariant touched —
the route's proj(PermWrite, …) authority is unchanged, and whoever could
already mint a public link can confirm one.

It is skipped in exactly two cases:

  • confirm: true in the request body — what --force and Share anyway send.
  • alreadyPublic(project, path) — a live, non-expired share for this path whose creator is still in the org. That last clause matters: List returns links whose creator has left, those 404 at /s/, and treating a dead link as "already public" would wave a secrets file straight through.

alreadyPublic deliberately does not key off ShareDB.Create's reuse branch,
which is narrower — it also requires no expiry on either side.

Fails closed

An unreadable blob answers 503 and mints nothing. The repo's "degrade rather
than fail" posture is for sync cycles; minting is a rare interactive action, and
a check that silently skips itself on a storage hiccup is exactly the false
confidence this change exists to remove.

Noted as a deliberate inconsistency, not an accident: the surrounding handler
answers 502 for a failed snapshot, and handleShared answers 502 for a failed
Open. A failed check is "try again"; a failed read is upstream.

The rules

rule id pattern
aws_access_key_id AKIA[0-9A-Z]{16}
openai_api_key sk-[A-Za-z0-9_-]{20,}
github_pat ghp_[A-Za-z0-9]{36}
slack_token xox[baprs]-[A-Za-z0-9-]{10,}
private_key -----BEGIN [A-Z ]*PRIVATE KEY-----
gitlab_pat glpat-[A-Za-z0-9_-]{20,}

Byte-oriented, not bufio.Scanner: a 1 MiB minified file with no newline blows
the scanner's 64 KiB token limit and returns nothing at all — a check that
silently passes everything. Findings are deduped on (rule, line) and sorted,
so a line holding three keys is one finding and the response is stable.

sk- is the false-positive risk; the {20,} body is what keeps it off prose.
If it still fires on real docs, tighten the body rather than dropping the rule —
--force and Share anyway are the escape hatch, which is exactly why they
ship in the same PR as the gate.

Copy is part of the feature

Every user-facing string says the file was checked at the moment you share
it
, never that the file is clean. A link serves the file's LATEST content
forever, so any present-tense claim is a false promise the next sync can break.
That wording is in the dialog, the CLI refusal, README.md, reference/cli.md,
guides/scoping.md and guides/agent-artifacts.md — and README.md's old
"don't share folders that hold secrets" line, the objection BearDrive wrote
about itself, is now the check plus its two limits.

Deviations from the reviewed plan

One, and it's a word: the plan's dialog copy read "BearDrive checked it at the
moment you shared it"
, but the dialog appears before anything is shared, so
the past tense was wrong on screen. It now reads "The check covers the file at
the moment you share it — a link always serves the file's latest content, so
later changes are never checked."
Same constraint, right tense. Everything else
follows the plan as written.

Architecture changes

architecture/webapp-server.md: two new types and one new dependency. secretScan
(secrets.goscanSecrets, secretScanLimit, the six rules) and its
secretFinding return type are new; Server gains alreadyPublic and depends on
secretScan from handleShareCreate; server.go gains writeJSONStatus beside
writeJSON (nothing in webapp wrote JSON with a non-200 status before this).
ShareDB, Share, and every relationship around them are unchanged.

flowchart LR
    Server["Server<br/>(shares.go)<br/>✅ +alreadyPublic(project, path) bool<br/>✅ +writeJSONStatus(w, code, v) — server.go, beside writeJSON"]
    Scan["✅ secretScan &lt;&lt;secrets.go&gt;&gt;<br/>secretScanLimit = 1 MiB<br/>secretRules six anchored regexes<br/>+scanSecrets(buf) []secretFinding"]
    Finding["✅ secretFinding<br/>+Rule string<br/>+Line int"]
    ShareDB["ShareDB<br/>+Create +Get +Revoke +SetExpiry +List"]
    Share["Share<br/>+Token +Project +Path +Creator +Expires"]

    Server -.->|"✅ handleShareCreate scans the first 1 MiB<br/>unless confirm:true or alreadyPublic"| Scan
    Scan -.->|"✅ rule ids and line numbers only"| Finding
    Server --o ShareDB
    Server -.->|"✅ alreadyPublic reads List + shareCreatorStillBelongs"| ShareDB
    ShareDB -.-> Share

    classDef added stroke:#2da44e,stroke-width:2px
    class Scan,Finding added
Loading

What was run

go test ./... all packages pass
go vet ./... clean
npm run e2e 152 passed, 1 skipped, 2 failed — both in sec13fe.spec.ts (single-volume VolumeApp), and both fail identically on origin/main with this branch stashed. Pre-existing, unrelated to this change.
check-dist.sh internal/webapp/static is fresh
UI driven at 1280px and 390px against the seeded hub — dialog legible at both, Cancel holds focus (danger default), Share anyway is the danger-styled action

New tests: secrets_test.go (table-driven, one positive per rule with its line
number, a multi-key line yielding one finding, a 300 KB no-newline buffer, a
clean file), TestShareSecretScan (409 + Shares.List unchanged, confirm: true → a serving link, already-shared skips, clean file unaffected, a key past
1 MiB mints silently), TestShareSecretNeverEchoed, TestShareSecretScanFailsClosed,
TestCLIShareSecretGate (the real binary against a real hub), and a
browse.spec.ts case covering Cancel → zero shares, Share anyway → a working
URL, and a second Share that doesn't ask again.

The loop this leaves open

Nobody has decided what happens when the next sync writes a key into a file
that is already shared. alreadyPublic means a re-share of that file never
scans either. Re-scan-on-write is a separate, larger feature; the "at the moment
you share it" copy is the only thing keeping v1 honest about it in the meantime.

Build session

cd $(git worktree list | grep bea-111-ph-idea-strip-secrets-out-of-a-file-before-it-goes-public | awk '{print $1}') && claude --resume 383b8b05-07c4-4bc7-a74e-56248eec6cdd

(only works on this machine)

🤖 Generated with Claude Code

…ials (BEA-111)

Minting a share link ran zero content checks: a file holding an AWS-shaped
key became a public URL on one click, and the CLI printed nothing but the
link. handleShareCreate now reads the first 1 MiB and runs six anchored
rules between the synced-path check and Shares.Create, answering 409 with
rule ids and line numbers unless the request carries confirm: true.

The matched text never leaves scanSecrets — not into the body, not into a
log line. TestShareSecretNeverEchoed greps both for the planted string,
because a 409 body is the easiest place in this codebase to leak it.

Both callers carry the override, since the gate alone would turn any false
positive into a hard block with no way out: `bdrive share --force`, and the
browser's Share-anyway dialog on modalConfirm (no new component). A path
that already has a live link skips the scan — its content is public
already, so withholding the URL protects nobody — but alreadyPublic drops
links whose creator left the org, since those 404 at /s/ and would
otherwise wave a secrets file straight through.

A failed blob read is 503, not a silent pass: the repo's "degrade rather
than fail" posture is for sync cycles, and a check that skips itself on a
storage hiccup is the false confidence this exists to remove.

Every user-facing string says the file was checked at the moment you shared
it. A link serves the file's LATEST content forever, so a key written into
an already-shared file is never caught — that open loop stays open, and the
copy is the only thing stopping v1 from claiming otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny requested a review from thefron August 8, 2026 16:24
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