OpenGraph collector for BloodHound that maps attack paths from DevOps to MLOps infrastructure, collecting CI/CD pipeline, service principal, and ML platform resources for lateral movement analysis.
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).
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 |
Dop2Mop models the five critical trust boundaries identified in the research:
git clone https://github.com/h4wkst3r/dop2mop.git
cd dop2mop
pip install -r requirements.txt
pip install -e .
Each collector gathers platform-specific resources and maps the trust boundaries between them:
| Collector | Resources Collected |
|---|---|
| GitHub | Organizations, repositories, workflows, secrets, branch protection rules, OIDC configurations, container image references, S3 bucket references |
| Azure DevOps | Organizations, projects, pipelines (YAML), service connections (with scope details), variable groups, agent pools, repositories |
| Azure ML | Workspaces, compute clusters/instances, datastores, ML environments, registered models, jobs/experiments, online & batch endpoints |
| SageMaker | Training jobs, models, endpoints, domains, notebook instances, IAM execution roles (with policy analysis), ECR repositories/images, S3 buckets (filtered to ML-relevant) |
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
// 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
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:
--configdop2mop.yaml / dop2mop.yml / .dop2mop.yaml in the current directory~/.dop2mop.yamlNote:
dop2mop.yamlis in.gitignoreto 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.
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.
You can use short names instead of full class names with --collectors:
| Alias | Collector |
|---|---|
github, gh | GitHubCollector |
ado, azuredevops, azure-devops | AzureDevOpsCollector |
azureml, azure-ml | AzureMLCollector |
sagemaker, sm | SageMakerCollector |
# These are equivalent:
dop2mop collect --collectors GitHubCollector,SageMakerCollector
dop2mop collect --collectors github,sagemaker
dop2mop collect --collectors gh,sm
dop2mop <command> [options]
Commands:
collect Collect data from DevOps/MLOps platforms
demo Generate demo data with attack scenarios
| Option | Description |
|---|---|
-v, --verbose | Enable verbose output (INFO level logging) |
--debug | Enable debug output (DEBUG level logging) |
dop2mop collectCollect data from configured DevOps and MLOps platforms.
dop2mop collect [OPTIONS]
| Option | Description |
|---|---|
-o, --output FILE | Output file path (default: dop2mop_output.json) |
--zip | Compress output to ZIP file |
--config FILE | Path to YAML/JSON config file |
--validate | Validate credentials before collection |
--collectors LIST | Comma-separated list of collectors or aliases (e.g. github,sm) |
--max-items N | Maximum items to collect per type |
--no-secrets | Skip secret/credential enumeration |
| Option | Description |
|---|---|
--github-token TOKEN | GitHub personal access token |
--github-org ORG | GitHub organization name |
--github-enterprise-url URL | GitHub Enterprise Server URL |
| 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 |
| Option | Description |
|---|---|
--azure-subscription-id ID | Azure subscription ID |
--azure-tenant-id ID | Azure AD tenant ID |
--azure-client-id ID | Service principal client ID (optional) |
--azure-client-secret SECRET | Service principal client secret (optional) |
--azure-access-token TOKEN | Azure access token for Azure ML authentication (optional) |
| Option | Description |
|---|---|
--aws-access-key-id KEY | AWS access key ID |
--aws-secret-access-key SECRET | AWS secret access key |
--aws-region REGION | AWS region (default: us-east-1) |
--aws-profile PROFILE | AWS CLI profile name |
| Collector Name | Platform | Required Credentials |
|---|---|---|
GitHubCollector | GitHub | --github-token, --github-org |
AzureDevOpsCollector | Azure DevOps | --azure-devops-token or --azure-devops-access-token, --azure-devops-org |
AzureMLCollector | Azure ML | --azure-subscription-id, --azure-tenant-id |
SageMakerCollector | AWS SageMaker | --aws-profile or --aws-access-key-id |
dop2mop demoGenerate demo data showing the four attack scenarios from the research.
dop2mop demo [OPTIONS]
| Option | Description |
|---|---|
-o, --output FILE | Output file path (default: dop2mop_demo.json) |
dop2mop iconsGenerate custom icon configuration for BloodHound node types.
dop2mop icons [OPTIONS]
| Option | Description |
|---|---|
-o, --output FILE | Output file path (default: dop2mop_icons.json) |
# 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
# 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
# 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.
# 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/.
# 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
# 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
All command-line options can be set via environment variables:
| Environment Variable | CLI Equivalent | Description |
|---|---|---|
GITHUB_TOKEN | --github-token | GitHub personal access token |
GITHUB_ORG | --github-org | GitHub organization name |
GITHUB_ENTERPRISE_URL | --github-enterprise-url | GitHub Enterprise Server URL |
AZURE_DEVOPS_TOKEN | --azure-devops-token | Azure DevOps PAT |
AZURE_DEVOPS_ACCESS_TOKEN | --azure-devops-access-token | Azure DevOps access token (Bearer) |
AZURE_DEVOPS_ORG | --azure-devops-org | Azure DevOps organization |
AZURE_SUBSCRIPTION_ID | --azure-subscription-id | Azure subscription ID |
AZURE_TENANT_ID | --azure-tenant-id | Azure AD tenant ID |
AZURE_CLIENT_ID | --azure-client-id | Azure service principal client ID |
AZURE_CLIENT_SECRET | --azure-client-secret | Azure service principal secret |
AZURE_ACCESS_TOKEN | --azure-access-token | Azure access token for Azure ML |
AWS_ACCESS_KEY_ID | --aws-access-key-id | AWS access key ID |
AWS_SECRET_ACCESS_KEY | --aws-secret-access-key | AWS secret access key |
AWS_REGION | --aws-region | AWS region (default: us-east-1) |
AWS_PROFILE | --aws-profile | AWS CLI profile name |
Priority: CLI arguments > config file > environment variables.
| Kind | Description |
|---|---|
GHOrganization | GitHub organization |
GHRepository | GitHub repository |
GHWorkflow | GitHub Actions workflow |
GHSecret | GitHub Actions secret |
ADOOrganization | Azure DevOps organization |
ADOProject | Azure DevOps project |
ADOPipeline | Azure DevOps pipeline |
ADOServiceConnection | Azure DevOps service connection (NHI) with scope details |
ADOAgent | Azure DevOps agent pool (hosted or self-hosted) |
ADOVariableGroup | Azure DevOps variable group |
| Kind | Description |
|---|---|
AzMLWorkspace | Azure ML workspace |
AzMLCompute | Azure ML compute cluster/instance |
AzMLExperiment | Azure ML job/experiment |
AzMLDatastore | Azure ML datastore |
AzMLEnvironment | Azure ML environment (container definition) |
AzMLModel | Azure ML registered model |
SMDomain | SageMaker Studio domain |
SMTrainingJob | SageMaker training job |
SMModel | SageMaker model |
SMEndpoint | SageMaker endpoint (also used for Azure ML endpoints) |
SMNotebook | SageMaker notebook instance |
| Kind | Description |
|---|---|
ServicePrincipal | Azure AD service principal |
IAMRole | AWS IAM role |
OIDCIdentity | OIDC federated identity |
ManagedIdentity | Azure managed identity |
| Kind | Description |
|---|---|
ContainerRegistry | Container registry (ECR, ACR) |
ContainerImage | Container image |
S3Bucket | AWS S3 bucket |
Dataset | ML dataset |
| Kind | Trust Boundary | Description |
|---|---|---|
TriggersPipeline | TB1 | Code commit triggers CI/CD |
HasBranchProtection | TB1 | Repository has branch protection rules |
BypassesProtection | TB1 | Weak/missing branch protection (exploitable) |
AuthenticatesAs | TB2 | Pipeline uses service principal |
CanAssumeRole | TB2 | OIDC identity can assume IAM role |
OIDCTrust | TB2 | Workflow uses OIDC federation |
UsesServiceConnection | TB2 | Pipeline uses ADO service connection |
PullsImage | TB3 | Training job pulls container image |
CanPoisonImage | TB3 | Pipeline can modify container image |
SubmitsJob | TB4 | Service principal submits ML job |
CodeExecution | TB4 | Job executes code on compute |
LoadsDataset | TB5 | Training job loads dataset |
CanPoisonDataset | TB5 | Pipeline can modify dataset |
Deserializes | TB5 | Unsafe deserialization |
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) |
InferredSubmitsJob | Workflow may submit training job via OIDC (inferred) |
InferredPullsImage | Training job may pull poisoned container image (inferred) |
InferredLoadsDataset | Training job may load poisoned dataset (inferred) |
| Kind | Description |
|---|---|
Contains | Parent-child relationship |
MemberOf | Group membership |
HasAccessTo | Permission to access resource |
HasExecutionRole | Resource uses an IAM/execution role |
Owns | Ownership relationship |
See queries/dop2mop_queries.cypher for comprehensive query examples.
Dop2Mop includes a pre-configured icons file for BloodHound custom node types in data/custom_icons.json.
custom_icons.jsonFirst, create an API token in BloodHound:
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()
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
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())
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")
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")
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.
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
============================================================
"GitHub collector not configured, skipping"
GITHUB_TOKEN and GITHUB_ORG environment variables are set, or pass --github-token and --github-org"Azure ML collector not configured, skipping"
AZURE_SUBSCRIPTION_ID and AZURE_TENANT_ID are setaz login if using DefaultAzureCredential--azure-access-token with a valid token from az account get-access-token"SageMaker collector not configured, skipping"
AWS_PROFILE or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEYBranch protection returns 403
Rate limiting (429 errors)
--max-items to reduce API callsNo results in BloodHound Cypher console
RETURN a, b) not properties (RETURN a.name, b.name)Filtering inferred vs. collected edges
InferredCanAssumeRole instead of CanAssumeRole)MATCH p=()-[:CanAssumeRole]->() RETURN pMATCH p=()-[:InferredCanAssumeRole]->() RETURN pMIT License - See LICENSE for details.