Skip to content

feat(graph): evidence graph node and edge population - #353

Open
TFT444 wants to merge 50 commits into
feat/331-evidence-graph-foundationfrom
feat/332-evidence-graph-node-edge-population
Open

TFT444 wants to merge 50 commits into
feat/331-evidence-graph-foundationfrom
feat/332-evidence-graph-node-edge-population

Conversation

@TFT444

@TFT444 TFT444 commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Publishes graph nodes, relationships and finding links atomically after findings are saved. Synthesizes ARG subnet nodes, preserves identity evidence, scopes joins by tenant, retains only explicitly observed current evidence, serializes publication and rejects older snapshots. Private endpoint reachability requires an approved connection and successful provisioning when specified. A publication failure preserves the completed scan outcome.

Runtime dependency inputs and Linux Python 3.11 hash locks include the patched aiohttp, multidict, Werkzeug and certifi versions. No new vulnerability suppressions were added.

Validation

  • Exact head: ddbee04594632749f525d92b33b6cc3abfedaf44.
  • Completed CI run: green, including backend, lint, security scans, dependency audit, container scan and builds.
  • Full backend suite: 1,778 passed, 3 skipped, 4 subtests passed; 88.81% coverage against the 80% gate.
  • The three skips are optional AI index/LLM checks, not newly skipped regressions.

56 focused graph/database checks passed, plus worker success/failure regressions. PostgreSQL coverage includes partial/complete/failed snapshots, stale evidence, tenant isolation, rollback, scope locks and rejection of unapproved private endpoints. The migration follows the repaired #352 foundation without duplicate revision IDs.

Review status

Code and current-head CI are ready for independent re-review. Existing reviewer approvals and conversations are intentionally left for reviewers to confirm; this is not approval or merge authorization.

Dependencies

Depends-On: #352

Required stack order: #326, #352, #353, #354.

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@parthrohit22 parthrohit22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the detector structure. Having one small class per relationship with a confidence value is easy to extend, and passing tenant_id explicitly into the link query was a good change.

I'm requesting changes. Apart from the #352 blocker (no snapshot in real scans), a few problems here mean the graph would still come out mostly empty even once a snapshot exists.

1. Findings are linked before they're saved

populate_graph() runs inside ScanEngine.run_scan(), but findings are only persisted afterwards, when the worker calls db.save_scan(result) (scanner/worker.py:70-76). So link_findings_to_nodes queries findings WHERE scan_id = ... before any rows exist, and it always links 0. That also means #354 never finds a source node. Graph population needs to run after save_scan commits, for example as a post-save step in the worker, or as its own job like enrichment in #325.

2. Three of the five detectors can't fire on ARG Resources data

  • Subnets aren't top-level rows in the ARG Resources table. They're nested under properties.subnets on the VNet. So there's never a subnet node: SubnetToResourceDetector skips everything because of the subnet_id in resource_ids check, and every PROTECTS edge's INSERT ... SELECT matches no target row.
  • identity is a top-level ARG column, not part of properties, and DEFAULT_QUERY doesn't project it. So IdentityToResourceDetector never sees userAssignedIdentities.
  • NICs carry their subnet under properties.ipConfigurations[].properties.subnet.id, not properties.subnet.id, so VMs/NICs, the most common members, aren't covered.

The unit tests build hand-shaped properties, so they pass. A fixture based on a real ARG response for a VNet, a NIC and a VM with a UAMI would show this straight away. Either synthesise subnet nodes from the VNet's properties.subnets or add them to the query, and project identity.

3. Edge upsert query

_UPSERT_EDGE_SQL joins graph_nodes src, graph_nodes tgt on lower(resource_id) without a tenant_id filter. Because of lower(), the idx_graph_nodes_resource_id index can't be used, so that's two sequential scans per edge. And if the same resource ID ever exists under two tenant rows, the join fans out across tenants. Please filter both sides on the snapshot's tenant, and either store resource_id lowercased or add an expression index on lower(resource_id). The same applies to _LINK_FINDINGS_SQL.

