Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
bd90853
feat(graph): add graph_nodes, graph_edges, finding_graph_nodes migration
TFT444 Sep 24, 2026
51d8d00
feat(graph): add snapshot_bridge to collect ARG inventory inside Scan…
TFT444 Sep 24, 2026
ac660bb
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 Sep 24, 2026
4e45a08
fix(graph): remove unused pytest import from snapshot_bridge tests
TFT444 Sep 24, 2026
855553c
fix(lint): apply ruff format to graph foundation files
TFT444 Sep 24, 2026
ee2ef5a
fix(lint): ruff 0.16.6 format for graph schema migration
TFT444 Sep 24, 2026
6328cd4
fix(logging): preserve pytest caplog handler in configure_logging
TFT444 Sep 25, 2026
ecd18c4
fix(alembic): preserve existing loggers when fileConfig runs
TFT444 Sep 25, 2026
a4b424b
fix(graph): add tenant_id to AzureClient, merge alembic heads, replac…
TFT444 Sep 27, 2026
4d86e42
fix(alembic): use single-parent down_revision for graph schema migration
TFT444 Sep 27, 2026
5214e30
fix(graph): resolve alembic head dynamically in admission test, move …
TFT444 Sep 27, 2026
96eabca
fix(lint): add blank lines around _resolve_head for ruff format compl…
TFT444 Sep 28, 2026
762163a
fix(graph): repoint migration down_revision to 3a76ff935bf6 (dev head)
TFT444 Sep 30, 2026
1f80b64
feat(graph): add node_service to upsert graph nodes and link findings
TFT444 Sep 24, 2026
d178989
feat(graph): add edge detectors for NSG, subnet, public IP, identity,…
TFT444 Sep 24, 2026
82dae3f
fix(graph): pass tenant_id explicitly to link_findings_to_nodes
TFT444 Sep 24, 2026
c873378
feat(graph): wire post-scan node and edge population into ScanEngine
TFT444 Sep 24, 2026
4c5b760
fix(lint): apply ruff format to node/edge population files
TFT444 Sep 24, 2026
cc4aa20
fix(graph): post-save graph population, fix detectors for ARG schema,…
TFT444 Sep 27, 2026
c6237df
fix(lint): remove unused MagicMock import from post-scan graph test
TFT444 Sep 27, 2026
bbd1a0e
fix(graph): synthesise subnet nodes from VNet properties.subnets befo…
TFT444 Sep 30, 2026
4de23ee
test(graph): verify DEFAULT_QUERY bag_merge and identity field parsin…
TFT444 Oct 4, 2026
ac403a1
fix: close psycopg2 connections explicitly, fix edge rowcount, correc…
TFT444 Oct 4, 2026
5e8f5d1
fix: add DATABASE_URL guard to migration test, use parameter name for…
TFT444 Oct 4, 2026
5cc317c
fix(graph): chain foundation on current dev and isolate migration tests
TFT444 Oct 8, 2026
706a575
fix(graph): publish atomic current scopes and expire complete snapshots
TFT444 Oct 8, 2026
930b9a4
feat(lifecycle): implement finding lifecycle tracking (#311)
TFT444 Aug 30, 2026
7f6d45b
fix(security): close IDOR and tenant-bypass in patterns API
TFT444 Aug 30, 2026
51ec84e
fix(lifecycle): address all code review findings from #311
TFT444 Aug 30, 2026
06880eb
fix(security): strip internal exception detail from all 500 error res…
TFT444 Aug 30, 2026
ddb6327
fix(lifecycle): address all reviewer-blocking issues before merge
TFT444 Aug 30, 2026
ddd17d0
fix(lint): remove unused imports and fix E402 in lifecycle test files
TFT444 Aug 30, 2026
d3751d3
fix(lint): apply ruff format to all lifecycle PR files
TFT444 Aug 30, 2026
46c95ed
fix: apply code review findings for lifecycle PR
TFT444 Aug 30, 2026
877ad2b
fix(graph): stack foundation after lifecycle migration
TFT444 Oct 8, 2026
7bcaf8d
fix(graph): preserve published evidence when an edge detector fails
TFT444 Oct 8, 2026
0ee319f
ci: run required checks for the lifecycle and graph PR stack
TFT444 Oct 8, 2026
2383ccd
ci: use a string key for the PostgreSQL service port
TFT444 Oct 8, 2026
ccb5022
fix(deps): require patched Werkzeug and multidict releases
TFT444 Oct 8, 2026
67fe1ef
test(ci): match validated PostgreSQL service expression
TFT444 Oct 8, 2026
9045038
fix(graph): serialize scope publication and reject delayed snapshots
TFT444 Oct 8, 2026
62fbf0c
fix(graph): lowercase resource_id on store, fix edge joins, add Postg…
TFT444 Oct 9, 2026
1fdc8be
fix(graph): apply ruff formatting to Postgres populator fixture test
TFT444 Oct 9, 2026
1afdcef
fix(graph): correct HAS_IDENTITY edge direction assertion in Postgres…
TFT444 Oct 9, 2026
6edf46d
fix(graph): update down_revision to b7c3d9e2f841 after PR-352 migrati…
TFT444 Oct 17, 2026
7efa075
fix(graph): rebase onto dev and resolve migration/lint issues
TFT444 Oct 20, 2026
89157f7
fix(graph): add missing enrich_findings import in scans.py
TFT444 Oct 20, 2026
025196c
fix(graph): reconcile population stack and reject unapproved private …
TFT444 Oct 22, 2026
eef8cba
fix(graph): carry verified tenant normalization through population stack
TFT444 Oct 22, 2026
ddbee04
fix(graph): preserve completed scan outcome when publication fails
TFT444 Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions alembic/versions/e2c4a6b8d013_graph_current_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Track the explicit current snapshot for each tenant/subscription graph.

Revision ID: e2c4a6b8d013
Revises: b7c3d9e2f841
"""

from typing import Sequence, Union
from alembic import op

revision: str = "e2c4a6b8d013"
down_revision: str = "b7c3d9e2f841"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("""
CREATE TABLE graph_snapshot_scopes (
tenant_id text NOT NULL,
subscription_id text NOT NULL,
snapshot_id text NOT NULL,
status text NOT NULL CHECK (status IN ('COMPLETE', 'PARTIAL')),
collected_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, subscription_id)
)
""")
op.execute("""
CREATE VIEW current_graph_nodes AS
SELECT n.* FROM graph_nodes n
JOIN graph_snapshot_scopes s
ON s.tenant_id = n.tenant_id AND s.subscription_id = n.subscription_id
AND s.snapshot_id = n.snapshot_id
""")
op.execute("""
CREATE VIEW current_graph_edges AS
SELECT e.* FROM graph_edges e
JOIN current_graph_nodes src ON src.node_id = e.source_node_id
JOIN current_graph_nodes tgt ON tgt.node_id = e.target_node_id
WHERE src.tenant_id = tgt.tenant_id
AND e.evidence_snapshot_id = src.snapshot_id
AND e.evidence_snapshot_id = tgt.snapshot_id
""")


def downgrade() -> None:
op.execute("DROP VIEW current_graph_edges")
op.execute("DROP VIEW current_graph_nodes")
op.execute("DROP TABLE graph_snapshot_scopes")
1 change: 1 addition & 0 deletions scanner/arg_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

DEFAULT_QUERY = """
Resources
| extend properties = bag_merge(properties, pack('identity', identity))
| project id, name, type, location, subscriptionId, resourceGroup, tenantId, tags, properties
| order by id asc
""".strip()
Expand Down
203 changes: 203 additions & 0 deletions scanner/graph/edge_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Typed edge detectors that infer relationships between Azure resources in an InventorySnapshot."""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from scanner.arg_inventory import InventorySnapshot

