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
core/secrets.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secret manager integration — resolve secret references in environment variables.
|
|
3
|
+
|
|
4
|
+
If an env var's value matches a secret reference pattern, the real value is
|
|
5
|
+
fetched from the corresponding cloud secret manager and the env var is updated
|
|
6
|
+
in-place before the config layer reads it.
|
|
7
|
+
|
|
8
|
+
Supported patterns:
|
|
9
|
+
arn:aws:secretsmanager:<region>:<account>:secret:<name>
|
|
10
|
+
→ AWS Secrets Manager
|
|
11
|
+
gcp-secret://<project>/<secret-name>[/<version>]
|
|
12
|
+
→ GCP Secret Manager
|
|
13
|
+
akv://<vault-name>/<secret-name>
|
|
14
|
+
→ Azure Key Vault
|
|
15
|
+
|
|
16
|
+
Call ``resolve_secrets()`` once at startup, before ``validate_environment()``.
|
|
17
|
+
It is safe to call when no secret references exist — it's a no-op.
|
|
18
|
+
|
|
19
|
+
This module lives in core/ but imports cloud SDKs lazily inside the resolver
|
|
20
|
+
functions (only when a matching reference is found). If the required SDK is
|
|
21
|
+
not installed, a clear error is raised.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
import structlog
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
_SECRET_VARS = (
|
|
34
|
+
"ANTHROPIC_API_KEY",
|
|
35
|
+
"SLACK_WEBHOOK_URL",
|
|
36
|
+
"TEAMS_WEBHOOK_URL",
|
|
37
|
+
"WEBHOOK_URL",
|
|
38
|
+
"AZURE_OPENAI_API_KEY",
|
|
39
|
+
"AZURE_OPENAI_ENDPOINT",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_AWS_ARN_PATTERN = re.compile(
|
|
43
|
+
r"^arn:aws:secretsmanager:[\w-]+:\d+:secret:.+"
|
|
44
|
+
)
|
|
45
|
+
_GCP_PATTERN = re.compile(r"^gcp-secret://([^/]+)/([^/]+)(?:/([^/]+))?$")
|
|
46
|
+
_AKV_PATTERN = re.compile(r"^akv://([^/]+)/(.+)$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_secrets() -> None:
|
|
50
|
+
"""
|
|
51
|
+
Scan secret-eligible env vars for reference patterns and resolve them.
|
|
52
|
+
|
|
53
|
+
Updates ``os.environ`` in-place so downstream code (config layer,
|
|
54
|
+
validation, providers) sees the real values transparently.
|
|
55
|
+
"""
|
|
56
|
+
for var in _SECRET_VARS:
|
|
57
|
+
value = os.environ.get(var, "")
|
|
58
|
+
if not value:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
resolved = _try_resolve(var, value)
|
|
62
|
+
if resolved is not None:
|
|
63
|
+
os.environ[var] = resolved
|
|
64
|
+
logger.info("secret_resolved", var=var)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _try_resolve(var: str, value: str) -> str | None:
|
|
68
|
+
"""Return the resolved secret value, or None if the value is not a reference."""
|
|
69
|
+
if _AWS_ARN_PATTERN.match(value):
|
|
70
|
+
return _resolve_aws(var, value)
|
|
71
|
+
match = _GCP_PATTERN.match(value)
|
|
72
|
+
if match:
|
|
73
|
+
project, name, version = match.groups()
|
|
74
|
+
return _resolve_gcp(var, project, name, version or "latest")
|
|
75
|
+
match = _AKV_PATTERN.match(value)
|
|
76
|
+
if match:
|
|
77
|
+
vault, name = match.groups()
|
|
78
|
+
return _resolve_azure(var, vault, name)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_aws(var: str, arn: str) -> str:
|
|
83
|
+
try:
|
|
84
|
+
import boto3
|
|
85
|
+
from botocore.exceptions import ClientError
|
|
86
|
+
except ImportError:
|
|
87
|
+
raise ImportError(
|
|
88
|
+
f"{var} references AWS Secrets Manager ({arn}) but boto3 is not "
|
|
89
|
+
"installed. Install with: pip install boto3"
|
|
90
|
+
) from None
|
|
91
|
+
|
|
92
|
+
region = arn.split(":")[3]
|
|
93
|
+
client = boto3.client("secretsmanager", region_name=region)
|
|
94
|
+
try:
|
|
95
|
+
resp = client.get_secret_value(SecretId=arn)
|
|
96
|
+
except ClientError as exc:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"Failed to resolve {var} from AWS Secrets Manager: {exc}"
|
|
99
|
+
) from exc
|
|
100
|
+
return resp["SecretString"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_gcp(var: str, project: str, name: str, version: str) -> str:
|
|
104
|
+
try:
|
|
105
|
+
from google.cloud import secretmanager
|
|
106
|
+
except ImportError:
|
|
107
|
+
raise ImportError(
|
|
108
|
+
f"{var} references GCP Secret Manager "
|
|
109
|
+
f"(gcp-secret://{project}/{name}/{version}) but "
|
|
110
|
+
"google-cloud-secret-manager is not installed. "
|
|
111
|
+
"Install with: pip install google-cloud-secret-manager"
|
|
112
|
+
) from None
|
|
113
|
+
|
|
114
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
115
|
+
resource = f"projects/{project}/secrets/{name}/versions/{version}"
|
|
116
|
+
try:
|
|
117
|
+
resp = client.access_secret_version(request={"name": resource})
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
f"Failed to resolve {var} from GCP Secret Manager: {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
return resp.payload.data.decode("utf-8")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_azure(var: str, vault_name: str, secret_name: str) -> str:
|
|
126
|
+
try:
|
|
127
|
+
from azure.identity import DefaultAzureCredential
|
|
128
|
+
from azure.keyvault.secrets import SecretClient
|
|
129
|
+
except ImportError:
|
|
130
|
+
raise ImportError(
|
|
131
|
+
f"{var} references Azure Key Vault "
|
|
132
|
+
f"(akv://{vault_name}/{secret_name}) but azure-keyvault-secrets "
|
|
133
|
+
"is not installed. Install with: pip install azure-keyvault-secrets "
|
|
134
|
+
"azure-identity"
|
|
135
|
+
) from None
|
|
136
|
+
|
|
137
|
+
vault_url = f"https://{vault_name}.vault.azure.net"
|
|
138
|
+
client = SecretClient(vault_url=vault_url, credential=DefaultAzureCredential())
|
|
139
|
+
try:
|
|
140
|
+
secret = client.get_secret(secret_name)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
f"Failed to resolve {var} from Azure Key Vault: {exc}"
|
|
144
|
+
) from exc
|
|
145
|
+
return secret.value
|
core/token_tracker.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM token and cost tracking with hard budget enforcement.
|
|
3
|
+
|
|
4
|
+
Tracks cumulative input/output tokens across agent iterations and estimates
|
|
5
|
+
USD cost using per-provider pricing. When ``LLM_BUDGET_USD`` is exceeded,
|
|
6
|
+
raises ``BudgetExceededError`` so the agent loop can abort gracefully and
|
|
7
|
+
still return partial findings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Per-million-token pricing (input, output) by provider.
|
|
19
|
+
# Updated 2025-05 — check provider pricing pages for current rates.
|
|
20
|
+
_PRICING: dict[str, tuple[float, float]] = {
|
|
21
|
+
"anthropic": (3.0, 15.0),
|
|
22
|
+
"bedrock": (3.0, 15.0),
|
|
23
|
+
"vertexai": (1.25, 5.0),
|
|
24
|
+
"azure_openai": (2.50, 10.0),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_DEFAULT_PRICING = (3.0, 15.0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BudgetExceededError(Exception):
|
|
31
|
+
"""Raised when cumulative LLM cost exceeds the configured budget."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, spent_usd: float, budget_usd: float) -> None:
|
|
34
|
+
self.spent_usd = spent_usd
|
|
35
|
+
self.budget_usd = budget_usd
|
|
36
|
+
super().__init__(
|
|
37
|
+
f"LLM budget exceeded: ${spent_usd:.4f} spent "
|
|
38
|
+
f"(budget: ${budget_usd:.2f})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TokenTracker:
|
|
44
|
+
"""Accumulates token usage and enforces a hard USD budget."""
|
|
45
|
+
|
|
46
|
+
budget_usd: float
|
|
47
|
+
provider: str = "anthropic"
|
|
48
|
+
|
|
49
|
+
total_input_tokens: int = field(default=0, init=False)
|
|
50
|
+
total_output_tokens: int = field(default=0, init=False)
|
|
51
|
+
iteration_count: int = field(default=0, init=False)
|
|
52
|
+
_per_iteration: list[dict[str, int]] = field(default_factory=list, init=False)
|
|
53
|
+
|
|
54
|
+
def record(self, input_tokens: int, output_tokens: int) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Record tokens from one AI call and check the budget.
|
|
57
|
+
|
|
58
|
+
Raises ``BudgetExceededError`` if cumulative cost exceeds budget.
|
|
59
|
+
"""
|
|
60
|
+
self.total_input_tokens += input_tokens
|
|
61
|
+
self.total_output_tokens += output_tokens
|
|
62
|
+
self.iteration_count += 1
|
|
63
|
+
self._per_iteration.append(
|
|
64
|
+
{"input": input_tokens, "output": output_tokens}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
spent = self.estimated_cost_usd
|
|
68
|
+
logger.info(
|
|
69
|
+
"token_usage",
|
|
70
|
+
iteration=self.iteration_count,
|
|
71
|
+
input_tokens=input_tokens,
|
|
72
|
+
output_tokens=output_tokens,
|
|
73
|
+
cumulative_input=self.total_input_tokens,
|
|
74
|
+
cumulative_output=self.total_output_tokens,
|
|
75
|
+
spent_usd=round(spent, 4),
|
|
76
|
+
budget_usd=self.budget_usd,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if self.budget_usd > 0 and spent > self.budget_usd:
|
|
80
|
+
raise BudgetExceededError(round(spent, 4), self.budget_usd)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def estimated_cost_usd(self) -> float:
|
|
84
|
+
input_rate, output_rate = _PRICING.get(self.provider, _DEFAULT_PRICING)
|
|
85
|
+
cost = (self.total_input_tokens / 1_000_000 * input_rate) + (
|
|
86
|
+
self.total_output_tokens / 1_000_000 * output_rate
|
|
87
|
+
)
|
|
88
|
+
return round(cost, 4)
|
|
89
|
+
|
|
90
|
+
def summary(self) -> dict[str, float | int]:
|
|
91
|
+
return {
|
|
92
|
+
"total_input_tokens": self.total_input_tokens,
|
|
93
|
+
"total_output_tokens": self.total_output_tokens,
|
|
94
|
+
"iterations": self.iteration_count,
|
|
95
|
+
"estimated_cost_usd": self.estimated_cost_usd,
|
|
96
|
+
"budget_usd": self.budget_usd,
|
|
97
|
+
}
|
core/validation.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Startup environment validation.
|
|
3
|
+
|
|
4
|
+
Called once at the top of each entrypoint before any cloud API calls are made.
|
|
5
|
+
Raises ConfigurationError with a clear, actionable message if required env vars
|
|
6
|
+
are missing or malformed. This prevents wasting a 15-minute scan that would
|
|
7
|
+
fail at the very end due to a missing credential.
|
|
8
|
+
|
|
9
|
+
No cloud SDK imports here — this is core/ and must stay cloud-free.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.parse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigurationError(Exception):
|
|
20
|
+
"""Raised when the environment is misconfigured at startup."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_environment(cloud: str) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Validate all required environment variables for the given cloud.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
cloud: "aws" | "gcp" | "azure"
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ConfigurationError: with a human-readable message describing every
|
|
32
|
+
problem found (not just the first one).
|
|
33
|
+
"""
|
|
34
|
+
errors: list[str] = []
|
|
35
|
+
|
|
36
|
+
_check_ai_provider(errors)
|
|
37
|
+
_check_slack(errors)
|
|
38
|
+
|
|
39
|
+
if cloud == "aws":
|
|
40
|
+
_check_aws(errors)
|
|
41
|
+
elif cloud == "gcp":
|
|
42
|
+
_check_gcp(errors)
|
|
43
|
+
elif cloud == "azure":
|
|
44
|
+
_check_azure(errors)
|
|
45
|
+
|
|
46
|
+
if errors:
|
|
47
|
+
bullet_list = "\n".join(f" • {e}" for e in errors)
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
f"Argus cannot start — {len(errors)} configuration error(s) found:\n"
|
|
50
|
+
f"{bullet_list}\n\n"
|
|
51
|
+
"Fix the above and re-deploy or re-run."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Shared checks
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_ai_provider(errors: list[str]) -> None:
|
|
61
|
+
provider = os.environ.get("AI_PROVIDER", "").strip().lower()
|
|
62
|
+
|
|
63
|
+
# Each cloud has its own default, so an empty AI_PROVIDER is fine —
|
|
64
|
+
# the entrypoint will pick the cloud-native default.
|
|
65
|
+
if not provider:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
known = {"anthropic", "bedrock", "vertexai", "azure_openai"}
|
|
69
|
+
if provider not in known:
|
|
70
|
+
errors.append(
|
|
71
|
+
f"AI_PROVIDER={provider!r} is not recognised. "
|
|
72
|
+
f"Valid values: {', '.join(sorted(known))}."
|
|
73
|
+
)
|
|
74
|
+
return # No point checking credentials for an unknown provider.
|
|
75
|
+
|
|
76
|
+
if provider == "anthropic":
|
|
77
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
78
|
+
if not key:
|
|
79
|
+
errors.append(
|
|
80
|
+
"AI_PROVIDER=anthropic requires ANTHROPIC_API_KEY to be set. "
|
|
81
|
+
"Get a key at https://console.anthropic.com/settings/api-keys"
|
|
82
|
+
)
|
|
83
|
+
elif not key.startswith("sk-ant-"):
|
|
84
|
+
errors.append(
|
|
85
|
+
"ANTHROPIC_API_KEY looks malformed "
|
|
86
|
+
"(expected it to start with 'sk-ant-'). "
|
|
87
|
+
"Check the key in the Anthropic console."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if provider == "azure_openai":
|
|
91
|
+
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
|
|
92
|
+
if not endpoint:
|
|
93
|
+
errors.append(
|
|
94
|
+
"AI_PROVIDER=azure_openai requires AZURE_OPENAI_ENDPOINT to be set. "
|
|
95
|
+
"Example: https://<resource>.openai.azure.com/"
|
|
96
|
+
)
|
|
97
|
+
elif not _is_https_url(endpoint):
|
|
98
|
+
errors.append(
|
|
99
|
+
f"AZURE_OPENAI_ENDPOINT={endpoint!r} is not a valid HTTPS URL."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_slack(errors: list[str]) -> None:
|
|
104
|
+
dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
|
|
105
|
+
if dry_run:
|
|
106
|
+
return # Webhook not needed in dry-run mode.
|
|
107
|
+
|
|
108
|
+
url = os.environ.get("SLACK_WEBHOOK_URL", "").strip()
|
|
109
|
+
if not url:
|
|
110
|
+
errors.append(
|
|
111
|
+
"SLACK_WEBHOOK_URL is not set. "
|
|
112
|
+
"Create an incoming webhook at https://api.slack.com/apps "
|
|
113
|
+
"or set DRY_RUN=true to skip Slack delivery."
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if not _is_https_url(url):
|
|
118
|
+
errors.append(
|
|
119
|
+
f"SLACK_WEBHOOK_URL={url!r} is not a valid HTTPS URL. "
|
|
120
|
+
"Expected format: https://hooks.slack.com/services/..."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Cloud-specific checks
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_aws(errors: list[str]) -> None:
|
|
130
|
+
accounts_mode = os.environ.get("ACCOUNTS_MODE", "single").lower()
|
|
131
|
+
|
|
132
|
+
if accounts_mode not in ("single", "multi"):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"ACCOUNTS_MODE={accounts_mode!r} is not valid. " "Use 'single' or 'multi'."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if accounts_mode == "multi":
|
|
138
|
+
raw = os.environ.get("ACCOUNTS_CONFIG", "").strip()
|
|
139
|
+
if not raw:
|
|
140
|
+
errors.append(
|
|
141
|
+
"ACCOUNTS_MODE=multi requires ACCOUNTS_CONFIG to be set. "
|
|
142
|
+
"Set it to a JSON array of account objects: "
|
|
143
|
+
'[{"id":"123456789012","name":"prod","role_arn":"arn:aws:iam::..."}]'
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
accounts = json.loads(raw)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
errors.append(
|
|
151
|
+
f"ACCOUNTS_CONFIG is not valid JSON: {exc}. "
|
|
152
|
+
"Expected a JSON array of account objects."
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if not isinstance(accounts, list) or len(accounts) == 0:
|
|
157
|
+
errors.append(
|
|
158
|
+
"ACCOUNTS_CONFIG must be a non-empty JSON array of account objects."
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
for i, acct in enumerate(accounts):
|
|
163
|
+
if not isinstance(acct, dict):
|
|
164
|
+
errors.append(
|
|
165
|
+
f"ACCOUNTS_CONFIG[{i}] is not an object — each account must be "
|
|
166
|
+
'{"id": "...", "name": "...", "role_arn": "..."}.'
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
missing = [f for f in ("id", "role_arn") if not acct.get(f)]
|
|
170
|
+
if missing:
|
|
171
|
+
name = acct.get("name", f"index {i}")
|
|
172
|
+
errors.append(
|
|
173
|
+
f"ACCOUNTS_CONFIG account '{name}' is missing required "
|
|
174
|
+
f"field(s): {', '.join(missing)}."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _check_gcp(errors: list[str]) -> None:
|
|
179
|
+
project_id = os.environ.get("GCP_PROJECT_ID", "").strip()
|
|
180
|
+
if not project_id:
|
|
181
|
+
errors.append(
|
|
182
|
+
"GCP_PROJECT_ID is required for GCP scans. "
|
|
183
|
+
"Set it to your GCP project ID (e.g. my-project-123)."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_azure(errors: list[str]) -> None:
|
|
188
|
+
raw = os.environ.get("AZURE_SUBSCRIPTION_IDS", "").strip()
|
|
189
|
+
if not raw:
|
|
190
|
+
errors.append(
|
|
191
|
+
"AZURE_SUBSCRIPTION_IDS is required for Azure scans. "
|
|
192
|
+
"Set it to one or more subscription IDs separated by commas."
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
subscription_ids = [s.strip() for s in raw.split(",") if s.strip()]
|
|
197
|
+
if not subscription_ids:
|
|
198
|
+
errors.append(
|
|
199
|
+
"AZURE_SUBSCRIPTION_IDS is set but contains no valid IDs. "
|
|
200
|
+
"Expected one or more GUIDs separated by commas."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Helpers
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_https_url(value: str) -> bool:
|
|
210
|
+
try:
|
|
211
|
+
parsed = urllib.parse.urlparse(value)
|
|
212
|
+
return parsed.scheme == "https" and bool(parsed.netloc)
|
|
213
|
+
except ValueError:
|
|
214
|
+
return False
|
entrypoints/__init__.py
ADDED
|
File without changes
|