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,331 @@
1
+ """AWS Cost monitoring checker."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from devguard.checkers.base import BaseChecker
9
+ from devguard.models import CheckResult, CostMetric, Finding, Severity
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Load budget config using utility module
14
+ from devguard.utils import load_budget_config
15
+
16
+
17
+ class AWSCostChecker(BaseChecker):
18
+ """Check AWS costs against budget thresholds."""
19
+
20
+ check_type = "aws_cost"
21
+
22
+ # Cost thresholds - loaded from ops/config/budget.yaml if available, else use defaults
23
+ # Defaults match ops/scripts/infra/check-cost-spend-ceiling.sh
24
+ _budget_config = load_budget_config()
25
+ DAILY_THRESHOLD = _budget_config.get("daily_warn", 5.0) # Alert if daily spend exceeds this
26
+
27
+ # Instance allowlist loaded from settings (AWS_ALLOWED_INSTANCES env var)
28
+
29
+ async def check(self) -> CheckResult:
30
+ """Check AWS costs and resource compliance."""
31
+ cost_metrics: list[CostMetric] = []
32
+ findings: list[Finding] = []
33
+ errors: list[str] = []
34
+ metadata: dict = {}
35
+
36
+ # Check MTD costs
37
+ mtd_result = await self._get_mtd_cost()
38
+ if mtd_result.get("error"):
39
+ errors.append(mtd_result["error"])
40
+ else:
41
+ mtd_cost = mtd_result.get("cost", 0.0)
42
+ metadata["mtd_cost"] = mtd_cost
43
+
44
+ cost_metrics.append(
45
+ CostMetric(
46
+ service="aws",
47
+ period="monthly",
48
+ amount=mtd_cost,
49
+ )
50
+ )
51
+
52
+ # Check against ceiling (configurable via settings, with fallback to budget.yaml)
53
+ budget_config = load_budget_config()
54
+ monthly_ceiling = self.settings.aws_monthly_cost_ceiling
55
+ # Override with budget.yaml if it exists and setting is still default
56
+ if monthly_ceiling == 100.0 and budget_config.get("monthly_ceiling"):
57
+ monthly_ceiling = budget_config["monthly_ceiling"]
58
+ if mtd_cost > monthly_ceiling:
59
+ findings.append(
60
+ Finding(
61
+ severity=Severity.CRITICAL,
62
+ title=f"AWS monthly ceiling exceeded: ${mtd_cost:.2f}",
63
+ description=f"MTD spend ${mtd_cost:.2f} exceeds ceiling ${monthly_ceiling:.2f}",
64
+ resource="aws-cost",
65
+ remediation="Review and reduce AWS resource usage immediately, or update aws_monthly_cost_ceiling if this is expected",
66
+ )
67
+ )
68
+ elif mtd_cost > monthly_ceiling * 0.8:
69
+ findings.append(
70
+ Finding(
71
+ severity=Severity.WARNING,
72
+ title=f"AWS costs approaching ceiling: ${mtd_cost:.2f}",
73
+ description=f"MTD spend ${mtd_cost:.2f} is at {(mtd_cost / monthly_ceiling) * 100:.0f}% of ceiling ${monthly_ceiling:.2f}",
74
+ resource="aws-cost",
75
+ remediation="Monitor spending closely",
76
+ )
77
+ )
78
+
79
+ # Check yesterday's cost
80
+ yesterday_result = await self._get_yesterday_cost()
81
+ if yesterday_result.get("error"):
82
+ errors.append(yesterday_result["error"])
83
+ else:
84
+ yesterday_cost = yesterday_result.get("cost", 0.0)
85
+ metadata["yesterday_cost"] = yesterday_cost
86
+
87
+ cost_metrics.append(
88
+ CostMetric(
89
+ service="aws",
90
+ period="daily",
91
+ amount=yesterday_cost,
92
+ )
93
+ )
94
+
95
+ if yesterday_cost > self.DAILY_THRESHOLD:
96
+ findings.append(
97
+ Finding(
98
+ severity=Severity.WARNING,
99
+ title=f"High daily AWS spend: ${yesterday_cost:.2f}",
100
+ description=f"Yesterday's spend ${yesterday_cost:.2f} exceeds threshold ${self.DAILY_THRESHOLD:.2f}",
101
+ resource="aws-cost",
102
+ remediation="Review Cost Explorer for unexpected charges",
103
+ )
104
+ )
105
+
106
+ # Check S3-specific costs
107
+ s3_result = await self._get_s3_costs()
108
+ if s3_result.get("error"):
109
+ errors.append(s3_result["error"])
110
+ else:
111
+ s3_cost = s3_result.get("cost", 0.0)
112
+ metadata["s3_cost"] = s3_cost
113
+
114
+ cost_metrics.append(
115
+ CostMetric(
116
+ service="s3",
117
+ period="monthly",
118
+ amount=s3_cost,
119
+ )
120
+ )
121
+
122
+ # Alert if S3 costs exceed $10/month (unusual for our usage)
123
+ if s3_cost > 10.0:
124
+ findings.append(
125
+ Finding(
126
+ severity=Severity.WARNING,
127
+ title=f"High S3 costs: ${s3_cost:.2f}/month",
128
+ description=f"S3 MTD cost ${s3_cost:.2f} exceeds expected threshold. Review storage usage, lifecycle policies, and request patterns.",
129
+ resource="s3-cost",
130
+ remediation="Review S3 storage classes, lifecycle policies, and list_objects_v2 call frequency",
131
+ )
132
+ )
133
+
134
+ # Check running instances against allowlist
135
+ instances_result = await self._check_running_instances()
136
+ if instances_result.get("error"):
137
+ errors.append(instances_result["error"])
138
+ else:
139
+ running = instances_result.get("instances", [])
140
+ metadata["running_instances"] = running
141
+
142
+ allowed = set(self.settings.aws_allowed_instances)
143
+ unauthorized = [i for i in running if allowed and i not in allowed]
144
+ if unauthorized:
145
+ findings.append(
146
+ Finding(
147
+ severity=Severity.CRITICAL,
148
+ title=f"Unauthorized EC2 instances running: {unauthorized}",
149
+ description=f"Instances not in allowlist: {unauthorized}",
150
+ resource="ec2-instances",
151
+ remediation="Terminate unauthorized instances or add to allowlist",
152
+ )
153
+ )
154
+
155
+ return CheckResult(
156
+ check_type=self.check_type,
157
+ success=len(errors) == 0 and not any(f.severity == Severity.CRITICAL for f in findings),
158
+ findings=findings,
159
+ cost_metrics=cost_metrics,
160
+ errors=errors,
161
+ metadata=metadata,
162
+ )
163
+
164
+ async def _get_mtd_cost(self) -> dict:
165
+ """Get month-to-date AWS cost."""
166
+ try:
167
+ now = datetime.now(UTC)
168
+ start_date = now.replace(day=1).strftime("%Y-%m-%d")
169
+ end_date = now.strftime("%Y-%m-%d")
170
+
171
+ proc = await asyncio.create_subprocess_exec(
172
+ "aws",
173
+ "ce",
174
+ "get-cost-and-usage",
175
+ "--time-period",
176
+ f"Start={start_date},End={end_date}",
177
+ "--granularity",
178
+ "MONTHLY",
179
+ "--metrics",
180
+ "UnblendedCost",
181
+ "--output",
182
+ "json",
183
+ stdout=asyncio.subprocess.PIPE,
184
+ stderr=asyncio.subprocess.PIPE,
185
+ )
186
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
187
+
188
+ if proc.returncode != 0:
189
+ return {"error": f"aws ce command failed: {stderr.decode()}"}
190
+
191
+ data = json.loads(stdout.decode())
192
+ results = data.get("ResultsByTime", [])
193
+ if results:
194
+ amount = results[0].get("Total", {}).get("UnblendedCost", {}).get("Amount", "0")
195
+ return {"cost": float(amount)}
196
+ return {"cost": 0.0}
197
+
198
+ except TimeoutError:
199
+ return {"error": "AWS CLI timeout"}
200
+ except FileNotFoundError:
201
+ return {"error": "aws CLI not found"}
202
+ except Exception as e:
203
+ return {"error": str(e)}
204
+
205
+ async def _get_yesterday_cost(self) -> dict:
206
+ """Get yesterday's AWS cost."""
207
+ try:
208
+ now = datetime.now(UTC)
209
+ yesterday = now - timedelta(days=1)
210
+ start_date = yesterday.strftime("%Y-%m-%d")
211
+ end_date = now.strftime("%Y-%m-%d")
212
+
213
+ proc = await asyncio.create_subprocess_exec(
214
+ "aws",
215
+ "ce",
216
+ "get-cost-and-usage",
217
+ "--time-period",
218
+ f"Start={start_date},End={end_date}",
219
+ "--granularity",
220
+ "DAILY",
221
+ "--metrics",
222
+ "UnblendedCost",
223
+ "--output",
224
+ "json",
225
+ stdout=asyncio.subprocess.PIPE,
226
+ stderr=asyncio.subprocess.PIPE,
227
+ )
228
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
229
+
230
+ if proc.returncode != 0:
231
+ return {"error": f"aws ce command failed: {stderr.decode()}"}
232
+
233
+ data = json.loads(stdout.decode())
234
+ results = data.get("ResultsByTime", [])
235
+ if results:
236
+ amount = results[0].get("Total", {}).get("UnblendedCost", {}).get("Amount", "0")
237
+ return {"cost": float(amount)}
238
+ return {"cost": 0.0}
239
+
240
+ except TimeoutError:
241
+ return {"error": "AWS CLI timeout"}
242
+ except FileNotFoundError:
243
+ return {"error": "aws CLI not found"}
244
+ except Exception as e:
245
+ return {"error": str(e)}
246
+
247
+ async def _get_s3_costs(self) -> dict:
248
+ """Get S3-specific costs for current month."""
249
+ try:
250
+ now = datetime.now(UTC)
251
+ start_date = now.replace(day=1).strftime("%Y-%m-%d")
252
+ end_date = now.strftime("%Y-%m-%d")
253
+
254
+ proc = await asyncio.create_subprocess_exec(
255
+ "aws",
256
+ "ce",
257
+ "get-cost-and-usage",
258
+ "--time-period",
259
+ f"Start={start_date},End={end_date}",
260
+ "--granularity",
261
+ "MONTHLY",
262
+ "--metrics",
263
+ "UnblendedCost",
264
+ "--group-by",
265
+ "Type=DIMENSION,Key=SERVICE",
266
+ "--filter",
267
+ '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Simple Storage Service"]}}',
268
+ "--output",
269
+ "json",
270
+ stdout=asyncio.subprocess.PIPE,
271
+ stderr=asyncio.subprocess.PIPE,
272
+ )
273
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
274
+
275
+ if proc.returncode != 0:
276
+ return {"error": f"aws ce command failed: {stderr.decode()}"}
277
+
278
+ data = json.loads(stdout.decode())
279
+ results = data.get("ResultsByTime", [])
280
+ if results:
281
+ # Extract S3 cost from grouped results
282
+ groups = results[0].get("Groups", [])
283
+ for group in groups:
284
+ keys = group.get("Keys", [])
285
+ if any("Simple Storage Service" in k for k in keys):
286
+ amount = (
287
+ group.get("Metrics", {}).get("UnblendedCost", {}).get("Amount", "0")
288
+ )
289
+ return {"cost": float(amount)}
290
+ # If no S3 group found, cost is 0
291
+ return {"cost": 0.0}
292
+ return {"cost": 0.0}
293
+
294
+ except TimeoutError:
295
+ return {"error": "AWS CLI timeout"}
296
+ except FileNotFoundError:
297
+ return {"error": "aws CLI not found"}
298
+ except Exception as e:
299
+ return {"error": str(e)}
300
+
301
+ async def _check_running_instances(self) -> dict:
302
+ """Check which EC2 instances are running."""
303
+ try:
304
+ proc = await asyncio.create_subprocess_exec(
305
+ "aws",
306
+ "ec2",
307
+ "describe-instances",
308
+ "--filters",
309
+ "Name=instance-state-name,Values=running",
310
+ "--query",
311
+ "Reservations[].Instances[].Tags[?Key==`Name`].Value[]",
312
+ "--output",
313
+ "json",
314
+ stdout=asyncio.subprocess.PIPE,
315
+ stderr=asyncio.subprocess.PIPE,
316
+ )
317
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
318
+
319
+ if proc.returncode != 0:
320
+ return {"error": f"aws ec2 command failed: {stderr.decode()}"}
321
+
322
+ instances = json.loads(stdout.decode())
323
+ # AWS CLI returns a flat list of instance names
324
+ return {"instances": instances if isinstance(instances, list) else []}
325
+
326
+ except TimeoutError:
327
+ return {"error": "AWS CLI timeout"}
328
+ except FileNotFoundError:
329
+ return {"error": "aws CLI not found"}
330
+ except Exception as e:
331
+ return {"error": str(e)}
@@ -0,0 +1,284 @@
1
+ """AWS IAM security checker for satellite nodes."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from devguard.checkers.base import BaseChecker
12
+ from devguard.config import Settings
13
+ from devguard.models import CheckResult, Finding, Severity
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def load_iam_posture(path: Path | None = None) -> dict[str, Any]:
19
+ """Load IAM posture configuration from YAML file."""
20
+ if path is None:
21
+ from devguard.utils import get_iam_posture_path
22
+
23
+ path = get_iam_posture_path()
24
+ if path is None:
25
+ return {}
26
+
27
+ if path.exists():
28
+ try:
29
+ with open(path) as f:
30
+ return yaml.safe_load(f)
31
+ except Exception as e:
32
+ logger.warning(f"Failed to load IAM posture config: {e}")
33
+ return {}
34
+
35
+
36
+ class AWSIAMChecker(BaseChecker):
37
+ """Check AWS IAM posture for satellite nodes.
38
+
39
+ Loads configuration from ops/security/iam-posture.yaml which defines:
40
+ - Satellite nodes and their IAM roles
41
+ - Forbidden policy patterns
42
+ - Security rules to enforce
43
+ """
44
+
45
+ check_type = "aws_iam"
46
+
47
+ # Policies that should NEVER be attached to satellite nodes
48
+ FORBIDDEN_POLICIES = [
49
+ "AdministratorAccess",
50
+ "AmazonS3FullAccess",
51
+ "AmazonS3ReadOnlyAccess",
52
+ "PowerUserAccess",
53
+ "IAMFullAccess",
54
+ ]
55
+
56
+ def __init__(self, settings: Settings):
57
+ super().__init__(settings)
58
+ self.posture = load_iam_posture()
59
+ self._init_from_posture()
60
+
61
+ def _init_from_posture(self) -> None:
62
+ """Initialize checker configuration from posture YAML."""
63
+ self.satellite_nodes: dict[str, dict[str, str]] = {}
64
+
65
+ satellites = self.posture.get("satellite_nodes", {})
66
+ for node_name, node_config in satellites.items():
67
+ self.satellite_nodes[node_name] = {
68
+ "role": node_config.get("role", ""),
69
+ "instance_id": node_config.get("instance_id", ""),
70
+ "purpose": node_config.get("purpose", ""),
71
+ }
72
+
73
+ if not self.satellite_nodes:
74
+ logger.info("No satellite nodes in posture config -- IAM check will be a no-op")
75
+
76
+ async def check(self) -> CheckResult:
77
+ """Check IAM roles for security issues."""
78
+ findings: list[Finding] = []
79
+ errors: list[str] = []
80
+ metadata: dict[str, Any] = {
81
+ "nodes_checked": [],
82
+ "posture_config": bool(self.posture),
83
+ }
84
+
85
+ for node_name, node_config in self.satellite_nodes.items():
86
+ role_name = node_config["role"]
87
+ instance_id = node_config["instance_id"]
88
+
89
+ try:
90
+ # Get attached policies using async subprocess
91
+ policies, error = await self._run_aws_command(
92
+ [
93
+ "aws",
94
+ "iam",
95
+ "list-attached-role-policies",
96
+ "--role-name",
97
+ role_name,
98
+ "--query",
99
+ "AttachedPolicies[].PolicyName",
100
+ "--output",
101
+ "json",
102
+ ]
103
+ )
104
+
105
+ if error:
106
+ findings.append(
107
+ Finding(
108
+ severity=Severity.LOW,
109
+ title=f"Cannot check role: {role_name}",
110
+ description=f"Failed to query IAM for {node_name}: {error}",
111
+ resource=role_name,
112
+ remediation="Verify AWS CLI is configured and has iam:ListAttachedRolePolicies permission",
113
+ metadata={"node": node_name, "instance_id": instance_id},
114
+ )
115
+ )
116
+ continue
117
+
118
+ node_info = {
119
+ "node": node_name,
120
+ "role": role_name,
121
+ "instance_id": instance_id,
122
+ "policies": policies,
123
+ }
124
+
125
+ # Check for forbidden policies
126
+ for policy in policies:
127
+ if policy in self.FORBIDDEN_POLICIES:
128
+ findings.append(
129
+ Finding(
130
+ severity=Severity.CRITICAL,
131
+ title=f"Overly broad policy on {node_name}",
132
+ description=(
133
+ f"Role '{role_name}' has '{policy}' attached. "
134
+ f"This violates least-privilege principle for satellite nodes."
135
+ ),
136
+ resource=role_name,
137
+ remediation=f"Replace {policy} with a scoped custom policy (see ops/security/iam-posture.yaml)",
138
+ metadata={
139
+ "policy": policy,
140
+ "node": node_name,
141
+ "instance_id": instance_id,
142
+ },
143
+ )
144
+ )
145
+
146
+ # Check inline policies
147
+ inline_policies, _ = await self._run_aws_command(
148
+ [
149
+ "aws",
150
+ "iam",
151
+ "list-role-policies",
152
+ "--role-name",
153
+ role_name,
154
+ "--query",
155
+ "PolicyNames",
156
+ "--output",
157
+ "json",
158
+ ]
159
+ )
160
+
161
+ if inline_policies:
162
+ node_info["inline_policies"] = inline_policies
163
+
164
+ metadata["nodes_checked"].append(node_info)
165
+
166
+ except TimeoutError:
167
+ findings.append(
168
+ Finding(
169
+ severity=Severity.LOW,
170
+ title=f"Timeout checking role: {role_name}",
171
+ description=f"AWS IAM query timed out for {node_name}",
172
+ resource=role_name,
173
+ remediation="Check network connectivity and AWS CLI configuration",
174
+ metadata={"node": node_name},
175
+ )
176
+ )
177
+ except json.JSONDecodeError as e:
178
+ findings.append(
179
+ Finding(
180
+ severity=Severity.LOW,
181
+ title=f"Parse error for role: {role_name}",
182
+ description=f"Could not parse IAM response: {e}",
183
+ resource=role_name,
184
+ remediation="Verify AWS CLI output format",
185
+ metadata={"node": node_name},
186
+ )
187
+ )
188
+
189
+ # Check for credentials on nodes via SSM (if enabled)
190
+ if self.posture:
191
+ await self._check_node_credentials(findings, metadata)
192
+
193
+ # success = no critical findings
194
+ critical_count = sum(1 for f in findings if f.severity == Severity.CRITICAL)
195
+
196
+ return CheckResult(
197
+ check_type=self.check_type,
198
+ success=critical_count == 0,
199
+ findings=findings,
200
+ errors=errors,
201
+ metadata=metadata,
202
+ )
203
+
204
+ async def _run_aws_command(
205
+ self, cmd: list[str], timeout: float = 30.0
206
+ ) -> tuple[list[str], str | None]:
207
+ """Run an AWS CLI command asynchronously and return parsed JSON or error."""
208
+ proc = None
209
+ try:
210
+ proc = await asyncio.create_subprocess_exec(
211
+ *cmd,
212
+ stdout=asyncio.subprocess.PIPE,
213
+ stderr=asyncio.subprocess.PIPE,
214
+ )
215
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
216
+
217
+ if proc.returncode != 0:
218
+ return [], stderr.decode().strip()
219
+
220
+ return json.loads(stdout.decode().strip()), None
221
+
222
+ except TimeoutError:
223
+ if proc:
224
+ try:
225
+ proc.kill()
226
+ await proc.wait()
227
+ except ProcessLookupError:
228
+ pass
229
+ raise
230
+ except json.JSONDecodeError:
231
+ raise
232
+ except Exception as e:
233
+ return [], str(e)
234
+
235
+ async def _check_node_credentials(
236
+ self, findings: list[Finding], metadata: dict[str, Any]
237
+ ) -> None:
238
+ """Check satellite nodes for credential files via SSM."""
239
+ metadata["ssm_checks"] = {}
240
+
241
+ for node_name, node_config in self.satellite_nodes.items():
242
+ instance_id = node_config.get("instance_id")
243
+ if not instance_id:
244
+ continue
245
+
246
+ try:
247
+ # Send SSM command to check for credentials
248
+ cmd = [
249
+ "aws",
250
+ "ssm",
251
+ "send-command",
252
+ "--instance-ids",
253
+ instance_id,
254
+ "--document-name",
255
+ "AWS-RunShellScript",
256
+ "--parameters",
257
+ 'commands=["ls /root/.aws/credentials /home/*/.aws/credentials 2>/dev/null && echo FOUND || echo CLEAN"]',
258
+ "--query",
259
+ "Command.CommandId",
260
+ "--output",
261
+ "text",
262
+ ]
263
+
264
+ proc = await asyncio.create_subprocess_exec(
265
+ *cmd,
266
+ stdout=asyncio.subprocess.PIPE,
267
+ stderr=asyncio.subprocess.PIPE,
268
+ )
269
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
270
+
271
+ if proc.returncode != 0:
272
+ # SSM not available, skip but note it
273
+ metadata["ssm_checks"][node_name] = "unavailable"
274
+ continue
275
+
276
+ # Note: Full implementation would wait and get results
277
+ # For now, we record that check was attempted
278
+ metadata["ssm_checks"][node_name] = "initiated"
279
+
280
+ except TimeoutError:
281
+ metadata["ssm_checks"][node_name] = "timeout"
282
+ except Exception as e:
283
+ logger.debug(f"SSM check failed for {node_name}: {e}")
284
+ metadata["ssm_checks"][node_name] = "error"
@@ -0,0 +1,25 @@
1
+ """Base checker interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from devguard.config import Settings
6
+ from devguard.models import CheckResult
7
+
8
+
9
+ class BaseChecker(ABC):
10
+ """Base class for all checkers."""
11
+
12
+ def __init__(self, settings: Settings):
13
+ """Initialize checker with settings."""
14
+ self.settings = settings
15
+
16
+ @abstractmethod
17
+ async def check(self) -> CheckResult:
18
+ """Perform the check and return results."""
19
+ pass
20
+
21
+ @property
22
+ @abstractmethod
23
+ def check_type(self) -> str:
24
+ """Return the type of check this checker performs."""
25
+ pass