Related docs: Main AGENTS.md | Enriching the Ontology
This guide covers how to create security rules in Cartography to identify attack surfaces, security gaps, and compliance issues across your infrastructure.
- Overview - Introduction to the rules system
- Rule Architecture - Rules, Facts, and Findings hierarchy
- Essential Imports - Required imports
- Creating Facts - Cypher queries for detection
- Creating Output Models - Pydantic models for results
- Creating Rules - Combining facts into rules
- Fact Maturity Levels - EXPERIMENTAL vs STABLE
- Rule Versioning - Semantic versioning
- Tagging Best Practices - Categorization tags
- Step-by-Step: Creating a New Rule - Complete walkthrough
- Cross-Provider Rules - Multi-cloud detection
- Using Ontology in Rules - Leverage semantic labels
- Compliance Frameworks - Framework object for structured metadata
- CIS Benchmark Rules Conventions - Compliance rules
Cartography includes a powerful rules system that allows you to write security queries using Cypher. Rules can detect issues across multiple cloud providers by combining facts from different modules or leveraging the ontology system.
Rules use a simple two-level hierarchy:
Rule (e.g., "database-exposed")
├─ Fact (e.g., "aws-rds-public")
├─ Fact (e.g., "azure-sql-public")
└─ Fact (e.g., "gcp-cloudsql-public")
- Rule: Represents a security issue or attack surface (e.g., "Publicly accessible databases")
- Fact: Individual Cypher query that gathers evidence about your environment
- Finding: Pydantic model that defines the structure of results
from cartography.rules.spec.model import (
Fact,
Finding,
Framework,
Maturity,
Module,
Rule,
RuleReference,
)A Fact is a Cypher query that detects a specific condition in your graph:
_aws_public_databases = Fact(
id="aws-rds-public",
name="Publicly accessible AWS RDS instances",
description="AWS RDS databases exposed to the internet",
cypher_query="""
MATCH (db:RDSInstance)
WHERE db.publicly_accessible = true
RETURN db.id AS id, db.db_instance_identifier AS name, db.region AS region
""",
cypher_visual_query="""
MATCH (db:RDSInstance)
WHERE db.publicly_accessible = true
RETURN db
""",
module=Module.AWS,
maturity=Maturity.STABLE,
)| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier (lowercase, hyphens) |
name |
Yes | Human-readable name |
description |
Yes | Detailed description of what this fact detects |
cypher_query |
Yes | Query returning structured data (must use aliases) |
cypher_visual_query |
Yes | Query returning nodes for visualization |
module |
Yes | Module enum (AWS, AZURE, GCP, GITHUB, etc.) |
maturity |
Yes | EXPERIMENTAL or STABLE |
cypher_query - Returns structured data for processing:
- Must use
ASaliases that match your Finding model fields - Should return relevant identifying information
- Keep queries efficient - avoid expensive operations
cypher_query="""
MATCH (resource:SomeNode)
WHERE resource.vulnerable = true
RETURN resource.id AS id,
resource.name AS name,
resource.region AS region,
resource.severity AS severity
"""cypher_visual_query - Returns nodes for graph visualization:
- Returns the actual nodes (not just properties)
- Used by UI tools to display affected resources
cypher_visual_query="""
MATCH (resource:SomeNode)
WHERE resource.vulnerable = true
RETURN resource
"""Each Rule must define an output model that extends Finding:
from cartography.rules.spec.model import Finding
class DatabaseExposedOutput(Finding):
"""Output model for publicly exposed databases."""
# Fields must match cypher_query aliases
id: str | None = None
name: str | None = None
region: str | None = NoneKey Points:
- Inherit from
Finding: Your model must extend the base class - Match Query Aliases: Field names must match
cypher_queryASaliases exactly - Use Optional Types: All fields should be
| Nonewith defaultNone - Automatic Fields: The
sourcefield is auto-populated with the module name
Combine one or more facts into a rule:
database_exposed = Rule(
id="database-exposed",
name="Publicly Accessible Databases",
description="Detects databases exposed to the internet across cloud providers",
output_model=DatabaseExposedOutput,
tags=("infrastructure", "attack_surface", "database"),
facts=(_aws_public_databases, _azure_public_databases, _gcp_cloudsql_public),
version="1.0.0",
)| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier (lowercase, underscores) |
name |
Yes | Human-readable name |
description |
Yes | What security issue this rule detects |
output_model |
Yes | Pydantic model class for results |
tags |
Yes | Tuple of categorization tags |
facts |
Yes | Tuple of Fact objects |
version |
Yes | Semantic version string |
references |
No | List of RuleReference for documentation |
Include references to external documentation:
from cartography.rules.spec.model import RuleReference
my_rule = Rule(
id="my-rule",
# ... other fields ...
references=[
RuleReference(
text="AWS Security Best Practices",
url="https://docs.aws.amazon.com/security/",
),
RuleReference(
text="OWASP Cloud Security",
url="https://owasp.org/www-project-cloud-security/",
),
],
)- New facts, recently added
- May have bugs or performance issues
- Limited production testing
- Use for testing new detection capabilities
maturity=Maturity.EXPERIMENTAL- Production-ready, well-tested
- Optimized queries, consistent results
- Use for production monitoring and compliance
maturity=Maturity.STABLEUse semantic versioning:
version="0.1.0" # Initial release
version="0.2.0" # Added new facts (minor)
version="0.2.1" # Bug fix (patch)
version="1.0.0" # Production ready (major)Use consistent tags for categorization:
tags=(
"infrastructure", # Category: infrastructure, identity, data, network
"attack_surface", # Type: attack_surface, misconfiguration, compliance
"database", # Specific area
"stride:tampering", # Optional: STRIDE threat model
)Common tag categories:
- Category:
infrastructure,identity,data,network,compute - Type:
attack_surface,misconfiguration,compliance,vulnerability - Provider:
aws,azure,gcp,github,okta - Threat model:
stride:spoofing,stride:tampering,stride:repudiation,stride:information_disclosure,stride:denial_of_service,stride:elevation_of_privilege
Create a new file in cartography/rules/data/rules/:
# cartography/rules/data/rules/my_security_rule.py
from cartography.rules.spec.model import Fact, Finding, Maturity, Module, Rule
# =============================================================================
# My Security Rule: Detect vulnerable configuration
# Main node: SomeResource
# =============================================================================
_my_fact = Fact(
id="my-fact-id",
name="My Fact Name",
description="Detailed description of what this detects",
cypher_query="""
MATCH (r:SomeResource)
WHERE r.vulnerable = true
RETURN r.id AS id, r.name AS name
""",
cypher_visual_query="""
MATCH (r:SomeResource)
WHERE r.vulnerable = true
RETURN r
""",
module=Module.AWS,
maturity=Maturity.EXPERIMENTAL,
)
class MyRuleOutput(Finding):
id: str | None = None
name: str | None = None
my_security_rule = Rule(
id="my_security_rule",
name="My Security Rule",
description="Detects vulnerable configurations",
output_model=MyRuleOutput,
tags=("security", "misconfiguration"),
facts=(_my_fact,),
version="0.1.0",
)Add to cartography/rules/data/rules/__init__.py:
from cartography.rules.data.rules.my_security_rule import my_security_rule
RULES = {
# ... existing rules
my_security_rule.id: my_security_rule,
}# List rule details
cartography-rules list my_security_rule
# Run the rule
cartography-rules run my_security_rule
# Run with JSON output
cartography-rules run my_security_rule --output json
# Exclude experimental facts
cartography-rules run my_security_rule --no-experimentalCreate rules that span multiple cloud providers:
# AWS fact
_aws_unencrypted_storage = Fact(
id="aws-s3-unencrypted",
name="Unencrypted AWS S3 Buckets",
cypher_query="""
MATCH (b:S3Bucket)
WHERE b.default_encryption IS NULL
RETURN b.id AS id, b.name AS name, 'aws' AS provider
""",
# ...
module=Module.AWS,
maturity=Maturity.STABLE,
)
# Azure fact
_azure_unencrypted_storage = Fact(
id="azure-storage-unencrypted",
name="Unencrypted Azure Storage Accounts",
cypher_query="""
MATCH (s:AzureStorageAccount)
WHERE s.encryption_enabled = false
RETURN s.id AS id, s.name AS name, 'azure' AS provider
""",
# ...
module=Module.AZURE,
maturity=Maturity.STABLE,
)
# Combined rule
class UnencryptedStorageOutput(Finding):
id: str | None = None
name: str | None = None
provider: str | None = None
unencrypted_storage = Rule(
id="unencrypted_storage",
name="Unencrypted Cloud Storage",
description="Detects unencrypted storage across cloud providers",
output_model=UnencryptedStorageOutput,
tags=("data", "encryption", "compliance"),
facts=(_aws_unencrypted_storage, _azure_unencrypted_storage),
version="1.0.0",
)Leverage the ontology system for cross-module detection:
_unmanaged_accounts = Fact(
id="unmanaged-accounts-ontology",
name="User Accounts Not Linked to Identity",
description="Detects user accounts without a corresponding User identity",
cypher_query="""
MATCH (ua:UserAccount)
WHERE NOT (ua)<-[:HAS_ACCOUNT]-(:User)
RETURN ua.id AS id, ua._ont_email AS email, ua._ont_source AS source
""",
cypher_visual_query="""
MATCH (ua:UserAccount)
WHERE NOT (ua)<-[:HAS_ACCOUNT]-(:User)
RETURN ua
""",
module=Module.ONTOLOGY,
maturity=Maturity.STABLE,
)Rules can be linked to compliance frameworks (CIS, NIST, SOC2, etc.) using the Framework dataclass. This provides structured metadata for filtering and reporting.
from cartography.rules.spec.model import Framework
Framework(
name="CIS AWS Foundations Benchmark", # Full framework name
short_name="CIS", # Abbreviated name for filtering
requirement="1.14", # Specific requirement identifier
scope="aws", # Optional: platform/domain (aws, gcp, googleworkspace)
revision="5.0", # Optional: framework version
)Key behaviors:
- All fields are case-insensitive and normalized to lowercase internally
scopeshould match the Cartography module identifier (e.g.,aws,gcp,googleworkspace)requirementis the specific control number from the framework
from cartography.rules.spec.model import Framework, Rule
my_rule = Rule(
id="cis_aws_1_14_access_key_not_rotated",
name="CIS AWS 1.14: Access Keys Not Rotated",
# ... other fields ...
tags=("iam", "credentials", "stride:spoofing"), # Category tags only
frameworks=(
Framework(
name="CIS AWS Foundations Benchmark",
short_name="CIS",
scope="aws",
revision="5.0",
requirement="1.14",
),
),
)Important: Compliance-specific tags like cis:1.14 and cis:aws-5.0 should be removed from tags and replaced with a Framework object. Keep only category tags (iam, credentials, stride:*) in tags.
Users can filter rules by framework using the --framework option:
# List all CIS rules
cartography-rules list --framework CIS
# List CIS rules for AWS
cartography-rules list --framework CIS:aws
# List CIS AWS 5.0 rules specifically
cartography-rules list --framework CIS:aws:5.0
# Run all CIS rules
cartography-rules run all --framework CIS
# List all available frameworks
cartography-rules frameworksUse Rule.has_framework() to check if a rule matches a framework:
# Check if rule has any CIS framework
rule.has_framework("CIS")
# Check if rule has CIS AWS framework
rule.has_framework("CIS", "aws")
# Check if rule has CIS AWS 5.0 specifically
rule.has_framework("CIS", "aws", "5.0")When creating CIS (Center for Internet Security) compliance rules, follow these additional conventions:
Use the format: CIS <PROVIDER> <CONTROL_NUMBER>: <Description>
# Correct
name="CIS AWS 1.14: Access Keys Not Rotated"
name="CIS AWS 2.1.1: S3 Bucket Versioning"
name="CIS GCP 3.9: SSL Policies With Weak Cipher Suites"
# Incorrect - missing provider
name="CIS 1.14: Access Keys Not Rotated"Use provider-prefixed rule IDs for CIS controls to avoid collisions across benchmarks.
Format: cis_<provider>_<control_number>_<short_slug>
# Correct
id="cis_aws_1_14_access_key_not_rotated"
id="cis_gcp_3_1_default_network"
id="cis_gw_4_1_1_3_user_2sv_not_enforced"
# Incorrect - missing provider
id="cis_1_14_access_key_not_rotated"CIS control numbers don't map 1:1 across cloud providers. For example:
- CIS AWS 1.18 (Expired SSL/TLS Certificates) has no GCP equivalent
- CIS AWS 5.1 vs CIS GCP 3.9 cover different networking concepts despite similar numbers
Including the provider ensures rule names are self-documenting when viewed in isolation (alerts, dashboards, reports, SIEM integrations).
Organize by provider and benchmark section:
cis_aws_iam.py # CIS AWS Section 1 (IAM)
cis_aws_storage.py # CIS AWS Section 2 (Storage)
cis_aws_logging.py # CIS AWS Section 3 (Logging)
cis_aws_networking.py # CIS AWS Section 5 (Networking)
cis_gcp_iam.py # CIS GCP IAM controls
cis_azure_iam.py # CIS Azure IAM controls
# =============================================================================
# CIS AWS 1.14: Access keys not rotated in 90 days
# Main node: AccountAccessKey
# =============================================================================Use frameworks for compliance references:
frameworks=(
Framework(
name="CIS AWS Foundations Benchmark",
short_name="CIS",
scope="aws",
revision="5.0",
requirement="1.14",
),
)Use tags for categories only:
tags=("iam", "credentials", "stride:spoofing")Do NOT mix compliance info in tags:
# Incorrect - compliance info belongs in frameworks
tags=("cis:1.14", "cis:aws-5.0", "iam", "credentials")Always include the official CIS benchmark reference:
CIS_REFERENCES = [
RuleReference(
text="CIS AWS Foundations Benchmark v5.0",
url="https://www.cisecurity.org/benchmark/amazon_web_services",
),
]- CIS AWS Foundations Benchmark
- CIS GCP Foundations Benchmark
- CIS Azure Foundations Benchmark
- CIS Kubernetes Benchmark
from cartography.rules.spec.model import (
Fact, Finding, Framework, Maturity, Module, Rule, RuleReference,
)
# =============================================================================
# CIS AWS 1.14: Access keys not rotated in 90 days
# Main node: AccountAccessKey
# =============================================================================
_cis_aws_1_14_fact = Fact(
id="cis-aws-1-14-access-key-not-rotated",
name="CIS AWS 1.14: Access Keys Not Rotated",
description="Identifies IAM access keys that have not been rotated in the past 90 days",
cypher_query="""
MATCH (key:AccountAccessKey)
WHERE key.create_date < datetime() - duration('P90D')
RETURN key.id AS id, key.user_name AS user_name, key.create_date AS create_date
""",
cypher_visual_query="""
MATCH (key:AccountAccessKey)
WHERE key.create_date < datetime() - duration('P90D')
RETURN key
""",
module=Module.AWS,
maturity=Maturity.STABLE,
)
class CIS114Output(Finding):
id: str | None = None
user_name: str | None = None
create_date: str | None = None
cis_aws_1_14_access_key_not_rotated = Rule(
id="cis_aws_1_14_access_key_not_rotated",
name="CIS AWS 1.14: Access Keys Not Rotated",
description="IAM access keys should be rotated every 90 days or less",
output_model=CIS114Output,
tags=("iam", "credentials", "stride:spoofing"),
facts=(_cis_aws_1_14_fact,),
references=[
RuleReference(
text="CIS AWS Foundations Benchmark v5.0",
url="https://www.cisecurity.org/benchmark/amazon_web_services",
),
],
frameworks=(
Framework(
name="CIS AWS Foundations Benchmark",
short_name="CIS",
scope="aws",
revision="5.0",
requirement="1.14",
),
),
version="1.0.0",
)