argus-cloud-optimizer 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.
- adapters/__init__.py +0 -0
- adapters/aws/__init__.py +0 -0
- adapters/aws/adapter.py +85 -0
- adapters/aws/auth.py +57 -0
- adapters/aws/cloudtrail.py +83 -0
- adapters/aws/cloudwatch.py +732 -0
- adapters/aws/config.py +9 -0
- adapters/aws/cost_explorer.py +116 -0
- adapters/aws/resource_explorer.py +186 -0
- adapters/aws/retry.py +55 -0
- adapters/azure/__init__.py +0 -0
- adapters/azure/activity_log.py +159 -0
- adapters/azure/adapter.py +117 -0
- adapters/azure/cost_management.py +125 -0
- adapters/azure/monitor.py +311 -0
- adapters/azure/resource_graph.py +113 -0
- adapters/azure/retry.py +57 -0
- adapters/base.py +105 -0
- adapters/gcp/__init__.py +0 -0
- adapters/gcp/adapter.py +86 -0
- adapters/gcp/asset_inventory.py +116 -0
- adapters/gcp/billing.py +118 -0
- adapters/gcp/cloud_logging.py +93 -0
- adapters/gcp/cloud_monitoring.py +276 -0
- adapters/gcp/retry.py +46 -0
- ai/__init__.py +0 -0
- ai/anthropic.py +174 -0
- ai/azure_openai.py +241 -0
- ai/base.py +78 -0
- ai/bedrock.py +169 -0
- ai/vertexai.py +234 -0
- argus_cloud_optimizer-0.2.0.dist-info/METADATA +433 -0
- argus_cloud_optimizer-0.2.0.dist-info/RECORD +62 -0
- argus_cloud_optimizer-0.2.0.dist-info/WHEEL +5 -0
- argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
- argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- argus_cloud_optimizer-0.2.0.dist-info/top_level.txt +4 -0
- core/__init__.py +0 -0
- core/__version__.py +1 -0
- core/agent/__init__.py +0 -0
- core/agent/loop.py +390 -0
- core/agent/prompts.py +317 -0
- core/config.py +235 -0
- core/log.py +69 -0
- core/models/__init__.py +0 -0
- core/models/finding.py +76 -0
- core/py.typed +0 -0
- core/reports/__init__.py +0 -0
- core/reports/comparison.py +49 -0
- core/reports/delivery.py +323 -0
- core/reports/export.py +111 -0
- core/reports/generator.py +168 -0
- core/reports/html.py +286 -0
- core/reports/multi_cloud.py +162 -0
- core/secrets.py +145 -0
- core/token_tracker.py +97 -0
- core/validation.py +214 -0
- entrypoints/__init__.py +0 -0
- entrypoints/aws_lambda.py +299 -0
- entrypoints/azure_function.py +257 -0
- entrypoints/cli.py +156 -0
- entrypoints/gcp_cloudrun.py +209 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Lambda entrypoint for Argus.
|
|
3
|
+
|
|
4
|
+
Environment variables (set in CloudFormation template or .env for local):
|
|
5
|
+
IGNORE_REGIONS Comma-separated regions to exclude from the scan
|
|
6
|
+
(default: empty = scan all)
|
|
7
|
+
PRIMARY_REGION AWS region for boto3 session and Bedrock calls (default: us-east-1)
|
|
8
|
+
DRY_RUN "true" to skip Slack post and S3 upload (default: false)
|
|
9
|
+
SLACK_WEBHOOK_URL Slack incoming webhook URL
|
|
10
|
+
AI_PROVIDER "bedrock" | "anthropic" (default: bedrock)
|
|
11
|
+
ANTHROPIC_API_KEY Required when AI_PROVIDER=anthropic
|
|
12
|
+
ACCOUNTS_MODE "single" | "multi" (default: single)
|
|
13
|
+
ACCOUNTS_CONFIG JSON array of account dicts when ACCOUNTS_MODE=multi
|
|
14
|
+
e.g. [{"id":"123","name":"prod","role_arn":"arn:..."}]
|
|
15
|
+
REPORT_S3_BUCKET S3 bucket name for saving full reports (JSON + HTML) (optional)
|
|
16
|
+
REPORT_URL_EXPIRY Pre-signed URL expiry in seconds (default: 604800 = 7 days)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import boto3
|
|
27
|
+
import structlog
|
|
28
|
+
from botocore.exceptions import ClientError
|
|
29
|
+
|
|
30
|
+
from adapters.aws.adapter import AWSAdapter
|
|
31
|
+
from core.agent.loop import AgentLoop
|
|
32
|
+
from core.log import configure_logging
|
|
33
|
+
from core.models.finding import ResourceFinding
|
|
34
|
+
from core.reports.comparison import compare_scans
|
|
35
|
+
from core.reports.delivery import (
|
|
36
|
+
notify_all,
|
|
37
|
+
save_reports_locally,
|
|
38
|
+
)
|
|
39
|
+
from core.reports.generator import build_report, build_slack_payload
|
|
40
|
+
from core.reports.html import build_html_report
|
|
41
|
+
from core.secrets import resolve_secrets
|
|
42
|
+
from core.validation import ConfigurationError, validate_environment
|
|
43
|
+
|
|
44
|
+
configure_logging()
|
|
45
|
+
logger = structlog.get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
|
|
49
|
+
"""Lambda entry point. Triggered by EventBridge on a schedule."""
|
|
50
|
+
cloud = "aws"
|
|
51
|
+
try:
|
|
52
|
+
resolve_secrets()
|
|
53
|
+
validate_environment(cloud)
|
|
54
|
+
except ConfigurationError as exc:
|
|
55
|
+
logger.error("startup_validation_failed", error=str(exc))
|
|
56
|
+
return {"statusCode": 500, "error": str(exc)}
|
|
57
|
+
|
|
58
|
+
ignore_regions = [
|
|
59
|
+
r.strip() for r in os.environ.get("IGNORE_REGIONS", "").split(",") if r.strip()
|
|
60
|
+
]
|
|
61
|
+
primary_region = os.environ.get("PRIMARY_REGION", "us-east-1")
|
|
62
|
+
accounts_mode = os.environ.get("ACCOUNTS_MODE", "single")
|
|
63
|
+
|
|
64
|
+
structlog.contextvars.bind_contextvars(cloud=cloud)
|
|
65
|
+
logger.info(
|
|
66
|
+
"scan_start",
|
|
67
|
+
ignore_regions=ignore_regions,
|
|
68
|
+
primary_region=primary_region,
|
|
69
|
+
mode=accounts_mode,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
ai_provider = _build_ai_provider()
|
|
73
|
+
|
|
74
|
+
if accounts_mode == "multi":
|
|
75
|
+
all_findings, executive_summary, account_ids, token_summary = (
|
|
76
|
+
_run_multi_account(ai_provider, ignore_regions, primary_region, cloud)
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
all_findings, executive_summary, account_ids, token_summary = (
|
|
80
|
+
_run_single_account(ai_provider, ignore_regions, primary_region, cloud)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
s3_bucket = os.environ.get("REPORT_S3_BUCKET", "").strip()
|
|
84
|
+
previous_report = _load_previous_report(cloud, s3_bucket)
|
|
85
|
+
all_findings, scan_diff = compare_scans(all_findings, previous_report)
|
|
86
|
+
|
|
87
|
+
report = build_report(
|
|
88
|
+
all_findings,
|
|
89
|
+
cloud=cloud,
|
|
90
|
+
executive_summary=executive_summary,
|
|
91
|
+
accounts_scanned=account_ids,
|
|
92
|
+
agent_input_tokens=token_summary.get("total_input_tokens", 0),
|
|
93
|
+
agent_output_tokens=token_summary.get("total_output_tokens", 0),
|
|
94
|
+
scan_diff=scan_diff,
|
|
95
|
+
)
|
|
96
|
+
report_url: str | None = None
|
|
97
|
+
if s3_bucket:
|
|
98
|
+
report_url = _save_reports_to_s3(report, s3_bucket)
|
|
99
|
+
else:
|
|
100
|
+
save_reports_locally(report)
|
|
101
|
+
|
|
102
|
+
structlog.contextvars.bind_contextvars(scan_id=report["scan_id"])
|
|
103
|
+
payload = build_slack_payload(report, report_url=report_url)
|
|
104
|
+
notify_all(payload)
|
|
105
|
+
|
|
106
|
+
logger.info(
|
|
107
|
+
"scan_complete",
|
|
108
|
+
findings=report["findings_count"],
|
|
109
|
+
total_waste_usd=round(report["total_estimated_waste_usd"], 2),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"statusCode": 200,
|
|
114
|
+
"scan_id": report["scan_id"],
|
|
115
|
+
"findings_count": report["findings_count"],
|
|
116
|
+
"total_estimated_waste_usd": report["total_estimated_waste_usd"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Scan runners
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_single_account(
|
|
126
|
+
ai_provider: Any,
|
|
127
|
+
ignore_regions: list[str],
|
|
128
|
+
primary_region: str,
|
|
129
|
+
cloud: str,
|
|
130
|
+
) -> tuple[list[ResourceFinding], str, list[str], dict]:
|
|
131
|
+
account_id = _get_current_account_id()
|
|
132
|
+
adapter = AWSAdapter.for_account(account=None, region=primary_region)
|
|
133
|
+
loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
|
|
134
|
+
findings, summary = loop.run(
|
|
135
|
+
cloud=cloud, ignore_regions=ignore_regions, accounts=[{"id": account_id}]
|
|
136
|
+
)
|
|
137
|
+
return findings, summary, [account_id], loop.tracker.summary()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_multi_account(
|
|
141
|
+
ai_provider: Any,
|
|
142
|
+
ignore_regions: list[str],
|
|
143
|
+
primary_region: str,
|
|
144
|
+
cloud: str,
|
|
145
|
+
) -> tuple[list[ResourceFinding], str, list[str], dict]:
|
|
146
|
+
raw = os.environ.get("ACCOUNTS_CONFIG", "[]")
|
|
147
|
+
try:
|
|
148
|
+
accounts: list[dict[str, Any]] = json.loads(raw)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
raise ValueError(f"ACCOUNTS_CONFIG is not valid JSON: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
if not accounts:
|
|
153
|
+
logger.warning(
|
|
154
|
+
"ACCOUNTS_MODE=multi but ACCOUNTS_CONFIG is empty "
|
|
155
|
+
"— falling back to single-account mode"
|
|
156
|
+
)
|
|
157
|
+
return _run_single_account(ai_provider, ignore_regions, primary_region, cloud)
|
|
158
|
+
|
|
159
|
+
all_findings: list[ResourceFinding] = []
|
|
160
|
+
all_summaries: list[str] = []
|
|
161
|
+
account_ids: list[str] = []
|
|
162
|
+
total_input = 0
|
|
163
|
+
total_output = 0
|
|
164
|
+
|
|
165
|
+
for account in accounts:
|
|
166
|
+
acct_id = account["id"]
|
|
167
|
+
acct_name = account.get("name", acct_id)
|
|
168
|
+
logger.info("scanning_account", account_id=acct_id, account_name=acct_name)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
adapter = AWSAdapter.for_account(account=account, region=primary_region)
|
|
172
|
+
loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
|
|
173
|
+
findings, summary = loop.run(
|
|
174
|
+
cloud=cloud,
|
|
175
|
+
ignore_regions=ignore_regions,
|
|
176
|
+
accounts=[{"id": acct_id, "name": acct_name}],
|
|
177
|
+
)
|
|
178
|
+
all_findings.extend(findings)
|
|
179
|
+
all_summaries.append(f"[{acct_name}] {summary}")
|
|
180
|
+
account_ids.append(acct_id)
|
|
181
|
+
total_input += loop.tracker.total_input_tokens
|
|
182
|
+
total_output += loop.tracker.total_output_tokens
|
|
183
|
+
except (PermissionError, ClientError) as exc:
|
|
184
|
+
logger.error("account_scan_failed", account_id=acct_id, error=str(exc))
|
|
185
|
+
|
|
186
|
+
executive_summary = (
|
|
187
|
+
" ".join(all_summaries) if all_summaries else "No findings across all accounts."
|
|
188
|
+
)
|
|
189
|
+
token_summary = {
|
|
190
|
+
"total_input_tokens": total_input,
|
|
191
|
+
"total_output_tokens": total_output,
|
|
192
|
+
}
|
|
193
|
+
return all_findings, executive_summary, account_ids, token_summary
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Helpers
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _build_ai_provider() -> Any:
|
|
202
|
+
provider_name = os.environ.get("AI_PROVIDER", "bedrock").lower()
|
|
203
|
+
if provider_name == "anthropic":
|
|
204
|
+
from ai.anthropic import AnthropicProvider
|
|
205
|
+
|
|
206
|
+
return AnthropicProvider()
|
|
207
|
+
from ai.bedrock import BedrockProvider
|
|
208
|
+
|
|
209
|
+
return BedrockProvider()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _get_current_account_id() -> str:
|
|
213
|
+
try:
|
|
214
|
+
sts = boto3.client("sts")
|
|
215
|
+
return str(sts.get_caller_identity()["Account"])
|
|
216
|
+
except ClientError as exc:
|
|
217
|
+
logger.warning("sts_get_account_id_failed", error=str(exc))
|
|
218
|
+
return "unknown"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_previous_report(cloud: str, s3_bucket: str) -> dict[str, Any] | None:
|
|
222
|
+
"""Load the most recent previous report from S3 or local storage."""
|
|
223
|
+
if s3_bucket:
|
|
224
|
+
try:
|
|
225
|
+
s3 = boto3.client("s3")
|
|
226
|
+
resp = s3.list_objects_v2(
|
|
227
|
+
Bucket=s3_bucket,
|
|
228
|
+
Prefix=f"reports/{cloud}/",
|
|
229
|
+
MaxKeys=1000,
|
|
230
|
+
)
|
|
231
|
+
json_keys = sorted(
|
|
232
|
+
(
|
|
233
|
+
obj["Key"]
|
|
234
|
+
for obj in resp.get("Contents", [])
|
|
235
|
+
if obj["Key"].endswith(".json")
|
|
236
|
+
),
|
|
237
|
+
reverse=True,
|
|
238
|
+
)
|
|
239
|
+
if json_keys:
|
|
240
|
+
body = s3.get_object(Bucket=s3_bucket, Key=json_keys[0])["Body"].read()
|
|
241
|
+
return json.loads(body)
|
|
242
|
+
except ClientError as exc:
|
|
243
|
+
logger.warning("previous_report_load_failed", error=str(exc))
|
|
244
|
+
else:
|
|
245
|
+
return _load_previous_report_local(cloud)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _load_previous_report_local(cloud: str) -> dict[str, Any] | None:
|
|
250
|
+
"""Load the most recent previous report from local_reports/."""
|
|
251
|
+
from pathlib import Path
|
|
252
|
+
|
|
253
|
+
base = Path(os.environ.get("LOCAL_REPORT_DIR", "local_reports")) / cloud
|
|
254
|
+
if not base.exists():
|
|
255
|
+
return None
|
|
256
|
+
json_files = sorted(base.rglob("*.json"), reverse=True)
|
|
257
|
+
if json_files:
|
|
258
|
+
return json.loads(json_files[0].read_text(encoding="utf-8"))
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _save_reports_to_s3(report: dict[str, Any], bucket: str) -> str | None:
|
|
263
|
+
"""Upload JSON + HTML reports to S3. Returns a pre-signed URL for the HTML."""
|
|
264
|
+
now = datetime.now(tz=timezone.utc)
|
|
265
|
+
prefix = f"reports/{report['cloud']}/{now.strftime('%Y/%m/%d')}/{report['scan_id']}"
|
|
266
|
+
json_key = f"{prefix}.json"
|
|
267
|
+
html_key = f"{prefix}.html"
|
|
268
|
+
expiry = int(os.environ.get("REPORT_URL_EXPIRY", "604800"))
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
s3 = boto3.client("s3")
|
|
272
|
+
|
|
273
|
+
s3.put_object(
|
|
274
|
+
Bucket=bucket,
|
|
275
|
+
Key=json_key,
|
|
276
|
+
Body=json.dumps(report, indent=2, default=str).encode("utf-8"),
|
|
277
|
+
ContentType="application/json",
|
|
278
|
+
)
|
|
279
|
+
logger.info("json_report_saved", location=f"s3://{bucket}/{json_key}")
|
|
280
|
+
|
|
281
|
+
html_body = build_html_report(report).encode("utf-8")
|
|
282
|
+
s3.put_object(
|
|
283
|
+
Bucket=bucket,
|
|
284
|
+
Key=html_key,
|
|
285
|
+
Body=html_body,
|
|
286
|
+
ContentType="text/html; charset=utf-8",
|
|
287
|
+
)
|
|
288
|
+
logger.info("html_report_saved", location=f"s3://{bucket}/{html_key}")
|
|
289
|
+
|
|
290
|
+
url: str = s3.generate_presigned_url(
|
|
291
|
+
"get_object",
|
|
292
|
+
Params={"Bucket": bucket, "Key": html_key},
|
|
293
|
+
ExpiresIn=expiry,
|
|
294
|
+
)
|
|
295
|
+
logger.info("presigned_url_generated", expires_in_seconds=expiry)
|
|
296
|
+
return url
|
|
297
|
+
except ClientError as exc:
|
|
298
|
+
logger.error("s3_upload_failed", error=str(exc))
|
|
299
|
+
return None
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Azure Function entrypoint for Argus (Timer trigger).
|
|
3
|
+
|
|
4
|
+
The function is triggered on a schedule by Azure Function's built-in timer trigger.
|
|
5
|
+
Deploy with the Bicep template in deploy/azure/function-app.bicep.
|
|
6
|
+
|
|
7
|
+
Environment variables (set in Azure Function App Configuration):
|
|
8
|
+
AZURE_SUBSCRIPTION_IDS Comma-separated subscription IDs to scan (required)
|
|
9
|
+
AZURE_LOG_ANALYTICS_WORKSPACE_ID Log Analytics workspace ID
|
|
10
|
+
(optional — for Activity Log KQL)
|
|
11
|
+
AI_PROVIDER "anthropic" | "azure_openai" (default: azure_openai)
|
|
12
|
+
ANTHROPIC_API_KEY Required when AI_PROVIDER=anthropic
|
|
13
|
+
AZURE_OPENAI_ENDPOINT Required when AI_PROVIDER=azure_openai
|
|
14
|
+
AZURE_OPENAI_DEPLOYMENT GPT-4o deployment name (default: gpt-4o)
|
|
15
|
+
IGNORE_REGIONS Comma-separated regions to exclude (default: empty)
|
|
16
|
+
SLACK_WEBHOOK_URL Slack incoming webhook URL
|
|
17
|
+
DRY_RUN "true" to skip Slack post (default: false)
|
|
18
|
+
REPORT_STORAGE_ACCOUNT Azure Storage account name for full reports (optional)
|
|
19
|
+
REPORT_STORAGE_CONTAINER Blob container name (default: argus-reports)
|
|
20
|
+
REPORT_URL_EXPIRY SAS URL expiry in seconds (default: 604800 = 7 days)
|
|
21
|
+
LOG_LEVEL DEBUG | INFO | WARNING | ERROR (default: INFO)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import structlog
|
|
32
|
+
|
|
33
|
+
from adapters.azure.adapter import AzureAdapter
|
|
34
|
+
from core.agent.loop import AgentLoop
|
|
35
|
+
from core.log import configure_logging
|
|
36
|
+
from core.reports.comparison import compare_scans
|
|
37
|
+
from core.reports.delivery import (
|
|
38
|
+
notify_all,
|
|
39
|
+
save_reports_locally,
|
|
40
|
+
)
|
|
41
|
+
from core.reports.generator import build_report, build_slack_payload
|
|
42
|
+
from core.reports.html import build_html_report
|
|
43
|
+
from core.secrets import resolve_secrets
|
|
44
|
+
from core.validation import ConfigurationError, validate_environment
|
|
45
|
+
|
|
46
|
+
configure_logging()
|
|
47
|
+
logger = structlog.get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(mytimer: Any) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Azure Function timer trigger entry point.
|
|
53
|
+
|
|
54
|
+
`mytimer` is an azure.functions.TimerRequest object injected by the runtime.
|
|
55
|
+
The function name and schedule are defined in function.json (generated by
|
|
56
|
+
the Bicep template / func CLI).
|
|
57
|
+
"""
|
|
58
|
+
if mytimer and mytimer.past_due:
|
|
59
|
+
logger.warning("Timer is past due — running scan now")
|
|
60
|
+
|
|
61
|
+
cloud = "azure"
|
|
62
|
+
try:
|
|
63
|
+
resolve_secrets()
|
|
64
|
+
validate_environment(cloud)
|
|
65
|
+
except ConfigurationError as exc:
|
|
66
|
+
logger.error("startup_validation_failed", error=str(exc))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
ignore_regions = [
|
|
70
|
+
r.strip() for r in os.environ.get("IGNORE_REGIONS", "").split(",") if r.strip()
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
subscription_ids_raw = os.environ.get("AZURE_SUBSCRIPTION_IDS", "").strip()
|
|
74
|
+
|
|
75
|
+
subscription_ids = [s.strip() for s in subscription_ids_raw.split(",") if s.strip()]
|
|
76
|
+
|
|
77
|
+
structlog.contextvars.bind_contextvars(
|
|
78
|
+
cloud=cloud, account_id=",".join(subscription_ids)
|
|
79
|
+
)
|
|
80
|
+
logger.info(
|
|
81
|
+
"scan_start", subscriptions=subscription_ids, ignore_regions=ignore_regions
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
ai_provider = _build_ai_provider()
|
|
85
|
+
adapter = AzureAdapter.from_env()
|
|
86
|
+
|
|
87
|
+
loop = AgentLoop(ai_provider=ai_provider, cloud_adapter=adapter)
|
|
88
|
+
findings, executive_summary = loop.run(
|
|
89
|
+
cloud=cloud,
|
|
90
|
+
ignore_regions=ignore_regions,
|
|
91
|
+
accounts=[{"id": sid, "name": sid} for sid in subscription_ids],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
storage_account = os.environ.get("REPORT_STORAGE_ACCOUNT", "").strip()
|
|
95
|
+
previous_report = _load_previous_report(cloud, storage_account)
|
|
96
|
+
findings, scan_diff = compare_scans(findings, previous_report)
|
|
97
|
+
|
|
98
|
+
token_summary = loop.tracker.summary()
|
|
99
|
+
report = build_report(
|
|
100
|
+
findings,
|
|
101
|
+
cloud=cloud,
|
|
102
|
+
executive_summary=executive_summary,
|
|
103
|
+
accounts_scanned=subscription_ids,
|
|
104
|
+
agent_input_tokens=token_summary["total_input_tokens"],
|
|
105
|
+
agent_output_tokens=token_summary["total_output_tokens"],
|
|
106
|
+
scan_diff=scan_diff,
|
|
107
|
+
)
|
|
108
|
+
report_url: str | None = None
|
|
109
|
+
if storage_account:
|
|
110
|
+
report_url = _save_reports_to_blob(report, storage_account)
|
|
111
|
+
else:
|
|
112
|
+
save_reports_locally(report)
|
|
113
|
+
|
|
114
|
+
structlog.contextvars.bind_contextvars(scan_id=report["scan_id"])
|
|
115
|
+
payload = build_slack_payload(report, report_url=report_url)
|
|
116
|
+
notify_all(payload)
|
|
117
|
+
|
|
118
|
+
logger.info(
|
|
119
|
+
"scan_complete",
|
|
120
|
+
findings=report["findings_count"],
|
|
121
|
+
total_waste_usd=round(report["total_estimated_waste_usd"], 2),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_ai_provider() -> Any:
|
|
126
|
+
provider_name = os.environ.get("AI_PROVIDER", "azure_openai").lower()
|
|
127
|
+
if provider_name == "anthropic":
|
|
128
|
+
from ai.anthropic import AnthropicProvider
|
|
129
|
+
|
|
130
|
+
return AnthropicProvider()
|
|
131
|
+
from ai.azure_openai import AzureOpenAIProvider
|
|
132
|
+
|
|
133
|
+
return AzureOpenAIProvider()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _load_previous_report(cloud: str, storage_account: str) -> dict[str, Any] | None:
|
|
137
|
+
"""Load the most recent previous report from Azure Blob or local storage."""
|
|
138
|
+
if storage_account:
|
|
139
|
+
try:
|
|
140
|
+
from azure.identity import (
|
|
141
|
+
DefaultAzureCredential, # type: ignore[import-untyped]
|
|
142
|
+
)
|
|
143
|
+
from azure.storage.blob import (
|
|
144
|
+
BlobServiceClient, # type: ignore[import-untyped]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
container = os.environ.get("REPORT_STORAGE_CONTAINER", "argus-reports")
|
|
148
|
+
credential = DefaultAzureCredential()
|
|
149
|
+
account_url = f"https://{storage_account}.blob.core.windows.net"
|
|
150
|
+
client = BlobServiceClient(account_url=account_url, credential=credential)
|
|
151
|
+
container_client = client.get_container_client(container)
|
|
152
|
+
blobs = sorted(
|
|
153
|
+
(
|
|
154
|
+
b.name
|
|
155
|
+
for b in container_client.list_blobs(prefix=f"reports/{cloud}/")
|
|
156
|
+
if b.name.endswith(".json")
|
|
157
|
+
),
|
|
158
|
+
reverse=True,
|
|
159
|
+
)
|
|
160
|
+
if blobs:
|
|
161
|
+
data = container_client.download_blob(blobs[0]).readall()
|
|
162
|
+
return json.loads(data)
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
logger.warning("previous_report_load_failed", error=str(exc))
|
|
165
|
+
else:
|
|
166
|
+
from pathlib import Path
|
|
167
|
+
|
|
168
|
+
base = Path(os.environ.get("LOCAL_REPORT_DIR", "local_reports")) / cloud
|
|
169
|
+
if base.exists():
|
|
170
|
+
json_files = sorted(base.rglob("*.json"), reverse=True)
|
|
171
|
+
if json_files:
|
|
172
|
+
return json.loads(json_files[0].read_text(encoding="utf-8"))
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _save_reports_to_blob(report: dict[str, Any], storage_account: str) -> str | None:
|
|
177
|
+
"""Upload JSON + HTML reports to Azure Blob. Returns a SAS URL for the HTML."""
|
|
178
|
+
try:
|
|
179
|
+
from azure.core.exceptions import ( # type: ignore[import-untyped]
|
|
180
|
+
AzureError,
|
|
181
|
+
ResourceExistsError,
|
|
182
|
+
)
|
|
183
|
+
from azure.identity import (
|
|
184
|
+
DefaultAzureCredential, # type: ignore[import-untyped]
|
|
185
|
+
)
|
|
186
|
+
from azure.storage.blob import ( # type: ignore[import-untyped]
|
|
187
|
+
BlobSasPermissions,
|
|
188
|
+
BlobServiceClient,
|
|
189
|
+
generate_blob_sas,
|
|
190
|
+
)
|
|
191
|
+
except ImportError:
|
|
192
|
+
logger.error(
|
|
193
|
+
"azure-storage-blob or azure-identity is not installed "
|
|
194
|
+
"— skipping Blob upload. "
|
|
195
|
+
"Run: pip install azure-storage-blob azure-identity"
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
container = os.environ.get("REPORT_STORAGE_CONTAINER", "argus-reports")
|
|
200
|
+
expiry_seconds = int(os.environ.get("REPORT_URL_EXPIRY", "604800"))
|
|
201
|
+
now = datetime.now(tz=timezone.utc)
|
|
202
|
+
prefix = f"reports/{report['cloud']}/{now.strftime('%Y/%m/%d')}/{report['scan_id']}"
|
|
203
|
+
json_key = f"{prefix}.json"
|
|
204
|
+
html_key = f"{prefix}.html"
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
credential = DefaultAzureCredential()
|
|
208
|
+
account_url = f"https://{storage_account}.blob.core.windows.net"
|
|
209
|
+
client = BlobServiceClient(account_url=account_url, credential=credential)
|
|
210
|
+
container_client = client.get_container_client(container)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
container_client.create_container()
|
|
214
|
+
except ResourceExistsError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
container_client.upload_blob(
|
|
218
|
+
json_key,
|
|
219
|
+
json.dumps(report, indent=2, default=str).encode("utf-8"),
|
|
220
|
+
content_settings={"content_type": "application/json"},
|
|
221
|
+
overwrite=True,
|
|
222
|
+
)
|
|
223
|
+
logger.info(
|
|
224
|
+
"json_report_saved",
|
|
225
|
+
location=f"{storage_account}/{container}/{json_key}",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
container_client.upload_blob(
|
|
229
|
+
html_key,
|
|
230
|
+
build_html_report(report).encode("utf-8"),
|
|
231
|
+
content_settings={"content_type": "text/html; charset=utf-8"},
|
|
232
|
+
overwrite=True,
|
|
233
|
+
)
|
|
234
|
+
logger.info(
|
|
235
|
+
"html_report_saved",
|
|
236
|
+
location=f"{storage_account}/{container}/{html_key}",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# SAS token scoped to just this blob
|
|
240
|
+
user_delegation_key = client.get_user_delegation_key(
|
|
241
|
+
key_start_time=now,
|
|
242
|
+
key_expiry_time=now + timedelta(seconds=expiry_seconds),
|
|
243
|
+
)
|
|
244
|
+
sas_token = generate_blob_sas(
|
|
245
|
+
account_name=storage_account,
|
|
246
|
+
container_name=container,
|
|
247
|
+
blob_name=html_key,
|
|
248
|
+
user_delegation_key=user_delegation_key,
|
|
249
|
+
permission=BlobSasPermissions(read=True),
|
|
250
|
+
expiry=now + timedelta(seconds=expiry_seconds),
|
|
251
|
+
)
|
|
252
|
+
url = f"{account_url}/{container}/{html_key}?{sas_token}"
|
|
253
|
+
logger.info("sas_url_generated", expires_in_seconds=expiry_seconds)
|
|
254
|
+
return url
|
|
255
|
+
except AzureError as exc:
|
|
256
|
+
logger.error("blob_upload_failed", error=str(exc))
|
|
257
|
+
return None
|