Files
beardrive/internal/webapp/secrets.go
T
d3d92bf904 feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) (#137)
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>
2026-08-11 04:33:05 +09:00

75 lines
2.8 KiB
Go

package webapp
import (
"bytes"
"regexp"
"sort"
)
// Minting a share link is the one place on the hub where a member turns private
// bytes into a public URL, so it is the one place worth reading the bytes
// first. The check is deliberately narrow: six anchored rules over the first
// 1 MiB, at mint time only.
//
// It says nothing about the file tomorrow. A link serves the file's LATEST
// content forever (see the package comment in shares.go), so every string a
// user sees says the file was checked *at the moment you shared it* — never
// that the file is clean.
// secretScanLimit is how much of a file the share gate reads. The boundary is
// a decision, not an accident: a key past the first MiB mints silently, which
// is asserted in shares_test.go so nobody "fixes" it by accident.
const secretScanLimit = 1 << 20
// secretFinding is one credential-shaped string: which rule fired, and where.
// Never the matched text — see scanSecrets.
type secretFinding struct {
Rule string `json:"rule"`
Line int `json:"line"`
}
var secretRules = []struct {
id string
re *regexp.Regexp
}{
{"aws_access_key_id", regexp.MustCompile(`AKIA[0-9A-Z]{16}`)},
// The bodies below are what keep the prefixes off prose: a bare `sk-` in a
// sentence is not a key. If one still fires on real docs, tighten the body
// rather than dropping the rule — `--force` and Share anyway are the
// escape hatch, which is why they ship in the same change.
{"openai_api_key", regexp.MustCompile(`sk-[A-Za-z0-9_-]{20,}`)},
{"github_pat", regexp.MustCompile(`ghp_[A-Za-z0-9]{36}`)},
{"slack_token", regexp.MustCompile(`xox[baprs]-[A-Za-z0-9-]{10,}`)},
{"private_key", regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----`)},
{"gitlab_pat", regexp.MustCompile(`glpat-[A-Za-z0-9_-]{20,}`)},
}
// scanSecrets reports credential-shaped strings in buf, as rule ids and line
// numbers ONLY. The matched text must never reach a response body, a log line,
// or a metric label — the same argument reads.go:28-40 makes for actor
// identity, and a 409 body is the easiest place in the codebase to leak it.
//
// Byte-oriented on purpose: a bufio.Scanner over a 1 MiB minified file with no
// newline blows its 64 KiB token limit and returns nothing at all, which is a
// check that silently passes everything.
func scanSecrets(buf []byte) []secretFinding {
seen := map[secretFinding]bool{}
var out []secretFinding
for _, rule := range secretRules {
for _, m := range rule.re.FindAllIndex(buf, -1) {
f := secretFinding{Rule: rule.id, Line: bytes.Count(buf[:m[0]], []byte("\n")) + 1}
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}