feat(outputs): add AWS inventory connectivity graph output format (#10382)

Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
This commit is contained in:
Sandiyo Christan
2026-05-13 12:52:37 +05:30
committed by GitHub
parent 7f3dcdf02f
commit e476bbde2d
15 changed files with 1958 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
# AWS Inventory Connectivity Graph
A community-contributed tool that generates interactive connectivity graphs from Prowler AWS scans, visualizing relationships between AWS resources with zero additional API calls.
## Overview
This tool extends Prowler by producing two artifacts after a scan completes:
- **`<output>.inventory.json`** Machine-readable graph (nodes + edges)
- **`<output>.inventory.html`** Interactive D3.js force-directed visualization
### Why?
Prowler's existing outputs (CSV, ASFF, OCSF, HTML) report individual check findings but provide no cross-service topology view. Security engineers need to understand **how** resources are connected—which Lambda functions sit inside which VPC, which IAM roles can be assumed by which services, which event sources trigger which functions—before they can reason about attack paths, blast-radius, or lateral-movement risk.
This tool fills that gap by building a connectivity graph from the service clients that are already loaded during a Prowler scan.
## Features
### Supported AWS Services
The tool currently extracts connectivity information from:
- **Lambda** Functions, VPC/subnet/SG edges, event source mappings, layers, DLQ, KMS
- **EC2** Instances, security groups, subnet/VPC edges
- **VPC** VPCs, subnets, peering connections
- **RDS** DB instances, VPC/SG/cluster/KMS edges
- **ELBv2** ALB/NLB load balancers, SG and VPC edges
- **S3** Buckets, replication targets, logging buckets, KMS keys
- **IAM** Roles, trust-relationship edges (who can assume what)
### Edge Semantic Types
Edges are typed for downstream filtering and attack-path analysis:
- `network` Resources share a network path (VPC/subnet/SG)
- `iam` IAM trust or permission relationship
- `triggers` One resource can invoke another (event source → Lambda)
- `data_flow` Data is written/read (Lambda → SQS dead-letter queue)
- `depends_on` Soft dependency (Lambda layer, subnet belongs to VPC)
- `routes_to` Traffic routing (LB → target)
- `replicates_to` S3 replication
- `encrypts` KMS key encrypts the resource
- `logs_to` Logging relationship
### Interactive HTML Graph Features
- Force-directed layout with drag-and-drop node pinning
- Zoom / pan (mouse wheel + click-drag on background)
- Per-service color-coded nodes with a legend
- Hover tooltips showing ARN + all metadata properties
- Service filter dropdown (show only Lambda, EC2, RDS, etc.)
- Adjustable link-distance and charge-strength physics sliders
- Edge labels on every arrow
## Installation
### Prerequisites
- Python 3.9.1 or higher
- Prowler installed and configured (see [Prowler documentation](https://docs.prowler.com/))
### Setup
1. Clone or download this directory to your local machine
2. Ensure Prowler is installed and working
3. No additional dependencies required beyond Prowler's existing requirements
## Usage
### Basic Usage
Run Prowler with your desired checks, then use the inventory graph script:
```bash
# Run Prowler scan (example)
prowler aws --output-formats csv
# Generate inventory graph from the scan
python contrib/inventory-graph/inventory_graph.py --output-directory ./output
```
### Command-Line Options
```bash
python contrib/inventory-graph/inventory_graph.py [OPTIONS]
Options:
--output-directory DIR Directory to save output files (default: ./output)
--output-filename NAME Base filename without extension (default: prowler-inventory-<timestamp>)
--help Show this help message and exit
```
### Example Workflow
```bash
# 1. Run a Prowler scan on your AWS account
prowler aws --profile my-aws-profile --output-formats csv html
# 2. Generate the inventory graph
python contrib/inventory-graph/inventory_graph.py \
--output-directory ./output \
--output-filename my-aws-inventory
# 3. Open the HTML file in your browser
open output/my-aws-inventory.inventory.html
```
### Integration with Prowler Scans
The tool reads from already-loaded AWS service clients in memory (via `sys.modules`). This means:
- **Zero extra AWS API calls** Uses data already collected during the Prowler scan
- **Graceful degradation** Services not scanned are silently skipped
- **Flexible** Works with any subset of Prowler checks
## Output Files
### JSON Output (`*.inventory.json`)
Machine-readable graph structure:
```json
{
"generated_at": "2026-03-19T12:34:56Z",
"nodes": [
{
"id": "arn:aws:lambda:us-east-1:123456789012:function:my-function",
"type": "lambda_function",
"name": "my-function",
"service": "lambda",
"region": "us-east-1",
"account_id": "123456789012",
"properties": {
"runtime": "python3.9",
"vpc_id": "vpc-abc123"
}
}
],
"edges": [
{
"source_id": "arn:aws:lambda:...",
"target_id": "arn:aws:ec2:...:vpc/vpc-abc123",
"edge_type": "network",
"label": "in-vpc"
}
],
"stats": {
"node_count": 42,
"edge_count": 87
}
}
```
### HTML Output (`*.inventory.html`)
Self-contained interactive visualization that opens in any modern browser. No server or build step required.
## Architecture
### Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Read from sys.modules** | Zero extra AWS API calls; services not scanned are silently skipped |
| **Self-contained HTML** | D3.js v7 via CDN; no server, no build step; opens in any browser |
| **One extractor per service** | Each extractor is independently testable; adding a new service = one new file + one line in the registry |
| **Typed edges** | Semantic types allow downstream consumers (attack-path tools, Neo4j import) to filter by relationship class |
### Project Structure
```
contrib/inventory-graph/
├── README.md # This file
├── inventory_graph.py # Main entry point script
├── lib/
│ ├── __init__.py
│ ├── models.py # ResourceNode, ResourceEdge, ConnectivityGraph dataclasses
│ ├── graph_builder.py # Reads loaded service clients from sys.modules
│ ├── inventory_output.py # write_json(), write_html()
│ └── extractors/
│ ├── __init__.py
│ ├── lambda_extractor.py # Lambda functions → VPC/subnet/SG/event-sources/layers/DLQ/KMS
│ ├── ec2_extractor.py # EC2 instances + security groups → subnet/VPC
│ ├── vpc_extractor.py # VPCs, subnets, peering connections
│ ├── rds_extractor.py # RDS instances → VPC/SG/cluster/KMS
│ ├── elbv2_extractor.py # ALB/NLB load balancers → SG/VPC
│ ├── s3_extractor.py # S3 buckets → replication targets/logging buckets/KMS keys
│ └── iam_extractor.py # IAM roles + trust-relationship edges
└── examples/
└── sample_output.html # Example output (optional)
```
## Testing
### Smoke Test (No AWS Credentials Needed)
```python
import sys
from unittest.mock import MagicMock
# Wire a fake Lambda client
mock_module = MagicMock()
mock_fn = MagicMock()
mock_fn.arn = "arn:aws:lambda:us-east-1:123:function:test"
mock_fn.name = "test"
mock_fn.region = "us-east-1"
mock_fn.vpc_id = "vpc-abc"
mock_fn.security_groups = ["sg-111"]
mock_fn.subnet_ids = {"subnet-aaa"}
mock_fn.environment = None
mock_fn.kms_key_arn = None
mock_fn.layers = []
mock_fn.dead_letter_config = None
mock_fn.event_source_mappings = []
mock_module.awslambda_client.functions = {mock_fn.arn: mock_fn}
mock_module.awslambda_client.audited_account = "123"
sys.modules["prowler.providers.aws.services.awslambda.awslambda_client"] = mock_module
from contrib.inventory_graph.lib.graph_builder import build_graph
from contrib.inventory_graph.lib.inventory_output import write_json, write_html
graph = build_graph()
write_json(graph, "/tmp/test.inventory.json")
write_html(graph, "/tmp/test.inventory.html")
# Open /tmp/test.inventory.html in a browser
```
## Extending
### Adding a New Service
1. Create a new extractor file in `lib/extractors/` (e.g., `dynamodb_extractor.py`)
2. Implement the `extract(client)` function that returns `(nodes, edges)`
3. Register it in `lib/graph_builder.py` in the `_SERVICE_REGISTRY` tuple
Example extractor template:
```python
from typing import List, Tuple
from prowler.lib.outputs.inventory.models import ResourceNode, ResourceEdge
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""Extract DynamoDB tables and their relationships."""
nodes = []
edges = []
for table in client.tables:
nodes.append(
ResourceNode(
id=table.arn,
type="dynamodb_table",
name=table.name,
service="dynamodb",
region=table.region,
account_id=client.audited_account,
properties={"billing_mode": table.billing_mode}
)
)
# Add edges for KMS encryption, streams, etc.
if table.kms_key_arn:
edges.append(
ResourceEdge(
source_id=table.kms_key_arn,
target_id=table.arn,
edge_type="encrypts",
label="encrypts"
)
)
return nodes, edges
```
## Troubleshooting
### No nodes discovered
**Problem:** The tool reports "no nodes discovered" after running.
**Solution:** Ensure you've run a Prowler scan first. The tool reads from in-memory service clients loaded during the scan. If no services were scanned, no nodes will be discovered.
### Missing services in the graph
**Problem:** Some AWS services are not appearing in the graph.
**Solution:** The tool only includes services that have been scanned by Prowler. Run Prowler with the services you want to include, or run without service filters to scan all available services.
### HTML file doesn't display properly
**Problem:** The HTML visualization doesn't load or shows errors.
**Solution:**
- Ensure you're opening the file in a modern browser (Chrome, Firefox, Safari, Edge)
- Check your browser's console for JavaScript errors
- Verify the file was generated completely (check file size > 0)
- The HTML requires internet access to load D3.js from CDN
## Roadmap
Potential future enhancements:
- [ ] Support for additional AWS services (DynamoDB, SQS, SNS, etc.)
- [ ] Export to Neo4j / Cartography format
- [ ] Attack path analysis integration
- [ ] Multi-account/multi-region aggregation
- [ ] Custom edge type filtering in HTML UI
- [ ] Graph diff between two scans
## Contributing
This is a community contribution. If you'd like to enhance it:
1. Fork the Prowler repository
2. Make your changes in `contrib/inventory-graph/`
3. Test thoroughly
4. Submit a pull request with a clear description
## License
This tool is part of the Prowler project and is licensed under the Apache License 2.0.
## Credits
- **Author:** [@sandiyochristan](https://github.com/sandiyochristan)
- **Related PR:** [#10382](https://github.com/prowler-cloud/prowler/pull/10382)
- **Prowler Project:** [prowler-cloud/prowler](https://github.com/prowler-cloud/prowler)
## Support
For issues or questions:
- Open an issue in the [Prowler repository](https://github.com/prowler-cloud/prowler/issues)
- Join the [Prowler Community Slack](https://goto.prowler.com/slack)
- Tag your issue with `contrib:inventory-graph`
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
Example: Generate AWS Inventory Graph with Mock Data
This example demonstrates how to use the inventory graph tool with mock AWS data.
No AWS credentials required.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from lib.graph_builder import build_graph
from lib.inventory_output import write_json, write_html
def create_mock_lambda_client():
"""Create a mock Lambda client with sample data."""
mock_module = MagicMock()
# Create a mock Lambda function
mock_fn = MagicMock()
mock_fn.arn = "arn:aws:lambda:us-east-1:123456789012:function:my-test-function"
mock_fn.name = "my-test-function"
mock_fn.region = "us-east-1"
mock_fn.vpc_id = "vpc-abc123"
mock_fn.security_groups = ["sg-111222"]
mock_fn.subnet_ids = {"subnet-aaa111", "subnet-bbb222"}
mock_fn.environment = {"Variables": {"ENV": "production"}}
mock_fn.kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
mock_fn.layers = []
mock_fn.dead_letter_config = None
mock_fn.event_source_mappings = []
mock_module.awslambda_client.functions = {mock_fn.arn: mock_fn}
mock_module.awslambda_client.audited_account = "123456789012"
return mock_module
def create_mock_ec2_client():
"""Create a mock EC2 client with sample data."""
mock_module = MagicMock()
# Create a mock EC2 instance
mock_instance = MagicMock()
mock_instance.arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"
mock_instance.id = "i-1234567890abcdef0"
mock_instance.region = "us-east-1"
mock_instance.vpc_id = "vpc-abc123"
mock_instance.subnet_id = "subnet-aaa111"
mock_instance.security_groups = [MagicMock(id="sg-111222")]
mock_instance.state = "running"
mock_instance.type = "t3.micro"
mock_instance.tags = [{"Key": "Name", "Value": "test-instance"}]
# Create a mock security group
mock_sg = MagicMock()
mock_sg.arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-111222"
mock_sg.id = "sg-111222"
mock_sg.name = "test-security-group"
mock_sg.region = "us-east-1"
mock_sg.vpc_id = "vpc-abc123"
mock_module.ec2_client.instances = [mock_instance]
mock_module.ec2_client.security_groups = [mock_sg]
mock_module.ec2_client.audited_account = "123456789012"
return mock_module
def create_mock_vpc_client():
"""Create a mock VPC client with sample data."""
mock_module = MagicMock()
# Create a mock VPC
mock_vpc = MagicMock()
mock_vpc.arn = "arn:aws:ec2:us-east-1:123456789012:vpc/vpc-abc123"
mock_vpc.id = "vpc-abc123"
mock_vpc.region = "us-east-1"
mock_vpc.cidr_block = "10.0.0.0/16"
mock_vpc.tags = [{"Key": "Name", "Value": "test-vpc"}]
# Create mock subnets
mock_subnet1 = MagicMock()
mock_subnet1.arn = "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-aaa111"
mock_subnet1.id = "subnet-aaa111"
mock_subnet1.region = "us-east-1"
mock_subnet1.vpc_id = "vpc-abc123"
mock_subnet1.cidr_block = "10.0.1.0/24"
mock_subnet1.availability_zone = "us-east-1a"
mock_subnet2 = MagicMock()
mock_subnet2.arn = "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-bbb222"
mock_subnet2.id = "subnet-bbb222"
mock_subnet2.region = "us-east-1"
mock_subnet2.vpc_id = "vpc-abc123"
mock_subnet2.cidr_block = "10.0.2.0/24"
mock_subnet2.availability_zone = "us-east-1b"
mock_module.vpc_client.vpcs = [mock_vpc]
mock_module.vpc_client.subnets = [mock_subnet1, mock_subnet2]
mock_module.vpc_client.vpc_peering_connections = []
mock_module.vpc_client.audited_account = "123456789012"
return mock_module
def main():
"""Main function to demonstrate the inventory graph generation."""
print("=" * 70)
print("AWS Inventory Graph - Mock Data Example")
print("=" * 70)
print()
# Create mock clients and inject them into sys.modules
print("Creating mock AWS service clients...")
sys.modules["prowler.providers.aws.services.awslambda.awslambda_client"] = create_mock_lambda_client()
sys.modules["prowler.providers.aws.services.ec2.ec2_client"] = create_mock_ec2_client()
sys.modules["prowler.providers.aws.services.vpc.vpc_client"] = create_mock_vpc_client()
print("✓ Mock clients created")
print()
# Build the graph
print("Building connectivity graph...")
graph = build_graph()
print(f"✓ Graph built: {len(graph.nodes)} nodes, {len(graph.edges)} edges")
print()
# Display discovered nodes
print("Discovered nodes:")
for node in graph.nodes:
print(f" - {node.type}: {node.name} ({node.region})")
print()
# Display discovered edges
print("Discovered edges:")
for edge in graph.edges:
source_node = next((n for n in graph.nodes if n.id == edge.source_id), None)
target_node = next((n for n in graph.nodes if n.id == edge.target_id), None)
source_name = source_node.name if source_node else edge.source_id
target_name = target_node.name if target_node else edge.target_id
print(f" - {source_name} --[{edge.edge_type}]--> {target_name}")
print()
# Write outputs
output_dir = Path(__file__).parent
json_path = output_dir / "example_output.inventory.json"
html_path = output_dir / "example_output.inventory.html"
print("Writing output files...")
write_json(graph, str(json_path))
write_html(graph, str(html_path))
print(f"✓ JSON written to: {json_path}")
print(f"✓ HTML written to: {html_path}")
print()
print("=" * 70)
print("✓ Example complete!")
print("=" * 70)
print()
print(f"Open the HTML file to view the interactive graph:")
print(f" open {html_path}")
print()
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
AWS Inventory Connectivity Graph Generator
A standalone tool that generates interactive connectivity graphs from Prowler AWS scans.
This tool reads from already-loaded AWS service clients in memory and produces:
- JSON graph (nodes + edges)
- Interactive HTML visualization
Usage:
python inventory_graph.py --output-directory ./output --output-filename my-inventory
For more information, see README.md
"""
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
# Add the contrib directory to the path so we can import the lib modules
CONTRIB_DIR = Path(__file__).parent
sys.path.insert(0, str(CONTRIB_DIR))
from lib.graph_builder import build_graph
from lib.inventory_output import write_json, write_html
def parse_arguments():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Generate AWS inventory connectivity graph from Prowler scan data",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate graph with default settings
python inventory_graph.py
# Specify custom output directory and filename
python inventory_graph.py --output-directory ./my-output --output-filename aws-inventory
# After running a Prowler scan
prowler aws --profile my-profile
python inventory_graph.py --output-directory ./output
For more information, see README.md
""",
)
parser.add_argument(
"--output-directory",
"-o",
default="./output",
help="Directory to save output files (default: ./output)",
)
parser.add_argument(
"--output-filename",
"-f",
default=None,
help="Base filename without extension (default: prowler-inventory-<timestamp>)",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Enable verbose output",
)
return parser.parse_args()
def main():
"""Main entry point for the inventory graph generator."""
args = parse_arguments()
# Set up output paths
output_dir = Path(args.output_directory)
output_dir.mkdir(parents=True, exist_ok=True)
# Generate filename with timestamp if not provided
if args.output_filename:
base_filename = args.output_filename
else:
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
base_filename = f"prowler-inventory-{timestamp}"
json_path = output_dir / f"{base_filename}.inventory.json"
html_path = output_dir / f"{base_filename}.inventory.html"
print("=" * 70)
print("AWS Inventory Connectivity Graph Generator")
print("=" * 70)
print()
# Build the graph from loaded service clients
if args.verbose:
print("Building connectivity graph from loaded AWS service clients...")
graph = build_graph()
# Check if any nodes were discovered
if not graph.nodes:
print("⚠️ WARNING: No nodes discovered!")
print()
print("This usually means:")
print(" 1. No Prowler scan has been run yet in this Python session")
print(" 2. No AWS service clients are loaded in memory")
print()
print("To fix this:")
print(" 1. Run a Prowler scan first: prowler aws --output-formats csv")
print(" 2. Then run this script in the same session")
print()
print("Alternatively, integrate this tool directly into Prowler's output pipeline.")
sys.exit(1)
print(f"✓ Discovered {len(graph.nodes)} nodes and {len(graph.edges)} edges")
print()
# Write outputs
if args.verbose:
print(f"Writing JSON output to: {json_path}")
write_json(graph, str(json_path))
if args.verbose:
print(f"Writing HTML output to: {html_path}")
write_html(graph, str(html_path))
print()
print("=" * 70)
print("✓ Graph generation complete!")
print("=" * 70)
print()
print(f"📄 JSON: {json_path}")
print(f"🌐 HTML: {html_path}")
print()
print(f"Open the HTML file in your browser to explore the interactive graph:")
print(f" open {html_path}")
print()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nInterrupted by user. Exiting...")
sys.exit(130)
except Exception as e:
print(f"\n❌ Error: {e}", file=sys.stderr)
if "--verbose" in sys.argv or "-v" in sys.argv:
import traceback
traceback.print_exc()
sys.exit(1)
@@ -0,0 +1,92 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract EC2 instance and security-group nodes with their edges.
Edges produced:
- instance → security-group [network]
- instance → subnet [network]
- security-group → VPC [network]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
# EC2 Instances
for instance in client.instances:
name = instance.id
for tag in instance.tags or []:
if tag.get("Key") == "Name":
name = tag["Value"]
break
props = {
"instance_type": getattr(instance, "type", None),
"state": getattr(instance, "state", None),
"vpc_id": getattr(instance, "vpc_id", None),
"subnet_id": getattr(instance, "subnet_id", None),
"public_ip": getattr(instance, "public_ip_address", None),
"private_ip": getattr(instance, "private_ip_address", None),
}
nodes.append(
ResourceNode(
id=instance.arn,
type="ec2_instance",
name=name,
service="ec2",
region=instance.region,
account_id=client.audited_account,
properties={k: v for k, v in props.items() if v is not None},
)
)
for sg_id in instance.security_groups or []:
edges.append(
ResourceEdge(
source_id=instance.arn,
target_id=sg_id,
edge_type="network",
label="sg",
)
)
if instance.subnet_id:
edges.append(
ResourceEdge(
source_id=instance.arn,
target_id=instance.subnet_id,
edge_type="network",
label="subnet",
)
)
# Security Groups
for sg in client.security_groups.values():
name = sg.name if hasattr(sg, "name") else sg.id if hasattr(sg, "id") else sg.arn
nodes.append(
ResourceNode(
id=sg.arn,
type="security_group",
name=name,
service="ec2",
region=sg.region,
account_id=client.audited_account,
properties={"vpc_id": sg.vpc_id},
)
)
if sg.vpc_id:
edges.append(
ResourceEdge(
source_id=sg.arn,
target_id=sg.vpc_id,
edge_type="network",
label="in-vpc",
)
)
return nodes, edges
@@ -0,0 +1,60 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract ELBv2 (ALB/NLB) load balancer nodes and their edges.
Edges produced:
- load_balancer → security-group [network]
- load_balancer → VPC [network]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
for lb in client.loadbalancersv2.values():
props = {
"type": getattr(lb, "type", None),
"scheme": getattr(lb, "scheme", None),
"dns_name": getattr(lb, "dns", None),
"vpc_id": getattr(lb, "vpc_id", None),
}
name = getattr(lb, "name", lb.arn.split("/")[-2] if "/" in lb.arn else lb.arn)
nodes.append(
ResourceNode(
id=lb.arn,
type="load_balancer",
name=name,
service="elbv2",
region=lb.region,
account_id=client.audited_account,
properties={k: v for k, v in props.items() if v is not None},
)
)
for sg_id in lb.security_groups or []:
edges.append(
ResourceEdge(
source_id=lb.arn,
target_id=sg_id,
edge_type="network",
label="sg",
)
)
vpc_id = getattr(lb, "vpc_id", None)
if vpc_id:
edges.append(
ResourceEdge(
source_id=lb.arn,
target_id=vpc_id,
edge_type="network",
label="in-vpc",
)
)
return nodes, edges
@@ -0,0 +1,82 @@
import json
from typing import Any, Dict, List, Tuple
from prowler.lib.logger import logger
from lib.models import ResourceEdge, ResourceNode
def _parse_trust_principals(assume_role_policy: Any) -> List[str]:
"""
Return a flat list of principal strings from an IAM assume-role policy document.
The policy may be a dict already or a JSON string.
"""
if not assume_role_policy:
return []
if isinstance(assume_role_policy, str):
try:
assume_role_policy = json.loads(assume_role_policy)
except (json.JSONDecodeError, ValueError):
return []
principals = []
for statement in assume_role_policy.get("Statement", []):
principal = statement.get("Principal", {})
if isinstance(principal, str):
principals.append(principal)
elif isinstance(principal, dict):
for v in principal.values():
if isinstance(v, list):
principals.extend(v)
else:
principals.append(v)
elif isinstance(principal, list):
principals.extend(principal)
return principals
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract IAM role nodes and their trust-relationship edges.
Edges produced:
- trusted-principal → role [iam] (who can assume this role)
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
for role in client.roles:
props: Dict[str, Any] = {
"path": getattr(role, "path", None),
"create_date": str(getattr(role, "create_date", "") or ""),
}
nodes.append(
ResourceNode(
id=role.arn,
type="iam_role",
name=role.name,
service="iam",
region="global",
account_id=client.audited_account,
properties={k: v for k, v in props.items() if v},
)
)
# Trust-relationship edges: principal → role (principal CAN assume role)
try:
for principal in _parse_trust_principals(role.assume_role_policy):
if principal and principal != "*":
edges.append(
ResourceEdge(
source_id=principal,
target_id=role.arn,
edge_type="iam",
label="can-assume",
)
)
except Exception as e:
logger.debug(f"inventory iam_extractor: could not parse trust policy for {role.arn}: {e}")
return nodes, edges
@@ -0,0 +1,118 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract Lambda function nodes and their edges from an awslambda_client.
Edges produced:
- lambda → VPC [network]
- lambda → subnet [network]
- lambda → sg [network]
- lambda → event-source[triggers] (from EventSourceMapping)
- lambda → layer ARN [depends_on]
- lambda → DLQ target [data_flow]
- lambda → KMS key [encrypts]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
for fn in client.functions.values():
props = {
"runtime": fn.runtime,
"vpc_id": fn.vpc_id,
}
if fn.environment:
props["has_env_vars"] = True
if fn.kms_key_arn:
props["kms_key_arn"] = fn.kms_key_arn
nodes.append(
ResourceNode(
id=fn.arn,
type="lambda_function",
name=fn.name,
service="lambda",
region=fn.region,
account_id=client.audited_account,
properties=props,
)
)
# Network edges → VPC, subnets, security groups
if fn.vpc_id:
edges.append(
ResourceEdge(
source_id=fn.arn,
target_id=fn.vpc_id,
edge_type="network",
label="in-vpc",
)
)
for sg_id in fn.security_groups or []:
edges.append(
ResourceEdge(
source_id=fn.arn,
target_id=sg_id,
edge_type="network",
label="sg",
)
)
for subnet_id in fn.subnet_ids or set():
edges.append(
ResourceEdge(
source_id=fn.arn,
target_id=subnet_id,
edge_type="network",
label="subnet",
)
)
# Trigger edges from event source mappings
for esm in getattr(fn, "event_source_mappings", []):
edges.append(
ResourceEdge(
source_id=esm.event_source_arn,
target_id=fn.arn,
edge_type="triggers",
label=f"esm:{esm.state}",
)
)
# Layer dependency edges
for layer in getattr(fn, "layers", []):
edges.append(
ResourceEdge(
source_id=fn.arn,
target_id=layer.arn,
edge_type="depends_on",
label="layer",
)
)
# Dead-letter queue data-flow edge
dlq = getattr(fn, "dead_letter_config", None)
if dlq and dlq.target_arn:
edges.append(
ResourceEdge(
source_id=fn.arn,
target_id=dlq.target_arn,
edge_type="data_flow",
label="dlq",
)
)
# KMS encryption edge
if fn.kms_key_arn:
edges.append(
ResourceEdge(
source_id=fn.kms_key_arn,
target_id=fn.arn,
edge_type="encrypts",
label="kms",
)
)
return nodes, edges
@@ -0,0 +1,86 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract RDS DB instance nodes and their edges.
Edges produced:
- db_instance → security-group [network]
- db_instance → VPC [network]
- db_instance → cluster [depends_on]
- db_instance → KMS key [encrypts]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
for db in client.db_instances.values():
props = {
"engine": getattr(db, "engine", None),
"engine_version": getattr(db, "engine_version", None),
"instance_class": getattr(db, "db_instance_class", None),
"vpc_id": getattr(db, "vpc_id", None),
"multi_az": getattr(db, "multi_az", None),
"publicly_accessible": getattr(db, "publicly_accessible", None),
"storage_encrypted": getattr(db, "storage_encrypted", None),
}
nodes.append(
ResourceNode(
id=db.arn,
type="rds_instance",
name=db.id,
service="rds",
region=db.region,
account_id=client.audited_account,
properties={k: v for k, v in props.items() if v is not None},
)
)
for sg in getattr(db, "security_groups", []):
sg_id = sg if isinstance(sg, str) else getattr(sg, "id", str(sg))
edges.append(
ResourceEdge(
source_id=db.arn,
target_id=sg_id,
edge_type="network",
label="sg",
)
)
vpc_id = getattr(db, "vpc_id", None)
if vpc_id:
edges.append(
ResourceEdge(
source_id=db.arn,
target_id=vpc_id,
edge_type="network",
label="in-vpc",
)
)
cluster_arn = getattr(db, "cluster_arn", None)
if cluster_arn:
edges.append(
ResourceEdge(
source_id=db.arn,
target_id=cluster_arn,
edge_type="depends_on",
label="cluster-member",
)
)
kms_key_id = getattr(db, "kms_key_id", None)
if kms_key_id:
edges.append(
ResourceEdge(
source_id=kms_key_id,
target_id=db.arn,
edge_type="encrypts",
label="kms",
)
)
return nodes, edges
@@ -0,0 +1,92 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract S3 bucket nodes and their edges.
Edges produced:
- bucket → replication-target bucket [replicates_to]
- bucket → KMS key [encrypts]
- bucket → logging bucket [logs_to]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
for bucket in client.buckets.values():
encryption = getattr(bucket, "encryption", None)
versioning = getattr(bucket, "versioning_enabled", None)
logging = getattr(bucket, "logging", None)
public = getattr(bucket, "public_access_block", None)
props = {}
if versioning is not None:
props["versioning"] = versioning
if encryption:
enc_type = getattr(encryption, "type", str(encryption))
props["encryption"] = enc_type
nodes.append(
ResourceNode(
id=bucket.arn,
type="s3_bucket",
name=bucket.name,
service="s3",
region=bucket.region,
account_id=client.audited_account,
properties=props,
)
)
# Replication edges
for rule in getattr(bucket, "replication_rules", None) or []:
dest_bucket = getattr(rule, "destination_bucket", None)
if dest_bucket:
dest_arn = (
dest_bucket
if dest_bucket.startswith("arn:")
else f"arn:aws:s3:::{dest_bucket}"
)
edges.append(
ResourceEdge(
source_id=bucket.arn,
target_id=dest_arn,
edge_type="replicates_to",
label="s3-replication",
)
)
# Logging edges
if logging:
target_bucket = getattr(logging, "target_bucket", None)
if target_bucket:
target_arn = (
target_bucket
if target_bucket.startswith("arn:")
else f"arn:aws:s3:::{target_bucket}"
)
edges.append(
ResourceEdge(
source_id=bucket.arn,
target_id=target_arn,
edge_type="logs_to",
label="access-logs",
)
)
# KMS encryption edges
if encryption:
kms_arn = getattr(encryption, "kms_master_key_id", None)
if kms_arn:
edges.append(
ResourceEdge(
source_id=kms_arn,
target_id=bucket.arn,
edge_type="encrypts",
label="kms",
)
)
return nodes, edges
@@ -0,0 +1,92 @@
from typing import List, Tuple
from lib.models import ResourceEdge, ResourceNode
def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]:
"""
Extract VPC and subnet nodes with their edges.
Edges produced:
- subnet → VPC [depends_on]
- peering connection between VPCs [network]
"""
nodes: List[ResourceNode] = []
edges: List[ResourceEdge] = []
# VPCs
for vpc in client.vpcs.values():
name = vpc.id if hasattr(vpc, "id") else vpc.arn
for tag in vpc.tags or []:
if isinstance(tag, dict) and tag.get("Key") == "Name":
name = tag["Value"]
break
nodes.append(
ResourceNode(
id=vpc.arn,
type="vpc",
name=name,
service="vpc",
region=vpc.region,
account_id=client.audited_account,
properties={
"cidr_block": getattr(vpc, "cidr_block", None),
"is_default": getattr(vpc, "is_default", None),
},
)
)
# VPC Subnets
for subnet in client.vpc_subnets.values():
name = subnet.id if hasattr(subnet, "id") else subnet.arn
for tag in getattr(subnet, "tags", None) or []:
if isinstance(tag, dict) and tag.get("Key") == "Name":
name = tag["Value"]
break
nodes.append(
ResourceNode(
id=subnet.arn,
type="subnet",
name=name,
service="vpc",
region=subnet.region,
account_id=client.audited_account,
properties={
"vpc_id": getattr(subnet, "vpc_id", None),
"cidr_block": getattr(subnet, "cidr_block", None),
"availability_zone": getattr(subnet, "availability_zone", None),
"public": getattr(subnet, "public", None),
},
)
)
vpc_id = getattr(subnet, "vpc_id", None)
if vpc_id:
# Find the VPC ARN for this vpc_id
vpc_arn = next(
(v.arn for v in client.vpcs.values() if v.id == vpc_id),
vpc_id,
)
edges.append(
ResourceEdge(
source_id=subnet.arn,
target_id=vpc_arn,
edge_type="depends_on",
label="subnet-of",
)
)
# VPC Peering Connections
for peering in getattr(client, "vpc_peering_connections", {}).values():
edges.append(
ResourceEdge(
source_id=peering.arn,
target_id=getattr(peering, "accepter_vpc_id", peering.arn),
edge_type="network",
label="vpc-peer",
)
)
return nodes, edges
@@ -0,0 +1,103 @@
"""
graph_builder.py
----------------
Builds a ConnectivityGraph by reading already-loaded AWS service clients from
sys.modules. Only services that were actually scanned (i.e. whose client
module is already imported) contribute nodes and edges. Unknown / unloaded
services are silently skipped, so the output degrades gracefully when only a
subset of checks has been run.
"""
import sys
from typing import Tuple
from prowler.lib.logger import logger
from lib.models import ConnectivityGraph
# Registry: (sys.modules key, attribute name inside that module, extractor module path)
_SERVICE_REGISTRY: Tuple[Tuple[str, str, str], ...] = (
(
"prowler.providers.aws.services.awslambda.awslambda_client",
"awslambda_client",
"lib.extractors.lambda_extractor",
),
(
"prowler.providers.aws.services.ec2.ec2_client",
"ec2_client",
"lib.extractors.ec2_extractor",
),
(
"prowler.providers.aws.services.vpc.vpc_client",
"vpc_client",
"lib.extractors.vpc_extractor",
),
(
"prowler.providers.aws.services.rds.rds_client",
"rds_client",
"lib.extractors.rds_extractor",
),
(
"prowler.providers.aws.services.elbv2.elbv2_client",
"elbv2_client",
"lib.extractors.elbv2_extractor",
),
(
"prowler.providers.aws.services.s3.s3_client",
"s3_client",
"lib.extractors.s3_extractor",
),
(
"prowler.providers.aws.services.iam.iam_client",
"iam_client",
"lib.extractors.iam_extractor",
),
)
def build_graph() -> ConnectivityGraph:
"""
Iterate over every registered service, check whether its client module is
already loaded, and call the corresponding extractor.
Returns a ConnectivityGraph with all discovered nodes and edges.
Duplicate node IDs are silently deduplicated (first occurrence wins).
"""
graph = ConnectivityGraph()
seen_node_ids: set = set()
for client_module_key, client_attr, extractor_module_key in _SERVICE_REGISTRY:
client_module = sys.modules.get(client_module_key)
if client_module is None:
continue
service_client = getattr(client_module, client_attr, None)
if service_client is None:
continue
extractor_module = sys.modules.get(extractor_module_key)
if extractor_module is None:
try:
import importlib
extractor_module = importlib.import_module(extractor_module_key)
except ImportError as e:
logger.debug(f"inventory graph_builder: cannot import extractor {extractor_module_key}: {e}")
continue
try:
nodes, edges = extractor_module.extract(service_client)
except Exception as e:
logger.error(
f"inventory graph_builder: extractor {extractor_module_key} failed: "
f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}"
)
continue
for node in nodes:
if node.id not in seen_node_ids:
graph.add_node(node)
seen_node_ids.add(node.id)
for edge in edges:
graph.add_edge(edge)
return graph
@@ -0,0 +1,500 @@
"""
inventory_output.py
-------------------
Writes the ConnectivityGraph produced by graph_builder to two files:
<output_path>.inventory.json machine-readable graph (nodes + edges)
<output_path>.inventory.html interactive D3.js force-directed graph
"""
import json
import os
from dataclasses import asdict
from datetime import datetime
from typing import Optional
from prowler.lib.logger import logger
from lib.models import ConnectivityGraph
# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------
def write_json(graph: ConnectivityGraph, file_path: str) -> None:
"""Serialise the graph to a JSON file."""
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
data = {
"generated_at": datetime.utcnow().isoformat() + "Z",
"nodes": [asdict(n) for n in graph.nodes],
"edges": [asdict(e) for e in graph.edges],
"stats": {
"node_count": len(graph.nodes),
"edge_count": len(graph.edges),
},
}
with open(file_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, default=str)
logger.info(f"Inventory graph JSON written to {file_path}")
except Exception as e:
logger.error(
f"inventory_output.write_json: {e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}"
)
# ---------------------------------------------------------------------------
# HTML output (self-contained, D3.js CDN)
# ---------------------------------------------------------------------------
# Colour palette per node type
_NODE_COLOURS = {
"lambda_function": "#f59e0b",
"ec2_instance": "#3b82f6",
"security_group": "#6366f1",
"vpc": "#10b981",
"subnet": "#34d399",
"rds_instance": "#ef4444",
"load_balancer": "#8b5cf6",
"s3_bucket": "#06b6d4",
"iam_role": "#f97316",
"default": "#94a3b8",
}
# Edge stroke colours per edge type
_EDGE_COLOURS = {
"network": "#64748b",
"iam": "#f97316",
"triggers": "#a855f7",
"data_flow": "#0ea5e9",
"depends_on": "#94a3b8",
"routes_to": "#22c55e",
"replicates_to": "#ec4899",
"encrypts": "#eab308",
"logs_to": "#78716c",
}
_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Prowler AWS Connectivity Graph</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
}}
#header {{
padding: 12px 20px;
background: #1e293b;
border-bottom: 1px solid #334155;
display: flex;
align-items: center;
gap: 16px;
}}
#header h1 {{ margin: 0; font-size: 18px; font-weight: 700; }}
#header .stats {{ font-size: 13px; color: #94a3b8; }}
#controls {{
padding: 8px 20px;
background: #1e293b;
border-bottom: 1px solid #334155;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}}
#controls label {{ font-size: 12px; color: #94a3b8; }}
#controls select, #controls input[type=range] {{
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
}}
#graph-container {{ width: 100%; height: calc(100vh - 100px); position: relative; }}
svg {{ width: 100%; height: 100%; }}
.node circle {{
stroke: #1e293b;
stroke-width: 1.5px;
cursor: pointer;
transition: r 0.15s;
}}
.node circle:hover {{ stroke-width: 3px; }}
.node text {{
font-size: 10px;
fill: #e2e8f0;
pointer-events: none;
text-shadow: 0 0 4px #0f172a;
}}
.link {{
stroke-opacity: 0.6;
stroke-width: 1.5px;
}}
.link-label {{
font-size: 8px;
fill: #94a3b8;
pointer-events: none;
}}
#tooltip {{
position: fixed;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
pointer-events: none;
max-width: 320px;
word-break: break-all;
z-index: 9999;
display: none;
}}
#tooltip strong {{ color: #f8fafc; }}
#tooltip .prop {{ color: #94a3b8; margin-top: 4px; }}
#legend {{
position: absolute;
top: 10px;
right: 10px;
background: rgba(30,41,59,0.9);
border: 1px solid #334155;
border-radius: 6px;
padding: 10px 14px;
font-size: 11px;
}}
#legend h3 {{ margin: 0 0 6px; font-size: 12px; }}
.legend-row {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; }}
.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
.legend-line {{ width: 20px; height: 2px; flex-shrink: 0; }}
</style>
</head>
<body>
<div id="header">
<h1>🔗 AWS Connectivity Graph</h1>
<span class="stats" id="stat-label">Generated: {generated_at}</span>
</div>
<div id="controls">
<label>Filter service:
<select id="filter-service">
<option value="">All services</option>
</select>
</label>
<label>Link distance:
<input type="range" id="link-distance" min="40" max="300" value="120"/>
</label>
<label>Charge strength:
<input type="range" id="charge-strength" min="-800" max="-20" value="-250"/>
</label>
<span class="stats" id="visible-count"></span>
</div>
<div id="graph-container">
<svg id="graph-svg"></svg>
<div id="tooltip"></div>
<div id="legend">
<h3>Node types</h3>
{legend_nodes_html}
<h3 style="margin-top:8px">Edge types</h3>
{legend_edges_html}
</div>
</div>
<script>
const RAW_NODES = {nodes_json};
const RAW_EDGES = {edges_json};
const NODE_COLOURS = {node_colours_json};
const EDGE_COLOURS = {edge_colours_json};
// ── helpers ──────────────────────────────────────────────────────────────
function nodeColour(d) {{
return NODE_COLOURS[d.type] || NODE_COLOURS["default"];
}}
function edgeColour(d) {{
return EDGE_COLOURS[d.edge_type] || "#94a3b8";
}}
function nodeRadius(d) {{
const base = {{
lambda_function: 9, ec2_instance: 10, vpc: 14, subnet: 8,
security_group: 7, rds_instance: 11, load_balancer: 12,
s3_bucket: 9, iam_role: 9
}};
return base[d.type] || 8;
}}
// ── filter controls ───────────────────────────────────────────────────────
const services = [...new Set(RAW_NODES.map(n => n.service))].sort();
const sel = document.getElementById("filter-service");
services.forEach(s => {{
const o = document.createElement("option");
o.value = s; o.textContent = s;
sel.appendChild(o);
}});
// ── D3 setup ──────────────────────────────────────────────────────────────
const svg = d3.select("#graph-svg");
const container = svg.append("g");
// zoom
svg.call(
d3.zoom().scaleExtent([0.05, 8])
.on("zoom", e => container.attr("transform", e.transform))
);
// arrowhead marker
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20).attr("refY", 0)
.attr("markerWidth", 6).attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#94a3b8");
// tooltip
const tooltip = document.getElementById("tooltip");
// ── simulation ────────────────────────────────────────────────────────────
let simulation, linkSel, nodeSel, labelSel;
function buildGraph(nodeFilter) {{
// Determine which nodes to show
const visibleNodes = nodeFilter
? RAW_NODES.filter(n => n.service === nodeFilter)
: RAW_NODES;
const visibleIds = new Set(visibleNodes.map(n => n.id));
// Only show edges where BOTH endpoints are visible
const visibleEdges = RAW_EDGES.filter(
e => visibleIds.has(e.source_id) && visibleIds.has(e.target_id)
);
document.getElementById("visible-count").textContent =
`Showing ${{visibleNodes.length}} nodes · ${{visibleEdges.length}} edges`;
container.selectAll("*").remove();
if (simulation) simulation.stop();
const nodes = visibleNodes.map(n => ({{ ...n }}));
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
const links = visibleEdges.map(e => ({{
...e,
source: nodeIndex[e.source_id] || e.source_id,
target: nodeIndex[e.target_id] || e.target_id,
}}));
const dist = +document.getElementById("link-distance").value;
const charge = +document.getElementById("charge-strength").value;
simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(dist))
.force("charge", d3.forceManyBody().strength(charge))
.force("center", d3.forceCenter(
document.getElementById("graph-container").clientWidth / 2,
document.getElementById("graph-container").clientHeight / 2
))
.force("collision", d3.forceCollide().radius(d => nodeRadius(d) + 6));
// Edges
linkSel = container.append("g").attr("class", "links")
.selectAll("line")
.data(links)
.join("line")
.attr("class", "link")
.attr("stroke", edgeColour)
.attr("marker-end", "url(#arrow)");
// Edge labels
labelSel = container.append("g").attr("class", "link-labels")
.selectAll("text")
.data(links)
.join("text")
.attr("class", "link-label")
.text(d => d.label || "");
// Nodes
nodeSel = container.append("g").attr("class", "nodes")
.selectAll("g")
.data(nodes)
.join("g")
.attr("class", "node")
.call(
d3.drag()
.on("start", (event, d) => {{
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}})
.on("drag", (event, d) => {{ d.fx = event.x; d.fy = event.y; }})
.on("end", (event, d) => {{
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}})
)
.on("mouseover", (event, d) => {{
const props = Object.entries(d.properties || {{}})
.map(([k, v]) => `<div class="prop"><b>${{k}}</b>: ${{v}}</div>`)
.join("");
tooltip.innerHTML = `
<strong>${{d.name}}</strong>
<div class="prop"><b>type</b>: ${{d.type}}</div>
<div class="prop"><b>service</b>: ${{d.service}}</div>
<div class="prop"><b>region</b>: ${{d.region}}</div>
<div class="prop"><b>account</b>: ${{d.account_id}}</div>
<div class="prop" style="word-break:break-all"><b>arn</b>: ${{d.id}}</div>
${{props}}
`;
tooltip.style.display = "block";
tooltip.style.left = (event.clientX + 12) + "px";
tooltip.style.top = (event.clientY - 10) + "px";
}})
.on("mousemove", event => {{
tooltip.style.left = (event.clientX + 12) + "px";
tooltip.style.top = (event.clientY - 10) + "px";
}})
.on("mouseout", () => {{ tooltip.style.display = "none"; }});
nodeSel.append("circle")
.attr("r", nodeRadius)
.attr("fill", nodeColour);
nodeSel.append("text")
.attr("dx", d => nodeRadius(d) + 3)
.attr("dy", "0.35em")
.text(d => d.name.length > 24 ? d.name.slice(0, 22) + "" : d.name);
simulation.on("tick", () => {{
linkSel
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
labelSel
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
nodeSel.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
}});
}}
// Initial render
buildGraph(null);
// Filter change
sel.addEventListener("change", () => buildGraph(sel.value || null));
// Simulation control sliders — restart on change
document.getElementById("link-distance").addEventListener("input", () => buildGraph(sel.value || null));
document.getElementById("charge-strength").addEventListener("input", () => buildGraph(sel.value || null));
</script>
</body>
</html>
"""
def _build_legend_html(colours: dict, shape: str) -> str:
rows = []
for key, colour in sorted(colours.items()):
if shape == "dot":
rows.append(
f'<div class="legend-row">'
f'<div class="legend-dot" style="background:{colour}"></div>'
f'<span>{key}</span></div>'
)
else:
rows.append(
f'<div class="legend-row">'
f'<div class="legend-line" style="background:{colour}"></div>'
f'<span>{key}</span></div>'
)
return "\n".join(rows)
def write_html(graph: ConnectivityGraph, file_path: str) -> None:
"""Render the graph as a self-contained interactive HTML page."""
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
nodes_json = json.dumps(
[
{
"id": n.id,
"type": n.type,
"name": n.name,
"service": n.service,
"region": n.region,
"account_id": n.account_id,
"properties": n.properties,
}
for n in graph.nodes
],
indent=None,
default=str,
)
edges_json = json.dumps(
[
{
"source_id": e.source_id,
"target_id": e.target_id,
"edge_type": e.edge_type,
"label": e.label or "",
}
for e in graph.edges
],
indent=None,
default=str,
)
html = _HTML_TEMPLATE.format(
generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
nodes_json=nodes_json,
edges_json=edges_json,
node_colours_json=json.dumps(_NODE_COLOURS),
edge_colours_json=json.dumps(_EDGE_COLOURS),
legend_nodes_html=_build_legend_html(_NODE_COLOURS, "dot"),
legend_edges_html=_build_legend_html(_EDGE_COLOURS, "line"),
)
with open(file_path, "w", encoding="utf-8") as fh:
fh.write(html)
logger.info(f"Inventory graph HTML written to {file_path}")
except Exception as e:
logger.error(
f"inventory_output.write_html: {e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}"
)
# ---------------------------------------------------------------------------
# Convenience entry-point called from __main__.py
# ---------------------------------------------------------------------------
def generate_inventory_outputs(output_path: str) -> None:
"""
Build the connectivity graph from currently-loaded service clients and write
both JSON and HTML outputs.
Args:
output_path: base file path WITHOUT extension, e.g.
"output/prowler-output-20240101120000".
The function appends .inventory.json and .inventory.html.
"""
from lib.graph_builder import build_graph
graph = build_graph()
if not graph.nodes:
logger.warning(
"Inventory graph: no nodes discovered. "
"Make sure at least one AWS service was scanned before generating the inventory."
)
write_json(graph, f"{output_path}.inventory.json")
write_html(graph, f"{output_path}.inventory.html")
+71
View File
@@ -0,0 +1,71 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ResourceNode:
"""
Represents a single AWS resource as a node in the connectivity graph.
id : globally unique identifier — always the resource ARN
type : coarse resource type used for grouping/colour, e.g. "lambda_function"
name : human-readable label shown on the graph
service : AWS service name, e.g. "lambda", "ec2", "rds"
region : AWS region the resource lives in
account_id: AWS account ID
properties: additional resource-specific metadata (runtime, vpc_id, etc.)
"""
id: str
type: str
name: str
service: str
region: str
account_id: str
properties: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ResourceEdge:
"""
Represents a directional relationship between two resource nodes.
source_id : ARN of the source node
target_id : ARN of the target node
edge_type : semantic type of the relationship, e.g.:
"network" resources share a network path (VPC/subnet/SG)
"iam" IAM trust or permission relationship
"triggers" one resource can invoke another (event source → Lambda)
"data_flow" data is written/read (Lambda → SQS dead-letter queue)
"depends_on" soft dependency (Lambda layer, subnet belongs to VPC)
"routes_to" traffic routing (LB → target)
"encrypts" KMS key encrypts the resource
label : optional short label rendered on the edge in the HTML graph
"""
source_id: str
target_id: str
edge_type: str
label: Optional[str] = None
@dataclass
class ConnectivityGraph:
"""
Container for the full inventory connectivity graph.
nodes: all discovered resource nodes
edges: all discovered edges between nodes
"""
nodes: List[ResourceNode] = field(default_factory=list)
edges: List[ResourceEdge] = field(default_factory=list)
def add_node(self, node: ResourceNode) -> None:
self.nodes.append(node)
def add_edge(self, edge: ResourceEdge) -> None:
self.edges.append(edge)
def node_ids(self) -> set:
return {n.id for n in self.nodes}