logger = logging.getLogger(__name__)

_NSG_TYPE = "microsoft.network/networksecuritygroups"
_PUBLIC_IP_TYPE = "microsoft.network/publicipaddresses"
_STORAGE_TYPE = "microsoft.storage/storageaccounts"


@dataclass
class GraphEdge:
"""A directed relationship between two Azure resources."""

source_resource_id: str
target_resource_id: str
relationship_type: str
evidence_source: str
confidence: float


class EdgeDetector(ABC):
"""Base class for relationship detectors."""

@abstractmethod
def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
"""Return edges inferred from this snapshot."""


class NsgToSubnetDetector(EdgeDetector):
"""NSG -> Subnet: PROTECTS (ARG-confirmed, confidence 1.0)."""

def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
edges = []
for resource in snapshot.resources:
if resource.resource_type.lower() != _NSG_TYPE:
continue
subnets = resource.properties.get("subnets") or []
for subnet in subnets:
subnet_id = subnet.get("id") if isinstance(subnet, dict) else None
if not subnet_id:
continue
edges.append(
GraphEdge(
source_resource_id=resource.resource_id,
target_resource_id=subnet_id,
relationship_type="PROTECTS",
evidence_source="arg:properties.subnets",
confidence=1.0,
)
)
return edges


class SubnetToResourceDetector(EdgeDetector):
"""Resource -> Subnet: MEMBER_OF (inferred from properties, confidence 0.8)."""

