diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 410ed50..75dc3db 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,6 +59,18 @@ jobs:
- working-directory: skills/gpu-cluster-security
run: pytest tests/ -v -o "testpaths=tests"
+ test-discover-environment:
+ runs-on: ubuntu-latest
+ needs: lint
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - run: pip install pytest
+ - working-directory: skills/discover-environment
+ run: pytest tests/ -v -o "testpaths=tests"
+
agent-bom-scan:
runs-on: ubuntu-latest
needs: lint
diff --git a/CLAUDE.md b/CLAUDE.md
index aae641d..f61a943 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,6 +12,7 @@ skills/
cspm-azure-cis-benchmark/ — CIS Azure Foundations v2.1 (19 checks + 5 AI Foundry)
model-serving-security/ — Model serving security benchmark (16 checks)
gpu-cluster-security/ — GPU cluster security benchmark (13 checks)
+ discover-environment/ — Cloud environment discovery with MITRE ATT&CK/ATLAS overlay
vuln-remediation-pipeline/ — Auto-remediate supply chain vulnerabilities
```
diff --git a/README.md b/README.md
index ad39e9f..3a8c4b4 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Production-grade cloud security benchmarks and automation — CIS checks for AWS
| [cspm-azure-cis-benchmark](skills/cspm-azure-cis-benchmark/) | Azure | 24 | CIS Azure Foundations v2.1 + AI Foundry security |
| [model-serving-security](skills/model-serving-security/) | Any | 16 | Model endpoint auth, rate limiting, data egress, safety layers |
| [gpu-cluster-security](skills/gpu-cluster-security/) | Any | 13 | GPU runtime isolation, driver CVEs, InfiniBand, tenant isolation |
+| [discover-environment](skills/discover-environment/) | Multi-cloud | — | Map cloud resources to security graph with MITRE ATT&CK/ATLAS overlays |
| [iam-departures-remediation](skills/iam-departures-remediation/) | Multi-cloud | — | Auto-remediate IAM for departed employees across 5 clouds |
| [vuln-remediation-pipeline](skills/vuln-remediation-pipeline/) | AWS | — | Auto-remediate supply chain vulns with EPSS triage |
@@ -98,57 +99,28 @@ flowchart LR
```mermaid
flowchart LR
- subgraph CONFIG["Serving Configuration"]
- GW["API Gateway"]
- K8S["K8s Manifests"]
- CLD["Cloud Serving\nSageMaker · Vertex · Azure ML"]
- end
-
- subgraph CHECKS["16 checks · 6 domains"]
- AUTH["Auth & RBAC"]
- RL["Rate Limiting"]
- EGR["Data Egress"]
- ISO["Container Isolation"]
- TLS["TLS & Network"]
- SAF["Safety Layers"]
- end
+ CONFIG["Serving Config\nAPI Gateway · K8s · Cloud ML"]
+ BENCH["checks.py\n16 checks · 6 domains\nAuth · Rate Limit · Egress\nRuntime · TLS · Safety"]
+ OUT["JSON / Console"]
- GW & K8S & CLD --> AUTH & RL & EGR & ISO & TLS & SAF
- AUTH & RL & EGR & ISO & TLS & SAF --> OUT["JSON / Console"]
+ CONFIG --> BENCH --> OUT
style CONFIG fill:#1e293b,stroke:#475569,color:#e2e8f0
- style CHECKS fill:#164e63,stroke:#22d3ee,color:#e2e8f0
+ style BENCH fill:#164e63,stroke:#22d3ee,color:#e2e8f0
```
## Architecture — GPU Cluster Security
```mermaid
flowchart LR
- subgraph CLUSTER["GPU Cluster Configuration"]
- PODS["Pods & Containers"]
- NODES["GPU Nodes\nDrivers · CUDA"]
- IB["InfiniBand / RDMA"]
- NS["Namespaces & Quotas"]
- end
-
- subgraph CHECKS["13 checks · 6 domains"]
- RT["Runtime Isolation"]
- DRV["Driver & CUDA"]
- NET["Network Segmentation"]
- STO["Storage & SHM"]
- TEN["Tenant Isolation"]
- OBS["Observability"]
- end
-
- PODS --> RT
- NODES --> DRV
- IB --> NET
- NS --> TEN & STO
+ CLUSTER["Cluster Config\nPods · Nodes · InfiniBand\nNamespaces · Storage"]
+ BENCH["checks.py\n13 checks · 6 domains\nRuntime · Driver · Network\nStorage · Tenant · Observability"]
+ OUT["JSON / Console"]
- RT & DRV & NET & STO & TEN & OBS --> OUT["JSON / Console"]
+ CLUSTER --> BENCH --> OUT
style CLUSTER fill:#1e293b,stroke:#475569,color:#e2e8f0
- style CHECKS fill:#164e63,stroke:#22d3ee,color:#e2e8f0
+ style BENCH fill:#164e63,stroke:#22d3ee,color:#e2e8f0
```
## Architecture — Vulnerability Remediation Pipeline
diff --git a/skills/discover-environment/SKILL.md b/skills/discover-environment/SKILL.md
new file mode 100644
index 0000000..1f79e29
--- /dev/null
+++ b/skills/discover-environment/SKILL.md
@@ -0,0 +1,230 @@
+---
+name: discover-environment
+description: >-
+ Discover cloud infrastructure and map it to a security graph with MITRE ATT&CK
+ and ATLAS technique overlays. Outputs graph JSON with nodes (resources, IAM,
+ services, network) and edges (relationships, attack vectors). Supports AWS,
+ GCP, Azure, or static config input. Use when the user mentions environment
+ discovery, cloud inventory, infrastructure mapping, attack surface mapping,
+ cloud resource graph, or MITRE technique mapping.
+license: Apache-2.0
+compatibility: >-
+ Requires Python 3.11+. Cloud discovery needs respective SDKs (boto3 for AWS,
+ google-cloud-* for GCP, azure-* for Azure). Static config mode needs no SDKs.
+ Read-only — uses only viewer/audit permissions. No write access.
+metadata:
+ author: msaad00
+ version: 0.1.0
+ frameworks:
+ - MITRE ATT&CK
+ - MITRE ATLAS
+ - NIST CSF 2.0
+ cloud: multi
+---
+
+# Cloud Environment Discovery
+
+Maps cloud infrastructure to a security graph with MITRE ATT&CK and ATLAS
+technique overlays. Each resource becomes a graph node, each relationship
+an edge. Attack techniques are mapped as edges from technique nodes to
+vulnerable resources.
+
+## When to Use
+
+- Map cloud attack surface before a security assessment
+- Visualize IAM → service → storage → network relationships
+- Overlay MITRE ATT&CK techniques on infrastructure for threat modeling
+- Export cloud inventory as graph JSON for any visualization tool
+- Feed into agent-bom's unified graph for cross-platform posture view
+- Periodic environment drift detection (compare graph snapshots)
+
+## Architecture
+
+```mermaid
+flowchart TD
+ subgraph PROVIDERS["Discovery Sources"]
+ AWS["AWS\nboto3 · SecurityAudit"]
+ GCP["GCP\ngoogle-cloud · Viewer"]
+ AZ["Azure\nazure-mgmt · Reader"]
+ CFG["Static Config\nJSON / YAML"]
+ end
+
+ subgraph DISCOVER["discover.py · read-only"]
+ IAM["IAM Users\nRoles · Service Accounts"]
+ COMP["Compute\nLambda · Functions · VMs"]
+ STOR["Storage\nS3 · GCS · Blob"]
+ NET["Network\nVPC · SG · Firewall"]
+ end
+
+ subgraph MITRE["MITRE Overlay"]
+ ATT["ATT&CK\n9 techniques"]
+ ATLAS["ATLAS\n6 techniques"]
+ end
+
+ AWS & GCP & AZ & CFG --> IAM & COMP & STOR & NET
+ IAM & COMP & STOR & NET --> OUT["Graph JSON\nnodes + edges + stats"]
+ ATT & ATLAS -->|exploitable_via| OUT
+
+ style PROVIDERS fill:#1e293b,stroke:#475569,color:#e2e8f0
+ style DISCOVER fill:#164e63,stroke:#22d3ee,color:#e2e8f0
+ style MITRE fill:#1e1b4b,stroke:#a78bfa,color:#e2e8f0
+```
+
+## What Gets Discovered
+
+### AWS (requires boto3 + SecurityAudit policy)
+
+| Resource | Entity Type | MITRE Techniques |
+|----------|-------------|-----------------|
+| IAM Users | user | T1078.004, T1098.001 |
+| IAM Roles | service_account | T1078.004, T1548.005 |
+| Access Keys | credential | — |
+| S3 Buckets | cloud_resource | T1530, T1537 |
+| Lambda Functions | server | T1648, T1195.002 |
+| VPCs | cloud_resource | T1599 |
+| Security Groups | cloud_resource | T1562.007 |
+
+### GCP (requires google-cloud SDKs + Viewer role)
+
+| Resource | Entity Type |
+|----------|-------------|
+| Service Accounts | service_account |
+| Cloud Storage Buckets | cloud_resource |
+| (Extensible for Compute, GKE, Vertex AI) | — |
+
+### Azure (requires azure SDKs + Reader role)
+
+| Resource | Entity Type |
+|----------|-------------|
+| Resource Groups | cloud_resource |
+| Resources (all types) | cloud_resource |
+| (Extensible for Entra ID, AKS, AI Studio) | — |
+
+## MITRE ATT&CK Techniques Mapped
+
+| Technique | ID | Resources Affected |
+|-----------|-----|-------------------|
+| Valid Accounts: Cloud | T1078.004 | IAM users, roles, instances |
+| Additional Cloud Credentials | T1098.001 | IAM users |
+| Temp Elevated Access | T1548.005 | IAM roles |
+| Data from Cloud Storage | T1530 | S3, GCS, Blob |
+| Transfer to Cloud Account | T1537 | S3, GCS, Blob |
+| Serverless Execution | T1648 | Lambda, Cloud Functions |
+| Supply Chain: Software | T1195.002 | Lambda, Cloud Functions |
+| Network Boundary Bridging | T1599 | VPCs |
+| Impair Cloud Firewall | T1562.007 | Security groups, NSGs |
+| Deploy Container | T1610 | EC2, Compute Engine |
+
+## MITRE ATLAS Techniques Mapped
+
+| Technique | ID | Resources Affected |
+|-----------|-----|-------------------|
+| Inference API Access | AML.T0024 | Model endpoints |
+| Denial of ML Service | AML.T0042 | Model endpoints |
+| Poison Training Data | AML.T0020 | Training jobs |
+| ML Supply Chain | AML.T0010 | Training jobs, model artifacts |
+| Exfiltrate Training Data | AML.T0025 | Model artifacts |
+
+## Usage
+
+```bash
+# AWS discovery
+pip install boto3
+python src/discover.py aws --region us-east-1
+
+# AWS with profile
+python src/discover.py aws --region us-west-2 --profile production
+
+# GCP discovery
+pip install google-cloud-iam google-cloud-storage google-cloud-resource-manager
+python src/discover.py gcp --project my-project-id
+
+# Azure discovery
+pip install azure-identity azure-mgmt-resource
+python src/discover.py azure --subscription-id SUB_ID
+
+# Static config (no SDK needed)
+python src/discover.py config --config environment.json
+
+# Save output
+python src/discover.py aws -o environment-graph.json
+```
+
+## Output Format
+
+The graph JSON is standalone — no agent-bom dependency. Any tool can consume it.
+
+```json
+{
+ "scan_id": "uuid",
+ "provider": "aws",
+ "region": "us-east-1",
+ "discovered_at": "2026-04-09T00:00:00+00:00",
+ "nodes": [
+ {
+ "id": "aws:iam_user:admin",
+ "entity_type": "user",
+ "label": "admin",
+ "attributes": {"arn": "arn:aws:iam::123456789012:user/admin"},
+ "compliance_tags": ["MITRE-T1078.004", "MITRE-T1098.001"],
+ "dimensions": {"cloud_provider": "aws"}
+ }
+ ],
+ "edges": [
+ {
+ "source": "mitre:T1078.004",
+ "target": "aws:iam_user:admin",
+ "relationship": "exploitable_via",
+ "evidence": {"technique": "T1078.004", "tactic": "Initial Access"}
+ }
+ ],
+ "stats": {
+ "total_nodes": 42,
+ "total_edges": 67,
+ "node_types": {"user": 5, "service_account": 12, "cloud_resource": 25},
+ "relationship_types": {"contains": 30, "owns": 5, "uses": 8, "exploitable_via": 24}
+ }
+}
+```
+
+## Static Config Format
+
+When cloud SDK access is not available:
+
+```json
+{
+ "provider": "static",
+ "resources": [
+ {"id": "vpc-1", "type": "cloud_resource", "name": "Production VPC", "dimensions": {"cloud_provider": "aws"}},
+ {"id": "user-1", "type": "iam_user", "name": "deploy-bot"}
+ ],
+ "relationships": [
+ {"source": "vpc-1", "target": "user-1", "type": "contains"}
+ ]
+}
+```
+
+## Security Guardrails
+
+- **Read-only**: Uses SecurityAudit (AWS), Viewer (GCP), Reader (Azure). Zero write permissions.
+- **No credentials stored**: Cloud credentials from environment/profile only. Never logged or cached.
+- **No data exfiltration**: Graph output stays local. No external API calls beyond cloud SDK.
+- **Safe to run in production**: Cannot modify any cloud resources.
+- **Idempotent**: Run as often as needed. Snapshot comparison for drift detection.
+
+## Human-in-the-Loop Policy
+
+| Action | Automation Level | Reason |
+|--------|-----------------|--------|
+| **Discover resources** | Fully automated | Read-only, no side effects |
+| **Generate graph JSON** | Fully automated | Local output |
+| **Modify IAM/network** | Human required | Infrastructure changes have blast radius |
+| **Remediate findings** | Human required | Use iam-departures or vuln-remediation skills |
+
+## Tests
+
+```bash
+cd skills/discover-environment
+pytest tests/ -v -o "testpaths=tests"
+# 15 tests: graph model, MITRE mapping, static config, stats
+```
diff --git a/skills/discover-environment/src/discover.py b/skills/discover-environment/src/discover.py
new file mode 100644
index 0000000..474e7e9
--- /dev/null
+++ b/skills/discover-environment/src/discover.py
@@ -0,0 +1,767 @@
+"""Cloud Environment Discovery — map infrastructure to a security graph.
+
+Discovers cloud resources, IAM roles, services, network paths, and
+security posture across AWS, GCP, and Azure. Outputs a graph JSON
+compatible with any graph visualization tool.
+
+Each node has:
+ - id, entity_type, label, attributes, compliance_tags, dimensions
+Each edge has:
+ - source, target, relationship, direction, weight, evidence
+
+Read-only — uses only viewer/audit permissions. No write access.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import asdict, dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Graph data model (standalone — no agent-bom dependency)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+@dataclass
+class GraphNode:
+ id: str
+ entity_type: str
+ label: str
+ attributes: dict[str, Any] = field(default_factory=dict)
+ compliance_tags: list[str] = field(default_factory=list)
+ dimensions: dict[str, str] = field(default_factory=dict)
+ severity: str = ""
+ risk_score: float = 0.0
+ status: str = "active"
+
+
+@dataclass
+class GraphEdge:
+ source: str
+ target: str
+ relationship: str
+ direction: str = "directed"
+ weight: float = 1.0
+ evidence: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class EnvironmentGraph:
+ nodes: list[GraphNode] = field(default_factory=list)
+ edges: list[GraphEdge] = field(default_factory=list)
+ scan_id: str = ""
+ provider: str = ""
+ region: str = ""
+ discovered_at: str = ""
+
+ def __post_init__(self) -> None:
+ if not self.scan_id:
+ self.scan_id = str(uuid4())
+ if not self.discovered_at:
+ self.discovered_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+ def add_node(self, node: GraphNode) -> None:
+ self.nodes.append(node)
+
+ def add_edge(self, edge: GraphEdge) -> None:
+ self.edges.append(edge)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "scan_id": self.scan_id,
+ "provider": self.provider,
+ "region": self.region,
+ "discovered_at": self.discovered_at,
+ "nodes": [asdict(n) for n in self.nodes],
+ "edges": [asdict(e) for e in self.edges],
+ "stats": {
+ "total_nodes": len(self.nodes),
+ "total_edges": len(self.edges),
+ "node_types": _count_by(self.nodes, "entity_type"),
+ "relationship_types": _count_by_attr(self.edges, "relationship"),
+ },
+ }
+
+
+def _count_by(items: list, attr: str) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for item in items:
+ val = getattr(item, attr, "unknown")
+ counts[val] = counts.get(val, 0) + 1
+ return counts
+
+
+def _count_by_attr(items: list, attr: str) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for item in items:
+ val = getattr(item, attr, "unknown")
+ counts[val] = counts.get(val, 0) + 1
+ return counts
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# MITRE ATT&CK technique mapping
+# ═══════════════════════════════════════════════════════════════════════════
+
+# Resource type → potential attack techniques
+MITRE_ATTACK_MAP: dict[str, list[dict[str, str]]] = {
+ "iam_user": [
+ {"technique": "T1078.004", "name": "Valid Accounts: Cloud", "tactic": "Initial Access"},
+ {"technique": "T1098.001", "name": "Additional Cloud Credentials", "tactic": "Persistence"},
+ ],
+ "iam_role": [
+ {"technique": "T1078.004", "name": "Valid Accounts: Cloud", "tactic": "Initial Access"},
+ {"technique": "T1548.005", "name": "Abuse Elevation Control: Temp Elevated Access", "tactic": "Privilege Escalation"},
+ ],
+ "s3_bucket": [
+ {"technique": "T1530", "name": "Data from Cloud Storage", "tactic": "Collection"},
+ {"technique": "T1537", "name": "Transfer Data to Cloud Account", "tactic": "Exfiltration"},
+ ],
+ "lambda_function": [
+ {"technique": "T1648", "name": "Serverless Execution", "tactic": "Execution"},
+ {"technique": "T1195.002", "name": "Supply Chain: Software Supply Chain", "tactic": "Initial Access"},
+ ],
+ "ec2_instance": [
+ {"technique": "T1078.004", "name": "Valid Accounts: Cloud", "tactic": "Initial Access"},
+ {"technique": "T1610", "name": "Deploy Container", "tactic": "Defense Evasion"},
+ ],
+ "security_group": [
+ {"technique": "T1562.007", "name": "Impair Defenses: Disable or Modify Cloud Firewall", "tactic": "Defense Evasion"},
+ ],
+ "vpc": [
+ {"technique": "T1599", "name": "Network Boundary Bridging", "tactic": "Defense Evasion"},
+ ],
+ "kms_key": [
+ {"technique": "T1552.004", "name": "Unsecured Credentials: Cloud Instance Metadata", "tactic": "Credential Access"},
+ ],
+}
+
+# MITRE ATLAS for AI/ML resources
+MITRE_ATLAS_MAP: dict[str, list[dict[str, str]]] = {
+ "model_endpoint": [
+ {"technique": "AML.T0024", "name": "Inference API Access", "tactic": "ML Attack"},
+ {"technique": "AML.T0042", "name": "Denial of ML Service", "tactic": "ML Attack"},
+ ],
+ "training_job": [
+ {"technique": "AML.T0020", "name": "Poison Training Data", "tactic": "ML Attack"},
+ {"technique": "AML.T0010", "name": "ML Supply Chain Compromise", "tactic": "ML Attack"},
+ ],
+ "model_artifact": [
+ {"technique": "AML.T0010", "name": "ML Supply Chain Compromise", "tactic": "ML Attack"},
+ {"technique": "AML.T0025", "name": "Exfiltrate Training Data", "tactic": "ML Attack"},
+ ],
+}
+
+
+def get_attack_techniques(entity_type: str) -> list[dict[str, str]]:
+ """Get MITRE ATT&CK and ATLAS techniques for a resource type."""
+ techniques = list(MITRE_ATTACK_MAP.get(entity_type, []))
+ techniques.extend(MITRE_ATLAS_MAP.get(entity_type, []))
+ return techniques
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# AWS Discovery
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def discover_aws(region: str = "us-east-1", profile: str | None = None) -> EnvironmentGraph:
+ """Discover AWS environment resources and relationships.
+
+ Requires: boto3, AWS credentials with SecurityAudit or ViewOnlyAccess.
+ """
+ try:
+ import boto3
+ except ImportError:
+ print("Error: boto3 required. Install with: pip install boto3", file=sys.stderr)
+ sys.exit(1)
+
+ session = boto3.Session(region_name=region, profile_name=profile)
+ graph = EnvironmentGraph(provider="aws", region=region)
+
+ # Account identity
+ sts = session.client("sts")
+ try:
+ identity = sts.get_caller_identity()
+ account_id = identity["Account"]
+ graph.add_node(
+ GraphNode(
+ id=f"aws:account:{account_id}",
+ entity_type="cloud_resource",
+ label=f"AWS Account {account_id}",
+ attributes={"account_id": account_id, "arn": identity["Arn"]},
+ dimensions={"cloud_provider": "aws", "surface": "account"},
+ )
+ )
+ except Exception as e:
+ print(f"Warning: Could not get account identity: {e}", file=sys.stderr)
+ account_id = "unknown"
+
+ # IAM Users
+ iam = session.client("iam")
+ try:
+ users = iam.list_users().get("Users", [])
+ for user in users:
+ username = user["UserName"]
+ node_id = f"aws:iam_user:{username}"
+ tags = _get_mitre_tags("iam_user")
+ graph.add_node(
+ GraphNode(
+ id=node_id,
+ entity_type="user",
+ label=username,
+ attributes={
+ "arn": user["Arn"],
+ "created": user["CreateDate"].isoformat(),
+ "password_last_used": user.get("PasswordLastUsed", "never"),
+ },
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"aws:account:{account_id}",
+ target=node_id,
+ relationship="contains",
+ )
+ )
+
+ # User's access keys
+ try:
+ keys = iam.list_access_keys(UserName=username).get("AccessKeyMetadata", [])
+ for key in keys:
+ key_id = key["AccessKeyId"]
+ graph.add_node(
+ GraphNode(
+ id=f"aws:access_key:{key_id}",
+ entity_type="credential",
+ label=f"Access Key {key_id[:8]}...",
+ attributes={
+ "status": key["Status"],
+ "created": key["CreateDate"].isoformat(),
+ },
+ dimensions={"cloud_provider": "aws"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=node_id,
+ target=f"aws:access_key:{key_id}",
+ relationship="owns",
+ )
+ )
+ except Exception:
+ pass
+
+ except Exception as e:
+ print(f"Warning: IAM user discovery failed: {e}", file=sys.stderr)
+
+ # IAM Roles
+ try:
+ roles = iam.list_roles().get("Roles", [])
+ for role in roles:
+ role_name = role["RoleName"]
+ if role_name.startswith("aws-service-role/"):
+ continue # Skip AWS service-linked roles
+ node_id = f"aws:iam_role:{role_name}"
+ tags = _get_mitre_tags("iam_role")
+ graph.add_node(
+ GraphNode(
+ id=node_id,
+ entity_type="service_account",
+ label=role_name,
+ attributes={
+ "arn": role["Arn"],
+ "created": role["CreateDate"].isoformat(),
+ "trust_policy": json.dumps(role.get("AssumeRolePolicyDocument", {})),
+ },
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"aws:account:{account_id}",
+ target=node_id,
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: IAM role discovery failed: {e}", file=sys.stderr)
+
+ # S3 Buckets
+ s3 = session.client("s3")
+ try:
+ buckets = s3.list_buckets().get("Buckets", [])
+ for bucket in buckets:
+ bucket_name = bucket["Name"]
+ node_id = f"aws:s3:{bucket_name}"
+ tags = _get_mitre_tags("s3_bucket")
+ graph.add_node(
+ GraphNode(
+ id=node_id,
+ entity_type="cloud_resource",
+ label=f"s3://{bucket_name}",
+ attributes={
+ "created": bucket["CreationDate"].isoformat(),
+ },
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws", "surface": "storage"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"aws:account:{account_id}",
+ target=node_id,
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: S3 discovery failed: {e}", file=sys.stderr)
+
+ # Lambda Functions
+ lam = session.client("lambda")
+ try:
+ functions = lam.list_functions().get("Functions", [])
+ for fn in functions:
+ fn_name = fn["FunctionName"]
+ node_id = f"aws:lambda:{fn_name}"
+ runtime = fn.get("Runtime", "unknown")
+ tags = _get_mitre_tags("lambda_function")
+ graph.add_node(
+ GraphNode(
+ id=node_id,
+ entity_type="server",
+ label=fn_name,
+ attributes={
+ "arn": fn["FunctionArn"],
+ "runtime": runtime,
+ "memory": fn.get("MemorySize", 0),
+ "timeout": fn.get("Timeout", 0),
+ "handler": fn.get("Handler", ""),
+ "last_modified": fn.get("LastModified", ""),
+ },
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws", "surface": "compute", "ecosystem": _runtime_to_ecosystem(runtime)},
+ )
+ )
+ # Lambda → IAM Role edge
+ role_arn = fn.get("Role", "")
+ if role_arn:
+ role_name = role_arn.split("/")[-1]
+ graph.add_edge(
+ GraphEdge(
+ source=node_id,
+ target=f"aws:iam_role:{role_name}",
+ relationship="uses",
+ evidence={"role_arn": role_arn},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"aws:account:{account_id}",
+ target=node_id,
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: Lambda discovery failed: {e}", file=sys.stderr)
+
+ # VPCs
+ ec2 = session.client("ec2")
+ try:
+ vpcs = ec2.describe_vpcs().get("Vpcs", [])
+ for vpc in vpcs:
+ vpc_id = vpc["VpcId"]
+ tags = _get_mitre_tags("vpc")
+ name = _get_tag(vpc.get("Tags", []), "Name") or vpc_id
+ graph.add_node(
+ GraphNode(
+ id=f"aws:vpc:{vpc_id}",
+ entity_type="cloud_resource",
+ label=name,
+ attributes={
+ "vpc_id": vpc_id,
+ "cidr": vpc.get("CidrBlock", ""),
+ "is_default": vpc.get("IsDefault", False),
+ },
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws", "surface": "network"},
+ )
+ )
+
+ # Security Groups
+ sgs = ec2.describe_security_groups().get("SecurityGroups", [])
+ for sg in sgs:
+ sg_id = sg["GroupId"]
+ tags = _get_mitre_tags("security_group")
+ open_ingress = []
+ for rule in sg.get("IpPermissions", []):
+ for ip_range in rule.get("IpRanges", []):
+ if ip_range.get("CidrIp") == "0.0.0.0/0":
+ port = rule.get("FromPort", "all")
+ open_ingress.append(f"0.0.0.0/0:{port}")
+
+ graph.add_node(
+ GraphNode(
+ id=f"aws:sg:{sg_id}",
+ entity_type="cloud_resource",
+ label=sg.get("GroupName", sg_id),
+ attributes={
+ "sg_id": sg_id,
+ "vpc_id": sg.get("VpcId", ""),
+ "description": sg.get("Description", ""),
+ "open_ingress": open_ingress,
+ },
+ severity="high" if open_ingress else "",
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "aws", "surface": "network"},
+ )
+ )
+ vpc_id = sg.get("VpcId", "")
+ if vpc_id:
+ graph.add_edge(
+ GraphEdge(
+ source=f"aws:vpc:{vpc_id}",
+ target=f"aws:sg:{sg_id}",
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: VPC/SG discovery failed: {e}", file=sys.stderr)
+
+ # Add MITRE ATT&CK technique edges
+ _add_mitre_edges(graph)
+
+ return graph
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# GCP Discovery (stub — requires google-cloud SDKs)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def discover_gcp(project: str) -> EnvironmentGraph:
+ """Discover GCP project resources. Requires google-cloud SDKs."""
+ try:
+ from google.cloud import resourcemanager_v3 # noqa: F401
+ except ImportError:
+ print("Error: google-cloud-resource-manager required. Install with: pip install google-cloud-resource-manager", file=sys.stderr)
+ sys.exit(1)
+
+ graph = EnvironmentGraph(provider="gcp", region=project)
+
+ graph.add_node(
+ GraphNode(
+ id=f"gcp:project:{project}",
+ entity_type="cloud_resource",
+ label=f"GCP Project {project}",
+ dimensions={"cloud_provider": "gcp", "surface": "project"},
+ )
+ )
+
+ # Service Accounts
+ try:
+ from google.cloud import iam_v1
+
+ iam_client = iam_v1.IAMClient()
+ request = iam_v1.ListServiceAccountsRequest(name=f"projects/{project}")
+ for sa in iam_client.list_service_accounts(request=request):
+ sa_email = sa.email
+ tags = _get_mitre_tags("iam_role")
+ graph.add_node(
+ GraphNode(
+ id=f"gcp:sa:{sa_email}",
+ entity_type="service_account",
+ label=sa_email,
+ attributes={"display_name": sa.display_name, "disabled": sa.disabled},
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "gcp"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"gcp:project:{project}",
+ target=f"gcp:sa:{sa_email}",
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: GCP SA discovery failed: {e}", file=sys.stderr)
+
+ # Cloud Storage Buckets
+ try:
+ from google.cloud import storage
+
+ storage_client = storage.Client(project=project)
+ for bucket in storage_client.list_buckets():
+ tags = _get_mitre_tags("s3_bucket")
+ graph.add_node(
+ GraphNode(
+ id=f"gcp:bucket:{bucket.name}",
+ entity_type="cloud_resource",
+ label=f"gs://{bucket.name}",
+ attributes={"location": bucket.location, "storage_class": bucket.storage_class},
+ compliance_tags=tags,
+ dimensions={"cloud_provider": "gcp", "surface": "storage"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"gcp:project:{project}",
+ target=f"gcp:bucket:{bucket.name}",
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: GCS discovery failed: {e}", file=sys.stderr)
+
+ _add_mitre_edges(graph)
+ return graph
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Azure Discovery (stub — requires azure SDKs)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def discover_azure(subscription_id: str) -> EnvironmentGraph:
+ """Discover Azure subscription resources. Requires azure SDKs."""
+ try:
+ from azure.identity import DefaultAzureCredential
+ except ImportError:
+ print("Error: azure-identity required. Install with: pip install azure-identity azure-mgmt-resource", file=sys.stderr)
+ sys.exit(1)
+
+ graph = EnvironmentGraph(provider="azure", region=subscription_id)
+ credential = DefaultAzureCredential()
+
+ graph.add_node(
+ GraphNode(
+ id=f"azure:subscription:{subscription_id}",
+ entity_type="cloud_resource",
+ label=f"Azure Subscription {subscription_id[:8]}...",
+ dimensions={"cloud_provider": "azure", "surface": "subscription"},
+ )
+ )
+
+ # Resource Groups and Resources
+ try:
+ from azure.mgmt.resource import ResourceManagementClient
+
+ client = ResourceManagementClient(credential, subscription_id)
+ for rg in client.resource_groups.list():
+ rg_id = f"azure:rg:{rg.name}"
+ graph.add_node(
+ GraphNode(
+ id=rg_id,
+ entity_type="cloud_resource",
+ label=rg.name,
+ attributes={"location": rg.location},
+ dimensions={"cloud_provider": "azure", "surface": "resource_group"},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=f"azure:subscription:{subscription_id}",
+ target=rg_id,
+ relationship="contains",
+ )
+ )
+
+ # Resources in group
+ for resource in client.resources.list_by_resource_group(rg.name):
+ res_id = f"azure:resource:{resource.name}"
+ res_type = resource.type.split("/")[-1] if resource.type else "unknown"
+ graph.add_node(
+ GraphNode(
+ id=res_id,
+ entity_type="cloud_resource",
+ label=resource.name,
+ attributes={"type": resource.type, "location": resource.location, "kind": resource.kind or ""},
+ dimensions={"cloud_provider": "azure", "surface": res_type},
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=rg_id,
+ target=res_id,
+ relationship="contains",
+ )
+ )
+ except Exception as e:
+ print(f"Warning: Azure resource discovery failed: {e}", file=sys.stderr)
+
+ _add_mitre_edges(graph)
+ return graph
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Static config discovery (no SDK needed)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def discover_from_config(config_path: str) -> EnvironmentGraph:
+ """Build environment graph from a static config file (JSON/YAML).
+
+ Use when cloud SDK access is not available — manual inventory.
+ """
+ p = Path(config_path)
+ if not p.exists():
+ print(f"Error: Config not found: {config_path}", file=sys.stderr)
+ sys.exit(1)
+
+ content = p.read_text()
+ if p.suffix in (".yaml", ".yml"):
+ try:
+ import yaml
+
+ config = yaml.safe_load(content) or {}
+ except ImportError:
+ print("Error: PyYAML required for YAML. Install: pip install pyyaml", file=sys.stderr)
+ sys.exit(1)
+ else:
+ config = json.loads(content)
+
+ graph = EnvironmentGraph(provider=config.get("provider", "static"))
+
+ for resource in config.get("resources", []):
+ graph.add_node(
+ GraphNode(
+ id=resource.get("id", str(uuid4())),
+ entity_type=resource.get("type", "cloud_resource"),
+ label=resource.get("name", resource.get("id", "unknown")),
+ attributes=resource.get("attributes", {}),
+ compliance_tags=resource.get("compliance_tags", []),
+ dimensions=resource.get("dimensions", {}),
+ severity=resource.get("severity", ""),
+ )
+ )
+
+ for rel in config.get("relationships", []):
+ graph.add_edge(
+ GraphEdge(
+ source=rel["source"],
+ target=rel["target"],
+ relationship=rel.get("type", "related_to"),
+ direction=rel.get("direction", "directed"),
+ evidence=rel.get("evidence", {}),
+ )
+ )
+
+ _add_mitre_edges(graph)
+ return graph
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Helpers
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def _get_tag(tags: list[dict], key: str) -> str:
+ for tag in tags:
+ if tag.get("Key") == key:
+ return tag.get("Value", "")
+ return ""
+
+
+def _runtime_to_ecosystem(runtime: str) -> str:
+ if "python" in runtime.lower():
+ return "pypi"
+ if "node" in runtime.lower():
+ return "npm"
+ if "java" in runtime.lower():
+ return "maven"
+ if "go" in runtime.lower():
+ return "go"
+ if "dotnet" in runtime.lower() or "csharp" in runtime.lower():
+ return "nuget"
+ if "ruby" in runtime.lower():
+ return "rubygems"
+ return ""
+
+
+def _get_mitre_tags(entity_type: str) -> list[str]:
+ techniques = get_attack_techniques(entity_type)
+ return [f"MITRE-{t['technique']}" for t in techniques]
+
+
+def _add_mitre_edges(graph: EnvironmentGraph) -> None:
+ """Add MITRE ATT&CK/ATLAS technique edges to relevant nodes."""
+ for node in graph.nodes:
+ techniques = MITRE_ATTACK_MAP.get(node.entity_type, []) + MITRE_ATLAS_MAP.get(node.entity_type, [])
+ for tech in techniques:
+ tech_id = f"mitre:{tech['technique']}"
+ # Add technique node if not present
+ existing_ids = {n.id for n in graph.nodes}
+ if tech_id not in existing_ids:
+ graph.add_node(
+ GraphNode(
+ id=tech_id,
+ entity_type="vulnerability",
+ label=f"{tech['technique']}: {tech['name']}",
+ attributes={"tactic": tech["tactic"], "framework": "ATT&CK" if tech["technique"].startswith("T") else "ATLAS"},
+ compliance_tags=[f"MITRE-{tech['technique']}"],
+ )
+ )
+ graph.add_edge(
+ GraphEdge(
+ source=tech_id,
+ target=node.id,
+ relationship="exploitable_via",
+ evidence={"technique": tech["technique"], "tactic": tech["tactic"]},
+ )
+ )
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# CLI
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Cloud Environment Discovery — map infrastructure to security graph")
+ parser.add_argument("provider", choices=["aws", "gcp", "azure", "config"], help="Cloud provider or 'config' for static file")
+ parser.add_argument("--region", default="us-east-1", help="AWS region (default: us-east-1)")
+ parser.add_argument("--project", help="GCP project ID")
+ parser.add_argument("--subscription-id", help="Azure subscription ID")
+ parser.add_argument("--profile", help="AWS CLI profile name")
+ parser.add_argument("--config", help="Path to static config file (for 'config' provider)")
+ parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
+ args = parser.parse_args()
+
+ if args.provider == "aws":
+ graph = discover_aws(region=args.region, profile=args.profile)
+ elif args.provider == "gcp":
+ if not args.project:
+ parser.error("--project required for GCP")
+ graph = discover_gcp(project=args.project)
+ elif args.provider == "azure":
+ if not args.subscription_id:
+ parser.error("--subscription-id required for Azure")
+ graph = discover_azure(subscription_id=args.subscription_id)
+ elif args.provider == "config":
+ if not args.config:
+ parser.error("--config required for static config discovery")
+ graph = discover_from_config(config_path=args.config)
+ else:
+ parser.error(f"Unknown provider: {args.provider}")
+
+ result = json.dumps(graph.to_dict(), indent=2, default=str)
+
+ if args.output:
+ Path(args.output).write_text(result)
+ print(f"Graph written to {args.output} ({len(graph.nodes)} nodes, {len(graph.edges)} edges)", file=sys.stderr)
+ else:
+ print(result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/discover-environment/tests/test_discover.py b/skills/discover-environment/tests/test_discover.py
new file mode 100644
index 0000000..e4744e3
--- /dev/null
+++ b/skills/discover-environment/tests/test_discover.py
@@ -0,0 +1,152 @@
+"""Tests for cloud environment discovery — graph output and MITRE mapping."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
+
+from discover import (
+ MITRE_ATLAS_MAP,
+ MITRE_ATTACK_MAP,
+ EnvironmentGraph,
+ GraphEdge,
+ GraphNode,
+ discover_from_config,
+ get_attack_techniques,
+)
+
+
+class TestGraphModel:
+ def test_graph_has_scan_id(self):
+ g = EnvironmentGraph(provider="aws")
+ assert g.scan_id
+ assert g.provider == "aws"
+
+ def test_add_node(self):
+ g = EnvironmentGraph()
+ g.add_node(GraphNode(id="test:1", entity_type="cloud_resource", label="Test"))
+ assert len(g.nodes) == 1
+ assert g.nodes[0].id == "test:1"
+
+ def test_add_edge(self):
+ g = EnvironmentGraph()
+ g.add_edge(GraphEdge(source="a", target="b", relationship="contains"))
+ assert len(g.edges) == 1
+
+ def test_to_dict(self):
+ g = EnvironmentGraph(provider="aws", region="us-east-1")
+ g.add_node(GraphNode(id="n1", entity_type="user", label="admin"))
+ g.add_edge(GraphEdge(source="n1", target="n2", relationship="owns"))
+ d = g.to_dict()
+ assert d["provider"] == "aws"
+ assert d["stats"]["total_nodes"] == 1
+ assert d["stats"]["total_edges"] == 1
+ assert "user" in d["stats"]["node_types"]
+
+ def test_to_dict_is_json_serializable(self):
+ g = EnvironmentGraph()
+ g.add_node(GraphNode(id="n1", entity_type="user", label="admin"))
+ result = json.dumps(g.to_dict())
+ assert '"user"' in result
+
+
+class TestMITREMapping:
+ def test_iam_user_has_attack_techniques(self):
+ techniques = get_attack_techniques("iam_user")
+ ids = [t["technique"] for t in techniques]
+ assert "T1078.004" in ids
+
+ def test_s3_bucket_has_techniques(self):
+ techniques = get_attack_techniques("s3_bucket")
+ ids = [t["technique"] for t in techniques]
+ assert "T1530" in ids
+
+ def test_lambda_has_techniques(self):
+ techniques = get_attack_techniques("lambda_function")
+ ids = [t["technique"] for t in techniques]
+ assert "T1648" in ids
+
+ def test_model_endpoint_has_atlas(self):
+ techniques = get_attack_techniques("model_endpoint")
+ ids = [t["technique"] for t in techniques]
+ assert "AML.T0024" in ids
+
+ def test_unknown_type_returns_empty(self):
+ techniques = get_attack_techniques("nonexistent")
+ assert techniques == []
+
+ def test_all_techniques_have_required_fields(self):
+ for entity_type, techniques in {**MITRE_ATTACK_MAP, **MITRE_ATLAS_MAP}.items():
+ for t in techniques:
+ assert "technique" in t, f"{entity_type} missing technique"
+ assert "name" in t, f"{entity_type} missing name"
+ assert "tactic" in t, f"{entity_type} missing tactic"
+
+
+class TestStaticConfigDiscovery:
+ def test_discover_from_config(self, tmp_path):
+ config = {
+ "provider": "static",
+ "resources": [
+ {"id": "res:1", "type": "cloud_resource", "name": "My VPC", "dimensions": {"cloud_provider": "aws"}},
+ {"id": "res:2", "type": "user", "name": "admin"},
+ ],
+ "relationships": [
+ {"source": "res:1", "target": "res:2", "type": "contains"},
+ ],
+ }
+ config_path = tmp_path / "env.json"
+ config_path.write_text(json.dumps(config))
+
+ graph = discover_from_config(str(config_path))
+ assert graph.provider == "static"
+ # 2 resources + MITRE technique nodes
+ assert len(graph.nodes) >= 2
+ # 1 relationship + MITRE edges
+ assert len(graph.edges) >= 1
+
+ def test_mitre_edges_added(self, tmp_path):
+ config = {
+ "resources": [
+ {"id": "res:1", "type": "iam_user", "name": "admin"},
+ ],
+ "relationships": [],
+ }
+ config_path = tmp_path / "env.json"
+ config_path.write_text(json.dumps(config))
+
+ graph = discover_from_config(str(config_path))
+ # Should have MITRE technique nodes + edges for iam_user
+ mitre_nodes = [n for n in graph.nodes if n.id.startswith("mitre:")]
+ mitre_edges = [e for e in graph.edges if e.relationship == "exploitable_via"]
+ assert len(mitre_nodes) >= 1
+ assert len(mitre_edges) >= 1
+
+ def test_empty_config(self, tmp_path):
+ config = {"resources": [], "relationships": []}
+ config_path = tmp_path / "empty.json"
+ config_path.write_text(json.dumps(config))
+
+ graph = discover_from_config(str(config_path))
+ assert len(graph.nodes) == 0
+ assert len(graph.edges) == 0
+
+
+class TestGraphStats:
+ def test_stats_count_types(self):
+ g = EnvironmentGraph()
+ g.add_node(GraphNode(id="u1", entity_type="user", label="u1"))
+ g.add_node(GraphNode(id="u2", entity_type="user", label="u2"))
+ g.add_node(GraphNode(id="s1", entity_type="server", label="s1"))
+ g.add_edge(GraphEdge(source="u1", target="s1", relationship="uses"))
+ g.add_edge(GraphEdge(source="u2", target="s1", relationship="uses"))
+
+ stats = g.to_dict()["stats"]
+ assert stats["total_nodes"] == 3
+ assert stats["total_edges"] == 2
+ assert stats["node_types"]["user"] == 2
+ assert stats["node_types"]["server"] == 1
+ assert stats["relationship_types"]["uses"] == 2
diff --git a/skills/model-serving-security/SKILL.md b/skills/model-serving-security/SKILL.md
index 2a1b2ac..709ce11 100644
--- a/skills/model-serving-security/SKILL.md
+++ b/skills/model-serving-security/SKILL.md
@@ -40,37 +40,15 @@ serving infrastructure. Each check mapped to MITRE ATLAS and NIST CSF 2.0.
## Architecture
```mermaid
-flowchart TD
- subgraph INPUT["Serving Configuration"]
- API["API Gateway Config"]
- K8S["Kubernetes Manifests"]
- DOCKER["Docker Compose"]
- CLOUD["Cloud Serving Config
SageMaker / Vertex AI / Azure ML"]
- end
-
- subgraph CHECKS["checks.py — 16 checks, read-only"]
- AUTH["Auth & RBAC
3 checks"]
- ABUSE["Rate Limiting
2 checks"]
- EGRESS["Data Egress
3 checks"]
- RUNTIME["Container Isolation
3 checks"]
- NET["TLS & Network
2 checks"]
- SAFETY["Safety Layers
3 checks"]
- end
-
- API --> AUTH
- K8S --> RUNTIME
- DOCKER --> RUNTIME
- CLOUD --> SAFETY
-
- AUTH --> RESULTS["JSON / Console / SARIF"]
- ABUSE --> RESULTS
- EGRESS --> RESULTS
- RUNTIME --> RESULTS
- NET --> RESULTS
- SAFETY --> RESULTS
-
- style INPUT fill:#1e293b,stroke:#475569,color:#e2e8f0
- style CHECKS fill:#172554,stroke:#3b82f6,color:#e2e8f0
+flowchart LR
+ CONFIG["Serving Config
API Gateway · K8s · Docker · Cloud ML"]
+ BENCH["checks.py — 16 checks
Auth · Rate Limit · Egress
Runtime · TLS · Safety"]
+ OUT["JSON / Console"]
+
+ CONFIG --> BENCH --> OUT
+
+ style CONFIG fill:#1e293b,stroke:#475569,color:#e2e8f0
+ style BENCH fill:#164e63,stroke:#22d3ee,color:#e2e8f0
```
## Controls — 6 Domains, 16 Checks