Merge remote-tracking branch 'origin/master' into wizard/upgrade-gate-held
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
This commit is contained in:
commit
3d948c7033
5 changed files with 332 additions and 4 deletions
|
|
@ -217,6 +217,23 @@ name charset) run via the dbaas primary pod — the same exec path as `k8s db`.
|
|||
| `edges --denied` | read | only `action='deny'` edges (blocked / lateral-movement) |
|
||||
| `edges --json` / `--limit N` | read | JSON array output / row cap (default 200) |
|
||||
|
||||
### v0.10 — `vault get --all` (browse every field)
|
||||
|
||||
`vault get <name> --all` returns the **whole item** as a normalized JSON object,
|
||||
so an agent can discover and read fields the single-field `--field` allowlist
|
||||
can't reach — notably arbitrary **custom fields**.
|
||||
|
||||
| Command | Tier | What it does |
|
||||
| --- | --- | --- |
|
||||
| `vault get <name> --all` | read | all fields as JSON: `{name, username?, password?, uris?, totp?, notes?, fields?}` |
|
||||
|
||||
Shape notes: present standard fields only (empty ones omitted); `fields` is a
|
||||
custom `name→value` map (duplicate names → last-wins; `linked` fields skipped).
|
||||
The TOTP **seed is never emitted** — `totp` is a presence flag (`true`), so the
|
||||
only seed-derived path stays the specially-audited `vault code`. Like
|
||||
`get --json`, the dump is all secret values, so it **refuses a terminal** — pipe
|
||||
it (`homelab vault get <name> --all | jq`).
|
||||
|
||||
## Build / install
|
||||
|
||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
v0.9.0
|
||||
v0.10.0
|
||||
|
|
|
|||
117
cli/cmd_vault.go
117
cli/cmd_vault.go
|
|
@ -26,7 +26,7 @@ func vaultCommands() []Command {
|
|||
{Path: []string{"vault", "list"}, Tier: TierRead,
|
||||
Summary: "list your item names: vault list [--search Q]", Run: vaultList},
|
||||
{Path: []string{"vault", "get"}, Tier: TierRead,
|
||||
Summary: "fetch one item: vault get <name> [--field password|username|uri|notes|totp] [--json]", Run: vaultGet},
|
||||
Summary: "fetch one item: vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet},
|
||||
{Path: []string{"vault", "search"}, Tier: TierRead,
|
||||
Summary: "search your item names: vault search <query>", Run: vaultSearch},
|
||||
{Path: []string{"vault", "code"}, Tier: TierRead,
|
||||
|
|
@ -48,6 +48,8 @@ func vaultHelp() string {
|
|||
homelab vault list [--search Q] list your item names (no secrets)
|
||||
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
|
||||
TTY → clipboard (auto-clears); piped → stdout
|
||||
homelab vault get <name> --all all fields (incl. custom) as JSON; piped only.
|
||||
TOTP shown as presence flag — use 'vault code' for a code.
|
||||
homelab vault code <name> current TOTP code
|
||||
homelab vault lock lock / log out the local bw session
|
||||
|
||||
|
|
@ -270,6 +272,7 @@ func bwSecretEnv(appdata string, c vwCreds, session string) []string {
|
|||
func bwLoginArgs() []string { return []string{"login", "--apikey"} }
|
||||
func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} }
|
||||
func bwGetArgs(field, name string) []string { return []string{"get", field, name} }
|
||||
func bwItemArgs(name string) []string { return []string{"get", "item", name} }
|
||||
func bwStatusArgs() []string { return []string{"status"} }
|
||||
|
||||
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
|
||||
|
|
@ -444,6 +447,7 @@ type getOpts struct {
|
|||
name string
|
||||
field string
|
||||
json bool
|
||||
all bool // dump every field (incl. custom) as normalized JSON
|
||||
}
|
||||
|
||||
var validGetFields = map[string]bool{"password": true, "username": true, "uri": true, "notes": true, "totp": true}
|
||||
|
|
@ -455,6 +459,8 @@ func parseGetArgs(args []string) (getOpts, error) {
|
|||
switch {
|
||||
case a == "--json":
|
||||
o.json = true
|
||||
case a == "--all":
|
||||
o.all = true
|
||||
case a == "--field" && i+1 < len(args):
|
||||
o.field = args[i+1]
|
||||
i++
|
||||
|
|
@ -465,9 +471,10 @@ func parseGetArgs(args []string) (getOpts, error) {
|
|||
}
|
||||
}
|
||||
if o.name == "" {
|
||||
return o, fmt.Errorf("usage: homelab vault get <name> [--field password|username|uri|notes|totp] [--json]")
|
||||
return o, fmt.Errorf("usage: homelab vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]")
|
||||
}
|
||||
if !validGetFields[o.field] {
|
||||
// --all dumps the whole item, so --field is irrelevant — skip its allowlist.
|
||||
if !o.all && !validGetFields[o.field] {
|
||||
return o, fmt.Errorf("invalid --field %q (want password|username|uri|notes|totp)", o.field)
|
||||
}
|
||||
return o, nil
|
||||
|
|
@ -483,6 +490,81 @@ func getValue(run cmdRunner, user, uid string, o getOpts) (string, error) {
|
|||
return bwGet(run, s.env, o.field, o.name)
|
||||
}
|
||||
|
||||
// getItem opens a session and returns the whole item as raw `bw get item` JSON.
|
||||
// Used by `get --all`; normalization is a separate, pure step (normalizeItem).
|
||||
func getItem(run cmdRunner, user, uid, name string) (string, error) {
|
||||
s, err := openSession(run, user, uid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return run("bw", bwItemArgs(name), s.env)
|
||||
}
|
||||
|
||||
// normalizedItem is the browse-all-fields projection of a Vaultwarden item: the
|
||||
// standard login fields that are present, notes, and a flat map of custom field
|
||||
// name→value. bw internals (id, object, reprompt, passwordHistory) are dropped,
|
||||
// and the TOTP *seed* is reduced to a presence flag — the only seed-derived path
|
||||
// stays the specially-audited `vault code` (see the design §10/§16).
|
||||
type normalizedItem struct {
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
URIs []string `json:"uris,omitempty"`
|
||||
TOTP bool `json:"totp,omitempty"` // presence only, never the seed
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"` // custom field name→value
|
||||
}
|
||||
|
||||
// bwFieldLinked is the Bitwarden custom-field type for a "linked" field: it
|
||||
// references another field and carries a null value, so it is not real data.
|
||||
const bwFieldLinked = 3
|
||||
|
||||
// normalizeItem parses a `bw get item` payload into the browse projection. It is
|
||||
// pure (no I/O), so it is the unit-tested heart of `get --all`.
|
||||
func normalizeItem(raw string) (normalizedItem, error) {
|
||||
var it struct {
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes"`
|
||||
Login *struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Totp string `json:"totp"`
|
||||
URIs []struct {
|
||||
URI string `json:"uri"`
|
||||
} `json:"uris"`
|
||||
} `json:"login"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Type int `json:"type"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &it); err != nil {
|
||||
return normalizedItem{}, fmt.Errorf("parse bw item: %w", err)
|
||||
}
|
||||
n := normalizedItem{Name: it.Name, Notes: it.Notes}
|
||||
if it.Login != nil {
|
||||
n.Username = it.Login.Username
|
||||
n.Password = it.Login.Password
|
||||
n.TOTP = it.Login.Totp != ""
|
||||
for _, u := range it.Login.URIs {
|
||||
if u.URI != "" {
|
||||
n.URIs = append(n.URIs, u.URI)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range it.Fields {
|
||||
if f.Type == bwFieldLinked {
|
||||
continue // references another field, no value of its own
|
||||
}
|
||||
if n.Fields == nil {
|
||||
n.Fields = map[string]string{}
|
||||
}
|
||||
n.Fields[f.Name] = f.Value // duplicate names: last-wins (rare; documented)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// clipboardDecision picks how to return a secret value. "stdout" prints it (a
|
||||
// pipe/agent — the intended machine path); "clipboard" copies via OSC52;
|
||||
// "refuse" emits nothing sensitive (would otherwise risk dumping the secret's
|
||||
|
|
@ -789,6 +871,9 @@ func vaultGet(args []string) error {
|
|||
}
|
||||
defer unlock()
|
||||
user := vaultCurrentUser()
|
||||
if o.all {
|
||||
return getAllFields(user, uid, o.name)
|
||||
}
|
||||
val, err := getValue(realRunner, user, uid, o)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -804,3 +889,29 @@ func vaultGet(args []string) error {
|
|||
emitSecret(val)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAllFields prints every field of one item as normalized JSON. Like
|
||||
// `get --json`, the payload is all secret values, so it refuses a terminal
|
||||
// (pipe it). The TOTP seed is never emitted — only a presence flag — so no extra
|
||||
// TOTP audit is needed; the op-log uses a distinct verb so a bulk dump is
|
||||
// distinguishable from a single-field get (the item name is still never logged).
|
||||
func getAllFields(user, uid, name string) error {
|
||||
if !jsonToStdoutOK(stdoutIsTTY()) {
|
||||
return fmt.Errorf("refusing to print all fields as JSON to a terminal; pipe it (e.g. | jq)")
|
||||
}
|
||||
raw, err := getItem(realRunner, user, uid, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := normalizeItem(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeOpLog(opRecord{User: user, Verb: "get-all", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: name})
|
||||
fmt.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -620,3 +621,201 @@ func TestGetValueFlow(t *testing.T) {
|
|||
t.Fatalf("getValue = %q, %v", val, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- vault get --all (browse all fields) ----------------------------------
|
||||
|
||||
func TestParseGetArgsAll(t *testing.T) {
|
||||
o, err := parseGetArgs([]string{"github", "--all"})
|
||||
if err != nil || o.name != "github" || !o.all {
|
||||
t.Fatalf("parseGetArgs(--all) = %+v err=%v", o, err)
|
||||
}
|
||||
// --all must skip --field validation (field is irrelevant for a full dump).
|
||||
if _, err := parseGetArgs([]string{"github", "--all", "--field", "evil"}); err != nil {
|
||||
t.Fatalf("--all must ignore an otherwise-invalid --field, got err=%v", err)
|
||||
}
|
||||
// A name is still required.
|
||||
if _, err := parseGetArgs([]string{"--all"}); err == nil {
|
||||
t.Fatal("get --all with no name must error")
|
||||
}
|
||||
// Without --all, the field allowlist still applies.
|
||||
if _, err := parseGetArgs([]string{"github", "--field", "evil"}); err == nil {
|
||||
t.Fatal("invalid --field without --all must still error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBwItemArgs(t *testing.T) {
|
||||
argv := bwItemArgs("github")
|
||||
if !reflect.DeepEqual(argv, []string{"get", "item", "github"}) {
|
||||
t.Fatalf("bwItemArgs = %v", argv)
|
||||
}
|
||||
for _, a := range argv {
|
||||
if strings.Contains(a, "SESSION") || a == "--session" {
|
||||
t.Fatalf("session must travel via env, not argv: %v", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a representative `bw get item` payload: login fields, multiple URIs, a TOTP
|
||||
// seed, notes, custom fields (text/hidden/boolean), plus bw internals that MUST
|
||||
// be dropped (id/object/reprompt/passwordHistory).
|
||||
const sampleLoginItemJSON = `{
|
||||
"object":"item","id":"abc-123","folderId":null,"type":1,"reprompt":0,
|
||||
"name":"GitHub","notes":"my notes","favorite":false,
|
||||
"fields":[
|
||||
{"name":"PIN","value":"1234","type":1},
|
||||
{"name":"endpoint","value":"https://api.gh","type":0},
|
||||
{"name":"enabled","value":"true","type":2}
|
||||
],
|
||||
"login":{
|
||||
"username":"octocat","password":"hunter2",
|
||||
"totp":"otpauth://totp/GitHub:octocat?secret=SEEDSEEDSEED",
|
||||
"uris":[{"match":null,"uri":"https://github.com"},{"match":null,"uri":"https://gist.github.com"}]
|
||||
},
|
||||
"passwordHistory":[{"password":"OLD-PASSWORD-XYZ"}]
|
||||
}`
|
||||
|
||||
func TestNormalizeItemLogin(t *testing.T) {
|
||||
n, err := normalizeItem(sampleLoginItemJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
if n.Name != "GitHub" || n.Username != "octocat" || n.Password != "hunter2" || n.Notes != "my notes" {
|
||||
t.Fatalf("standard fields wrong: %+v", n)
|
||||
}
|
||||
if !n.TOTP {
|
||||
t.Fatal("TOTP presence flag must be true when a seed exists")
|
||||
}
|
||||
if !reflect.DeepEqual(n.URIs, []string{"https://github.com", "https://gist.github.com"}) {
|
||||
t.Fatalf("URIs = %v", n.URIs)
|
||||
}
|
||||
want := map[string]string{"PIN": "1234", "endpoint": "https://api.gh", "enabled": "true"}
|
||||
if !reflect.DeepEqual(n.Fields, want) {
|
||||
t.Fatalf("custom fields = %v want %v", n.Fields, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The load-bearing security test: the raw TOTP seed (more powerful than a
|
||||
// one-time code) and the password history must NEVER appear in the dump.
|
||||
func TestNormalizeItemNeverLeaksSeedOrHistory(t *testing.T) {
|
||||
n, err := normalizeItem(sampleLoginItemJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
out, err := json.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
for _, leak := range []string{"SEEDSEEDSEED", "otpauth", "OLD-PASSWORD-XYZ", "passwordHistory", "abc-123"} {
|
||||
if strings.Contains(string(out), leak) {
|
||||
t.Fatalf("dump leaked %q: %s", leak, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemNoTOTP(t *testing.T) {
|
||||
n, err := normalizeItem(`{"name":"X","type":1,"login":{"username":"u","password":"p"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
if n.TOTP {
|
||||
t.Fatal("TOTP must be false when no seed present")
|
||||
}
|
||||
out, _ := json.Marshal(n)
|
||||
if strings.Contains(string(out), "totp") {
|
||||
t.Fatalf("no-totp item must omit the totp key entirely: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemEmptyStandardFieldsOmitted(t *testing.T) {
|
||||
n, err := normalizeItem(`{"name":"Bare","type":1,"login":{"username":"","password":"","totp":"","uris":[]},"fields":[{"name":"only","value":"x","type":0}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
out, _ := json.Marshal(n)
|
||||
for _, k := range []string{"username", "password", "uris", "notes", "totp"} {
|
||||
if strings.Contains(string(out), `"`+k+`"`) {
|
||||
t.Fatalf("empty standard field %q must be omitted: %s", k, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), `"name":"Bare"`) || !strings.Contains(string(out), `"only":"x"`) {
|
||||
t.Fatalf("name + custom field must survive: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemSecureNoteNullLogin(t *testing.T) {
|
||||
// type 2 (secure note): login is null — must not panic; notes + custom fields survive.
|
||||
n, err := normalizeItem(`{"name":"SN","type":2,"notes":"secret note","login":null,"fields":[{"name":"k","value":"v","type":1}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem(null login): %v", err)
|
||||
}
|
||||
if n.Name != "SN" || n.Notes != "secret note" || n.Fields["k"] != "v" {
|
||||
t.Fatalf("secure-note normalize wrong: %+v", n)
|
||||
}
|
||||
if n.Username != "" || n.Password != "" || n.TOTP {
|
||||
t.Fatalf("login fields must be empty for a login-less item: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemDuplicateCustomNames(t *testing.T) {
|
||||
// Bitwarden permits duplicate custom-field names; a JSON object can't hold
|
||||
// dups, so last-wins (documented).
|
||||
n, err := normalizeItem(`{"name":"D","fields":[{"name":"k","value":"first","type":0},{"name":"k","value":"second","type":0}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
if n.Fields["k"] != "second" {
|
||||
t.Fatalf("duplicate custom names must be last-wins, got %q", n.Fields["k"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemLinkedFieldSkipped(t *testing.T) {
|
||||
// type 3 (linked) fields reference another field and carry a null value —
|
||||
// they are not real data and must be skipped.
|
||||
n, err := normalizeItem(`{"name":"L","login":{"username":"u"},"fields":[{"name":"linked","value":null,"type":3},{"name":"real","value":"r","type":0}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeItem: %v", err)
|
||||
}
|
||||
if _, ok := n.Fields["linked"]; ok {
|
||||
t.Fatalf("linked field must be skipped: %v", n.Fields)
|
||||
}
|
||||
if n.Fields["real"] != "r" {
|
||||
t.Fatalf("real custom field dropped: %v", n.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemMalformed(t *testing.T) {
|
||||
if _, err := normalizeItem("not json"); err == nil {
|
||||
t.Fatal("malformed item JSON must error")
|
||||
}
|
||||
}
|
||||
|
||||
// getItem opens a session and runs `bw get item <name>`, returning raw JSON.
|
||||
func TestGetItemFlow(t *testing.T) {
|
||||
f := &fakeRunner{out: map[string]string{
|
||||
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
|
||||
"bw status": `{"status":"locked"}`,
|
||||
"bw unlock": "SESS",
|
||||
"bw get item github": sampleLoginItemJSON,
|
||||
}}
|
||||
uid := fmt.Sprintf("%d", os.Getuid())
|
||||
raw, err := getItem(f.run, "emo", uid, "github")
|
||||
if err != nil || !strings.Contains(raw, `"name":"GitHub"`) {
|
||||
t.Fatalf("getItem = %q, %v", raw, err)
|
||||
}
|
||||
// The session key must reach bw via env, never argv.
|
||||
for _, call := range f.calls {
|
||||
for _, arg := range call {
|
||||
if strings.Contains(arg, "SESS") {
|
||||
t.Errorf("session leaked into argv: %v", call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultHelpMentionsAll(t *testing.T) {
|
||||
if !strings.Contains(vaultHelp(), "--all") {
|
||||
t.Error("vault help must document --all")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ homelab vault setup one-time: store VW email + master password + API
|
|||
homelab vault status configured / unlocked / reachable (no secrets)
|
||||
homelab vault list [--search Q] item names (no secrets)
|
||||
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
|
||||
homelab vault get <name> --all all fields (incl. custom) as JSON; pipe it (| jq)
|
||||
homelab vault code <name> current TOTP code
|
||||
homelab vault lock lock / log out the local bw session
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue