Skip to content

Exact Data Match, claim by claim ​

WIN-DLP-EPIC-010 asks for privacy-preserving exact data match over approved structured records, because pattern detection cannot tell a customer's account number from any other number, and EDM can.

Every part of it is built and tested. The parts are not connected: no dataset has ever reached an endpoint. That is the finding, and it is the kind that hides behind a full green test run, because both ends are thoroughly covered and nothing tests the join.

Implementation: packages/crypto/src/edm.rs for the tags, packages/detector-engine/src/edm_runtime.rs for matching, apps/management-api/src/domain/edm.rs for the server.


1. Privacy-preserving - the property the feature is named for ​

ClaimProved by
The dataset is not recoverable from the indexedm_runtime::tests::the_dataset_itself_is_not_recoverable_from_the_index
A different key matches nothingedm_runtime::tests::a_different_key_matches_nothing
Fingerprints are stable and key-dependentedm::tests::fingerprints_are_stable_and_key_dependent
The field name is bound into the tagedm::tests::field_name_is_bound_into_the_tag
A separator prevents field-value smugglingedm::tests::separator_prevents_field_value_smuggling
Truncation is clamped to a safe rangeedm::tests::truncation_is_clamped_to_a_safe_range
Collision probability grows with dataset size, and is statededm::tests::collision_probability_grows_with_dataset_size

Two of those are about attacks rather than about correctness. Binding the field name into the tag stops a value in one column matching a rule about another; the separator stops ["ab","c"] and ["a","bc"] producing the same record tag, which is how a concatenation becomes a forgery.

SPIKE-005 measured the collision rate rather than calculating it, and docs/DETECTOR_CATALOG.md carries the number.

2. Matching ​

ClaimProved by
A full record in a document is matchededm_runtime::tests::full_record_in_a_document_is_matched
A single field alone is not a recordedm_runtime::tests::a_single_field_alone_is_not_a_record
Fields far apart do not form a recordedm_runtime::tests::fields_far_apart_do_not_form_a_record
A partial record does not matchedm::tests::partial_record_does_not_match
Spacing variation does not defeat matchingedm_runtime::tests::spacing_variation_does_not_defeat_matching
Normalisation is locale-robustedm::tests::normalization_is_locale_robust
Two separated records are both foundedm::tests::two_separated_records_are_both_found
A value not in the dataset does not matchedm_runtime::tests::a_value_not_in_the_dataset_does_not_match
Empty and punctuation-only values produce no tagedm::tests::empty_and_punctuation_only_values_produce_no_tag
The hit cap is honoured, and the time budget with itedm_runtime::tests::hit_cap_is_honoured; packages/detector-engine/tests/detector_budget.rs (DLP-257)

The proximity rules are the interesting pair. A single field alone is not a record and fields far apart do not form a record are what stop a document that happens to contain one customer's surname from being an exact match, which is the difference between EDM and a very expensive keyword list.

3. The server side ​

26 tests in domain::edm. Import, versioning, listing, deletion, an audit entry written inside the same transaction as the change, a per-field quality judgement that refuses a column too weak to fingerprint, and a size projection that refuses an import larger than an endpoint could hold (DLP-240, tests/edm_budget.sh).

DLP-241 packs the tags as bytes rather than hex, and DLP-242 prices a set per field. Both were measured, not estimated.

4. The join that does not exist ​

PolicyBundlePayload::edm_sets is in the contract and carries exactly the right thing - per-field keyed hashes, never source values.

Every bundle is built with edm_sets: vec![]. One line in apps/management-api/src/domain/policies.rs, three lines above the one that does fill fingerprint_corpora from fingerprint_corpus::for_bundle.

And with_edm_indices - the engine's way in - is called from tests and examples only. Nothing in agent/core mentions EDM at all.

So an EDM detector in a published policy is reported by the endpoint as exact data match index '<id>' is not loaded, and not evaluated.

What saves it from being a silent failure is that last sentence: the engine reports not_evaluated rather than returning no match, so nothing is called clean on the strength of a dataset that was never there. The capability-clamping discipline holds even here. The feature still does not work.

The blocker is the key, not the wiring. The bundle field's own documentation says the HMAC key "is delivered separately over the mTLS channel" - a mechanism that does not exist: mTLS defaults to Off, no endpoint serves a key, and the agent has nowhere to keep one. Filling edm_sets alone would ship an endpoint holding fingerprints it cannot compute comparisons against.

Filed as DLP-267, with the design questions rather than a patch: where the salt lives on the endpoint (the device keystore already wraps a secret of this class and a second mechanism should not be invented), how it gets there (bundle, check-in, or gated on mTLS Required), and whether a bundle should carry every dataset or only those its own policies reference - the latter, almost certainly.

Document fingerprinting solved the identical shape in DLP-244 and is the worked example to follow.


5. What is not covered ​

  • No end-to-end test, because there is no end to end. The nearest thing is engine::tests::exact_data_match_finds_a_customer_record_end_to_end, which is end to end within the engine - it builds the index in the test.
  • Source-free key rotation cannot be in-place. Rotation keeps the old and new key valid together while each source is re-imported, then retires the old key only after every active endpoint has accepted the replacement bundle (ADR-0071). The server cannot perform the re-import because it never retains source values.
  • No measurement of EDM on a real endpoint, for the same reason: nothing has run there. SPIKE-005 and edm_latency.rs measure the engine on a server-class host, and DLP-257 records that an eight-field set costs about 5.9 s per megabyte - which will matter a great deal on the day datasets actually travel.

Statements here describe what is built and tested; planned work is marked as planned.