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.
79 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|