#!/usr/bin/env python3 """HashiCorp Vault secrets management agent. Manages secrets lifecycle using the HashiCorp Vault KV v2 secrets engine via the hvac Python library. Supports reading, writing, listing, deleting secrets, and auditing secret access patterns. """ import argparse import json import os import sys from datetime import datetime, timezone try: import hvac except ImportError: print("[!] 'hvac' library required: pip install hvac", file=sys.stderr) sys.exit(1) def get_vault_client(addr=None, token=None, namespace=None): """Create and authenticate a Vault client.""" vault_addr = addr or os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200") vault_token = token or os.environ.get("VAULT_TOKEN", "") vault_ns = namespace or os.environ.get("VAULT_NAMESPACE") if not vault_token: print("[!] Set VAULT_TOKEN env var or use --token", file=sys.stderr) sys.exit(1) client = hvac.Client(url=vault_addr, token=vault_token, namespace=vault_ns) if not client.is_authenticated(): print("[!] Vault authentication failed", file=sys.stderr) sys.exit(1) print(f"[+] Connected to Vault at {vault_addr}") return client def write_secret(client, path, data, mount_point="secret"): """Write or update a secret in KV v2.""" print(f"[*] Writing secret to {mount_point}/{path}") response = client.secrets.kv.v2.create_or_update_secret( path=path, secret=data, mount_point=mount_point, ) version = response.get("data", {}).get("version", "unknown") print(f"[+] Secret written (version: {version})") return {"path": path, "version": version, "action": "write"} def read_secret(client, path, mount_point="secret", version=None): """Read a secret from KV v2.""" print(f"[*] Reading secret from {mount_point}/{path}") kwargs = {"path": path, "mount_point": mount_point} if version: kwargs["version"] = version response = client.secrets.kv.v2.read_secret_version(**kwargs) secret_data = response.get("data", {}).get("data", {}) metadata = response.get("data", {}).get("metadata", {}) print(f"[+] Secret read (version: {metadata.get('version', '?')}, " f"created: {metadata.get('created_time', 'unknown')})") masked = {k: v[:3] + "***" if isinstance(v, str) and len(v) > 3 else "***" for k, v in secret_data.items()} return { "path": path, "keys": list(secret_data.keys()), "masked_values": masked, "version": metadata.get("version"), "created_time": metadata.get("created_time"), "deletion_time": metadata.get("deletion_time"), "destroyed": metadata.get("destroyed"), } def list_secrets(client, path="", mount_point="secret"): """List secret paths under a given prefix.""" print(f"[*] Listing secrets under {mount_point}/{path}") try: response = client.secrets.kv.v2.list_secrets( path=path, mount_point=mount_point ) keys = response.get("data", {}).get("keys", []) print(f"[+] Found {len(keys)} entries") for key in keys: marker = "/" if key.endswith("/") else " " print(f" {marker} {key}") return {"path": path, "entries": keys, "count": len(keys)} except hvac.exceptions.InvalidPath: print(f"[*] No secrets found at {mount_point}/{path}") return {"path": path, "entries": [], "count": 0} def delete_secret(client, path, mount_point="secret", versions=None, destroy=False): """Delete or destroy a secret.""" if destroy and versions: print(f"[*] Permanently destroying versions {versions} at {mount_point}/{path}") client.secrets.kv.v2.destroy_secret_versions( path=path, versions=versions, mount_point=mount_point ) print(f"[+] Versions {versions} permanently destroyed") return {"path": path, "action": "destroy", "versions": versions} elif versions: print(f"[*] Soft-deleting versions {versions} at {mount_point}/{path}") client.secrets.kv.v2.delete_secret_versions( path=path, versions=versions, mount_point=mount_point ) print(f"[+] Versions {versions} soft-deleted (recoverable)") return {"path": path, "action": "soft_delete", "versions": versions} else: print(f"[*] Deleting latest version at {mount_point}/{path}") client.secrets.kv.v2.delete_latest_version_of_secret( path=path, mount_point=mount_point ) print(f"[+] Latest version deleted") return {"path": path, "action": "delete_latest"} def read_metadata(client, path, mount_point="secret"): """Read secret metadata including version history.""" print(f"[*] Reading metadata for {mount_point}/{path}") response = client.secrets.kv.v2.read_secret_metadata( path=path, mount_point=mount_point ) meta = response.get("data", {}) versions = meta.get("versions", {}) print(f"[+] {len(versions)} version(s), max_versions: {meta.get('max_versions', 0)}") version_info = [] for ver_num, ver_data in sorted(versions.items(), key=lambda x: int(x[0])): version_info.append({ "version": int(ver_num), "created_time": ver_data.get("created_time"), "deletion_time": ver_data.get("deletion_time"), "destroyed": ver_data.get("destroyed", False), }) return { "path": path, "current_version": meta.get("current_version"), "max_versions": meta.get("max_versions"), "cas_required": meta.get("cas_required"), "created_time": meta.get("created_time"), "updated_time": meta.get("updated_time"), "versions": version_info, } def audit_secrets(client, mount_point="secret", path_prefix=""): """Audit all secrets under a path, reporting metadata and age.""" print(f"[*] Auditing secrets under {mount_point}/{path_prefix}") audit_results = [] def _recurse(current_path): try: resp = client.secrets.kv.v2.list_secrets( path=current_path, mount_point=mount_point ) except hvac.exceptions.InvalidPath: return keys = resp.get("data", {}).get("keys", []) for key in keys: full_path = f"{current_path}{key}" if current_path else key if key.endswith("/"): _recurse(full_path) else: try: meta_resp = client.secrets.kv.v2.read_secret_metadata( path=full_path, mount_point=mount_point ) meta = meta_resp.get("data", {}) audit_results.append({ "path": full_path, "current_version": meta.get("current_version"), "created_time": meta.get("created_time"), "updated_time": meta.get("updated_time"), "version_count": len(meta.get("versions", {})), }) except Exception as e: audit_results.append({"path": full_path, "error": str(e)}) _recurse(path_prefix) print(f"[+] Audited {len(audit_results)} secret(s)") for item in audit_results: if "error" in item: print(f" [ERR] {item['path']}: {item['error']}") else: print(f" {item['path']:40s} v{item['current_version']} " f"(updated: {str(item.get('updated_time', 'N/A'))[:19]})") return audit_results def main(): parser = argparse.ArgumentParser( description="HashiCorp Vault secrets management agent" ) sub = parser.add_subparsers(dest="command", help="Action to perform") p_write = sub.add_parser("write", help="Write a secret") p_write.add_argument("--path", required=True, help="Secret path") p_write.add_argument("--data", required=True, help='JSON data') p_write.add_argument("--mount", default="secret", help="KV mount point") p_read = sub.add_parser("read", help="Read a secret") p_read.add_argument("--path", required=True) p_read.add_argument("--version", type=int, help="Specific version") p_read.add_argument("--mount", default="secret") p_list = sub.add_parser("list", help="List secrets") p_list.add_argument("--path", default="") p_list.add_argument("--mount", default="secret") p_del = sub.add_parser("delete", help="Delete a secret") p_del.add_argument("--path", required=True) p_del.add_argument("--versions", type=int, nargs="+") p_del.add_argument("--destroy", action="store_true") p_del.add_argument("--mount", default="secret") p_meta = sub.add_parser("metadata", help="Read secret metadata") p_meta.add_argument("--path", required=True) p_meta.add_argument("--mount", default="secret") p_audit = sub.add_parser("audit", help="Audit all secrets") p_audit.add_argument("--path", default="") p_audit.add_argument("--mount", default="secret") parser.add_argument("--addr", help="Vault address (or VAULT_ADDR env)") parser.add_argument("--token", help="Vault token (or VAULT_TOKEN env)") parser.add_argument("--namespace", help="Vault namespace") parser.add_argument("--output", "-o", help="Output JSON report") parser.add_argument("--verbose", "-v", action="store_true") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) client = get_vault_client(args.addr, args.token, args.namespace) if args.command == "write": secret_data = json.loads(args.data) result = write_secret(client, args.path, secret_data, args.mount) elif args.command == "read": result = read_secret(client, args.path, args.mount, getattr(args, "version", None)) elif args.command == "list": result = list_secrets(client, args.path, args.mount) elif args.command == "delete": result = delete_secret(client, args.path, args.mount, getattr(args, "versions", None), getattr(args, "destroy", False)) elif args.command == "metadata": result = read_metadata(client, args.path, args.mount) elif args.command == "audit": result = audit_secrets(client, args.mount, args.path) else: parser.print_help() sys.exit(1) report = { "timestamp": datetime.now(timezone.utc).isoformat(), "tool": "HashiCorp Vault", "command": args.command, "result": result, } if args.output: with open(args.output, "w") as f: json.dump(report, f, indent=2) print(f"\n[+] Report saved to {args.output}") elif args.verbose: print(json.dumps(report, indent=2)) if __name__ == "__main__": main()