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 --denied` | read | only `action='deny'` edges (blocked / lateral-movement) |
|
||||||
| `edges --json` / `--limit N` | read | JSON array output / row cap (default 200) |
|
| `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
|
## Build / install
|
||||||
|
|
||||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
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,
|
{Path: []string{"vault", "list"}, Tier: TierRead,
|
||||||
Summary: "list your item names: vault list [--search Q]", Run: vaultList},
|
Summary: "list your item names: vault list [--search Q]", Run: vaultList},
|
||||||
{Path: []string{"vault", "get"}, Tier: TierRead,
|
{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,
|
{Path: []string{"vault", "search"}, Tier: TierRead,
|
||||||
Summary: "search your item names: vault search <query>", Run: vaultSearch},
|
Summary: "search your item names: vault search <query>", Run: vaultSearch},
|
||||||
{Path: []string{"vault", "code"}, Tier: TierRead,
|
{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 list [--search Q] list your item names (no secrets)
|
||||||
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
|
homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
|
||||||
TTY → clipboard (auto-clears); piped → stdout
|
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 code <name> current TOTP code
|
||||||
homelab vault lock lock / log out the local bw session
|
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 bwLoginArgs() []string { return []string{"login", "--apikey"} }
|
||||||
func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} }
|
func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} }
|
||||||
func bwGetArgs(field, name string) []string { return []string{"get", field, name} }
|
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"} }
|
func bwStatusArgs() []string { return []string{"status"} }
|
||||||
|
|
||||||
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
|
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
|
||||||
|
|
@ -444,6 +447,7 @@ type getOpts struct {
|
||||||
name string
|
name string
|
||||||
field string
|
field string
|
||||||
json bool
|
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}
|
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 {
|
switch {
|
||||||
case a == "--json":
|
case a == "--json":
|
||||||
o.json = true
|
o.json = true
|
||||||
|
case a == "--all":
|
||||||
|
o.all = true
|
||||||
case a == "--field" && i+1 < len(args):
|
case a == "--field" && i+1 < len(args):
|
||||||
o.field = args[i+1]
|
o.field = args[i+1]
|
||||||
i++
|
i++
|
||||||
|
|
@ -465,9 +471,10 @@ func parseGetArgs(args []string) (getOpts, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.name == "" {
|
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, fmt.Errorf("invalid --field %q (want password|username|uri|notes|totp)", o.field)
|
||||||
}
|
}
|
||||||
return o, nil
|
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)
|
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
|
// clipboardDecision picks how to return a secret value. "stdout" prints it (a
|
||||||
// pipe/agent — the intended machine path); "clipboard" copies via OSC52;
|
// pipe/agent — the intended machine path); "clipboard" copies via OSC52;
|
||||||
// "refuse" emits nothing sensitive (would otherwise risk dumping the secret's
|
// "refuse" emits nothing sensitive (would otherwise risk dumping the secret's
|
||||||
|
|
@ -789,6 +871,9 @@ func vaultGet(args []string) error {
|
||||||
}
|
}
|
||||||
defer unlock()
|
defer unlock()
|
||||||
user := vaultCurrentUser()
|
user := vaultCurrentUser()
|
||||||
|
if o.all {
|
||||||
|
return getAllFields(user, uid, o.name)
|
||||||
|
}
|
||||||
val, err := getValue(realRunner, user, uid, o)
|
val, err := getValue(realRunner, user, uid, o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -804,3 +889,29 @@ func vaultGet(args []string) error {
|
||||||
emitSecret(val)
|
emitSecret(val)
|
||||||
return nil
|
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 (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -620,3 +621,201 @@ func TestGetValueFlow(t *testing.T) {
|
||||||
t.Fatalf("getValue = %q, %v", val, err)
|
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 status configured / unlocked / reachable (no secrets)
|
||||||
homelab vault list [--search Q] item names (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> [--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 code <name> current TOTP code
|
||||||
homelab vault lock lock / log out the local bw session
|
homelab vault lock lock / log out the local bw session
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue