Dop2Mop
A proof-of-concept OpenGraph collector for BloodHound that maps attack paths from DevOps to MLOps infrastructure.
Based on Pipelines of Privilege: Attack Paths from DevOps to MLOps Infrastructure by Brett Hawkins (@h4wkst3r).
Table of Contents
Acknowledgments
Overview
Dop2Mop collects data from DevOps and MLOps platforms to identify attack paths that enable lateral movement from CI/CD pipelines to machine learning training infrastructure. It outputs BloodHound-compatible OpenGraph JSON for visualization and analysis.
| DevOps Platforms | MLOps Platforms | Identity Providers |
|---|
| GitHub (Actions, Repos, Secrets) | Azure Machine Learning | Azure AD Service Principals |
| Azure DevOps (Pipelines, Service Connections) | Amazon SageMaker | AWS IAM Roles OIDC/Federated Identity |
Trust Boundaries Mapped
Dop2Mop models the five critical trust boundaries identified in the research:
- TB1: Code Repository to CI/CD Pipeline - Automatic triggering of pipelines from code commits
- TB2: Service Principal Authentication - NHI credentials used by pipelines to access ML platforms
- TB3: Container Artifact Trust - Implicit trust in container images from internal registries
- TB4: Job Definition Execution - ML platforms executing job definitions without validation
- TB5: Code Deserialization - Unsafe deserialization of datasets (pickle, joblib)
Installation
git clone https://github.com/h4wkst3r/dop2mop.git
cd dop2mop
pip install -r requirements.txt
pip install -e .
What's Collected
Each collector gathers platform-specific resources and maps the trust boundaries between them:
Quick Start
Generate Demo Data
The fastest way to see Dop2Mop in action is to generate demo data showing the attack scenarios from the research:
dop2mop demo -o demo.json
Ingest into BloodHound
- Open BloodHound CE (v8.0+)
- Navigate to Administration → File Ingest
- Upload the generated JSON file
- Query with Cypher:
// Azure DevOps to Azure ML Lateral Movement
MATCH p=(repo)-[:TriggersPipeline]->(pipeline)-[:UsesServiceConnection]->(svcconn)-[:AuthenticatesAs]->(workspace)-[:CodeExecution]->(compute)
RETURN p
// Container Image Poisoning (Supply Chain Attack)
MATCH p=(workflow)-[:CanPoisonImage]->(image)<-[:PullsImage]-(job)
RETURN p
// OIDC/Federated Identity Abuse (confirmed edges)
MATCH p=(workflow)-[:OIDCTrust]->(oidc)-[:CanAssumeRole]->(role)-[:SubmitsJob]->(job)
RETURN p
// OIDC abuse including inferred paths
MATCH p=(workflow)-[:OIDCTrust]->(oidc)-[:InferredCanAssumeRole]->(role)
RETURN p
// Dataset Poisoning via Pickle Deserialization
MATCH p=(workflow)-[:CanPoisonDataset]->(dataset)<-[:LoadsDataset]-(job)
RETURN p
// Find repos with weak/no branch protection (TB1 exploitable)
MATCH (repo)-[:BypassesProtection]->(repo)
RETURN repo.name, repo.default_branch
// Find overprivileged SageMaker IAM roles
MATCH (role:IAMRole) WHERE role.is_admin = true OR role.has_s3_full_access = true
RETURN role.name, role.attached_policies
// Find SageMaker notebooks with root + internet access
MATCH (nb:SMNotebook) WHERE nb.root_access = 'Enabled' AND nb.direct_internet_access = 'Enabled'
RETURN nb.name, nb.status
// Find self-hosted ADO agent pools
MATCH (agent:ADOAgent) WHERE agent.is_hosted = false
RETURN agent.name, agent.pool_type
Configuration
Config File
Instead of passing credentials on every run, you can save them in a config file. Copy the included example and fill in your values:
cp dop2mop.yaml.example dop2mop.yaml
# Edit dop2mop.yaml with your credentials
Dop2Mop checks these locations in order:
- Path specified by
--config
dop2mop.yaml / dop2mop.yml / .dop2mop.yaml in the current directory
~/.dop2mop.yaml
Note: dop2mop.yaml is in .gitignore to prevent accidentally committing credentials. The example file (dop2mop.yaml.example) is safe to commit.
See dop2mop.yaml.example for all available options with comments.
Priority order: CLI arguments > config file > environment variables.
Credential Validation
Test credentials before running a full collection:
# Validate all configured collectors
dop2mop collect --validate -v
# Validate specific collectors
dop2mop collect --validate --collectors github,sagemaker -v
This makes a lightweight API call per platform to verify tokens are valid before starting collection.
Collector Aliases
You can use short names instead of full class names with --collectors:
# These are equivalent:
dop2mop collect --collectors GitHubCollector,SageMakerCollector
dop2mop collect --collectors github,sagemaker
dop2mop collect --collectors gh,sm
CLI Reference
Commands Overview
dop2mop <command> [options]
Commands:
collect Collect data from DevOps/MLOps platforms
demo Generate demo data with attack scenarios
Global Options
| Option | Description |
|---|
-v, --verbose | Enable verbose output (INFO level logging) |
--debug | Enable debug output (DEBUG level logging) |
dop2mop collect
Collect data from configured DevOps and MLOps platforms.
dop2mop collect [OPTIONS]
General Options
GitHub Options
| Option | Description |
|---|
--github-token TOKEN | GitHub personal access token |
--github-org ORG | GitHub organization name |
--github-enterprise-url URL | GitHub Enterprise Server URL |
Azure DevOps Options
| Option | Description |
|---|
--azure-devops-token TOKEN | Azure DevOps personal access token (PAT) |
--azure-devops-access-token TOKEN | Azure DevOps access token (Bearer auth, optional) |
--azure-devops-org ORG | Azure DevOps organization name |
Azure ML Options
AWS SageMaker Options
Available Collectors
dop2mop demo
Generate demo data showing the four attack scenarios from the research.
| Option | Description |
|---|
-o, --output FILE | Output file path (default: dop2mop_demo.json) |
dop2mop icons
Generate custom icon configuration for BloodHound node types.
| Option | Description |
|---|
-o, --output FILE | Output file path (default: dop2mop_icons.json) |
CLI Usage Examples
Basic Collection
# Collect from all configured platforms (uses environment variables)
dop2mop collect -o output.json
# Use a config file
dop2mop collect --config dop2mop.yaml -o output.json -v
# Validate credentials first, then collect
dop2mop collect --validate -o output.json -v
# Collect with verbose logging
dop2mop collect -o output.json -v
# Collect with debug logging
dop2mop collect -o output.json --debug
# Collect and compress to ZIP
dop2mop collect -o output.json --zip
# Limit collection size
dop2mop collect --max-items 100 -o output.json
# Skip secret enumeration
dop2mop collect --no-secrets -o output.json
# GitHub only (aliases: github, gh)
dop2mop collect --collectors github -o github.json -v
# Azure DevOps only (aliases: ado, azuredevops)
dop2mop collect --collectors ado -o ado.json -v
# Azure ML only (aliases: azureml, azure-ml)
dop2mop collect --collectors azureml -o azureml.json -v
# SageMaker only (aliases: sagemaker, sm)
dop2mop collect --collectors sm -o sagemaker.json -v
# GitHub + SageMaker
dop2mop collect --collectors github,sagemaker -o output.json -v
# GitHub + Azure DevOps
dop2mop collect --collectors github,ado -o output.json -v
# Azure DevOps + Azure ML (full Azure stack)
dop2mop collect --collectors ado,azureml -o azure.json -v
# All collectors explicitly
dop2mop collect --collectors github,ado,azureml,sagemaker -o full.json -v
GitHub Collection Examples
# Using environment variables
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
export GITHUB_ORG="your-org"
dop2mop collect --collectors GitHubCollector -o github.json -v
# Using command-line arguments
dop2mop collect --collectors GitHubCollector \
--github-token ghp_xxxxxxxxxxxx \
--github-org your-org \
-o github.json -v
# GitHub Enterprise Server
dop2mop collect --collectors GitHubCollector \
--github-token ghp_xxxxxxxxxxxx \
--github-org your-org \
--github-enterprise-url https://github.yourcompany.com/api/v3 \
-o github.json -v
# Skip secret enumeration
dop2mop collect --collectors GitHubCollector \
--github-token ghp_xxxxxxxxxxxx \
--github-org your-org \
--no-secrets \
-o github.json -v
# Limit to 50 repositories
dop2mop collect --collectors GitHubCollector \
--github-token ghp_xxxxxxxxxxxx \
--github-org your-org \
--max-items 50 \
-o github.json -v
Azure DevOps Collection Examples
# Using environment variables
export AZURE_DEVOPS_TOKEN="your-pat"
export AZURE_DEVOPS_ORG="your-org"
dop2mop collect --collectors AzureDevOpsCollector -o ado.json -v
# Using command-line arguments (PAT)
dop2mop collect --collectors AzureDevOpsCollector \
--azure-devops-token your-pat \
--azure-devops-org your-org \
-o ado.json -v
# Using access token (Bearer auth)
dop2mop collect --collectors AzureDevOpsCollector \
--azure-devops-access-token eyJ0... \
--azure-devops-org your-org \
-o ado.json -v
Note: Azure DevOps authentication priority: access token (Bearer) > PAT (Basic).
Access tokens can be obtained via az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798.
Azure ML Collection Examples
# Using environment variables
export AZURE_SUBSCRIPTION_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export AZURE_TENANT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
dop2mop collect --collectors AzureMLCollector -o azureml.json -v
# Using DefaultAzureCredential (az login)
dop2mop collect --collectors AzureMLCollector \
--azure-subscription-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-tenant-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-o azureml.json -v
# Using Service Principal
dop2mop collect --collectors AzureMLCollector \
--azure-subscription-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-tenant-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-client-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-client-secret your-secret \
-o azureml.json -v
# Using Access Token
dop2mop collect --collectors AzureMLCollector \
--azure-subscription-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-tenant-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--azure-access-token eyJ0... \
-o azureml.json -v
Note: Azure ML authentication priority: access token > service principal > DefaultAzureCredential (az login).
Access tokens can be obtained via az account get-access-token --resource https://management.azure.com/.
AWS SageMaker Collection Examples
# Using AWS profile (environment variable)
export AWS_PROFILE="your-profile"
dop2mop collect --collectors SageMakerCollector -o sagemaker.json -v
# Using AWS profile (command-line)
dop2mop collect --collectors SageMakerCollector \
--aws-profile your-profile \
-o sagemaker.json -v
# Using access keys
dop2mop collect --collectors SageMakerCollector \
--aws-access-key-id AKIAXXXXXXXXXXXXXXXX \
--aws-secret-access-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--aws-region us-east-1 \
-o sagemaker.json -v
# Different AWS region
dop2mop collect --collectors SageMakerCollector \
--aws-profile your-profile \
--aws-region us-west-2 \
-o sagemaker.json -v
Full Environment Collection
# Set all environment variables
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
export GITHUB_ORG="your-org"
export AZURE_DEVOPS_TOKEN="your-pat"
export AZURE_DEVOPS_ORG="your-org"
export AZURE_SUBSCRIPTION_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export AZURE_TENANT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export AWS_PROFILE="your-profile"
# Collect from all platforms (auto-detects configured collectors)
dop2mop collect -o full_collection.json -v
# Or with explicit collectors
dop2mop collect \
--collectors GitHubCollector,AzureDevOpsCollector,AzureMLCollector,SageMakerCollector \
-o full_collection.json -v
Environment Variables
All command-line options can be set via environment variables:
Priority: CLI arguments > config file > environment variables.
Node Types
DevOps Nodes
MLOps Nodes
Identity Nodes
| Kind | Description |
|---|
ServicePrincipal | Azure AD service principal |
IAMRole | AWS IAM role |
OIDCIdentity |
Artifact Nodes
| Kind | Description |
|---|
ContainerRegistry | Container registry (ECR, ACR) |
ContainerImage | Container image |
S3Bucket |
Edge Types
Attack Path Edges
Inferred Edges
These edges are created by heuristic analysis when explicit API data isn't available (e.g., role ARNs stored in secrets). They use distinct edge types so you can filter them in BloodHound queries.
| Kind | Description |
|---|
InferredCanAssumeRole | OIDC identity may be able to assume role (inferred) |
Structural Edges
Example Queries
See queries/dop2mop_queries.cypher for comprehensive query examples.
Custom Icons
Dop2Mop includes a pre-configured icons file for BloodHound custom node types in data/custom_icons.json.
Uploading Icons to BloodHound
Option 1: API Explorer (Easiest)
- Open BloodHound CE and go to Settings → API Explorer
- Find POST /api/v2/custom-nodes
- Click "Try it out"
- Paste the contents of
custom_icons.json
- Click "Execute"
Option 2: HMAC Authentication (Recommended for Automation)
First, create an API token in BloodHound:
- Go to Settings → Administration → Manage Users
- Click your user → Create Token
- Save the Token ID and Token Key
Then use this Python script to upload:
#!/usr/bin/env python3
"""Upload custom icons to BloodHound CE using HMAC authentication."""
import base64
import hashlib
import hmac
import json
from datetime import datetime, timezone
import requests
# Configuration
BLOODHOUND_URL = "http://localhost:8080"
TOKEN_ID = "your-token-id"
TOKEN_KEY = "your-token-key"
ICONS_FILE = "custom_icons.json"
def hmac_auth(method: str, uri: str, body: bytes = b"") -> dict:
"""Generate HMAC authentication headers."""
digester = hmac.new(
base64.b64decode(TOKEN_KEY),
msg=None,
digestmod=hashlib.sha256
)
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
digester.update(f"{method}".encode())
digester.update(f"{uri}".encode())
digester.update(timestamp.encode())
if body:
digester.update(body)
signature = base64.b64encode(digester.digest()).decode()
return {
"Authorization": f"bhesignature {TOKEN_ID}",
"RequestDate": timestamp,
"Signature": signature,
"Content-Type": "application/json",
}
def upload_icons():
"""Upload custom icons to BloodHound."""
uri = "/api/v2/custom-nodes"
url = f"{BLOODHOUND_URL}{uri}"
with open(ICONS_FILE, "rb") as f:
body = f.read()
headers = hmac_auth("POST", uri, body)
response = requests.post(url, headers=headers, data=body)
print(f"Status: {response.status_code}")
print(f"Response: {response.text}")
return response.status_code == 200
if __name__ == "__main__":
upload_icons()
Option 3: Bearer Token (Quick Testing)
Get a JWT from your browser's DevTools Network tab while logged into BloodHound:
curl -X POST http://localhost:8080/api/v2/custom-nodes \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d @custom_icons.json
Python API
from dop2mop import Dop2MopCollector, CollectorConfig
# Configure collection
config = CollectorConfig(
github_token="ghp_xxx",
github_org="myorg",
aws_profile="default",
)
# Validate credentials first
collector = Dop2MopCollector(config)
results = collector.validate_all()
print(results) # {'GitHubCollector': True, 'SageMakerCollector': True, ...}
# Run collection (supports aliases)
collector.run(collectors=["github", "sm"])
# Save output
collector.save("output.json")
# Get statistics
print(collector.get_stats())
# Get per-collector failure/skip details
print(collector.get_collection_summary())
Using Individual Collectors
from dop2mop import CollectorConfig, GitHubCollector, SageMakerCollector
from dop2mop.graph import OpenGraphBuilder
# Create shared builder
builder = OpenGraphBuilder(source_kind="MLOpsBase")
# Configure
config = CollectorConfig(
github_token="ghp_xxx",
github_org="myorg",
aws_profile="default",
)
# Run individual collectors
github = GitHubCollector(config, builder)
github.collect()
sagemaker = SageMakerCollector(config, builder)
sagemaker.collect()
# Save combined output
builder.save("combined.json")
Building Custom Graphs
from dop2mop.graph import OpenGraphBuilder
from dop2mop.models import NodeKind, EdgeType
builder = OpenGraphBuilder(source_kind="CustomSource")
# Add nodes
builder.create_node(
id="my-pipeline",
kinds=[NodeKind.AZURE_DEVOPS_PIPELINE.value],
name="My Pipeline",
displayname="Production Pipeline",
)
builder.create_node(
id="my-ml-workspace",
kinds=[NodeKind.AZURE_ML_WORKSPACE.value],
name="ML Workspace",
displayname="Training Workspace",
)
# Add edge
builder.create_edge(
start_id="my-pipeline",
end_id="my-ml-workspace",
kind=EdgeType.SUBMITS_JOB,
properties={"trust_boundary": "TB4"}
)
# Export
builder.save("custom_graph.json")
Troubleshooting
Validate First
Always start with credential validation to catch auth issues early:
dop2mop collect --validate --collectors github,sm -v
The output will show OK/FAILED per collector before any collection begins.
Collection Summary
After every run, Dop2Mop prints a summary showing what was collected and what failed:
============================================================
Dop2Mop Collection Summary
============================================================
Total Nodes: 142
DevOps: 45
MLOps: 38
Identity: 12
Artifact: 47
Total Edges: 201
Contains: 62
TriggersPipeline: 15
...
────────────────────────────────────────────────────────
Collection Issues:
GitHubCollector: 45 collected, 3 failed, 1 skipped
FAIL: branch_protection:org/repo - 404 Not Found
SKIP: branch_protection:org/private - Insufficient permissions
Output: output.json
============================================================
Common Issues
"GitHub collector not configured, skipping"
- Ensure
GITHUB_TOKEN and GITHUB_ORG environment variables are set, or pass --github-token and --github-org
"Azure ML collector not configured, skipping"
- Ensure
AZURE_SUBSCRIPTION_ID and AZURE_TENANT_ID are set
- Run
az login if using DefaultAzureCredential
- Alternatively, pass
--azure-access-token with a valid token from az account get-access-token
"SageMaker collector not configured, skipping"
- Set
AWS_PROFILE or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
- Ensure the credentials have SageMaker read permissions
Branch protection returns 403
- The GitHub API requires admin access to read branch protection rules
- Non-admin tokens will skip branch protection collection (reported in summary)
Rate limiting (429 errors)
- Dop2Mop automatically retries with exponential backoff on rate limit responses
- For large orgs, use
--max-items to reduce API calls
- GitHub rate limits: 5,000 requests/hour for authenticated users
No results in BloodHound Cypher console
- Ensure queries return full nodes (
RETURN a, b) not properties (RETURN a.name, b.name)
- BloodHound's Cypher console is for graph visualization, not tabular data
- Use Neo4j Browser (localhost:7474) for property-based queries
Filtering inferred vs. collected edges
- Inferred edges use distinct types (e.g.,
InferredCanAssumeRole instead of CanAssumeRole)
- Query only confirmed edges:
MATCH p=()-[:CanAssumeRole]->() RETURN p
- Query inferred edges:
MATCH p=()-[:InferredCanAssumeRole]->() RETURN p
License
MIT License - See LICENSE for details.