aihound 3.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. aihound/__init__.py +3 -0
  2. aihound/__main__.py +7 -0
  3. aihound/cli.py +456 -0
  4. aihound/core/__init__.py +0 -0
  5. aihound/core/mcp.py +297 -0
  6. aihound/core/permissions.py +185 -0
  7. aihound/core/platform.py +203 -0
  8. aihound/core/redactor.py +112 -0
  9. aihound/core/scanner.py +131 -0
  10. aihound/mcp_server.py +312 -0
  11. aihound/notifications.py +184 -0
  12. aihound/output/__init__.py +0 -0
  13. aihound/output/html_report.py +336 -0
  14. aihound/output/json_export.py +60 -0
  15. aihound/output/opengraph_export.py +664 -0
  16. aihound/output/table.py +208 -0
  17. aihound/output/watch_formatters.py +199 -0
  18. aihound/remediation.py +105 -0
  19. aihound/scanners/__init__.py +30 -0
  20. aihound/scanners/aider.py +138 -0
  21. aihound/scanners/amazon_q.py +147 -0
  22. aihound/scanners/browser_sessions.py +519 -0
  23. aihound/scanners/chatgpt.py +118 -0
  24. aihound/scanners/claude_code.py +333 -0
  25. aihound/scanners/claude_desktop.py +60 -0
  26. aihound/scanners/claude_sessions.py +564 -0
  27. aihound/scanners/cline.py +64 -0
  28. aihound/scanners/continue_dev.py +141 -0
  29. aihound/scanners/cursor.py +62 -0
  30. aihound/scanners/docker.py +319 -0
  31. aihound/scanners/envvars.py +139 -0
  32. aihound/scanners/gemini.py +190 -0
  33. aihound/scanners/git_credentials.py +238 -0
  34. aihound/scanners/github_copilot.py +196 -0
  35. aihound/scanners/huggingface.py +97 -0
  36. aihound/scanners/jupyter.py +341 -0
  37. aihound/scanners/lm_studio.py +274 -0
  38. aihound/scanners/ml_platforms.py +209 -0
  39. aihound/scanners/network_exposure.py +132 -0
  40. aihound/scanners/ollama.py +299 -0
  41. aihound/scanners/openai_cli.py +234 -0
  42. aihound/scanners/openclaw.py +419 -0
  43. aihound/scanners/persistent_env.py +453 -0
  44. aihound/scanners/powershell.py +287 -0
  45. aihound/scanners/shell_history.py +286 -0
  46. aihound/scanners/shell_rc.py +316 -0
  47. aihound/scanners/vscode_extensions.py +231 -0
  48. aihound/scanners/windsurf.py +129 -0
  49. aihound/utils/__init__.py +0 -0
  50. aihound/utils/credman.py +65 -0
  51. aihound/utils/keychain.py +90 -0
  52. aihound/utils/vscdb.py +80 -0
  53. aihound/watch.py +308 -0
  54. aihound-3.2.1.dist-info/LICENSE +21 -0
  55. aihound-3.2.1.dist-info/METADATA +271 -0
  56. aihound-3.2.1.dist-info/RECORD +59 -0
  57. aihound-3.2.1.dist-info/WHEEL +5 -0
  58. aihound-3.2.1.dist-info/entry_points.txt +2 -0
  59. aihound-3.2.1.dist-info/top_level.txt +1 -0
aihound/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AIHound - AI Credential & Secrets Scanner."""
2
+
3
+ __version__ = "3.2.1"
aihound/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entry point for python -m aihound."""
2
+
3
+ import sys
4
+ from aihound.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
aihound/cli.py ADDED
@@ -0,0 +1,456 @@
1
+ """CLI entry point for AIHound."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from aihound import __version__
11
+ from aihound.core.platform import detect_platform
12
+ from aihound.scanners import get_all_scanners
13
+ from aihound.output.table import print_banner, print_results
14
+ from aihound.output.json_export import export_json
15
+ from aihound.output.html_report import export_html
16
+
17
+ logger = logging.getLogger("aihound")
18
+
19
+ # Resolve the banner image path relative to the project root
20
+ _PACKAGE_DIR = Path(__file__).resolve().parent
21
+ _PROJECT_ROOT = _PACKAGE_DIR.parent
22
+ _DEFAULT_BANNER = _PROJECT_ROOT / "aihound.png"
23
+
24
+
25
+ def _setup_logging(verbose: bool = False, json_output: bool = False) -> None:
26
+ """Configure logging levels and format.
27
+
28
+ - Default: WARNING and above (quiet — only problems)
29
+ - --verbose: DEBUG and above (scanner progress, paths checked, stack traces on errors)
30
+ - --json: logs go to stderr so stdout stays clean for JSON
31
+ """
32
+ level = logging.DEBUG if verbose else logging.WARNING
33
+ stream = sys.stderr if json_output else sys.stdout
34
+ handler = logging.StreamHandler(stream)
35
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
36
+
37
+ root = logging.getLogger("aihound")
38
+ root.setLevel(level)
39
+ root.handlers = [handler]
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="aihound",
45
+ description="AIHound - AI Credential & Secrets Scanner",
46
+ )
47
+ parser.add_argument(
48
+ "--version", action="version", version=f"aihound {__version__}"
49
+ )
50
+ parser.add_argument(
51
+ "--show-secrets",
52
+ action="store_true",
53
+ help="Show actual credential values (USE WITH CAUTION)",
54
+ )
55
+ parser.add_argument(
56
+ "--json",
57
+ action="store_true",
58
+ dest="json_output",
59
+ help="Output results as JSON to stdout",
60
+ )
61
+ parser.add_argument(
62
+ "--json-file",
63
+ type=str,
64
+ metavar="PATH",
65
+ help="Write JSON report to file",
66
+ )
67
+ parser.add_argument(
68
+ "--html-file",
69
+ type=str,
70
+ metavar="PATH",
71
+ help="Write HTML report to file",
72
+ )
73
+ parser.add_argument(
74
+ "--banner",
75
+ type=str,
76
+ metavar="PATH",
77
+ help="Custom banner image for HTML report (default: aihound.png)",
78
+ )
79
+ parser.add_argument(
80
+ "--bloodhound",
81
+ type=str,
82
+ metavar="PATH",
83
+ help="Write BloodHound CE OpenGraph JSON to file (for attack path visualization)",
84
+ )
85
+ parser.add_argument(
86
+ "--tools",
87
+ nargs="+",
88
+ metavar="TOOL",
89
+ help="Only scan specific tools (use slugs from --list-tools)",
90
+ )
91
+ parser.add_argument(
92
+ "--list-tools",
93
+ action="store_true",
94
+ help="List all available scanners and exit",
95
+ )
96
+ parser.add_argument(
97
+ "--verbose", "-v",
98
+ action="store_true",
99
+ help="Show debug output (paths checked, errors, stack traces)",
100
+ )
101
+ parser.add_argument(
102
+ "--no-color",
103
+ action="store_true",
104
+ help="Disable colored output",
105
+ )
106
+
107
+ # Watch/monitor mode flags
108
+ watch_group = parser.add_argument_group(
109
+ "watch mode", "Continuous monitoring: re-scan on an interval and emit events on changes"
110
+ )
111
+ watch_group.add_argument(
112
+ "--watch",
113
+ action="store_true",
114
+ help="Run continuously, alerting on new/changed credentials (Ctrl+C to stop)",
115
+ )
116
+ watch_group.add_argument(
117
+ "--interval",
118
+ type=float,
119
+ default=30.0,
120
+ metavar="SECONDS",
121
+ help="Polling interval in seconds (default: 30)",
122
+ )
123
+ watch_group.add_argument(
124
+ "--watch-log",
125
+ type=str,
126
+ metavar="PATH",
127
+ help="Append watch events as NDJSON to this file",
128
+ )
129
+ watch_group.add_argument(
130
+ "--notify",
131
+ action="store_true",
132
+ help="Fire OS-native desktop notifications for watch events",
133
+ )
134
+ watch_group.add_argument(
135
+ "--notify-min-risk",
136
+ type=str,
137
+ choices=["critical", "high", "medium", "low", "info"],
138
+ default="high",
139
+ metavar="LEVEL",
140
+ help="Minimum risk level to notify on (default: high)",
141
+ )
142
+ watch_group.add_argument(
143
+ "--min-risk",
144
+ type=str,
145
+ choices=["critical", "high", "medium", "low", "info"],
146
+ default="info",
147
+ metavar="LEVEL",
148
+ help="Minimum risk level for watch events (default: info — show all)",
149
+ )
150
+ watch_group.add_argument(
151
+ "--debounce",
152
+ type=float,
153
+ default=10.0,
154
+ metavar="SECONDS",
155
+ help="Suppress duplicate events within this window (default: 10, 0 disables)",
156
+ )
157
+
158
+ # MCP server mode
159
+ mcp_group = parser.add_argument_group(
160
+ "mcp server mode", "Expose AIHound to AI assistants via Model Context Protocol"
161
+ )
162
+ mcp_group.add_argument(
163
+ "--mcp",
164
+ action="store_true",
165
+ help=(
166
+ "Run as an MCP stdio server. Requires `pip install aihound[mcp]`. "
167
+ "Use in an MCP client config (e.g. claude_desktop_config.json) rather "
168
+ "than invoking directly."
169
+ ),
170
+ )
171
+
172
+ return parser
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ parser = build_parser()
177
+ args = parser.parse_args(argv)
178
+
179
+ # MCP server mode takes over immediately — no banner, no scan, no output formatting.
180
+ # Logs go to stderr so stdout stays clean for JSON-RPC.
181
+ if args.mcp:
182
+ return _run_mcp_mode(verbose=args.verbose)
183
+
184
+ _setup_logging(verbose=args.verbose, json_output=args.json_output)
185
+
186
+ scanners = get_all_scanners()
187
+
188
+ # --list-tools
189
+ if args.list_tools:
190
+ print("Available scanners:")
191
+ for s in scanners:
192
+ applicable = "yes" if s.is_applicable() else "no (not applicable on this platform)"
193
+ print(f" {s.slug():<20} {s.name():<30} Applicable: {applicable}")
194
+ return 0
195
+
196
+ # --show-secrets safety gate
197
+ show_secrets = False
198
+ if args.show_secrets:
199
+ if not sys.stdin.isatty():
200
+ print(
201
+ "ERROR: --show-secrets requires an interactive terminal. "
202
+ "Refusing to expose credentials in non-interactive mode.",
203
+ file=sys.stderr,
204
+ )
205
+ return 1
206
+ logger.warning(
207
+ "--show-secrets will display raw credential values. "
208
+ "Only use on YOUR OWN machine for research purposes."
209
+ )
210
+ try:
211
+ confirm = input("Type 'YES' to confirm: ")
212
+ if confirm.strip() != "YES":
213
+ print("Aborted.")
214
+ return 1
215
+ except (EOFError, KeyboardInterrupt):
216
+ print("\nAborted.")
217
+ return 1
218
+ show_secrets = True
219
+
220
+ # Filter scanners
221
+ if args.tools:
222
+ tool_slugs = set(args.tools)
223
+ scanners = [s for s in scanners if s.slug() in tool_slugs]
224
+ if not scanners:
225
+ logger.error("No scanners matched: %s — use --list-tools to see available scanners.", args.tools)
226
+ return 1
227
+
228
+ # Filter by platform applicability
229
+ scanners = [s for s in scanners if s.is_applicable()]
230
+
231
+ # Watch/monitor mode: takes over and never reaches the one-shot output path below
232
+ if args.watch:
233
+ return _run_watch_mode(scanners, args, show_secrets)
234
+
235
+ # Print banner (unless JSON-only output)
236
+ if not args.json_output:
237
+ plat = detect_platform()
238
+ print_banner(no_color=args.no_color)
239
+ print(f"Platform: {plat.value}")
240
+ if plat.value == "wsl":
241
+ print("WSL detected - scanning both Linux and Windows credential paths\n")
242
+ else:
243
+ print()
244
+
245
+ # Run scanners
246
+ results = []
247
+ for scanner in scanners:
248
+ logger.debug("Scanning: %s...", scanner.name())
249
+ result = scanner.run(show_secrets=show_secrets)
250
+ results.append(result)
251
+
252
+ for err in result.errors:
253
+ logger.warning("[%s] %s", scanner.name(), err)
254
+
255
+ # Output
256
+ if args.json_output:
257
+ export_json(results, file=sys.stdout)
258
+ else:
259
+ print_results(results, no_color=args.no_color, verbose=args.verbose)
260
+
261
+ if args.json_file:
262
+ out_path = _prepare_output_path(args.json_file, "JSON")
263
+ if out_path is None:
264
+ return 1
265
+ try:
266
+ export_json(results, filepath=str(out_path))
267
+ except OSError as e:
268
+ print(f"ERROR: Failed to write JSON report to {out_path}: {e}", file=sys.stderr)
269
+ return 1
270
+ logger.info("JSON report written to: %s", out_path)
271
+ if not args.json_output:
272
+ print(f"\nJSON report written to: {out_path}")
273
+
274
+ if args.html_file:
275
+ out_path = _prepare_output_path(args.html_file, "HTML")
276
+ if out_path is None:
277
+ return 1
278
+ banner = Path(args.banner).expanduser() if args.banner else _DEFAULT_BANNER
279
+ try:
280
+ export_html(results, filepath=str(out_path), banner_path=banner)
281
+ except OSError as e:
282
+ print(f"ERROR: Failed to write HTML report to {out_path}: {e}", file=sys.stderr)
283
+ return 1
284
+ logger.info("HTML report written to: %s", out_path)
285
+ if not args.json_output:
286
+ print(f"HTML report written to: {out_path}")
287
+
288
+ if args.bloodhound:
289
+ out_path = _prepare_output_path(args.bloodhound, "BloodHound")
290
+ if out_path is None:
291
+ return 1
292
+ try:
293
+ from aihound.output.opengraph_export import export_opengraph
294
+ export_opengraph(results, filepath=str(out_path))
295
+ except ImportError:
296
+ print(
297
+ "ERROR: aihound400 package not found. Ensure the aihound400 directory "
298
+ "is in your Python path.",
299
+ file=sys.stderr,
300
+ )
301
+ return 1
302
+ except OSError as e:
303
+ print(f"ERROR: Failed to write BloodHound export to {out_path}: {e}", file=sys.stderr)
304
+ return 1
305
+ logger.info("BloodHound OpenGraph JSON written to: %s", out_path)
306
+ if not args.json_output:
307
+ print(f"BloodHound OpenGraph JSON written to: {out_path}")
308
+
309
+ return 0
310
+
311
+
312
+ def _prepare_output_path(raw_path: str, label: str) -> Path | None:
313
+ """Normalize an output file path: expand ~, resolve, auto-create parent dir.
314
+
315
+ Returns the resolved Path on success, None on failure (after printing an error).
316
+ """
317
+ try:
318
+ path = Path(raw_path).expanduser()
319
+ except (RuntimeError, ValueError) as e:
320
+ print(f"ERROR: Invalid {label} output path '{raw_path}': {e}", file=sys.stderr)
321
+ return None
322
+
323
+ parent = path.parent
324
+ if parent and not parent.exists():
325
+ try:
326
+ parent.mkdir(parents=True, exist_ok=True)
327
+ logger.debug("Created parent directory: %s", parent)
328
+ except OSError as e:
329
+ print(
330
+ f"ERROR: Cannot create directory {parent} for {label} output: {e}\n"
331
+ f" Check that you have write permission to this location.",
332
+ file=sys.stderr,
333
+ )
334
+ return None
335
+
336
+ # Preflight write permission check
337
+ if path.exists():
338
+ if not path.is_file():
339
+ print(
340
+ f"ERROR: {label} output path {path} exists but is not a regular file.",
341
+ file=sys.stderr,
342
+ )
343
+ return None
344
+ return path
345
+
346
+
347
+ def _run_watch_mode(scanners: list, args: argparse.Namespace, show_secrets: bool) -> int:
348
+ """Entry point for --watch. Builds sinks, starts the watch loop."""
349
+ # Import here to keep watch-mode deps lazy
350
+ from aihound.core.scanner import RiskLevel
351
+ from aihound.watch import WatchLoop
352
+ from aihound.output.watch_formatters import (
353
+ NDJSONEventSink,
354
+ NotificationEventSink,
355
+ TerminalEventSink,
356
+ )
357
+
358
+ risk_map = {
359
+ "critical": RiskLevel.CRITICAL,
360
+ "high": RiskLevel.HIGH,
361
+ "medium": RiskLevel.MEDIUM,
362
+ "low": RiskLevel.LOW,
363
+ "info": RiskLevel.INFO,
364
+ }
365
+ min_risk = risk_map[args.min_risk]
366
+ notify_min_risk = risk_map[args.notify_min_risk]
367
+
368
+ # Build sinks
369
+ sinks = []
370
+
371
+ if args.json_output:
372
+ # In JSON mode, NDJSON to stdout is the primary output
373
+ sinks.append(NDJSONEventSink(file=sys.stdout))
374
+ else:
375
+ # Otherwise terminal is primary — banner + live events
376
+ plat = detect_platform()
377
+ print_banner(no_color=args.no_color)
378
+ print(f"Platform: {plat.value}")
379
+ if plat.value == "wsl":
380
+ print("WSL detected - scanning both Linux and Windows credential paths")
381
+ print(
382
+ f"Watch mode: interval={int(args.interval)}s, scanners={len(scanners)}, "
383
+ f"min-risk={args.min_risk}. Press Ctrl+C to stop.\n"
384
+ )
385
+ sinks.append(TerminalEventSink(no_color=args.no_color))
386
+
387
+ if args.watch_log:
388
+ try:
389
+ sinks.append(NDJSONEventSink(filepath=args.watch_log))
390
+ except OSError as e:
391
+ print(f"ERROR: Cannot open watch log {args.watch_log}: {e}", file=sys.stderr)
392
+ return 1
393
+
394
+ if args.notify:
395
+ sinks.append(NotificationEventSink(min_risk=notify_min_risk))
396
+
397
+ loop = WatchLoop(
398
+ scanners=scanners,
399
+ sinks=sinks,
400
+ interval=args.interval,
401
+ min_risk=min_risk,
402
+ debounce_seconds=args.debounce,
403
+ show_secrets=show_secrets,
404
+ )
405
+
406
+ try:
407
+ event_count = loop.run()
408
+ except KeyboardInterrupt:
409
+ event_count = 0
410
+
411
+ # Close any owned file handles in sinks
412
+ for sink in sinks:
413
+ close = getattr(sink, "close", None)
414
+ if callable(close):
415
+ close()
416
+
417
+ # Final summary to stderr so it doesn't pollute NDJSON stdout
418
+ if not args.json_output:
419
+ print(f"\nWatch stopped. {event_count} event(s) emitted.", file=sys.stderr)
420
+ return 0
421
+
422
+
423
+ def _run_mcp_mode(verbose: bool = False) -> int:
424
+ """Entry point for --mcp. Runs AIHound as an MCP stdio server.
425
+
426
+ Prints a friendly install hint (to stderr) and exits 1 if the optional
427
+ `mcp` SDK isn't installed.
428
+ """
429
+ # All logging to stderr — stdout is reserved for JSON-RPC
430
+ level = logging.DEBUG if verbose else logging.INFO
431
+ handler = logging.StreamHandler(sys.stderr)
432
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
433
+ root = logging.getLogger("aihound")
434
+ root.setLevel(level)
435
+ root.handlers = [handler]
436
+
437
+ try:
438
+ from aihound.mcp_server import run_server
439
+ except ImportError as e:
440
+ print(f"ERROR: {e}", file=sys.stderr)
441
+ return 1
442
+
443
+ try:
444
+ run_server()
445
+ except ImportError as e:
446
+ # run_server() raises ImportError with a helpful message when the mcp SDK is missing
447
+ print(f"ERROR: {e}", file=sys.stderr)
448
+ return 1
449
+ except KeyboardInterrupt:
450
+ logger.debug("MCP server interrupted")
451
+
452
+ return 0
453
+
454
+
455
+ if __name__ == "__main__":
456
+ sys.exit(main())
File without changes