11#!/usr/bin/env python3
22import argparse
3+ import base64
34import json
45import sys
5- import base64
66from pathlib import Path
77
88import requests
@@ -32,18 +32,30 @@ def get_engine_info(base_url: str, timeout: int = 10) -> dict:
3232
3333
3434def get_secrets (base_url : str , timeout : int = 10 ):
35- """Return list of secrets (metadata)."""
35+ """
36+ Return (secrets_list, error_message).
37+ If the Docker API returns an error (e.g., not a Swarm manager), we parse and return it.
38+ """
3639 try :
3740 resp = requests .get (f"{ base_url .rstrip ('/' )} /secrets" , timeout = timeout )
38- resp .raise_for_status ()
39- return resp .json ()
41+ if resp .status_code != 200 :
42+ # Try to extract { "message": "..." } or fall back to text
43+ err = None
44+ try :
45+ j = resp .json ()
46+ err = j .get ("message" ) or j
47+ except Exception :
48+ err = (resp .text or "" ).strip ()
49+ if not err :
50+ err = f"HTTP { resp .status_code } "
51+ return [], str (err )
52+ return resp .json (), None
4053 except requests .RequestException as e :
41- print (f"[!] Failed to get secrets from { base_url } : { e } " , file = sys .stderr )
42- return []
54+ return [], f"Request failed: { e } "
4355
4456
4557def get_secret_detail (base_url : str , secret_id : str , timeout : int = 10 ):
46- """Return secret detail (usually metadata only; value is not exposed by Docker API )."""
58+ """Return secret detail (usually metadata only)."""
4759 try :
4860 resp = requests .get (f"{ base_url .rstrip ('/' )} /secrets/{ secret_id } " , timeout = timeout )
4961 resp .raise_for_status ()
@@ -54,32 +66,24 @@ def get_secret_detail(base_url: str, secret_id: str, timeout: int = 10):
5466
5567
5668def maybe_decode_base64 (val ):
57- """Try to base64-decode a string; return (decoded_text , success_flag)."""
69+ """Try to base64-decode a string; return (decoded_text_or_bytes , success_flag)."""
5870 if not isinstance (val , (bytes , str )):
5971 return None , False
60- if isinstance (val , str ):
61- s = val .strip ()
62- # base64 requires length multiple of 4; try padding
63- pad_len = (- len (s )) % 4
64- s_padded = s + ("=" * pad_len )
65- try :
66- raw = base64 .b64decode (s_padded , validate = False )
67- try :
68- return raw .decode ("utf-8" , errors = "replace" ), True
69- except Exception :
70- return raw , True
71- except Exception :
72- return None , False
73- else :
74- # bytes; try to decode directly
75- try :
72+ try :
73+ if isinstance (val , str ):
74+ s = val .strip ()
75+ pad_len = (- len (s )) % 4
76+ s = s + ("=" * pad_len )
77+ raw = base64 .b64decode (s , validate = False )
78+ else :
7679 raw = base64 .b64decode (val , validate = False )
77- try :
78- return raw .decode ("utf-8" , errors = "replace" ), True
79- except Exception :
80- return raw , True
81- except Exception :
82- return None , False
80+ except Exception :
81+ return None , False
82+
83+ try :
84+ return raw .decode ("utf-8" , errors = "replace" ), True
85+ except Exception :
86+ return raw , True
8387
8488
8589def parse_args ():
@@ -126,11 +130,28 @@ def main():
126130 if args .show_info_json and engine_info :
127131 print (json .dumps (engine_info , indent = 2 ))
128132
129- # 2) Secrets enumeration
130- secrets = get_secrets (args .url , timeout = args .timeout )
133+ # 2) Secrets enumeration (with error capture)
134+ secrets , err = get_secrets (args .url , timeout = args .timeout )
135+
136+ results = {"engine_info" : engine_info , "secrets" : []}
137+ if err :
138+ # Mirror the Docker API error clearly for the user
139+ print (f"[!] Failed to enumerate secrets: { err } " , file = sys .stderr )
140+ # Common hint for Swarm-related errors
141+ if "swarm" in err .lower () and "manager" in err .lower ():
142+ print ("Hint: Connect to a Swarm **manager** node to enumerate secrets." , file = sys .stderr )
143+ # Save partial output if requested
144+ if args .out :
145+ try :
146+ Path (args .out ).write_text (json .dumps ({** results , "error" : err }, indent = 2 ))
147+ print (f"\n Saved results (with error) to { Path (args .out ).resolve ()} " )
148+ except Exception as e :
149+ print (f"[!] Failed to write output file '{ args .out } ': { e } " , file = sys .stderr )
150+ return 2
151+ return 0 # Exit gracefully; we surfaced the error
152+
131153 if not secrets :
132154 print ("No secrets found." )
133- results = {"engine_info" : engine_info , "secrets" : []}
134155 if args .out :
135156 try :
136157 Path (args .out ).write_text (json .dumps (results , indent = 2 ))
@@ -141,63 +162,52 @@ def main():
141162 return 0
142163
143164 print (f"Found { len (secrets )} secrets. Inspecting" )
144- results = {"engine_info" : engine_info , "secrets" : []}
145165
166+ # 3) Inspect each secret (metadata; value usually unavailable)
167+ from alive_progress import alive_bar # local import to speed startup if not used
146168 with alive_bar (len (secrets ), title = "Investigating secrets" ) as bar :
147169 for s in secrets :
148170 secret_id = s .get ("ID" ) or s .get ("Id" ) or ""
149171 spec = s .get ("Spec" , {}) or {}
150172 name = spec .get ("Name" ) or s .get ("Name" ) or "(unnamed)"
151173
152- # Fetch detail (usually metadata only)
153174 detail = get_secret_detail (args .url , secret_id , timeout = args .timeout ) or {}
154175
155- # Attempt to find a value-like field (non-standard)
156- decoded_text = None
157- value_found = False
158- if args .attempt - values : # <-- hyphen not allowed in var name; fix below
159- pass
160- # ^ We'll fix in code block below.
161-
162- # Print
163176 print (f"\n Secret Name: { name } " )
164177 print (f"Secret ID: { secret_id } " )
178+
165179 if args .attempt_values :
166- # Look in a few plausible spots
167180 raw_val = (
168181 detail .get ("Spec" , {}).get ("Data" )
169182 or detail .get ("Spec" , {}).get ("Value" )
170183 or detail .get ("Data" )
171184 or detail .get ("Value" )
172185 )
173186 if raw_val is not None :
174- decoded_text , value_found = maybe_decode_base64 (raw_val )
175- if value_found :
176- # If bytes, show repr; if text, show text
177- if isinstance (decoded_text , (bytes , bytearray )):
178- print ("Secret Value (decoded bytes):" , decoded_text )
187+ decoded , ok = maybe_decode_base64 (raw_val )
188+ if ok :
189+ if isinstance (decoded , (bytes , bytearray )):
190+ print ("Secret Value (decoded bytes):" , decoded )
191+ else :
192+ print ("Secret Value (decoded):" )
193+ for line in str (decoded ).splitlines () or ["(empty)" ]:
194+ print (f" { line } " )
195+ results ["secrets" ].append (
196+ {"id" : secret_id , "name" : name , "detail" : detail , "decoded_value" : decoded }
197+ )
179198 else :
180- print ("Secret Value (decoded):" )
181- # indent multi-line for readability
182- for line in str (decoded_text ).splitlines () or ["(empty)" ]:
183- print (f" { line } " )
199+ print ("Secret Value: (present but could not decode)" )
200+ results ["secrets" ].append ({"id" : secret_id , "name" : name , "detail" : detail })
184201 else :
185- print ("Secret Value: (not available via Docker API or could not decode)" )
202+ print ("Secret Value: (not available via Docker API)" )
203+ results ["secrets" ].append ({"id" : secret_id , "name" : name , "detail" : detail })
186204 else :
187205 print ("Secret Value: (skipped; use --attempt-values to try extracting when available)" )
188-
189- results ["secrets" ].append (
190- {
191- "id" : secret_id ,
192- "name" : name ,
193- "detail" : detail ,
194- ** ({"decoded_value" : decoded_text } if value_found else {}),
195- }
196- )
206+ results ["secrets" ].append ({"id" : secret_id , "name" : name , "detail" : detail })
197207
198208 bar () # progress
199209
200- # 3 ) Save if requested
210+ # 4 ) Save if requested
201211 if args .out :
202212 try :
203213 out_path = Path (args .out )
0 commit comments