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                            // For secrets (not folders), fetch metadata to get timestamps
130                            let (created_time, updated_time) = if is_folder {
131                                (None, None)
132                            } else {
133                                let metadata_path =
134                                    format!("{}/metadata/{}", mount_trimmed, key_str);
135                                fetch_secret_metadata(client, &metadata_path).await
136                            };
137
138                            let children = if is_folder && current_depth < max_depth {
139                                // Pass just the relative path, not the full mount path
140                                let rel_path = key_str.trim_end_matches('/');
141                                let full_path = format!("{}/{}", mount_trimmed, rel_path);
142
143                                // Check for cycles
144                                if visited.contains(&full_path) {
145                                    eprintln!(
146                                        "Warning: Detected circular reference at path: {}",
147                                        full_path
148                                    );
149                                    None
150                                } else {
151                                    visited.insert(full_path.clone());
152                                    Some(
153                                        list_kv_v2_subpath_with_visited(
154                                            client,
155                                            mount_trimmed,
156                                            rel_path,
157                                            current_depth + 1,
158                                            max_depth,
159                                            visited,
160                                        )
161                                        .await?,
162                                    )
163                                }
164                            } else {
165                                None
166                            };
167
168                            entries.push(PathEntry {
169                                path: key_str.to_string(),
170                                entry_type: entry_type.to_string(),
171                                children,
172                                created_time,
173                                updated_time,
174                            });
175                        }
176                    }
177                }
178            }
179        }
180    }
181    // If we can't list the root, that's okay - mount might be empty or no permissions
182
183    Ok(entries)
184}
185
186/// List paths within a KV v2 subpath (folder) with cycle detection
187#[allow(clippy::future_not_send)]
188fn list_kv_v2_subpath_with_visited<'a>(
189    client: &'a VaultClient,
190    mount_path: &'a str,
191    rel_path: &'a str,
192    current_depth: usize,
193    max_depth: usize,
194    visited: &'a mut std::collections::HashSet<String>,
195) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
196    Box::pin(async move {
197        if current_depth > max_depth {
198            return Ok(Vec::new());
199        }
200
201        let mut entries = Vec::new();
202        let mount_trimmed = mount_path.trim_end_matches('/');
203
204        // For KV v2, the metadata endpoint is /v1/{mount}/metadata/{path}
205        let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
206
207        let response: Result<Value> = client.list_json(&list_path).await;
208
209        if let Ok(resp) = response {
210            if let Some(data) = resp.get("data") {
211                if let Some(keys) = data.get("keys") {
212                    if let Some(keys_array) = keys.as_array() {
213                        for key in keys_array {
214                            if let Some(key_str) = key.as_str() {
215                                let is_folder = key_str.ends_with('/');
216                                let entry_type = if is_folder { "folder" } else { "secret" };
217
218                                // For secrets (not folders), fetch metadata to get timestamps
219                                let (created_time, updated_time) = if is_folder {
220                                    (None, None)
221                                } else {
222                                    let metadata_path = format!(
223                                        "{}/metadata/{}/{}",
224                                        mount_trimmed, rel_path, key_str
225                                    );
226                                    fetch_secret_metadata(client, &metadata_path).await
227                                };
228
229                                let children = if is_folder && current_depth < max_depth {
230                                    let new_rel_path =
231                                        format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
232                                    let full_path = format!("{}/{}", mount_trimmed, new_rel_path);
233
234                                    // Check for cycles
235                                    if visited.contains(&full_path) {
236                                        eprintln!(
237                                            "Warning: Detected circular reference at path: {}",
238                                            full_path
239                                        );
240                                        None
241                                    } else {
242                                        visited.insert(full_path.clone());
243                                        Some(
244                                            list_kv_v2_subpath_with_visited(
245                                                client,
246                                                mount_path,
247                                                &new_rel_path,
248                                                current_depth + 1,
249                                                max_depth,
250                                                visited,
251                                            )
252                                            .await?,
253                                        )
254                                    }
255                                } else {
256                                    None
257                                };
258
259                                entries.push(PathEntry {
260                                    path: key_str.to_string(),
261                                    entry_type: entry_type.to_string(),
262                                    children,
263                                    created_time,
264                                    updated_time,
265                                });
266                            }
267                        }
268                    }
269                }
270            }
271        }
272        // Silently ignore list errors for subpaths
273
274        Ok(entries)
275    })
276}
277
278/// Recursively list paths within a KV v1 mount up to a specified depth
279#[allow(clippy::future_not_send)]
280fn list_kv_v1_paths<'a>(
281    client: &'a VaultClient,
282    mount_path: &'a str,
283    subpath: &'a str,
284    current_depth: usize,
285    max_depth: usize,
286) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
287    Box::pin(async move {
288        list_kv_v1_paths_with_visited(
289            client,
290            mount_path,
291            subpath,
292            current_depth,
293            max_depth,
294            &mut std::collections::HashSet::new(),
295        )
296        .await
297    })
298}
299
300/// Internal KV v1 function with cycle detection
301#[allow(clippy::future_not_send)]
302fn list_kv_v1_paths_with_visited<'a>(
303    client: &'a VaultClient,
304    mount_path: &'a str,
305    subpath: &'a str,
306    current_depth: usize,
307    max_depth: usize,
308    visited: &'a mut std::collections::HashSet<String>,
309) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
310    Box::pin(async move {
311        if current_depth > max_depth {
312            return Ok(Vec::new());
313        }
314
315        let mut entries = Vec::new();
316        let mount_trimmed = mount_path.trim_end_matches('/');
317
318        // For KV v1, use LIST on the mount path directly
319        let list_path = if subpath.is_empty() {
320            format!("/v1/{}", mount_trimmed)
321        } else {
322            format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
323        };
324
325        let response: Result<Value> = client.list_json(&list_path).await;
326
327        if let Ok(resp) = response {
328            if let Some(data) = resp.get("data") {
329                if let Some(keys) = data.get("keys") {
330                    if let Some(keys_array) = keys.as_array() {
331                        for key in keys_array {
332                            if let Some(key_str) = key.as_str() {
333                                let is_folder = key_str.ends_with('/');
334                                let entry_type = if is_folder { "folder" } else { "secret" };
335
336                                let children = if is_folder && current_depth < max_depth {
337                                    let new_subpath = if subpath.is_empty() {
338                                        key_str.trim_end_matches('/').to_string()
339                                    } else {
340                                        format!(
341                                            "{}/{}",
342                                            subpath.trim_end_matches('/'),
343                                            key_str.trim_end_matches('/')
344                                        )
345                                    };
346
347                                    let full_path = format!("{}/{}", mount_trimmed, new_subpath);
348
349                                    // Check for cycles
350                                    if visited.contains(&full_path) {
351                                        eprintln!(
352                                            "Warning: Detected circular reference at path: {}",
353                                            full_path
354                                        );
355                                        None
356                                    } else {
357                                        visited.insert(full_path.clone());
358                                        Some(
359                                            list_kv_v1_paths_with_visited(
360                                                client,
361                                                mount_path,
362                                                &new_subpath,
363                                                current_depth + 1,
364                                                max_depth,
365                                                visited,
366                                            )
367                                            .await?,
368                                        )
369                                    }
370                                } else {
371                                    None
372                                };
373
374                                // KV v1 doesn't support metadata endpoint, so timestamps are None
375                                entries.push(PathEntry {
376                                    path: key_str.to_string(),
377                                    entry_type: entry_type.to_string(),
378                                    children,
379                                    created_time: None,
380                                    updated_time: None,
381                                });
382                            }
383                        }
384                    }
385                }
386            }
387        }
388        // If we can't list, that's okay - might be empty or no permissions
389
390        Ok(entries)
391    })
392}
393
394/// Fetch metadata for a KV v2 secret to get `created_time` and `updated_time`
395#[allow(clippy::future_not_send)]
396async fn fetch_secret_metadata(
397    client: &VaultClient,
398    metadata_path: &str,
399) -> (Option<String>, Option<String>) {
400    let full_path = format!("/v1/{}", metadata_path);
401
402    match client.get_json(&full_path).await {
403        Ok(resp) => {
404            let created = resp
405                .get("data")
406                .and_then(|d| d.get("created_time"))
407                .and_then(|v| v.as_str())
408                .map(String::from);
409
410            let updated = resp
411                .get("data")
412                .and_then(|d| d.get("updated_time"))
413                .and_then(|v| v.as_str())
414                .map(String::from);
415
416            (created, updated)
417        }
418        Err(_) => {
419            // Silently ignore metadata fetch errors (e.g., permissions, non-existent)
420            (None, None)
421        }
422    }
423}
424
425/// Helper function to flatten nested path entries to CSV format
426fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
427    use std::fmt::Write as _;
428    for entry in entries {
429        let full_path = format!("{}{}", base_path, entry.path);
430        let created = entry.created_time.as_deref().unwrap_or("");
431        let updated = entry.updated_time.as_deref().unwrap_or("");
432        let _ = writeln!(
433            output,
434            "\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\"",
435            full_path.replace('"', "\"\""),
436            entry.entry_type,
437            base_path.replace('"', "\"\""),
438            depth,
439            created.replace('"', "\"\""),
440            updated.replace('"', "\"\"")
441        );
442
443        if let Some(children) = &entry.children {
444            let new_base = format!("{}{}", base_path, entry.path);
445            flatten_paths_to_csv(output, &new_base, children, depth + 1);
446        }
447    }
448}
449
450/// Helper function to print nested paths in tree format
451#[allow(clippy::only_used_in_recursion)]
452fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
453    for (i, entry) in entries.iter().enumerate() {
454        let is_last = i == entries.len() - 1;
455        let connector = if is_last { "└──" } else { "├──" };
456
457        let mut output = format!(
458            "{}{} {} ({})",
459            prefix, connector, entry.path, entry.entry_type
460        );
461
462        // Add timestamps for secrets (if available)
463        if entry.entry_type == "secret" {
464            if let (Some(created), Some(updated)) = (&entry.created_time, &entry.updated_time) {
465                use std::fmt::Write as _;
466                let _ = write!(output, " [created: {}, updated: {}]", created, updated);
467            }
468        }
469
470        println!("{}", output);
471
472        if let Some(children) = &entry.children {
473            let mut new_prefix = prefix.to_string();
474            new_prefix.push_str(if is_last { "    " } else { "│   " });
475
476            let mut new_is_last = is_last_at_level.to_vec();
477            new_is_last.push(is_last);
478            print_tree(base_path, children, &new_prefix, &new_is_last);
479        }
480    }
481}
482
483#[derive(Debug, Serialize)]
484struct KvMountOutput {
485    path: String,
486    mount_type: String,
487    description: String,
488    version: String,
489    accessor: String,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    children: Option<Vec<PathEntry>>,
492}
493
494#[derive(Debug, Serialize, Clone)]
495struct PathEntry {
496    path: String,
497    #[serde(rename = "type")]
498    entry_type: String, // "folder" or "secret"
499    #[serde(skip_serializing_if = "Option::is_none")]
500    children: Option<Vec<Self>>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    created_time: Option<String>,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    updated_time: Option<String>,
505}
506
507/// Run the KV mount enumeration command
508#[allow(clippy::future_not_send)]
509pub async fn run(
510    vault_addr: Option<&str>,
511    vault_token: Option<&str>,
512    vault_namespace: Option<&str>,
513    insecure: bool,
514    output: Option<&str>,
515    format: &str,
516    depth: usize,
517) -> Result<()> {
518    let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
519
520    eprintln!("Querying Vault API for KV mounts...");
521    eprintln!("   Vault Address: {}", client.addr());
522
523    // Query /sys/mounts to get all secret mounts
524    let response: Value = client
525        .get("/v1/sys/mounts")
526        .await
527        .context("Failed to query /v1/sys/mounts")?;
528
529    // Extract the data field which contains the actual mounts
530    let mounts_data = response
531        .get("data")
532        .or(Some(&response)) // Fallback to root if no data field
533        .context("Failed to get mounts data")?;
534
535    let mounts = mounts_data
536        .as_object()
537        .context("Expected object response from /v1/sys/mounts")?;
538
539    let mut kv_mounts = Vec::new();
540
541    for (path, mount_data) in mounts {
542        // Skip metadata fields like "request_id"
543        if path == "request_id"
544            || path == "lease_id"
545            || path == "renewable"
546            || path == "lease_duration"
547            || path == "data"
548            || path == "wrap_info"
549            || path == "warnings"
550            || path == "auth"
551        {
552            continue;
553        }
554
555        let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
556            .with_context(|| format!("Failed to parse mount info for {}", path))?;
557
558        // Filter for ALL KV mounts (v1 and v2)
559        if mount_info.mount_type == "kv" {
560            let version = mount_info
561                .options
562                .get("version")
563                .and_then(|v| v.as_str())
564                .or_else(|| {
565                    mount_info
566                        .options
567                        .get("version")
568                        .and_then(serde_json::Value::as_i64)
569                        .map(|_| "2")
570                })
571                .unwrap_or("1");
572
573            // Traverse paths if depth > 0
574            let children = if depth > 0 {
575                if version == "2" {
576                    Some(list_kv_v2_paths(&client, path, 1, depth).await?)
577                } else {
578                    Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
579                }
580            } else {
581                None
582            };
583
584            kv_mounts.push(KvMountOutput {
585                path: path.clone(),
586                mount_type: mount_info.mount_type.clone(),
587                description: mount_info.description.clone(),
588                version: version.to_string(),
589                accessor: mount_info.accessor.clone(),
590                children,
591            });
592        }
593    }
594
595    eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
596
597    // Output results
598    match format {
599        "json" => {
600            let json_output =
601                serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
602
603            if let Some(output_path) = output {
604                let mut file = File::create(output_path).context("Failed to create output file")?;
605                file.write_all(json_output.as_bytes())
606                    .context("Failed to write JSON to file")?;
607                eprintln!("Output written to: {}", output_path);
608            } else {
609                println!("{}", json_output);
610            }
611        }
612        "csv" => {
613            use std::fmt::Write as _;
614            let mut csv_output = String::new();
615            if depth > 0 {
616                csv_output.push_str("full_path,type,mount,depth,created_time,updated_time\n");
617                for mount in &kv_mounts {
618                    // Write mount itself
619                    let _ = writeln!(
620                        csv_output,
621                        "\"{}\",\"mount\",\"{}\",0",
622                        mount.path.replace('"', "\"\""),
623                        mount.path.replace('"', "\"\"")
624                    );
625
626                    // Write nested paths
627                    if let Some(children) = &mount.children {
628                        flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
629                    }
630                }
631            } else {
632                csv_output.push_str("path,type,description,version,accessor\n");
633                for mount in &kv_mounts {
634                    let _ = writeln!(
635                        csv_output,
636                        "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
637                        mount.path.replace('"', "\"\""),
638                        mount.mount_type,
639                        mount.description.replace('"', "\"\""),
640                        mount.version,
641                        mount.accessor
642                    );
643                }
644            }
645
646            if let Some(output_path) = output {
647                let mut file = File::create(output_path).context("Failed to create output file")?;
648                file.write_all(csv_output.as_bytes())
649                    .context("Failed to write CSV to file")?;
650                eprintln!("Output written to: {}", output_path);
651            } else {
652                print!("{}", csv_output);
653            }
654        }
655        "stdout" => {
656            println!("\nKV v2 Mounts:");
657            println!("{}", "=".repeat(80));
658            for mount in &kv_mounts {
659                println!("Path: {}", mount.path);
660                println!("  Type: {}", mount.mount_type);
661                println!("  Version: {}", mount.version);
662                println!("  Description: {}", mount.description);
663                println!("  Accessor: {}", mount.accessor);
664
665                if let Some(children) = &mount.children {
666                    if !children.is_empty() {
667                        println!("  Contents:");
668                        print_tree(&mount.path, children, "    ", &[]);
669                    }
670                }
671                println!();
672            }
673        }
674        _ => {
675            return Err(anyhow::anyhow!(
676                "Invalid format: {}. Must be one of: csv, json, stdout",
677                format
678            ));
679        }
680    }
681
682    Ok(())
683}