-
Notifications
You must be signed in to change notification settings - Fork 68
feat(graph): BFS attack path traversal and API endpoints #354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TFT444
wants to merge
13
commits into
feat/332-evidence-graph-node-edge-population
from
feat/333-evidence-graph-path-api
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
811b178
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 8218f20
feat(graph): wire post-scan node and edge population into ScanEngine
TFT444 b8b493a
feat(graph): BFS path traversal, attack_paths migration, and API endp…
TFT444 94bc6b2
fix(graph): remove query-param tenant_id fallback to prevent cross-te…
TFT444 49a9004
fix(lint): ruff format + remove f-string SQL to clear Bandit B608
TFT444 87c9994
fix(graph): bidirectional BFS traversal and AND filter for attack-gra…
TFT444 fd26716
fix(graph): remove double populate_graph, scope BFS reversal, fix pat…
TFT444 05e0cfe
fix(graph): add subnet-bridging test, return 403 for shared-secret ca…
TFT444 59a6907
fix(lint+test): remove unused os import, update 400->403 assertion, f…
TFT444 0267dd0
fix(graph): return attempted path count instead of rowcount from exec…
TFT444 9afea97
fix(graph): fix rowcount from execute_values, add path retention, wra…
TFT444 880210f
fix(tests): remove leftover merge-conflict marker in integration test
TFT444 bcf1d7a
fix: correct teardown scope, int coerce limit param, module-level _RE…
TFT444 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: e1f2a3b4c5d6 | ||
| 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] = "e1f2a3b4c5d6" | ||
| 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| """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.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. | ||
|
|
||
| OIDC mode: the 'tenant' field is populated from the 'tid' claim in the token. | ||
| Shared-secret mode: 'tenant' is always None; admins may supply | ||
| X-Tenant-Id as a request header (never a query param, which leaks into | ||
| logs and caches). Non-admin tokens cannot override the header. | ||
| """ | ||
| user = getattr(g, "user", {}) or {} | ||
| # OIDC path: tid claim decoded by the verifier into user["tenant"] | ||
| tid = user.get("tenant") | ||
| if tid: | ||
| return tid | ||
| # Shared-secret path: admin-only header override for multi-tenant deployments | ||
| if user.get("role") == "admin": | ||
| return request.headers.get("X-Tenant-Id") or None | ||
| 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/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 as exc: | ||
| return jsonify({"error": str(exc)}), 400 | ||
|
|
||
|
|
||
| tenant_id = _tenant_id() | ||
| if not tenant_id: | ||
| # Shared-secret callers have no tenant claim; OIDC is required for | ||
| # tenant-scoped graph endpoints. Return 403 (not 400) since the | ||
| # request is well-formed but the auth method is insufficient. | ||
| return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403 | ||
|
|
||
| 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 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 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 graph_edges e | ||
| WHERE e.source_node_id = ANY(%(node_ids)s::uuid[]) | ||
| AND e.target_node_id = ANY(%(node_ids)s::uuid[]) | ||
| """, | ||
| {"node_ids": node_ids}, | ||
| ) | ||
| 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/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 as exc: | ||
| return jsonify({"error": str(exc)}), 400 | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| tenant_id = _tenant_id() | ||
| if not tenant_id: | ||
| # Shared-secret callers have no tenant claim; OIDC is required for | ||
| # tenant-scoped graph endpoints. Return 403 (not 400) since the | ||
| # request is well-formed but the auth method is insufficient. | ||
| return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403 | ||
|
|
||
| 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 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 | ||
| ORDER BY ap.path_length ASC, ap.min_confidence DESC | ||
| LIMIT %(limit)s | ||
| """, | ||
| {"scan_id": scan_id, "tenant_id": tenant_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/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 as exc: | ||
| return jsonify({"error": str(exc)}), 400 | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| tenant_id = _tenant_id() | ||
| if not tenant_id: | ||
| # Shared-secret callers have no tenant claim; OIDC is required for | ||
| # tenant-scoped graph endpoints. Return 403 (not 400) since the | ||
| # request is well-formed but the auth method is insufficient. | ||
| return jsonify({"error": "tenant_id not available; OIDC authentication required"}), 403 | ||
|
|
||
| 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 | ||
| WHERE ap.path_id = %(path_id)s::uuid | ||
| AND ap.tenant_id = %(tenant_id)s | ||
| """, | ||
| {"path_id": path_id, "tenant_id": tenant_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 | ||
| """, | ||
| {"ids": node_ids, "tenant_id": tenant_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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.