def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
edges = []
resource_ids = {r.resource_id.lower() for r in snapshot.resources}
for resource in snapshot.resources:
subnet_ids: list[str] = []

# Direct subnet property (VMs attached directly)
direct = (resource.properties.get("subnet") or {}).get("id")
if direct:
subnet_ids.append(direct)

# NIC ipConfigurations pattern (most common path for NICs)
for ip_cfg in resource.properties.get("ipConfigurations", []):
sid = (ip_cfg.get("properties", {}).get("subnet") or {}).get("id")
if sid:
subnet_ids.append(sid)

for subnet_id in subnet_ids:
if not subnet_id or subnet_id.lower() not in resource_ids:
continue
edges.append(
GraphEdge(
source_resource_id=resource.resource_id,
target_resource_id=subnet_id,
relationship_type="MEMBER_OF",
evidence_source="arg:properties.subnet.id",
confidence=0.8,
)
)
return edges


class PublicIpToResourceDetector(EdgeDetector):
"""PublicIP -> Resource: EXPOSES (ARG-confirmed, confidence 1.0)."""

def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
edges = []
for resource in snapshot.resources:
if resource.resource_type.lower() != _PUBLIC_IP_TYPE:
continue
ip_config = resource.properties.get("ipConfiguration") or {}
target_id = ip_config.get("id")
if not target_id:
continue
# Trim to the NIC resource ID (namespace/type/name = 3 segments).
parts = target_id.split("/providers/")
if len(parts) >= 2:
provider_path = parts[-1].split("/")
if len(provider_path) >= 3:
target_id = "/providers/".join(parts[:-1]) + "/providers/" + "/".join(provider_path[:3])
edges.append(
GraphEdge(
source_resource_id=resource.resource_id,
target_resource_id=target_id,
relationship_type="EXPOSES",
evidence_source="arg:properties.ipConfiguration.id",
confidence=1.0,
)
)
return edges


class IdentityToResourceDetector(EdgeDetector):
"""Identity -> Resource: HAS_IDENTITY (inferred from properties, confidence 0.8)."""

def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
edges = []
for resource in snapshot.resources:
identity = resource.properties.get("identity") or {}
user_assigned = identity.get("userAssignedIdentities") or {}
for identity_id in user_assigned:
if not identity_id:
continue
edges.append(
GraphEdge(
source_resource_id=identity_id,
target_resource_id=resource.resource_id,
relationship_type="HAS_IDENTITY",
evidence_source="arg:properties.identity.userAssignedIdentities",
confidence=0.8,
)
)
return edges


class StoragePrivateEndpointDetector(EdgeDetector):
"""Storage -> PrivateEndpoint: REACHABLE_VIA (ARG-confirmed, confidence 1.0)."""

def detect(self, snapshot: InventorySnapshot) -> list[GraphEdge]:
edges = []
for resource in snapshot.resources:
if resource.resource_type.lower() != _STORAGE_TYPE:
continue
connections = resource.properties.get("privateEndpointConnections") or []
for conn in connections:
properties = conn.get("properties") or {}
state = properties.get("privateLinkServiceConnectionState") or {}
if str(state.get("status", "")).lower() != "approved":
continue
if str(properties.get("provisioningState", "Succeeded")).lower() != "succeeded":
continue
pe_id = (properties.get("privateEndpoint") or {}).get("id")
if not pe_id:
continue
edges.append(
GraphEdge(
source_resource_id=resource.resource_id,
target_resource_id=pe_id,
relationship_type="REACHABLE_VIA",
evidence_source="arg:properties.privateEndpointConnections",
confidence=1.0,
)
)
return edges


def detect_all_edges(snapshot: InventorySnapshot) -> list[GraphEdge]:
"""Run all detectors and return the combined edge list.

A detector failure aborts graph publication. Publishing a partial edge list
as complete would remove valid relationships from the preceding snapshot.
"""
detectors: list[EdgeDetector] = [
NsgToSubnetDetector(),
SubnetToResourceDetector(),
PublicIpToResourceDetector(),
IdentityToResourceDetector(),
StoragePrivateEndpointDetector(),
]
edges: list[GraphEdge] = []
for detector in detectors:
try:
edges.extend(detector.detect(snapshot))
except Exception as exc:
logger.warning("edge_detector: %s failed: %s", type(detector).__name__, exc)
raise
return edges
Loading
Loading