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,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
|