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.
Files changed (60) hide show
  1. devguard/INTEGRATION_SUMMARY.md +121 -0
  2. devguard/__init__.py +3 -0
  3. devguard/__main__.py +6 -0
  4. devguard/checkers/__init__.py +41 -0
  5. devguard/checkers/api_usage.py +523 -0
  6. devguard/checkers/aws_cost.py +331 -0
  7. devguard/checkers/aws_iam.py +284 -0
  8. devguard/checkers/base.py +25 -0
  9. devguard/checkers/container.py +137 -0
  10. devguard/checkers/domain.py +189 -0
  11. devguard/checkers/firecrawl.py +117 -0
  12. devguard/checkers/fly.py +225 -0
  13. devguard/checkers/github.py +210 -0
  14. devguard/checkers/npm.py +327 -0
  15. devguard/checkers/npm_security.py +244 -0
  16. devguard/checkers/redteam.py +290 -0
  17. devguard/checkers/secret.py +279 -0
  18. devguard/checkers/swarm.py +376 -0
  19. devguard/checkers/tailscale.py +143 -0
  20. devguard/checkers/tailsnitch.py +303 -0
  21. devguard/checkers/tavily.py +179 -0
  22. devguard/checkers/vercel.py +192 -0
  23. devguard/cli.py +1510 -0
  24. devguard/cli_helpers.py +189 -0
  25. devguard/config.py +249 -0
  26. devguard/core.py +293 -0
  27. devguard/dashboard.py +715 -0
  28. devguard/discovery.py +363 -0
  29. devguard/http_client.py +142 -0
  30. devguard/llm_service.py +481 -0
  31. devguard/mcp_server.py +259 -0
  32. devguard/metrics.py +144 -0
  33. devguard/models.py +208 -0
  34. devguard/reporting.py +1571 -0
  35. devguard/sarif.py +295 -0
  36. devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
  37. devguard/scripts/README.md +221 -0
  38. devguard/scripts/auto_fix_recommendations.py +145 -0
  39. devguard/scripts/generate_npmignore.py +175 -0
  40. devguard/scripts/generate_security_report.py +324 -0
  41. devguard/scripts/prepublish_check.sh +29 -0
  42. devguard/scripts/redteam_npm_packages.py +1262 -0
  43. devguard/scripts/review_all_repos.py +300 -0
  44. devguard/spec.py +617 -0
  45. devguard/sweeps/__init__.py +23 -0
  46. devguard/sweeps/ai_editor_config_audit.py +697 -0
  47. devguard/sweeps/cargo_publish_audit.py +655 -0
  48. devguard/sweeps/dependency_audit.py +419 -0
  49. devguard/sweeps/gitignore_audit.py +336 -0
  50. devguard/sweeps/local_dev.py +260 -0
  51. devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
  52. devguard/sweeps/project_flaudit.py +636 -0
  53. devguard/sweeps/public_github_secrets.py +680 -0
  54. devguard/sweeps/publish_audit.py +478 -0
  55. devguard/sweeps/ssh_key_audit.py +327 -0
  56. devguard/utils.py +174 -0
  57. devguard-0.2.0.dist-info/METADATA +225 -0
  58. devguard-0.2.0.dist-info/RECORD +60 -0
  59. devguard-0.2.0.dist-info/WHEEL +4 -0
  60. 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