Non-blocking

  • Nodes and edges are upserted but never removed. If a NSG is detached or a PE deleted, the old edge stays forever and later paths are built on it. Pruning edges whose evidence_snapshot_id isn't the current snapshot for that subscription would cover it.
  • In PublicIpToResourceDetector the comment says it resolves to the parent VM, but trimming to four provider segments gives you the NIC (or the load balancer). The behaviour is fine, it's just the comment that's wrong.
  • populate_nodes / _write_edges open a fresh psycopg2.connect and run one execute per row. execute_values would make a large subscription a lot cheaper.

@TFT444
TFT444 force-pushed the feat/332-evidence-graph-node-edge-population branch from 337afad to 909cd35 Compare September 27, 2026 10:35
@TFT444

TFT444 commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

All blocking items addressed:

  1. Timing bug: populate_graph() removed from run_scan(). ScanEngine now stores the snapshot on self.snapshot. Worker calls populate_graph() after db.save_scan() so findings exist in the DB when link_findings_to_nodes runs.
  2. IdentityToResourceDetector: Updated DEFAULT_QUERY to use bag_merge(properties, pack('identity', identity)) so the top-level ARG identity field is projected into the properties bag. Existing detector code now finds userAssignedIdentities correctly.
  3. SubnetToResourceDetector: Now checks both properties.subnet.id (direct) and ipConfigurations[].properties.subnet.id (NIC pattern) so the most common MEMBER_OF path is detected.
  4. Edge upsert IDOR: _UPSERT_EDGE_SQL now filters both graph_nodes joins by tenant_id, preventing cross-tenant edge creation. _write_edges updated to accept and pass tenant_id.

@parthrohit22 parthrohit22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 909cd35. Moving populate_graph() into the worker after save_scan() fixes the timing bug: findings now exist when link_findings_to_nodes runs. Adding tenant_id to both joins in _UPSERT_EDGE_SQL closes the cross-tenant fan-out. Thanks for both.

Two things still block, lint is red, and there are the shared CI failures from #352.

1. Subnets still never become graph nodes, so every subnet edge is dropped

The NIC path you added (ipConfigurations[].properties.subnet.id) is the right path, but the result is still gated on subnet_id.lower() in resource_ids. ARG Resources has no top-level rows for subnets; they are nested under the VNet's properties.subnets. I fed the detectors a VNet with one subnet, the NSG protecting it, and a NIC in it, shaped the way ARG returns them:

PROTECTS nsg1 -> app   target_is_node=False
edges: 1
  • MEMBER_OF: zero edges. The NIC → subnet link is filtered out because the subnet isn't in resource_ids.
  • PROTECTS: emitted, but its target is not a node. _UPSERT_EDGE_SQL is an INSERT ... SELECT joined on graph_nodes, so the row is silently not written.

So the graph has no subnets at all, and #354 can't traverse NSG → subnet → NIC. Either synthesise a node for each entry in a VNet's properties.subnets before detection, or mv-expand properties.subnets in the graph query so subnets come back as rows of their own. Please also add one fixture built from a real ARG response (a VNet, a NIC and a VM with a user-assigned identity) and assert on the edges written to Postgres, not on detector output alone. The hand-shaped fixtures are why this passes.

2. The KQL change isn't exercised by any test

Moving identity into properties via bag_merge(properties, pack('identity', identity)) is the right idea for the identity detector. But no test runs the query against Resource Graph, or even against a recorded ARG response, so nothing shows that bag_merge is accepted by ARG's KQL subset. A recorded response fixture (see 1) would cover it.

DEFAULT_QUERY is also the default for the whole ARG inventory module, not just the graph. Projecting identity as its own column would keep properties exactly as Azure returns it for any future consumer of the inventory snapshots. That part is non-blocking.

Lint is red

ruff check fails with F401: unittest.mock.MagicMock imported but unused in tests/test_graph_engine_post_scan.py:3.

Also failing here

The same 5 failures and 1 error as #352: the ambiguous two-parent down_revision and the pinned _HEAD in the admission tests. They come from the base commit and will clear once #352 is fixed and rebased.

