Skip to content

security(gis): apply k-anonymity suppression to spatial query beneficiary counts - #366

Draft
gonzalesedwin1123 wants to merge 5 commits into
19.0from
security-gis-count-kanon-suppression
Draft

security(gis): apply k-anonymity suppression to spatial query beneficiary counts#366
gonzalesedwin1123 wants to merge 5 commits into
19.0from
security-gis-count-kanon-suppression

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Addresses the reported issue "GIS queries leak exact beneficiary counts."

Problem

The authenticated GIS statistics endpoints (/gis/query/statistics, .../batch, /gis/query/proximity) returned total_count = len(registrant_ids) with no k-anonymity/suppression, even though the aggregate statistics are suppressed by the analytics engine. A client with only gis:read or statistics:read could send very small polygons/buffers (or repeated grid/batch queries) and read total_count to learn whether one or more beneficiaries live at a precise location or in a small area — location/presence disclosure — while the actual statistics would be suppressed.

Root cause (all on 19.0): services/spatial_query_service.py sets total_count = len(registrant_ids) at every result site; the routers strip the raw IDs (spatial_query.py:69, proximity.py:66) but keep the exact count. The count never passed through spp.metric.privacy.

Fix

Suppress every registrant count at the SpatialQueryService choke point (covers single, batch per-geometry, batch summary, and proximity in one place), reusing the existing privacy machinery — no new threshold invented:

  • spp_analytics: expose spp.analytics.service.get_effective_k_threshold() so the GIS service uses the caller's access-rule k-anonymity threshold (the same value the aggregation engine applies to statistics), falling back to spp.metric.privacy.DEFAULT_K_THRESHOLD (5).
  • spp_api_v2_gis: _get_k_threshold() + _suppress_count(); a new count_suppressed: bool field on the four response schemas.

Any count in [0, k) — including a genuinely empty area — is reported as total_count = 0 with count_suppressed = True. Merging true-zero into the suppressed band is deliberate: if empty→false and 1..k−1→true, the flag itself would disclose presence (≥1) at an attacker-chosen location — the exact leak. Counts of k or more pass through unchanged.

routers/statistics.py's total_count counts published indicator definitions (catalog metadata), not registrants — verified not a leak, left unchanged. No schema/data change → no migration.

Tests

  • spp_analytics: get_effective_k_threshold() default + reads-rule (252 pass).
  • spp_api_v2_gis: _suppress_count band (0/1/4 → suppressed; 5/123 → passthrough), _get_k_threshold (default + rule), and end-to-end empty-area suppression for single/batch/proximity (197 pass).
  • Updated one pre-existing test (test_within_returns_registrants_in_nearby_areas): its 2-registrant near area is below k, so the exact count is now suppressed to 0 — assertion updated to verify suppression; matching is still proven via registrant_ids (strengthened, not weakened).

Follow-ups (differencing residuals, to be filed)

v1 suppresses each count independently below k, closing the primary "tiny polygon/buffer → presence" leak. Residual differencing attacks (hard against attacker-chosen geometries) to track separately: (a) batch — visible per-geometry counts + visible summary; (b) proximity beyond = total − within.

Draft — awaiting human security review before undraft/merge.

Authenticated GIS statistics endpoints returned total_count = len(registrant_ids)
with no k-anonymity/suppression, while the aggregate statistics were suppressed.
A client with gis:read or statistics:read could send tiny polygons or proximity
buffers (or grid/batch queries) to learn whether beneficiaries live at a precise
location or in a small area — location/presence disclosure.

Fix at the SpatialQueryService choke point: every registrant count it emits
(single query, batch per-geometry items, batch summary, and proximity) is now
passed through k-anonymity suppression using the caller's access-rule threshold
(the same value the aggregation engine applies to statistics; falls back to the
privacy service default). Any count below the threshold — including a genuinely
empty area — is reported as total_count = 0 with a new count_suppressed flag, so
small and empty results are indistinguishable and the flag itself cannot be used
as a presence oracle.

