From 46a824bac9a3750f34f42056581ec14a37e437ff Mon Sep 17 00:00:00 2001 From: Wegz Date: Wed, 8 Apr 2026 23:09:06 -0400 Subject: [PATCH] feat: add discover-environment skill + fix diagram aesthetics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skill: discover-environment - Maps cloud resources to security graph with MITRE ATT&CK and ATLAS technique overlays (15 techniques across 8 resource types) - Supports AWS (boto3), GCP (google-cloud), Azure (azure-mgmt), and static config (JSON/YAML — no SDK needed) - Output: standalone graph JSON (nodes + edges + stats) compatible with any visualization tool - 15 tests covering graph model, MITRE mapping, static config, stats Diagram fixes: - Simplified model-serving and GPU cluster diagrams — removed spaghetti edges (every-to-every connections). Clean linear flow instead. - All diagrams now use consistent cloud palette (slate, teal, cyan, indigo — no red, no green) CI: added test-discover-environment job README + CLAUDE.md: added discover-environment to skills list --- .github/workflows/ci.yml | 12 + CLAUDE.md | 1 + README.md | 50 +- skills/discover-environment/SKILL.md | 230 ++++++ skills/discover-environment/src/discover.py | 767 ++++++++++++++++++ .../tests/test_discover.py | 152 ++++ skills/model-serving-security/SKILL.md | 40 +- 7 files changed, 1182 insertions(+), 70 deletions(-) create mode 100644 skills/discover-environment/SKILL.md create mode 100644 skills/discover-environment/src/discover.py create mode 100644 skills/discover-environment/tests/test_discover.py 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