Non-blocking (still open)

  • The joins on lower(resource_id) still can't use idx_graph_nodes_resource_id. Store resource_id lowercased, or add an expression index on lower(resource_id).
  • Stale edges are never pruned when an NSG is detached or a private endpoint is deleted.
  • Edges are still written with one execute per row.

@TFT444

TFT444 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed all blockers from your latest review:

1. Subnet nodes now synthesised from VNet properties.subnets
Added _synthesise_subnet_resources() in graph_populator.py. Before calling populate_nodes and detect_all_edges, it walks every VNet resource, extracts each entry from properties.subnets, and creates synthetic InventoryResource entries inheriting the VNet's tenant, subscription, location and resource_group. populate_graph builds an augmented snapshot via dataclasses.replace (the frozen dataclass stays untouched) before all downstream calls. Subnet nodes now exist in graph_nodes before edge upsert runs, so the NSG->subnet->NIC path is traversable.

2. ARG-shaped fixture test added
New file tests/test_graph_populator_subnet_synthesis.py with 7 tests using realistic ARG response fixtures: a VNet with a default subnet, an NSG protecting it, a NIC attached to it, and a VM with a user-assigned identity. Tests assert synthesis output, metadata inheritance, properties preservation, empty/missing cases, and the regression case (edge dropped without synthesis, present after synthesis). 14/14 passing locally.

3. Lint was already clean from the previous push (abf3228 removed the unused MagicMock import). ruff check + format both pass on this head.

The shared CI failures from #352's down_revision will clear once #352 merges and this branch rebases.

Please re-review when you get a chance @parthrohit22

@TFT444
TFT444 force-pushed the feat/332-evidence-graph-node-edge-population branch from 79804c4 to 627abaf Compare October 1, 2026 01:21
@TFT444

TFT444 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@parthrohit22 all requested changes are addressed: subnet nodes are now synthesised from each VNet's properties.subnets before detection runs, the unused MagicMock import is removed, and two new tests cover the bag_merge/identity KQL change (one asserting the query string, one exercising IdentityToResourceDetector against a real ARG-shaped fixture). Could you re-review when you get a chance? Thanks.

@TFT444

TFT444 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

@parthrohit22 All five blocking concerns from both your review rounds are addressed. Please re-review when you get a chance.

@TFT444
TFT444 requested a review from parthrohit22 October 8, 2026 22:30
@TFT444

TFT444 commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

@ritiksah141 @parthrohit22 could you please re-review the latest head (a01afb5)?

Graph publication is atomic, detector failures preserve evidence, and scope locks reject delayed older snapshots. Depends on #352.

The fixes are pushed and all checks are passing on this head (the deployment skip is expected). Please review the updated code and tests, and update your review decision or resolve the relevant conversations when satisfied.

@TFT444

TFT444 commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @parthrohit22 - subnet nodes are now synthesised from VNet properties.subnets via AzureClient.get_subnet(), the bag_merge KQL change is in, and the unused MagicMock import is removed. CI is fully green. Ready for re-review.

parthrohit22
parthrohit22 previously approved these changes Oct 12, 2026

@parthrohit22 parthrohit22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed a7168f4. Both blockers from my last review are fixed:

  • Subnets: _synthesise_subnet_resources builds a node for each entry in a VNet's properties.subnets, so MEMBER_OF and PROTECTS edges now have a target node.
  • Edges asserted in Postgres: test_graph_populator_postgres.py writes a VNet + subnet + NSG + NIC + VM + UAMI graph and checks the PROTECTS, MEMBER_OF and HAS_IDENTITY rows in graph_edges, plus the cross-tenant case.

The non-blocking items are mostly done too: resource_id is stored lowercased, so the lower() joins can use the index; stale edges and nodes are pruned on COMPLETE snapshots; and scope publication is serialised with an advisory lock, with delayed snapshots rejected.

Approving the graph code. It still can't merge until #352's base is settled.

