Repository navigation
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
parthrohit22
left a comment
There was a problem hiding this comment.
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
Resourcestable. They're nested underproperties.subnetson the VNet. So there's never a subnet node:SubnetToResourceDetectorskips everything because of thesubnet_id in resource_idscheck, and everyPROTECTSedge'sINSERT ... SELECTmatches no target row. identityis a top-level ARG column, not part ofproperties, andDEFAULT_QUERYdoesn't project it. SoIdentityToResourceDetectornever seesuserAssignedIdentities.- NICs carry their subnet under
properties.ipConfigurations[].properties.subnet.id, notproperties.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_idisn't the current snapshot for that subscription would cover it. - In
PublicIpToResourceDetectorthe 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_edgesopen a freshpsycopg2.connectand run oneexecuteper row.execute_valueswould make a large subscription a lot cheaper.
337afad to
909cd35
Compare
|
All blocking items addressed:
|
parthrohit22
left a comment
There was a problem hiding this comment.
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_SQLis anINSERT ... SELECTjoined ongraph_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 useidx_graph_nodes_resource_id. Storeresource_idlowercased, or add an expression index onlower(resource_id). - Stale edges are never pruned when an NSG is detached or a private endpoint is deleted.
- Edges are still written with one
executeper row.
|
Fixed all blockers from your latest review: 1. Subnet nodes now synthesised from VNet properties.subnets 2. ARG-shaped fixture test added 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 |
79804c4 to
627abaf
Compare
|
@parthrohit22 all requested changes are addressed: subnet nodes are now synthesised from each VNet's |
|
@parthrohit22 All five blocking concerns from both your review rounds are addressed. Please re-review when you get a chance. |
|
@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. |
|
Hi @parthrohit22 - subnet nodes are now synthesised from VNet |
parthrohit22
left a comment
There was a problem hiding this comment.
Re-reviewed a7168f4. Both blockers from my last review are fixed:
- Subnets:
_synthesise_subnet_resourcesbuilds a node for each entry in a VNet'sproperties.subnets, so MEMBER_OF and PROTECTS edges now have a target node. - Edges asserted in Postgres:
test_graph_populator_postgres.pywrites a VNet + subnet + NSG + NIC + VM + UAMI graph and checks the PROTECTS, MEMBER_OF and HAS_IDENTITY rows ingraph_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
InventoryResourceobjects in Python rather than parsed from a recorded ARG response, sobag_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
executeper row;execute_valueswould 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>
…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>
c30ccf8 to
89157f7
Compare
…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>
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
ddbee04594632749f525d92b33b6cc3abfedaf44.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.