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

79 lines
2.6 KiB
Go

package secrets
import (
"reflect"
"strings"
"testing"
)
// Fabricated, structurally-valid-looking strings. None is a real credential.
const (
fakeAWSKey = "AKIAIOSFODNN7EXAMPLE"
fakeOpenAIKey = "sk-abcdefghijklmnopqrstuvwxyz0123456789"
fakeGitHubPAT = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789ab"
fakeSlackTok = "xoxb-1234567890-abcdefghij"
fakeGitLabPAT = "glpat-abcdefghij0123456789XY"
fakePrivKey = "-----BEGIN RSA PRIVATE KEY-----"
)
func TestScanSecrets(t *testing.T) {
for _, tc := range []struct {
name string
buf string
want []Finding
}{
{"clean", "# Notes\n\nnothing to see, sk- is just a prefix here\n", nil},
{"aws", "line1\nline2\nkey = " + fakeAWSKey + "\n", []Finding{{"aws_access_key_id", 3}}},
{"openai", "OPENAI=" + fakeOpenAIKey, []Finding{{"openai_api_key", 1}}},
{"github", "\n\n" + fakeGitHubPAT, []Finding{{"github_pat", 3}}},
{"slack", "token: " + fakeSlackTok, []Finding{{"slack_token", 1}}},
{"gitlab", "x\n" + fakeGitLabPAT, []Finding{{"gitlab_pat", 2}}},
{"private key", "a\nb\nc\n" + fakePrivKey + "\nMIIE...\n", []Finding{{"private_key", 4}}},
{
// One line, one rule, three keys: one finding, not three.
"multi key line deduped",
"a=" + fakeAWSKey + " b=AKIAZZZZZZZZZZZZZZZZ c=AKIAYYYYYYYYYYYYYYYY",
[]Finding{{"aws_access_key_id", 1}},
},
{
"two rules same line",
"env: " + fakeAWSKey + " " + fakeSlackTok,
[]Finding{{"aws_access_key_id", 1}, {"slack_token", 1}},
},
{
// A bufio.Scanner would blow its 64 KiB token limit here and report
// nothing at all — which is why Scan is byte-oriented.
"no newline in a big buffer",
strings.Repeat("x", 300_000) + fakeAWSKey,
[]Finding{{"aws_access_key_id", 1}},
},
{
"sorted by line then rule",
fakeSlackTok + "\n" + fakeAWSKey,
[]Finding{{"slack_token", 1}, {"aws_access_key_id", 2}},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := Scan([]byte(tc.buf))
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("Scan = %v, want %v", got, tc.want)
}
})
}
}
// A rule id reaches a human in three places (the share dialog, `bdrive share`,
// `bdrive status`), and two of them go through Label. A new rule without a
// label ships as `openai_api_key_v2` in a terminal, which is the failure this
// catches at the only point that knows both lists.
func TestLabelCoversEveryRule(t *testing.T) {
for _, r := range secretRules {
if Label(r.id) == r.id {
t.Errorf("rule %q has no human label", r.id)
}
}
if got := Label("not_a_rule"); got != "not_a_rule" {
t.Errorf("Label(unknown) = %q, want the id back", got)
}
}