- spp_analytics: expose spp.analytics.service.get_effective_k_threshold() so the
  GIS service reuses the caller's threshold rather than a hardcoded value.
- spp_api_v2_gis: _get_k_threshold() + _suppress_count() helpers; count_suppressed
  added to the four response schemas.

No schema/data change → no migration. statistics.py's total_count counts
published indicator definitions (catalog metadata), not registrants — not a leak.

Refs: GIS count-leak report. Follow-ups (differencing residuals) to be filed.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Differencing-residual follow-ups filed: #367 (batch: per-geometry + summary counts differenceable across attacker-chosen tiles) and #368 (proximity beyond = total − within enables recovering a suppressed small within). Both need a design decision; this PR closes the primary tiny-polygon presence leak.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.93939% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.22%. Comparing base (1caf794) to head (59164ee).
⚠️ Report is 3 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_api_v2_gis/services/spatial_query_service.py 90.90% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #366      +/-   ##
==========================================
- Coverage   74.28%   71.22%   -3.06%     
==========================================
  Files         372      175     -197     
  Lines       25385    14920   -10465     
==========================================
- Hits        18857    10627    -8230     
+ Misses       6528     4293    -2235     
Flag Coverage Δ
spp_analytics 93.27% <100.00%> (+0.14%) ⬆️
spp_api_v2_cycles ?
spp_api_v2_entitlements ?
spp_api_v2_gis 72.01% <93.33%> (+0.48%) ⬆️
spp_api_v2_programs ?
spp_api_v2_simulation 71.19% <ø> (+0.07%) ⬆️
spp_audit_programs ?
spp_base_common 91.07% <ø> (+0.80%) ⬆️
spp_case_demo ?
spp_case_entitlements ?
spp_case_programs ?
spp_cr_type_assign_program ?
spp_dci_compliance ?
spp_dci_demo 94.28% <ø> (+0.88%) ⬆️
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
spp_farmer_registry_demo ?
spp_gis_report ?
spp_grm_demo ?
spp_indicator ?
spp_indicator_studio ?
spp_metric_service ?
spp_mis_demo_v2 70.99% <ø> (+0.03%) ⬆️
spp_programs 65.27% <ø> (+<0.01%) ⬆️
spp_registry 86.94% <ø> (+0.10%) ⬆️
spp_security 69.56% <ø> (+2.89%) ⬆️
spp_simulation 79.67% <ø> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_analytics/models/service_aggregation.py 96.85% <100.00%> (+0.07%) ⬆️
spp_api_v2_gis/schemas/query.py 100.00% <100.00%> (ø)
spp_api_v2_gis/services/spatial_query_service.py 77.11% <90.90%> (+1.88%) ⬆️

... and 197 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Applied verbatim from CI's oca-gen-addon-readme output for the HISTORY
fragments added in this branch.
Adversarial staff review found the count-flooring alone did NOT close the
presence oracle: an empty region returned statistics={}, access_level=None,
computed_at=None, query_method=area_fallback while a 1..(k-1)-person region
returned populated statistics, access_level=aggregate, a real computed_at, and
query_method=coordinates. A client could read computed_at (or the others) as a
binary presence oracle for an attacker-chosen location despite total_count=0.

Fix: _apply_suppression() now returns a canonical suppressed response for any
count in [0,k) — floors total_count to 0 AND blanks every people-correlated
field (statistics, access_level, from_cache, computed_at, query_method,
areas_matched) to fixed values, so an empty region and a small one are
byte-identical. Applied at all query/proximity/batch sites and the summary.
Request echoes (radius_km, relation, reference_points_count, geometries_queried,
id) are preserved. Schema query_method doc notes the 'suppressed' value.

Tests strengthened to assert the canonical form (empty == small, byte-identical)
and adjusted the proximity area-fallback tests for the new behavior. 197 pass.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Adversarial staff-engineer review done — one must-fix applied, one follow-up filed, one nit noted.

Re-verified: spp_api_v2_gis 197/0, spp_analytics 252/0.

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.

1 participant