mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* refactor(secrets): lift the share-time credential rules into internal/secrets The rules only ever ran on the rarest path a file takes. Moving them out of internal/webapp is what lets internal/syncer run the same six rules on the path every file takes, without inverting the dependency. Pure move plus one addition: Label(), the six human strings that until now lived only in the frontend's SECRET_LABELS — so 'bdrive share' stops printing a bare rule id where the web dialog says 'an AWS access key'. Rule ids and the rule/line JSON tags are unchanged: Browser.tsx keys off them, so they are a wire contract. * feat(sync): warn when a synced file looks like it holds a credential The six share-time rules now run on the path every file takes. A file with an AWS key in it used to ride a normal sync to the hub, to every teammate's disk and into every future agent's context with no badge and no warning — while the Share dialog one click later blocked that exact file. Warn, never block: the op is journaled and pushed exactly as before. A hold arm would mean a false positive silently parks someone's changes, and it would break the cycle's degrade-to-offline posture. - scan() reads the blob PutBlobFile just wrote (the bytes that were actually journaled), only on the branches that wrote one — an unchanged file is still never re-read. - Findings persist per path in secrets-<mount>.json, merged rather than replaced: nearly every cycle scans zero files, and a whole-set rewrite would erase the warning seconds after it appeared. Fixing the file clears it. - bdrive status grows a secrets block; the agent hook appends one advisory sentence. Rule ids and line numbers only, never the matched bytes. - SaveSecrets failing logs and continues: advisory telemetry never gets a veto over convergence. * docs: the credential check now runs on sync, not only on share README, the CLI reference and project-files get the new bdrive status block and the warn-never-block posture, with the three limits stated (checked when it changes, first 1 MiB, writing device only). Diagrams: internal/secrets is a package of its own in the overview, secretLog joins the sync engine, and the share-gate class notes that it no longer owns the rules. * test(sync): assert an unchanged file is never re-read for credentials The check must ride the branch that already reads the file. Clearing the record by hand and cycling proves it: a scan that re-read unchanged files would put the finding back, and the daemon's 3-second tick would pay for it on every file.
100 lines
3.7 KiB
Go
100 lines
3.7 KiB
Go
// Package secrets is the one credential-shaped-string rule set, shared by the
|
|
// two places that look: the hub's share gate (internal/webapp/shares.go, which
|
|
// refuses to mint) and the sync scan (internal/syncer, which only warns). It
|
|
// imports the standard library and nothing else, so the syncer can use it
|
|
// without depending on the server.
|
|
//
|
|
// Both callers say the same thing about time: the check ran when the bytes
|
|
// were read — at the moment you shared it, or the moment the file last
|
|
// changed. Neither one ever claims a file is clean.
|
|
//
|
|
// The matched text never leaves this package. Rule ids and line numbers only,
|
|
// in a 409 body, in `bdrive status`, in an agent's context, in a log line.
|
|
package secrets
|
|
|
|
import (
|
|
"bytes"
|
|
"regexp"
|
|
"sort"
|
|
)
|
|
|
|
// ScanLimit 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 ScanLimit = 1 << 20
|
|
|
|
// Finding is one credential-shaped string: which rule fired, and where.
|
|
// Never the matched text — see Scan.
|
|
type Finding 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,}`)},
|
|
}
|
|
|
|
// labels are the rule ids in words. They existed only in the frontend
|
|
// (SECRET_LABELS in Browser.tsx) while `bdrive share` printed the bare id, so
|
|
// the same finding read as "an AWS access key" in the browser and
|
|
// "aws_access_key_id" in a terminal. TestLabelCoversEveryRule is what stops a
|
|
// seventh rule from shipping as a bare id again.
|
|
var labels = map[string]string{
|
|
"aws_access_key_id": "an AWS access key",
|
|
"openai_api_key": "an OpenAI API key",
|
|
"github_pat": "a GitHub token",
|
|
"slack_token": "a Slack token",
|
|
"private_key": "a private key",
|
|
"gitlab_pat": "a GitLab token",
|
|
}
|
|
|
|
// Label renders a rule id for a human. An id with no label renders as itself —
|
|
// a bare id is ugly, but a rule that reports nothing is worse.
|
|
func Label(rule string) string {
|
|
if l, ok := labels[rule]; ok {
|
|
return l
|
|
}
|
|
return rule
|
|
}
|
|
|
|
// Scan 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 Scan(buf []byte) []Finding {
|
|
seen := map[Finding]bool{}
|
|
var out []Finding
|
|
for _, rule := range secretRules {
|
|
for _, m := range rule.re.FindAllIndex(buf, -1) {
|
|
f := Finding{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
|
|
}
|