Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
0d4461f
feat(graph): add graph_nodes, graph_edges, finding_graph_nodes migration
TFT444 Sep 24, 2026
1db1436
feat(graph): add snapshot_bridge to collect ARG inventory inside Scan…
TFT444 Sep 24, 2026
079af34
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 Sep 24, 2026
95e0b5d
fix(graph): remove unused pytest import from snapshot_bridge tests
TFT444 Sep 24, 2026
5d66765
fix(lint): apply ruff format to graph foundation files
TFT444 Sep 24, 2026
691c265
fix(lint): ruff 0.16.6 format for graph schema migration
TFT444 Sep 24, 2026
a92c2aa
fix(logging): preserve pytest caplog handler in configure_logging
TFT444 Sep 25, 2026
5270edf
fix(alembic): preserve existing loggers when fileConfig runs
TFT444 Sep 25, 2026
204a740
fix(graph): add tenant_id to AzureClient, merge alembic heads, replac…
TFT444 Sep 27, 2026
923c60f
fix(alembic): use single-parent down_revision for graph schema migration
TFT444 Sep 27, 2026
4a55acb
fix(graph): resolve alembic head dynamically in admission test, move …
TFT444 Sep 27, 2026
cc6dfaf
fix(lint): add blank lines around _resolve_head for ruff format compl…
TFT444 Sep 28, 2026
583aa47
fix(graph): repoint migration down_revision to 3a76ff935bf6 (dev head)
TFT444 Sep 30, 2026
a927187
feat(graph): add node_service to upsert graph nodes and link findings
TFT444 Sep 24, 2026
fbcf094
feat(graph): add edge detectors for NSG, subnet, public IP, identity,…
TFT444 Sep 24, 2026
5c662ff
fix(graph): pass tenant_id explicitly to link_findings_to_nodes
TFT444 Sep 24, 2026
dbbba38
feat(graph): wire post-scan node and edge population into ScanEngine
TFT444 Sep 24, 2026
149deca
fix(lint): apply ruff format to node/edge population files
TFT444 Sep 24, 2026
d861b4e
fix(graph): post-save graph population, fix detectors for ARG schema,…
TFT444 Sep 27, 2026
06db8ce
fix(lint): remove unused MagicMock import from post-scan graph test
TFT444 Sep 27, 2026
2478c87
fix(graph): synthesise subnet nodes from VNet properties.subnets befo…
TFT444 Sep 30, 2026
ae4d441
test(graph): verify DEFAULT_QUERY bag_merge and identity field parsin…
TFT444 Oct 4, 2026
b959ba9
fix: close psycopg2 connections explicitly, fix edge rowcount, correc…
TFT444 Oct 4, 2026
54e2582
fix: add DATABASE_URL guard to migration test, use parameter name for…
TFT444 Oct 4, 2026
6f461df
fix(graph): chain foundation on current dev and isolate migration tests
TFT444 Oct 8, 2026
0592c66
fix(graph): publish atomic current scopes and expire complete snapshots
TFT444 Oct 8, 2026
ad395bb
feat(lifecycle): implement finding lifecycle tracking (#311)
TFT444 Aug 30, 2026
8c4ecb3
fix(security): close IDOR and tenant-bypass in patterns API
TFT444 Aug 30, 2026
46002c7
fix(lifecycle): address all code review findings from #311
TFT444 Aug 30, 2026
2d99c5a
fix(security): strip internal exception detail from all 500 error res…
TFT444 Aug 30, 2026
63abf31
fix(lifecycle): address all reviewer-blocking issues before merge
TFT444 Aug 30, 2026
36473f8
fix(lint): remove unused imports and fix E402 in lifecycle test files
TFT444 Aug 30, 2026
d49c10a
fix(lint): apply ruff format to all lifecycle PR files
TFT444 Aug 30, 2026
cba24f0
fix: apply code review findings for lifecycle PR
TFT444 Aug 30, 2026
0874d68
fix(graph): stack foundation after lifecycle migration
TFT444 Oct 8, 2026
c8fba2e
fix(graph): preserve published evidence when an edge detector fails
TFT444 Oct 8, 2026
d83f619
ci: run required checks for the lifecycle and graph PR stack
TFT444 Oct 8, 2026
3eebef0
ci: use a string key for the PostgreSQL service port
TFT444 Oct 8, 2026
b66af8d
fix(deps): require patched Werkzeug and multidict releases
TFT444 Oct 8, 2026
a49415e
test(ci): match validated PostgreSQL service expression
TFT444 Oct 8, 2026
6b32ac4
fix(graph): serialize scope publication and reject delayed snapshots
TFT444 Oct 8, 2026
fd4bbb5
fix(graph): lowercase resource_id on store, fix edge joins, add Postg…
TFT444 Oct 9, 2026
2cf0261
fix(graph): apply ruff formatting to Postgres populator fixture test
TFT444 Oct 9, 2026
8ee85ed
fix(graph): correct HAS_IDENTITY edge direction assertion in Postgres…
TFT444 Oct 9, 2026
78e7d29
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 Sep 24, 2026
861a574
feat(graph): wire post-scan node and edge population into ScanEngine
TFT444 Sep 24, 2026
2bf1bc3
feat(graph): BFS path traversal, attack_paths migration, and API endp…
TFT444 Sep 24, 2026
40089ac
fix(graph): remove query-param tenant_id fallback to prevent cross-te…
TFT444 Sep 24, 2026
1d880f2
fix(lint): ruff format + remove f-string SQL to clear Bandit B608
TFT444 Sep 24, 2026
252dab6
fix(graph): bidirectional BFS traversal and AND filter for attack-gra…
TFT444 Sep 27, 2026
1acef40
fix(graph): remove double populate_graph, scope BFS reversal, fix pat…
TFT444 Sep 27, 2026
1f01cf3
fix(graph): add subnet-bridging test, return 403 for shared-secret ca…
TFT444 Sep 27, 2026
4fc7b66
fix(lint+test): remove unused os import, update 400->403 assertion, f…
TFT444 Sep 28, 2026
0214b0a
fix(graph): return attempted path count instead of rowcount from exec…
TFT444 Sep 30, 2026
4cdf560
fix(graph): fix rowcount from execute_values, add path retention, wra…
TFT444 Oct 4, 2026
2708806
fix(tests): remove leftover merge-conflict marker in integration test
TFT444 Oct 4, 2026
11d42f9
fix: correct teardown scope, int coerce limit param, module-level _RE…
TFT444 Oct 4, 2026
922a903
fix(graph-api): reject malformed limits and describe admin tenant fal…
TFT444 Oct 8, 2026
66c7cc9
fix(graph): traverse current evidence and prune clean scan paths by s…
TFT444 Oct 8, 2026
3546ed9
fix(graph): guard path replay and sanitize graph API validation
TFT444 Oct 8, 2026
68ec943
docs(graph): preserve original API reference encoding
TFT444 Oct 8, 2026
571a479
fix(graph): add /v1/ prefix to all attack-graph and attack-paths API …
TFT444 Oct 9, 2026
f38e3c3
fix(graph): fix remaining old-path references in attack-graph test pa…
TFT444 Oct 9, 2026
a40be23
fix(graph): update /api/attack-graph path to /api/v1/attack-graph in …
TFT444 Oct 9, 2026
e0a38cd
fix(graph): rechain attack_paths migration onto graph_current_scope t…
TFT444 Oct 11, 2026
373d599
fix(graph): cap unbounded edges query and adjacency load in BFS trave…
TFT444 Oct 17, 2026
b1c636a
fix(graph): rebase onto dev, fix lint in graph populator and path tra…
TFT444 Oct 20, 2026
31692ef
fix(graph): reconcile attack path stack and enforce subscription auth…
TFT444 Oct 22, 2026
9ff9d10
Merge verified population fixes into attack path stack
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
65 changes: 65 additions & 0 deletions alembic/versions/f2a3b4c5d6e7_attack_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Add attack_paths table for pre-computed BFS traversal results.

Revision ID: f2a3b4c5d6e7
Revises: e2c4a6b8d013
Create Date: 2026-09-24 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

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


def upgrade() -> None:
op.create_table(
"attack_paths",
sa.Column("path_id", postgresql.UUID(), nullable=False),
sa.Column("tenant_id", sa.Text(), nullable=False),
sa.Column("scan_id", sa.Text(), nullable=False),
sa.Column("source_node_id", postgresql.UUID(), nullable=False),
sa.Column("target_node_id", postgresql.UUID(), nullable=False),
sa.Column("path_node_ids", postgresql.ARRAY(postgresql.UUID()), nullable=False),
sa.Column("path_length", sa.Integer(), nullable=False),
sa.Column("min_confidence", sa.Float(), nullable=False, server_default=sa.text("1.0")),
sa.Column(
"relationship_types", postgresql.ARRAY(sa.Text()), nullable=False, server_default=sa.text("ARRAY[]::text[]")
),
sa.Column("computed_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.ForeignKeyConstraint(
["source_node_id"],
["graph_nodes.node_id"],
name="attack_paths_source_fkey",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["target_node_id"],
["graph_nodes.node_id"],
name="attack_paths_target_fkey",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("path_id", name="attack_paths_pkey"),
)
op.create_index("idx_attack_paths_tenant_scan", "attack_paths", ["tenant_id", "scan_id"])
op.create_index("idx_attack_paths_source", "attack_paths", ["source_node_id"])
op.create_index("idx_attack_paths_target", "attack_paths", ["target_node_id"])
op.create_index(
"uq_attack_paths_source_target_scan",
"attack_paths",
["source_node_id", "target_node_id", "scan_id"],
unique=True,
)


def downgrade() -> None:
op.drop_index("uq_attack_paths_source_target_scan", table_name="attack_paths")
op.drop_index("idx_attack_paths_target", table_name="attack_paths")
op.drop_index("idx_attack_paths_source", table_name="attack_paths")
op.drop_index("idx_attack_paths_tenant_scan", table_name="attack_paths")
op.drop_table("attack_paths")
2 changes: 2 additions & 0 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ def verify_jwt() -> None:
# ------------------------------------------------------------------ #
from api.routes.ai import ai_bp
from api.routes.assurance import assurance_bp
from api.routes.attack_graph import attack_graph_bp
from api.routes.cbom import cbom_bp
from api.routes.compliance import compliance_bp
from api.routes.drift import drift_bp
Expand All @@ -268,6 +269,7 @@ def verify_jwt() -> None:

app.register_blueprint(ai_bp)
app.register_blueprint(assurance_bp)
app.register_blueprint(attack_graph_bp)
app.register_blueprint(cbom_bp)
app.register_blueprint(compliance_bp)
app.register_blueprint(drift_bp)
Expand Down
259 changes: 259 additions & 0 deletions api/routes/attack_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
"""Attack graph API: resource nodes, edges, and pre-computed attack paths."""

import logging
import os

import psycopg2.extras
from flask import Blueprint, g, jsonify, request

from api.models.finding import DatabaseManager
from api.routes.patterns import _effective_subscription, _ValidationError
from api.validation import ValidationError, positive_integer, uuid_string

attack_graph_bp = Blueprint("attack_graph", __name__)
logger = logging.getLogger(__name__)

_DEFAULT_LIMIT = 100
_MAX_LIMIT = 500


def _get_db() -> DatabaseManager:
if "db" not in g:
g.db = DatabaseManager(os.environ["DATABASE_URL"])
g.db.connect()
return g.db


def _tenant_id() -> str | None:
"""Resolve tenant_id from the verified principal.

The 'tenant' field comes from the verified token's tenant claim.
Headers and query parameters cannot supply a missing verified tenant.
"""
user = getattr(g, "user", {}) or {}
# OIDC path: tid claim decoded by the verifier into user["tenant"]
tid = user.get("tenant")
if tid:
return tid
return None


@attack_graph_bp.teardown_request
def _close_db(exc):
db = g.pop("db", None)
if db is not None:
db.close()


@attack_graph_bp.get("/api/v1/attack-graph")
def get_attack_graph():
"""Return graph nodes and edges for the caller's tenant (latest snapshot).

Query params: subscription_id (optional), limit (default 100, max 500)
"""
try:
limit = positive_integer(int(request.args.get("limit", _DEFAULT_LIMIT)), "limit")
if limit > _MAX_LIMIT:
limit = _MAX_LIMIT
subscription_id = request.args.get("subscription_id")
except (ValidationError, _ValidationError, ValueError):
return jsonify({"error": "Invalid request parameters"}), 400

tenant_id = _tenant_id()
if not tenant_id:
# A caller without a verified tenant or admin fallback cannot access
# tenant-scoped graph evidence.
return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403

try:
subscription_id = _effective_subscription(subscription_id)
except _ValidationError:
return jsonify({"error": "Invalid request parameters"}), 400

try:
conn = _get_db().conn
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
if subscription_id:
cur.execute(
"""
SELECT n.node_id::text, n.resource_id, n.resource_type, n.name,
n.location, n.resource_group, n.subscription_id, n.snapshot_id,
n.updated_at
FROM current_graph_nodes n
WHERE n.tenant_id = %(tenant_id)s
AND n.subscription_id = %(subscription_id)s
ORDER BY n.updated_at DESC
LIMIT %(limit)s
""",
{"tenant_id": tenant_id, "subscription_id": subscription_id, "limit": limit},
)
else:
cur.execute(
"""
SELECT n.node_id::text, n.resource_id, n.resource_type, n.name,
n.location, n.resource_group, n.subscription_id, n.snapshot_id,
n.updated_at
FROM current_graph_nodes n
WHERE n.tenant_id = %(tenant_id)s
ORDER BY n.updated_at DESC
LIMIT %(limit)s
""",
{"tenant_id": tenant_id, "limit": limit},
)
nodes = cur.fetchall()

node_ids = [row["node_id"] for row in nodes]
edges: list = []
if node_ids:
cur.execute(
"""
SELECT e.edge_id::text, e.source_node_id::text, e.target_node_id::text,
e.relationship_type, e.confidence, e.evidence_source, e.collected_at
FROM current_graph_edges e
WHERE e.source_node_id = ANY(%(node_ids)s::uuid[])
AND e.target_node_id = ANY(%(node_ids)s::uuid[])
LIMIT %(edges_limit)s
""",
{"node_ids": node_ids, "edges_limit": limit * 10},
)
edges = cur.fetchall()
except Exception:
logger.exception("get_attack_graph failed for tenant %s", tenant_id)
return jsonify({"error": "internal server error"}), 500

return jsonify({"nodes": [dict(r) for r in nodes], "edges": [dict(r) for r in edges]})


@attack_graph_bp.get("/api/v1/attack-paths")
def list_attack_paths():
"""Return pre-computed attack paths for a scan.

Query params: scan_id (required), limit (default 100, max 500)
"""
scan_id = request.args.get("scan_id")
if not scan_id:
return jsonify({"error": "scan_id is required"}), 400
try:
scan_id = uuid_string(scan_id, "scan_id")
limit = positive_integer(int(request.args.get("limit", _DEFAULT_LIMIT)), "limit")
if limit > _MAX_LIMIT:
limit = _MAX_LIMIT
except (ValidationError, ValueError):
return jsonify({"error": "Invalid request parameters"}), 400

tenant_id = _tenant_id()
if not tenant_id:
# A caller without a verified tenant or admin fallback cannot access
# tenant-scoped graph evidence.
return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403

try:
subscription_id = _effective_subscription(request.args.get("subscription_id"))
except _ValidationError:
return jsonify({"error": "Invalid request parameters"}), 400

try:
conn = _get_db().conn
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT ap.path_id::text, ap.source_node_id::text, ap.target_node_id::text,
ap.path_node_ids, ap.path_length, ap.min_confidence,
ap.relationship_types, ap.computed_at,
src.resource_type AS source_type, src.name AS source_name,
tgt.resource_type AS target_type, tgt.name AS target_name
FROM attack_paths ap
JOIN scans s ON s.scan_id::text = ap.scan_id
JOIN graph_nodes src ON src.node_id = ap.source_node_id
JOIN graph_nodes tgt ON tgt.node_id = ap.target_node_id
WHERE ap.scan_id = %(scan_id)s
AND ap.tenant_id = %(tenant_id)s
AND s.subscription_id = %(subscription_id)s
AND NOT EXISTS (
SELECT 1 FROM graph_nodes hop
WHERE hop.node_id = ANY(ap.path_node_ids)
AND (hop.tenant_id <> %(tenant_id)s OR hop.subscription_id <> %(subscription_id)s)
)
ORDER BY ap.path_length ASC, ap.min_confidence DESC
LIMIT %(limit)s
""",
{"scan_id": scan_id, "tenant_id": tenant_id, "subscription_id": subscription_id, "limit": limit},
)
rows = cur.fetchall()
except Exception:
logger.exception("list_attack_paths failed for scan %s", scan_id)
return jsonify({"error": "internal server error"}), 500

return jsonify({"scan_id": scan_id, "paths": [dict(r) for r in rows]})


@attack_graph_bp.get("/api/v1/attack-paths/<path_id>")
def get_attack_path(path_id: str):
"""Return a single attack path with full node detail for each hop."""
try:
path_id = uuid_string(path_id, "path_id")
except (ValidationError, ValueError):
return jsonify({"error": "Invalid request parameters"}), 400

tenant_id = _tenant_id()
if not tenant_id:
# A caller without a verified tenant or admin fallback cannot access
# tenant-scoped graph evidence.
return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403

try:
subscription_id = _effective_subscription(request.args.get("subscription_id"))
except _ValidationError:
return jsonify({"error": "Invalid request parameters"}), 400

try:
conn = _get_db().conn
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT ap.path_id::text, ap.scan_id, ap.source_node_id::text,
ap.target_node_id::text, ap.path_node_ids, ap.path_length,
ap.min_confidence, ap.relationship_types, ap.computed_at
FROM attack_paths ap
JOIN scans s ON s.scan_id::text = ap.scan_id
WHERE ap.path_id = %(path_id)s::uuid
AND ap.tenant_id = %(tenant_id)s
AND s.subscription_id = %(subscription_id)s
AND NOT EXISTS (
SELECT 1 FROM graph_nodes hop
WHERE hop.node_id = ANY(ap.path_node_ids)
AND (hop.tenant_id <> %(tenant_id)s OR hop.subscription_id <> %(subscription_id)s)
)
""",
{"path_id": path_id, "tenant_id": tenant_id, "subscription_id": subscription_id},
)
row = cur.fetchone()

if row is None:
return jsonify({"error": "not found"}), 404

path = dict(row)

# Fetch full node detail for each hop
node_ids = [str(nid) for nid in path["path_node_ids"]]
if node_ids:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT node_id::text, resource_id, resource_type, name,
location, resource_group, subscription_id
FROM graph_nodes
WHERE node_id = ANY(%(ids)s::uuid[])
AND tenant_id = %(tenant_id)s
AND subscription_id = %(subscription_id)s
""",
{"ids": node_ids, "tenant_id": tenant_id, "subscription_id": subscription_id},
)
nodes_by_id = {r["node_id"]: dict(r) for r in cur.fetchall()}
path["hops"] = [nodes_by_id.get(str(nid), {"node_id": str(nid)}) for nid in path["path_node_ids"]]
except Exception:
logger.exception("get_attack_path failed for path_id %s", path_id)
return jsonify({"error": "internal server error"}), 500

path["path_node_ids"] = [str(nid) for nid in path["path_node_ids"]]
return jsonify(path)
16 changes: 16 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,19 @@ The following endpoints are called by the frontend but have no backend implement
| Endpoint | Used by | Status |
|---|---|---|
| `GET /api/monitoring` | Monitoring page — score trend chart, category distribution | Deferred. Score and findings data come from `GET /api/score` and `GET /api/findings` instead. |

---

## Attack graph endpoints

`GET /api/v1/attack-graph` returns explicitly observed nodes and relationships from the latest published inventory snapshot for an authorized subscription in the caller's tenant. Complete snapshots expire absent resources and relationships within their tenant/subscription scope. Partial snapshots retain historical evidence in storage, while current graph queries and traversal exclude unobserved resources and relationships. Failed collection keeps the previous published snapshot. `GET /api/v1/attack-paths` and `GET /api/v1/attack-paths/<path_id>` expose paths computed for a requested scan. Successful subsequent scans replace prior paths for the same tenant/subscription, including scans with no findings.

### Authentication requirement

Callers must have a verified tenant claim in either authentication mode. Headers and query parameters cannot supply a missing tenant, including for admins. Requests without a usable tenant return `403 {"error": "tenant_id not available; OIDC authentication required"}`.

Subscription scope follows the patterns API: use a signed subscription claim, or the configured deployment allowlist only when the verified tenant matches the deployment tenant. When that allowlist contains several subscriptions, select one with `subscription_id`. A selection outside the authorized scope returns 400. Graph nodes, paths, path details and every returned hop are restricted to that subscription; paths crossing an unauthorized subscription are omitted.

### Attack-path retention

Attack paths are replaced by a newer successful scan within the same tenant/subscription. Clean scans remove prior paths even when there are no findings. Failed scans and delayed older scans retain the newer published evidence. Scope publication and traversal serialize on the same database lock.
4 changes: 4 additions & 0 deletions scanner/graph/graph_populator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from scanner.arg_inventory import InventoryResource, InventoryStatus
from scanner.graph.node_service import graph_connection, link_findings_to_nodes, lock_graph_scopes, populate_nodes
from scanner.graph.edge_detector import detect_all_edges
from scanner.graph.path_traversal import compute_attack_paths

if TYPE_CHECKING:
from scanner.arg_inventory import InventorySnapshot
Expand Down Expand Up @@ -189,3 +190,6 @@ def populate_graph(scan_id: str, snapshot: InventorySnapshot, dsn: str) -> None:
)
except Exception:
logger.warning("graph: population failed for scan %s", scan_id, exc_info=True)
return

compute_attack_paths(scan_id, snapshot.tenant_id, dsn)
Loading
Loading