Non-blocking

  • The fixtures are built as InventoryResource objects in Python rather than parsed from a recorded ARG response, so bag_merge(properties, pack('identity', identity)) still hasn't been run through real Resource Graph. A one-off check against a live subscription before this ships would close that.
  • Nodes and edges are still written with one execute per row; execute_values would help on large subscriptions.

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
…Engine

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
TFT444 added 23 commits October 20, 2026 01:10
…t PublicIp path trimming to 3 segments

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
… snapshot dispatch, exc_info on ARG failure, tighten down_revision type

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Add six new tables (Alembic migration e1f2a3b4c5d6), two service
classes, a patterns API route, and engine outcome recording.

Tables added:
- scan_rule_outcomes: per-rule status per scan
- scan_lifecycle_applications: idempotency sentinel
- finding_fingerprints: stable immutable SHA-256 identity per finding
- finding_lifecycles: mutable OPEN/RESOLVED/ACCEPTED/SUPPRESSED/REOPENED state
- finding_lifecycle_transitions: append-only audit trail
- patterns: published persistent_finding / cross_resource_recurrence /
  reopened_finding detections

Services:
- LifecycleService.apply_scan(): idempotent, fail-closed, single transaction
  with FOR UPDATE row locking
- PatternService.detect_and_publish(): three pattern types with hardcoded
  thresholds stored in the record

Routes:
- GET /api/v1/patterns  (list with subscription_id / pattern_type / limit)
- GET /api/v1/patterns/<id>  (single pattern or 404)

Engine:
- ScanEngine.run_scan() now records per-rule outcome status
  (SUCCESS / EMPTY_SUCCESS / PERMISSION_DENIED / TIMEOUT / FAILED)

Tests:
- tests/test_finding_lifecycle.py: 18 cases (mocked DB, no live Postgres)
- tests/test_patterns.py: 9 cases (service unit + Flask route tests)

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
GET /api/v1/patterns: the subscription scope is now always enforced in
SQL (WHERE subscription_id = %s, never a nullable IS NULL bypass). The
JWT subscription_id is the authority; a query-param that disagrees with
the JWT is rejected with 400. A request with no subscription_id in
either the JWT or the query param is also rejected.

GET /api/v1/patterns/<id>: the WHERE clause now includes subscription_id
so a caller cannot enumerate patterns from other subscriptions by ID.
An out-of-scope ID returns 404 to avoid disclosing that the pattern
exists in another subscription.

Adds three new regression tests: no-subscription-400, cross-subscription
query-param-400, and cross-subscription IDOR-404.

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Critical fixes:
- Wire LifecycleService.apply_scan() and PatternService.detect_and_publish()
  into scanner/worker.py immediately after db.save_scan(); lifecycle failures
  are non-fatal so the scan record is always preserved
- Write durable per-rule outcome rows to scan_rule_outcomes at the start of
  apply_scan so the audit record exists even if lifecycle processing fails
- Add subscription_id = effective_sub filter to GET /api/v1/patterns/<id>
  via _effective_subscription() helper (already present in patterns.py HEAD)

Important fixes:
- Add UNIQUE(pattern_type, lifecycle_id, scan_id) constraint to patterns
  table in migration; change ON CONFLICT DO NOTHING to ON CONFLICT ON
  CONSTRAINT uq_patterns_type_lifecycle_scan DO NOTHING
- Collect detection query results before closing RealDictCursor, then open
  fresh cursors for each _upsert_pattern call, eliminating nested-cursor
  overlap in pattern_service.py
- Narrow the absent-findings bulk lock to only rules with a resolving
  outcome (resolving_rule_ids); skip the query entirely when no rule
  produced SUCCESS/EMPTY_SUCCESS, reducing unnecessary lock contention
- Reset consecutive_success_count = 0 when a RESOLVED/ACCEPTED/SUPPRESSED
  finding is reopened
- Remove unused _BLOCKING_STATUSES frozenset from lifecycle_service.py
- Move 'import json' to module level in pattern_service.py

Test fixes:
- Refactor _FakeCursor/_FakeConn to use a shared deque so multiple
  cursor() calls on one connection consume from the same result stream
- Update scripted result sequences in all tests to include the new
  scan_rule_outcomes INSERT in the execute order
