Files
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

85 lines
2.6 KiB
Go

package store
import (
"os"
"path/filepath"
"testing"
"github.com/runbear-io/beardrive/internal/secrets"
)
func TestSecretsRoundtripAndIsolation(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
want := map[string][]secrets.Finding{"deploy.md": {{Rule: "aws_access_key_id", Line: 12}}}
if err := s.SaveSecrets("m1", want); err != nil {
t.Fatal(err)
}
got, err := s.LoadSecrets("m1")
if err != nil || len(got["deploy.md"]) != 1 || got["deploy.md"][0].Line != 12 {
t.Fatalf("roundtrip = %+v, %v", got, err)
}
if other, err := s.LoadSecrets("m2"); err != nil || len(other) != 0 {
t.Fatalf("mounts must be isolated: %+v %v", other, err)
}
// A mount id with a separator would put the file wherever its author
// chose — .bdrive/config.json travels with the folder, so it is checked.
if _, err := s.LoadSecrets("../evil"); err == nil {
t.Fatal("LoadSecrets accepted a mount id with a separator")
}
}
// The record is plain JSON in $BDRIVE_HOME that anything running as the user
// can write, and its keys are rendered into a terminal and matched against
// cache keys. Same treatment as LoadCache's: an out-of-volume key is dropped
// on the way in, not on the way out.
func TestLoadSecretsDropsForeignKeys(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
p, err := s.secretsPath("m1")
if err != nil {
t.Fatal(err)
}
const raw = `{
"notes.md": [{"rule":"private_key","line":1}],
"../../secret.md": [{"rule":"private_key","line":1}],
"/etc/shadow": [{"rule":"private_key","line":1}],
"empty.md": []
}`
if err := os.WriteFile(p, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
got, err := s.LoadSecrets("m1")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got["notes.md"]) != 1 {
t.Fatalf("LoadSecrets = %+v, want only notes.md", got)
}
// A torn file starts the mount empty rather than wedging every later
// cycle: these findings are advisory, and the next scan rewrites them.
if err := os.WriteFile(p, []byte(`{"notes.md": [{"rule":`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadSecrets("m1"); err == nil {
t.Fatal("a torn record must report its error to the caller that ignores it")
}
// And the file it wrote is 0600 — it lists a private project's paths.
if err := s.SaveSecrets("m1", map[string][]secrets.Finding{"notes.md": {{Rule: "private_key", Line: 1}}}); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(filepath.Join(s.Dir(), "secrets-m1.json"))
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600", fi.Mode().Perm())
}
}