vault_audit_tools/commands/
kv_mounts.rs

1//! KV mount enumeration and tree listing.
2//!
3//! This command queries the Vault API to automatically discover and enumerate all KV secret mounts
4//! (both v1 and v2) and recursively lists their contents in a hierarchical tree structure.
5//!
6//! # Features
7//!
8//! - **Automatic Discovery**: Discovers all KV mounts without needing to know mount names
9//! - **Version Detection**: Automatically detects and handles both KV v1 and KV v2 mounts
10//! - **Depth Control**: Optional depth parameter to control traversal (unlimited by default)
11//! - **Multiple Output Formats**: CSV (flattened with depth), JSON (nested tree), or stdout (visual tree)
12//!
13//! # Usage Examples
14//!
15//! ```bash
16//! # List all KV mounts with unlimited depth (default)
17//! vault-audit kv-mounts --format stdout
18//!
19//! # List only the mounts themselves (no traversal)
20//! vault-audit kv-mounts --depth 0 --format csv
21//!
22//! # List mounts and traverse 2 levels deep
23//! vault-audit kv-mounts --depth 2 --format json
24//!
25//! # Save full tree to CSV file
26//! vault-audit kv-mounts --format csv --output kv-inventory.csv
27//! ```
28//!
29//! # Output Formats
30//!
31//! - **CSV**: Flattened paths with depth column, one row per path/secret
32//! - **JSON**: Nested tree structure with parent-child relationships
33//! - **stdout**: Visual tree with Unicode box-drawing characters (├──, └──, │)
34//!
35//! # Depth Parameter
36//!
37//! - `--depth 0`: Show only mount points (no traversal)
38//! - `--depth 1`: Show mounts + first level folders/secrets
39//! - `--depth 2`: Show mounts + two levels of traversal
40//! - No flag: Unlimited depth (discovers entire tree structure)
41//!
42//! # API Endpoints Used
43//!
44//! - `/v1/sys/mounts` - Discover all secret mounts
45//! - `/v1/{mount}/metadata/{path}` - List KV v2 paths (using LIST method)
46//! - `/v1/{mount}/{path}` - List KV v1 paths (using LIST method)
47
48use anyhow::{Context, Result};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::fs::File;
53use std::io::Write;
54
55use crate::vault_api::VaultClient;
56
57#[derive(Debug, Serialize, Deserialize)]
58struct MountInfo {
59    #[serde(rename = "type")]
60    mount_type: String,
61    #[serde(default)]
62    description: String,
63    #[serde(default)]
64    accessor: String,
65    #[serde(default, deserialize_with = "deserialize_null_default")]
66    config: HashMap<String, Value>,
67    #[serde(default, deserialize_with = "deserialize_null_default")]
68    options: HashMap<String, Value>,
69}
70
71fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
72where
73    T: Default + Deserialize<'de>,
74    D: serde::Deserializer<'de>,
75{
76    let opt = Option::deserialize(deserializer)?;
77    Ok(opt.unwrap_or_default())
78}
79
80/// Recursively list paths within a KV v2 mount up to a specified depth
81#[allow(clippy::future_not_send)]
82async fn list_kv_v2_paths(
83    client: &VaultClient,
84    mount_path: &str,
85    current_depth: usize,
86    max_depth: usize,
87) -> Result<Vec<PathEntry>> {
88    list_kv_v2_paths_with_visited(
89        client,
90        mount_path,
91        current_depth,
92        max_depth,
93        &mut std::collections::HashSet::new(),
94    )
95    .await
96}
97
98/// Internal function with cycle detection
99#[allow(clippy::future_not_send)]
100async fn list_kv_v2_paths_with_visited(
101    client: &VaultClient,
102    mount_path: &str,
103    current_depth: usize,
104    max_depth: usize,
105    visited: &mut std::collections::HashSet<String>,
106) -> Result<Vec<PathEntry>> {
107    if current_depth > max_depth {
108        return Ok(Vec::new());
109    }
110
111    let mut entries = Vec::new();
112    let mount_trimmed = mount_path.trim_end_matches('/');
113
114    // List the root of the mount using LIST method on metadata endpoint
115    let list_path = format!("/v1/{}/metadata", mount_trimmed);
116
117    let response: Result<Value> = client.list_json(&list_path).await;
118
119    if let Ok(resp) = response {
120        // Extract keys from the data.keys field
121        if let Some(data) = resp.get("data") {
122            if let Some(keys) = data.get("keys") {
123                if let Some(keys_array) = keys.as_array() {
124                    for key in keys_array {
125                        if let Some(key_str) = key.as_str() {
126                            let is_folder = key_str.ends_with('/');
127                            let entry_type = if is_folder { "folder" } else { "secret" };
128
129                            let children = if is_folder && current_depth < max_depth {
130                                // Pass just the relative path, not the full mount path
131                                let rel_path = key_str.trim_end_matches('/');
132                                let full_path = format!("{}/{}", mount_trimmed, rel_path);
133
134                                // Check for cycles
135                                if visited.contains(&full_path) {
136                                    eprintln!(
137                                        "Warning: Detected circular reference at path: {}",
138                                        full_path
139                                    );
140                                    None
141                                } else {
142                                    visited.insert(full_path.clone());
143                                    Some(
144                                        list_kv_v2_subpath_with_visited(
145                                            client,
146                                            mount_trimmed,
147                                            rel_path,
148                                            current_depth + 1,
149                                            max_depth,
150                                            visited,
151                                        )
152                                        .await?,
153                                    )
154                                }
155                            } else {
156                                None
157                            };
158
159                            entries.push(PathEntry {
160                                path: key_str.to_string(),
161                                entry_type: entry_type.to_string(),
162                                children,
163                            });
164                        }
165                    }
166                }
167            }
168        }
169    }
170    // If we can't list the root, that's okay - mount might be empty or no permissions
171
172    Ok(entries)
173}
174
175/// List paths within a KV v2 subpath (folder) with cycle detection
176#[allow(clippy::future_not_send)]
177fn list_kv_v2_subpath_with_visited<'a>(
178    client: &'a VaultClient,
179    mount_path: &'a str,
180    rel_path: &'a str,
181    current_depth: usize,
182    max_depth: usize,
183    visited: &'a mut std::collections::HashSet<String>,
184) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
185    Box::pin(async move {
186        if current_depth > max_depth {
187            return Ok(Vec::new());
188        }
189
190        let mut entries = Vec::new();
191        let mount_trimmed = mount_path.trim_end_matches('/');
192
193        // For KV v2, the metadata endpoint is /v1/{mount}/metadata/{path}
194        let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
195
196        let response: Result<Value> = client.list_json(&list_path).await;
197
198        if let Ok(resp) = response {
199            if let Some(data) = resp.get("data") {
200                if let Some(keys) = data.get("keys") {
201                    if let Some(keys_array) = keys.as_array() {
202                        for key in keys_array {
203                            if let Some(key_str) = key.as_str() {
204                                let is_folder = key_str.ends_with('/');
205                                let entry_type = if is_folder { "folder" } else { "secret" };
206
207                                let children = if is_folder && current_depth < max_depth {
208                                    let new_rel_path =
209                                        format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
210                                    let full_path = format!("{}/{}", mount_trimmed, new_rel_path);
211
212                                    // Check for cycles
213                                    if visited.contains(&full_path) {
214                                        eprintln!(
215                                            "Warning: Detected circular reference at path: {}",
216                                            full_path
217                                        );
218                                        None
219                                    } else {
220                                        visited.insert(full_path.clone());
221                                        Some(
222                                            list_kv_v2_subpath_with_visited(
223                                                client,
224                                                mount_path,
225                                                &new_rel_path,
226                                                current_depth + 1,
227                                                max_depth,
228                                                visited,
229                                            )
230                                            .await?,
231                                        )
232                                    }
233                                } else {
234                                    None
235                                };
236
237                                entries.push(PathEntry {
238                                    path: key_str.to_string(),
239                                    entry_type: entry_type.to_string(),
240                                    children,
241                                });
242                            }
243                        }
244                    }
245                }
246            }
247        }
248        // Silently ignore list errors for subpaths
249
250        Ok(entries)
251    })
252}
253
254/// Recursively list paths within a KV v1 mount up to a specified depth
255#[allow(clippy::future_not_send)]
256fn list_kv_v1_paths<'a>(
257    client: &'a VaultClient,
258    mount_path: &'a str,
259    subpath: &'a str,
260    current_depth: usize,
261    max_depth: usize,
262) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
263    Box::pin(async move {
264        list_kv_v1_paths_with_visited(
265            client,
266            mount_path,
267            subpath,
268            current_depth,
269            max_depth,
270            &mut std::collections::HashSet::new(),
271        )
272        .await
273    })
274}
275
276/// Internal KV v1 function with cycle detection
277#[allow(clippy::future_not_send)]
278fn list_kv_v1_paths_with_visited<'a>(
279    client: &'a VaultClient,
280    mount_path: &'a str,
281    subpath: &'a str,
282    current_depth: usize,
283    max_depth: usize,
284    visited: &'a mut std::collections::HashSet<String>,
285) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
286    Box::pin(async move {
287        if current_depth > max_depth {
288            return Ok(Vec::new());
289        }
290
291        let mut entries = Vec::new();
292        let mount_trimmed = mount_path.trim_end_matches('/');
293
294        // For KV v1, use LIST on the mount path directly
295        let list_path = if subpath.is_empty() {
296            format!("/v1/{}", mount_trimmed)
297        } else {
298            format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
299        };
300
301        let response: Result<Value> = client.list_json(&list_path).await;
302
303        if let Ok(resp) = response {
304            if let Some(data) = resp.get("data") {
305                if let Some(keys) = data.get("keys") {
306                    if let Some(keys_array) = keys.as_array() {
307                        for key in keys_array {
308                            if let Some(key_str) = key.as_str() {
309                                let is_folder = key_str.ends_with('/');
310                                let entry_type = if is_folder { "folder" } else { "secret" };
311
312                                let children = if is_folder && current_depth < max_depth {
313                                    let new_subpath = if subpath.is_empty() {
314                                        key_str.trim_end_matches('/').to_string()
315                                    } else {
316                                        format!(
317                                            "{}/{}",
318                                            subpath.trim_end_matches('/'),
319                                            key_str.trim_end_matches('/')
320                                        )
321                                    };
322
323                                    let full_path = format!("{}/{}", mount_trimmed, new_subpath);
324
325                                    // Check for cycles
326                                    if visited.contains(&full_path) {
327                                        eprintln!(
328                                            "Warning: Detected circular reference at path: {}",
329                                            full_path
330                                        );
331                                        None
332                                    } else {
333                                        visited.insert(full_path.clone());
334                                        Some(
335                                            list_kv_v1_paths_with_visited(
336                                                client,
337                                                mount_path,
338                                                &new_subpath,
339                                                current_depth + 1,
340                                                max_depth,
341                                                visited,
342                                            )
343                                            .await?,
344                                        )
345                                    }
346                                } else {
347                                    None
348                                };
349
350                                entries.push(PathEntry {
351                                    path: key_str.to_string(),
352                                    entry_type: entry_type.to_string(),
353                                    children,
354                                });
355                            }
356                        }
357                    }
358                }
359            }
360        }
361        // If we can't list, that's okay - might be empty or no permissions
362
363        Ok(entries)
364    })
365}
366
367/// Helper function to flatten nested path entries to CSV format
368fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
369    use std::fmt::Write as _;
370    for entry in entries {
371        let full_path = format!("{}{}", base_path, entry.path);
372        let _ = writeln!(
373            output,
374            "\"{}\",\"{}\",\"{}\",{}",
375            full_path.replace('"', "\"\""),
376            entry.entry_type,
377            base_path.replace('"', "\"\""),
378            depth
379        );
380
381        if let Some(children) = &entry.children {
382            let new_base = format!("{}{}", base_path, entry.path);
383            flatten_paths_to_csv(output, &new_base, children, depth + 1);
384        }
385    }
386}
387
388/// Helper function to print nested paths in tree format
389#[allow(clippy::only_used_in_recursion)]
390fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
391    for (i, entry) in entries.iter().enumerate() {
392        let is_last = i == entries.len() - 1;
393        let connector = if is_last { "└──" } else { "├──" };
394
395        println!(
396            "{}{} {} ({})",
397            prefix, connector, entry.path, entry.entry_type
398        );
399
400        if let Some(children) = &entry.children {
401            let mut new_prefix = prefix.to_string();
402            new_prefix.push_str(if is_last { "    " } else { "│   " });
403
404            let mut new_is_last = is_last_at_level.to_vec();
405            new_is_last.push(is_last);
406            print_tree(base_path, children, &new_prefix, &new_is_last);
407        }
408    }
409}
410
411#[derive(Debug, Serialize)]
412struct KvMountOutput {
413    path: String,
414    mount_type: String,
415    description: String,
416    version: String,
417    accessor: String,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    children: Option<Vec<PathEntry>>,
420}
421
422#[derive(Debug, Serialize, Clone)]
423struct PathEntry {
424    path: String,
425    #[serde(rename = "type")]
426    entry_type: String, // "folder" or "secret"
427    #[serde(skip_serializing_if = "Option::is_none")]
428    children: Option<Vec<PathEntry>>,
429}
430
431/// Run the KV mount enumeration command
432#[allow(clippy::future_not_send)]
433pub async fn run(
434    vault_addr: Option<&str>,
435    vault_token: Option<&str>,
436    vault_namespace: Option<&str>,
437    insecure: bool,
438    output: Option<&str>,
439    format: &str,
440    depth: usize,
441) -> Result<()> {
442    let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
443
444    eprintln!("Querying Vault API for KV mounts...");
445    eprintln!("   Vault Address: {}", client.addr());
446
447    // Query /sys/mounts to get all secret mounts
448    let response: Value = client
449        .get("/v1/sys/mounts")
450        .await
451        .context("Failed to query /v1/sys/mounts")?;
452
453    // Extract the data field which contains the actual mounts
454    let mounts_data = response
455        .get("data")
456        .or(Some(&response)) // Fallback to root if no data field
457        .context("Failed to get mounts data")?;
458
459    let mounts = mounts_data
460        .as_object()
461        .context("Expected object response from /v1/sys/mounts")?;
462
463    let mut kv_mounts = Vec::new();
464
465    for (path, mount_data) in mounts {
466        // Skip metadata fields like "request_id"
467        if path == "request_id"
468            || path == "lease_id"
469            || path == "renewable"
470            || path == "lease_duration"
471            || path == "data"
472            || path == "wrap_info"
473            || path == "warnings"
474            || path == "auth"
475        {
476            continue;
477        }
478
479        let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
480            .with_context(|| format!("Failed to parse mount info for {}", path))?;
481
482        // Filter for ALL KV mounts (v1 and v2)
483        if mount_info.mount_type == "kv" {
484            let version = mount_info
485                .options
486                .get("version")
487                .and_then(|v| v.as_str())
488                .or_else(|| {
489                    mount_info
490                        .options
491                        .get("version")
492                        .and_then(serde_json::Value::as_i64)
493                        .map(|_| "2")
494                })
495                .unwrap_or("1");
496
497            // Traverse paths if depth > 0
498            let children = if depth > 0 {
499                if version == "2" {
500                    Some(list_kv_v2_paths(&client, path, 1, depth).await?)
501                } else {
502                    Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
503                }
504            } else {
505                None
506            };
507
508            kv_mounts.push(KvMountOutput {
509                path: path.clone(),
510                mount_type: mount_info.mount_type.clone(),
511                description: mount_info.description.clone(),
512                version: version.to_string(),
513                accessor: mount_info.accessor.clone(),
514                children,
515            });
516        }
517    }
518
519    eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
520
521    // Output results
522    match format {
523        "json" => {
524            let json_output =
525                serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
526
527            if let Some(output_path) = output {
528                let mut file = File::create(output_path).context("Failed to create output file")?;
529                file.write_all(json_output.as_bytes())
530                    .context("Failed to write JSON to file")?;
531                eprintln!("Output written to: {}", output_path);
532            } else {
533                println!("{}", json_output);
534            }
535        }
536        "csv" => {
537            use std::fmt::Write as _;
538            let mut csv_output = String::new();
539            if depth > 0 {
540                csv_output.push_str("full_path,type,mount,depth\n");
541                for mount in &kv_mounts {
542                    // Write mount itself
543                    let _ = writeln!(
544                        csv_output,
545                        "\"{}\",\"mount\",\"{}\",0",
546                        mount.path.replace('"', "\"\""),
547                        mount.path.replace('"', "\"\"")
548                    );
549
550                    // Write nested paths
551                    if let Some(children) = &mount.children {
552                        flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
553                    }
554                }
555            } else {
556                csv_output.push_str("path,type,description,version,accessor\n");
557                for mount in &kv_mounts {
558                    let _ = writeln!(
559                        csv_output,
560                        "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
561                        mount.path.replace('"', "\"\""),
562                        mount.mount_type,
563                        mount.description.replace('"', "\"\""),
564                        mount.version,
565                        mount.accessor
566                    );
567                }
568            }
569
570            if let Some(output_path) = output {
571                let mut file = File::create(output_path).context("Failed to create output file")?;
572                file.write_all(csv_output.as_bytes())
573                    .context("Failed to write CSV to file")?;
574                eprintln!("Output written to: {}", output_path);
575            } else {
576                print!("{}", csv_output);
577            }
578        }
579        "stdout" => {
580            println!("\nKV v2 Mounts:");
581            println!("{}", "=".repeat(80));
582            for mount in &kv_mounts {
583                println!("Path: {}", mount.path);
584                println!("  Type: {}", mount.mount_type);
585                println!("  Version: {}", mount.version);
586                println!("  Description: {}", mount.description);
587                println!("  Accessor: {}", mount.accessor);
588
589                if let Some(children) = &mount.children {
590                    if !children.is_empty() {
591                        println!("  Contents:");
592                        print_tree(&mount.path, children, "    ", &[]);
593                    }
594                }
595                println!();
596            }
597        }
598        _ => {
599            return Err(anyhow::anyhow!(
600                "Invalid format: {}. Must be one of: csv, json, stdout",
601                format
602            ));
603        }
604    }
605
606    Ok(())
607}