- Update fail-closed tests to reflect that no absent-findings query is
  issued when resolving_rule_ids is empty (FAILED/PERMISSION_DENIED)
- Add test_scan_rule_outcomes_written to verify audit record is emitted
- Add consecutive_success_count = 0 assertion to reopen test

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
…ponses

Five routes were echoing str(exc) directly to the client in error
response bodies, exposing DB connection strings, filesystem paths, and
stack trace fragments. The JWT middleware was also leaking the specific
JWT validation failure reason via f-string interpolation.

Changes:
- api/app.py: InvalidTokenError now returns generic 'Invalid token' (no exc detail)
- api/routes/compliance.py: FileNotFoundError logs path, returns opaque message;
  general handler drops 'detail' field
- api/routes/findings.py: both endpoints drop 'detail: str(exc)' from 500 body
- api/routes/scans.py: all four catch blocks drop 'detail: str(exc)' from 500 body

Internal errors are still logged server-side at ERROR level.

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Critical:
- lifecycle_service: wrap entire transaction in try/except with explicit
  rollback() to prevent connection wedge on mid-transaction failure
- pattern_service: same rollback guard for detect_and_publish transaction
- lifecycle_service: replace NOT IN %s + tuple() with != ALL(%s) + list()
  to fix single-element tuple syntax error (PostgreSQL rejects trailing comma)
- patterns API: add tenant_id = %s to both SQL WHERE clauses to close
  cross-tenant pattern read in multi-tenant deployments

Important:
- patterns API: add _effective_tenant() helper using OPENSHIELD_TENANT_ID env
- migration: add 4 missing indexes (fingerprints tenant+sub, lifecycles state
  partial, transitions lifecycle_id, patterns sub+created_at)
- lifecycle_service: remove unused psycopg2.extras import
- test_finding_lifecycle: add test_rule_a_success_does_not_resolve_rule_b_finding
  to verify cross-rule resolution isolation (the key correctness invariant)
- test_finding_lifecycle: add rollback() to _FakeConn and _TrackingConn
- test_patterns: assert subscription_id appears in SQL params for IDOR test
  and subscription scoping test (not just mock return value)

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
- Add _VALID_OUTCOME_STATUSES allowlist; unknown status defaults to FAILED
  instead of letting DB constraint throw IntegrityError mid-transaction
- Fix dead-code if/elif block: restructure to if resolving_rule_ids / nested
  if seen_fingerprint_ids (fail-closed logic now clear at a glance)
- Add tenant_id to ix_patterns_sub_created index (was subscription_id only;
  multi-tenant queries filter on both columns)
- Add CASCADE to all downgrade() DROP TABLE statements (future FK safety)
- Add row_version comment clarifying it is a monotonic counter, not OCC
- Fix _FakeConn in test_patterns.py to share a single deque across all
  cursor() calls, matching test_finding_lifecycle.py and real psycopg2 behaviour
- Add REOPENED -> RESOLVED test covering finding absent from SUCCESS scan

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
…res ARG fixture test

- node_service.py: store resource_id lowercased so the unique index on
  (tenant_id, resource_id) works correctly for mixed-case ARM IDs and
  removes lower() from join queries
- graph_populator.py: update _UPSERT_EDGE_SQL to compare against stored
  lowercase values instead of applying lower() at query time
- test_graph_node_service.py: set mock_cur.rowcount = 1 so max() call
  does not fail on MagicMock comparison
- test_graph_populator_postgres.py: add Postgres end-to-end fixture test
  using real ARG-shaped VNet+NIC+VM+UAMI snapshot asserting on DB edges

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
… test

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
…on rename

PR #352 renamed the graph schema migration revision from e1f2a3b4c5d6 to
b7c3d9e2f841 to resolve a chain collision. Update this migration and its
schema assertion test to point to the correct parent revision.

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
@TFT444
TFT444 force-pushed the feat/332-evidence-graph-node-edge-population branch from c30ccf8 to 89157f7 Compare October 20, 2026 18:46
…endpoints

Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>
Signed-off-by: Tanvir Farhad <tamimtarafder12@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants