devguard 0.2.0__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.
- devguard/INTEGRATION_SUMMARY.md +121 -0
- devguard/__init__.py +3 -0
- devguard/__main__.py +6 -0
- devguard/checkers/__init__.py +41 -0
- devguard/checkers/api_usage.py +523 -0
- devguard/checkers/aws_cost.py +331 -0
- devguard/checkers/aws_iam.py +284 -0
- devguard/checkers/base.py +25 -0
- devguard/checkers/container.py +137 -0
- devguard/checkers/domain.py +189 -0
- devguard/checkers/firecrawl.py +117 -0
- devguard/checkers/fly.py +225 -0
- devguard/checkers/github.py +210 -0
- devguard/checkers/npm.py +327 -0
- devguard/checkers/npm_security.py +244 -0
- devguard/checkers/redteam.py +290 -0
- devguard/checkers/secret.py +279 -0
- devguard/checkers/swarm.py +376 -0
- devguard/checkers/tailscale.py +143 -0
- devguard/checkers/tailsnitch.py +303 -0
- devguard/checkers/tavily.py +179 -0
- devguard/checkers/vercel.py +192 -0
- devguard/cli.py +1510 -0
- devguard/cli_helpers.py +189 -0
- devguard/config.py +249 -0
- devguard/core.py +293 -0
- devguard/dashboard.py +715 -0
- devguard/discovery.py +363 -0
- devguard/http_client.py +142 -0
- devguard/llm_service.py +481 -0
- devguard/mcp_server.py +259 -0
- devguard/metrics.py +144 -0
- devguard/models.py +208 -0
- devguard/reporting.py +1571 -0
- devguard/sarif.py +295 -0
- devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
- devguard/scripts/README.md +221 -0
- devguard/scripts/auto_fix_recommendations.py +145 -0
- devguard/scripts/generate_npmignore.py +175 -0
- devguard/scripts/generate_security_report.py +324 -0
- devguard/scripts/prepublish_check.sh +29 -0
- devguard/scripts/redteam_npm_packages.py +1262 -0
- devguard/scripts/review_all_repos.py +300 -0
- devguard/spec.py +617 -0
- devguard/sweeps/__init__.py +23 -0
- devguard/sweeps/ai_editor_config_audit.py +697 -0
- devguard/sweeps/cargo_publish_audit.py +655 -0
- devguard/sweeps/dependency_audit.py +419 -0
- devguard/sweeps/gitignore_audit.py +336 -0
- devguard/sweeps/local_dev.py +260 -0
- devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
- devguard/sweeps/project_flaudit.py +636 -0
- devguard/sweeps/public_github_secrets.py +680 -0
- devguard/sweeps/publish_audit.py +478 -0
- devguard/sweeps/ssh_key_audit.py +327 -0
- devguard/utils.py +174 -0
- devguard-0.2.0.dist-info/METADATA +225 -0
- devguard-0.2.0.dist-info/RECORD +60 -0
- devguard-0.2.0.dist-info/WHEEL +4 -0
- devguard-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""SSH key hygiene audit sweep: detect weak, unprotected, or stale SSH keys.
|
|
2
|
+
|
|
3
|
+
Scans ~/.ssh/ for private key files and checks:
|
|
4
|
+
- Algorithm type and bit strength (flags DSA, short RSA, optionally ECDSA)
|
|
5
|
+
- Passphrase protection (keys without a passphrase are flagged)
|
|
6
|
+
- File permissions (should be 600 or 400)
|
|
7
|
+
- GitHub registration (cross-references with `gh ssh-key list`)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _utc_now() -> str:
|
|
23
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Well-known private key filenames (without path).
|
|
27
|
+
_WELL_KNOWN_NAMES = {"id_rsa", "id_ecdsa", "id_ed25519", "id_dsa"}
|
|
28
|
+
|
|
29
|
+
# PEM header that marks a private key file.
|
|
30
|
+
_PRIVATE_KEY_HEADER = b"-----BEGIN"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_private_key_file(path: Path) -> bool:
|
|
34
|
+
"""Heuristic: file looks like an SSH private key."""
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
return False
|
|
37
|
+
# Skip .pub files
|
|
38
|
+
if path.suffix == ".pub":
|
|
39
|
+
return False
|
|
40
|
+
# Well-known names are always candidates
|
|
41
|
+
if path.name in _WELL_KNOWN_NAMES:
|
|
42
|
+
return True
|
|
43
|
+
# Otherwise check for PEM header in first 64 bytes
|
|
44
|
+
try:
|
|
45
|
+
head = path.read_bytes()[:64]
|
|
46
|
+
return _PRIVATE_KEY_HEADER in head
|
|
47
|
+
except (OSError, PermissionError):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_keygen_fingerprint(output: str) -> tuple[int, str, str]:
|
|
52
|
+
"""Parse `ssh-keygen -l` output into (bits, fingerprint, key_type).
|
|
53
|
+
|
|
54
|
+
Example lines:
|
|
55
|
+
256 SHA256:abc...xyz user@host (ED25519)
|
|
56
|
+
3072 SHA256:def...uvw user@host (RSA)
|
|
57
|
+
"""
|
|
58
|
+
# Pattern: <bits> <fingerprint> <comment> (<type>)
|
|
59
|
+
m = re.match(r"(\d+)\s+(SHA256:\S+).*\((\w+)\)", output.strip())
|
|
60
|
+
if m:
|
|
61
|
+
return int(m.group(1)), m.group(2), m.group(3).upper()
|
|
62
|
+
return 0, "", "UNKNOWN"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_key_info(key_path: Path) -> tuple[int, str, str, list[str]]:
|
|
66
|
+
"""Run ssh-keygen -l to get bits, fingerprint, type. Returns (bits, fingerprint, algo, errors)."""
|
|
67
|
+
errors: list[str] = []
|
|
68
|
+
if not shutil.which("ssh-keygen"):
|
|
69
|
+
return 0, "", "UNKNOWN", ["ssh-keygen not found on PATH"]
|
|
70
|
+
try:
|
|
71
|
+
res = subprocess.run(
|
|
72
|
+
["ssh-keygen", "-l", "-f", str(key_path)],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=10,
|
|
76
|
+
)
|
|
77
|
+
if res.returncode != 0:
|
|
78
|
+
errors.append(f"ssh-keygen -l failed: {res.stderr.strip()}")
|
|
79
|
+
return 0, "", "UNKNOWN", errors
|
|
80
|
+
bits, fingerprint, algo = _parse_keygen_fingerprint(res.stdout)
|
|
81
|
+
return bits, fingerprint, algo, errors
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
return 0, "", "UNKNOWN", ["ssh-keygen -l timed out"]
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
return 0, "", "UNKNOWN", [f"ssh-keygen -l error: {exc}"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_passphrase(key_path: Path) -> tuple[bool | None, list[str]]:
|
|
89
|
+
"""Check whether a private key has a passphrase.
|
|
90
|
+
|
|
91
|
+
Returns (has_passphrase, errors).
|
|
92
|
+
- True = passphrase-protected (good)
|
|
93
|
+
- False = no passphrase (bad)
|
|
94
|
+
- None = could not determine
|
|
95
|
+
"""
|
|
96
|
+
errors: list[str] = []
|
|
97
|
+
if not shutil.which("ssh-keygen"):
|
|
98
|
+
return None, ["ssh-keygen not found on PATH"]
|
|
99
|
+
try:
|
|
100
|
+
res = subprocess.run(
|
|
101
|
+
["ssh-keygen", "-y", "-P", "", "-f", str(key_path)],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
timeout=10,
|
|
105
|
+
)
|
|
106
|
+
# Exit 0 + public key on stdout => no passphrase (bad)
|
|
107
|
+
if res.returncode == 0:
|
|
108
|
+
return False, errors
|
|
109
|
+
# Non-zero => passphrase required (good) or other error
|
|
110
|
+
stderr = res.stderr.strip().lower()
|
|
111
|
+
if "incorrect passphrase" in stderr or "bad passphrase" in stderr:
|
|
112
|
+
return True, errors
|
|
113
|
+
# Some other failure (corrupt key, permission issue, etc.)
|
|
114
|
+
return None, [f"passphrase check inconclusive: {res.stderr.strip()}"]
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
return None, ["passphrase check timed out"]
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
return None, [f"passphrase check error: {exc}"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_permissions(key_path: Path) -> tuple[bool, str]:
|
|
122
|
+
"""Check file permissions. Returns (ok, octal_string)."""
|
|
123
|
+
try:
|
|
124
|
+
mode = key_path.stat().st_mode
|
|
125
|
+
file_perms = stat.S_IMODE(mode)
|
|
126
|
+
octal_str = f"{file_perms:04o}"
|
|
127
|
+
# Acceptable: 0600 (owner rw) or 0400 (owner r)
|
|
128
|
+
ok = file_perms in (0o600, 0o400)
|
|
129
|
+
return ok, octal_str
|
|
130
|
+
except OSError:
|
|
131
|
+
return False, "????"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _get_github_keys() -> tuple[list[dict[str, str]], list[str]]:
|
|
135
|
+
"""Fetch SSH keys registered on GitHub via `gh ssh-key list`.
|
|
136
|
+
|
|
137
|
+
Returns (keys, errors) where each key is {"fingerprint": ..., "title": ...}.
|
|
138
|
+
"""
|
|
139
|
+
errors: list[str] = []
|
|
140
|
+
if not shutil.which("gh"):
|
|
141
|
+
return [], ["gh CLI not found on PATH; skipping GitHub cross-reference"]
|
|
142
|
+
try:
|
|
143
|
+
res = subprocess.run(
|
|
144
|
+
["gh", "ssh-key", "list"],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
timeout=15,
|
|
148
|
+
)
|
|
149
|
+
if res.returncode != 0:
|
|
150
|
+
stderr = res.stderr.strip()
|
|
151
|
+
return [], [f"gh ssh-key list failed: {stderr}"]
|
|
152
|
+
except subprocess.TimeoutExpired:
|
|
153
|
+
return [], ["gh ssh-key list timed out"]
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
return [], [f"gh ssh-key list error: {exc}"]
|
|
156
|
+
|
|
157
|
+
keys: list[dict[str, str]] = []
|
|
158
|
+
for line in res.stdout.strip().splitlines():
|
|
159
|
+
# Format: TITLE\tTYPE\tFINGERPRINT\tADDED
|
|
160
|
+
# or: TITLE\tFINGERPRINT\tADDED (older gh versions)
|
|
161
|
+
parts = line.split("\t")
|
|
162
|
+
if len(parts) >= 3:
|
|
163
|
+
title = parts[0].strip()
|
|
164
|
+
# Fingerprint is the part that starts with SHA256:
|
|
165
|
+
fingerprint = ""
|
|
166
|
+
for p in parts[1:]:
|
|
167
|
+
p = p.strip()
|
|
168
|
+
if p.startswith("SHA256:"):
|
|
169
|
+
fingerprint = p
|
|
170
|
+
break
|
|
171
|
+
if fingerprint:
|
|
172
|
+
keys.append({"title": title, "fingerprint": fingerprint})
|
|
173
|
+
return keys, errors
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def audit_ssh_keys(
|
|
177
|
+
*,
|
|
178
|
+
ssh_dir: Path | None = None,
|
|
179
|
+
check_github: bool = True,
|
|
180
|
+
min_rsa_bits: int = 3072,
|
|
181
|
+
flag_ecdsa: bool = False,
|
|
182
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
183
|
+
"""Audit SSH keys and return (report, errors)."""
|
|
184
|
+
errors: list[str] = []
|
|
185
|
+
ssh_path = ssh_dir if ssh_dir is not None else Path("~/.ssh").expanduser()
|
|
186
|
+
|
|
187
|
+
if not ssh_path.is_dir():
|
|
188
|
+
report: dict[str, Any] = {
|
|
189
|
+
"generated_at": _utc_now(),
|
|
190
|
+
"scope": {"ssh_dir": str(ssh_path)},
|
|
191
|
+
"summary": {"keys_scanned": 0, "issues_total": 0},
|
|
192
|
+
"keys": [],
|
|
193
|
+
"github_cross_reference": None,
|
|
194
|
+
"errors": [f"SSH directory not found: {ssh_path}"],
|
|
195
|
+
}
|
|
196
|
+
return report, [f"SSH directory not found: {ssh_path}"]
|
|
197
|
+
|
|
198
|
+
# Discover private key files
|
|
199
|
+
private_keys: list[Path] = []
|
|
200
|
+
try:
|
|
201
|
+
for entry in sorted(ssh_path.iterdir()):
|
|
202
|
+
if _is_private_key_file(entry):
|
|
203
|
+
private_keys.append(entry)
|
|
204
|
+
except PermissionError as exc:
|
|
205
|
+
errors.append(f"cannot read {ssh_path}: {exc}")
|
|
206
|
+
|
|
207
|
+
# Analyze each key
|
|
208
|
+
key_results: list[dict[str, Any]] = []
|
|
209
|
+
local_fingerprints: dict[str, str] = {} # fingerprint -> key_path
|
|
210
|
+
|
|
211
|
+
for key_path in private_keys:
|
|
212
|
+
issues: list[str] = []
|
|
213
|
+
|
|
214
|
+
# Algorithm and bit strength
|
|
215
|
+
bits, fingerprint, algo, key_errors = _get_key_info(key_path)
|
|
216
|
+
errors.extend(key_errors)
|
|
217
|
+
|
|
218
|
+
if fingerprint:
|
|
219
|
+
local_fingerprints[fingerprint] = str(key_path)
|
|
220
|
+
|
|
221
|
+
# Weak algorithm checks
|
|
222
|
+
if algo == "DSA":
|
|
223
|
+
issues.append("DSA algorithm is deprecated and weak")
|
|
224
|
+
elif algo == "RSA" and bits > 0 and bits < min_rsa_bits:
|
|
225
|
+
issues.append(f"RSA key is {bits}-bit (minimum recommended: {min_rsa_bits})")
|
|
226
|
+
elif algo == "ECDSA" and flag_ecdsa:
|
|
227
|
+
issues.append("ECDSA uses NIST curves (flagged by policy)")
|
|
228
|
+
|
|
229
|
+
# Passphrase check
|
|
230
|
+
has_passphrase, pp_errors = _check_passphrase(key_path)
|
|
231
|
+
errors.extend(pp_errors)
|
|
232
|
+
if has_passphrase is False:
|
|
233
|
+
issues.append("no passphrase protection")
|
|
234
|
+
|
|
235
|
+
# Permissions check
|
|
236
|
+
perms_ok, perms_octal = _check_permissions(key_path)
|
|
237
|
+
if not perms_ok:
|
|
238
|
+
issues.append(f"permissions too open: {perms_octal} (should be 0600 or 0400)")
|
|
239
|
+
|
|
240
|
+
key_results.append({
|
|
241
|
+
"key_path": str(key_path),
|
|
242
|
+
"algorithm": algo,
|
|
243
|
+
"bits": bits,
|
|
244
|
+
"fingerprint": fingerprint,
|
|
245
|
+
"has_passphrase": has_passphrase,
|
|
246
|
+
"permissions": perms_octal,
|
|
247
|
+
"permissions_ok": perms_ok,
|
|
248
|
+
"issues": issues,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
# GitHub cross-reference
|
|
252
|
+
github_cross_ref: dict[str, Any] | None = None
|
|
253
|
+
if check_github:
|
|
254
|
+
gh_keys, gh_errors = _get_github_keys()
|
|
255
|
+
errors.extend(gh_errors)
|
|
256
|
+
|
|
257
|
+
if gh_keys or not gh_errors:
|
|
258
|
+
gh_fingerprints = {k["fingerprint"] for k in gh_keys}
|
|
259
|
+
local_fp_set = set(local_fingerprints.keys())
|
|
260
|
+
|
|
261
|
+
local_not_on_github = [
|
|
262
|
+
{"fingerprint": fp, "key_path": local_fingerprints[fp]}
|
|
263
|
+
for fp in sorted(local_fp_set - gh_fingerprints)
|
|
264
|
+
]
|
|
265
|
+
github_not_local = [
|
|
266
|
+
{"fingerprint": k["fingerprint"], "title": k["title"]}
|
|
267
|
+
for k in gh_keys
|
|
268
|
+
if k["fingerprint"] not in local_fp_set
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
github_cross_ref = {
|
|
272
|
+
"github_keys_count": len(gh_keys),
|
|
273
|
+
"local_not_on_github": local_not_on_github,
|
|
274
|
+
"github_not_local": github_not_local,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Add cross-ref issues to relevant keys
|
|
278
|
+
for entry in local_not_on_github:
|
|
279
|
+
for kr in key_results:
|
|
280
|
+
if kr["fingerprint"] == entry["fingerprint"]:
|
|
281
|
+
kr["registered_on_github"] = False
|
|
282
|
+
kr["issues"].append("not registered on GitHub (stale?)")
|
|
283
|
+
|
|
284
|
+
# Mark keys that are registered
|
|
285
|
+
for kr in key_results:
|
|
286
|
+
if "registered_on_github" not in kr:
|
|
287
|
+
if kr["fingerprint"] and kr["fingerprint"] in gh_fingerprints:
|
|
288
|
+
kr["registered_on_github"] = True
|
|
289
|
+
elif not kr["fingerprint"]:
|
|
290
|
+
kr["registered_on_github"] = None
|
|
291
|
+
else:
|
|
292
|
+
kr["registered_on_github"] = False
|
|
293
|
+
|
|
294
|
+
total_issues = sum(len(k["issues"]) for k in key_results)
|
|
295
|
+
|
|
296
|
+
report = {
|
|
297
|
+
"generated_at": _utc_now(),
|
|
298
|
+
"scope": {
|
|
299
|
+
"ssh_dir": str(ssh_path),
|
|
300
|
+
"check_github": check_github,
|
|
301
|
+
"min_rsa_bits": min_rsa_bits,
|
|
302
|
+
"flag_ecdsa": flag_ecdsa,
|
|
303
|
+
},
|
|
304
|
+
"summary": {
|
|
305
|
+
"keys_scanned": len(key_results),
|
|
306
|
+
"issues_total": total_issues,
|
|
307
|
+
"keys_without_passphrase": sum(
|
|
308
|
+
1 for k in key_results if k["has_passphrase"] is False
|
|
309
|
+
),
|
|
310
|
+
"keys_with_weak_algorithm": sum(
|
|
311
|
+
1 for k in key_results
|
|
312
|
+
if any("deprecated" in i or "bit" in i or "NIST" in i for i in k["issues"])
|
|
313
|
+
),
|
|
314
|
+
"keys_with_bad_permissions": sum(
|
|
315
|
+
1 for k in key_results if not k["permissions_ok"]
|
|
316
|
+
),
|
|
317
|
+
},
|
|
318
|
+
"keys": key_results,
|
|
319
|
+
"github_cross_reference": github_cross_ref,
|
|
320
|
+
"errors": errors,
|
|
321
|
+
}
|
|
322
|
+
return report, errors
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def write_report(path: Path, report: dict[str, Any]) -> None:
|
|
326
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
path.write_text(json.dumps(report, indent=2) + "\n")
|
devguard/utils.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Utility functions for Guardian.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for accessing external modules (like ops/agent)
|
|
4
|
+
without fragile path manipulation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from devguard.config import Settings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Cache for resolved paths
|
|
18
|
+
_resolved_paths: dict[str, Path | None] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_ops_agent_path() -> Path | None:
|
|
22
|
+
"""Get the path to ops/agent directory.
|
|
23
|
+
|
|
24
|
+
Returns None if not found. Uses cached result after first resolution.
|
|
25
|
+
"""
|
|
26
|
+
cache_key = "ops_agent"
|
|
27
|
+
if cache_key in _resolved_paths:
|
|
28
|
+
return _resolved_paths[cache_key]
|
|
29
|
+
|
|
30
|
+
# Try to find ops/agent relative to devguard
|
|
31
|
+
devguard_path = Path(__file__).parent.parent.parent
|
|
32
|
+
ops_agent_path = devguard_path.parent / "ops" / "agent"
|
|
33
|
+
|
|
34
|
+
if ops_agent_path.exists() and ops_agent_path.is_dir():
|
|
35
|
+
_resolved_paths[cache_key] = ops_agent_path
|
|
36
|
+
return ops_agent_path
|
|
37
|
+
|
|
38
|
+
_resolved_paths[cache_key] = None
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_ops_config_path() -> Path | None:
|
|
43
|
+
"""Get the path to ops/config directory.
|
|
44
|
+
|
|
45
|
+
Returns None if not found. Uses cached result after first resolution.
|
|
46
|
+
"""
|
|
47
|
+
cache_key = "ops_config"
|
|
48
|
+
if cache_key in _resolved_paths:
|
|
49
|
+
return _resolved_paths[cache_key]
|
|
50
|
+
|
|
51
|
+
devguard_path = Path(__file__).parent.parent.parent
|
|
52
|
+
ops_config_path = devguard_path.parent / "ops" / "config"
|
|
53
|
+
|
|
54
|
+
if ops_config_path.exists() and ops_config_path.is_dir():
|
|
55
|
+
_resolved_paths[cache_key] = ops_config_path
|
|
56
|
+
return ops_config_path
|
|
57
|
+
|
|
58
|
+
_resolved_paths[cache_key] = None
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def import_smart_email() -> Any:
|
|
63
|
+
"""Import smart_email module from ops/agent.
|
|
64
|
+
|
|
65
|
+
Returns the module if found, None otherwise.
|
|
66
|
+
Handles path manipulation internally.
|
|
67
|
+
"""
|
|
68
|
+
ops_agent_path = get_ops_agent_path()
|
|
69
|
+
if not ops_agent_path:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# Add to path if not already there
|
|
74
|
+
ops_agent_str = str(ops_agent_path)
|
|
75
|
+
if ops_agent_str not in sys.path:
|
|
76
|
+
sys.path.insert(0, ops_agent_str)
|
|
77
|
+
|
|
78
|
+
import smart_email
|
|
79
|
+
|
|
80
|
+
return smart_email
|
|
81
|
+
except ImportError as e:
|
|
82
|
+
logger.debug(f"Could not import smart_email: {e}")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def import_llm_service() -> Any:
|
|
87
|
+
"""Import LLMService from ops/agent.
|
|
88
|
+
|
|
89
|
+
Returns the class if found, None otherwise.
|
|
90
|
+
"""
|
|
91
|
+
ops_agent_path = get_ops_agent_path()
|
|
92
|
+
if not ops_agent_path:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
ops_agent_str = str(ops_agent_path)
|
|
97
|
+
if ops_agent_str not in sys.path:
|
|
98
|
+
sys.path.insert(0, ops_agent_str)
|
|
99
|
+
|
|
100
|
+
from llm_service import LLMService
|
|
101
|
+
|
|
102
|
+
return LLMService
|
|
103
|
+
except ImportError as e:
|
|
104
|
+
logger.debug(f"Could not import LLMService: {e}")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_smart_email_db_path(settings: "Settings") -> Path:
|
|
109
|
+
"""Get the smart_email database path from settings or environment.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
settings: Settings object with smart_email_db_path attribute
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to database file
|
|
116
|
+
"""
|
|
117
|
+
db_path_str = getattr(settings, "smart_email_db_path", None)
|
|
118
|
+
if db_path_str:
|
|
119
|
+
return Path(db_path_str)
|
|
120
|
+
|
|
121
|
+
import os
|
|
122
|
+
|
|
123
|
+
db_path_str = os.getenv("SMART_EMAIL_DB", "/data/smart_email.db")
|
|
124
|
+
return Path(db_path_str)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_budget_config_path() -> Path | None:
|
|
128
|
+
"""Get the path to ops/config/budget.yaml.
|
|
129
|
+
|
|
130
|
+
Returns None if not found.
|
|
131
|
+
"""
|
|
132
|
+
ops_config_path = get_ops_config_path()
|
|
133
|
+
if not ops_config_path:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
budget_path = ops_config_path / "budget.yaml"
|
|
137
|
+
if budget_path.exists():
|
|
138
|
+
return budget_path
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_budget_config() -> dict[str, Any]:
|
|
144
|
+
"""Load budget configuration from ops/config/budget.yaml.
|
|
145
|
+
|
|
146
|
+
Returns empty dict if file not found or error loading.
|
|
147
|
+
"""
|
|
148
|
+
budget_path = get_budget_config_path()
|
|
149
|
+
if not budget_path:
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
import yaml
|
|
154
|
+
|
|
155
|
+
with open(budget_path) as f:
|
|
156
|
+
config = yaml.safe_load(f)
|
|
157
|
+
return config.get("aws", {})
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.debug(f"Failed to load budget config from {budget_path}: {e}")
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_iam_posture_path() -> Path | None:
|
|
164
|
+
"""Get the path to ops/security/iam-posture.yaml.
|
|
165
|
+
|
|
166
|
+
Returns None if not found.
|
|
167
|
+
"""
|
|
168
|
+
devguard_path = Path(__file__).parent.parent.parent
|
|
169
|
+
iam_path = devguard_path.parent / "ops" / "security" / "iam-posture.yaml"
|
|
170
|
+
|
|
171
|
+
if iam_path.exists():
|
|
172
|
+
return iam_path
|
|
173
|
+
|
|
174
|
+
return None
|