Files
beardrive/internal/syncer/secrets.go
T
Snow Lee (Sungwon)andGitHub a3dfa73fef Catch a credential when it syncs, not only when you share it (#162)
* 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.
2026-08-18 15:08:44 -07:00

96 lines
2.9 KiB
Go

package syncer
import (
"io"
"github.com/runbear-io/beardrive/internal/secrets"
"github.com/runbear-io/beardrive/internal/store"
)
// The credential check runs on the path every file takes — the sync scan —
// and it only ever WARNS. The op is journaled and pushed exactly as it was
// before this check existed; a hit adds a line to `bdrive status` and a
// sentence to the agent hook's context, and nothing else. Holding the op back
// would mean a false positive silently parks someone's changes, which costs
// more trust than the check buys, and it would break the cycle's "degrade to
// offline, never fail" posture. Warn keeps that; hold breaks it.
//
// It reads the BLOB the scan just wrote, not the working file again: those are
// the exact bytes that were hashed and journaled, so a line number can never
// describe content no op ever captured, and the read is the same page-cache
// read the hash pass just did.
//
// Nothing here may fail a cycle. Every error path drops the finding.
// secretsPerFile caps what one file contributes. A generated file full of
// key-shaped strings would otherwise put thousands of lines into a status
// report nobody can read, and the first few are what a person acts on.
const secretsPerFile = 8
// secretLog is one cycle's view of the mount's findings: loaded whole, merged
// per path, written back only if the cycle actually touched it. Per-path merge
// is the point — nearly every cycle scans zero changed files, and a whole-set
// rewrite would erase the warning seconds after it appeared.
type secretLog struct {
found map[string][]secrets.Finding
dirty bool
}
// scanBlob records what the blob just written for rel contains. Called only on
// the branches that ran PutBlobFile, so an unchanged file is never re-read.
func (l *secretLog) scanBlob(st *store.Store, rel, sum string) {
if l == nil {
return
}
f, err := st.OpenBlob(sum)
if err != nil {
return // unreadable: skipped like every other unreadable-file case
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, secrets.ScanLimit))
if err != nil {
return
}
found := secrets.Scan(buf)
if len(found) > secretsPerFile {
found = found[:secretsPerFile]
}
l.set(rel, found)
}
// set records rel's current findings, dropping the entry when there are none —
// which is how fixing the file clears the warning with no command and no flag.
func (l *secretLog) set(rel string, found []secrets.Finding) {
if len(found) == 0 {
l.drop(rel)
return
}
if !sameFindings(l.found[rel], found) {
l.found[rel] = found
l.dirty = true
}
}
// drop forgets a path: its credential is gone, or the path is.
func (l *secretLog) drop(rel string) {
if l == nil {
return
}
if _, ok := l.found[rel]; ok {
delete(l.found, rel)
l.dirty = true
}
}
func sameFindings(a, b []secrets.Finding) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}