Skip to content

Add configurable DLP controls with optional Presidio endpoint support - #917

Open
zarg-dev wants to merge 25 commits into
microsoft:Developmentfrom
zarg-dev:feature/dlp-control-plane-presidio
Open

Add configurable DLP controls with optional Presidio endpoint support#917
zarg-dev wants to merge 25 commits into
microsoft:Developmentfrom
zarg-dev:feature/dlp-control-plane-presidio

Conversation

@zarg-dev

@zarg-dev zarg-dev commented Jun 18, 2026

Copy link
Copy Markdown

This PR adds a configurable DLP layer for web-search egress and upload ingestion. The built-in path is regex-based with narrow defaults, and admins can tune or expand the rules. I also added optional integration with an external Presidio Analyzer-compatible endpoint for customers who want richer detectors without adding Presidio packages and models to the SimpleChat app image.

What changed

  • Added DLP evaluation before web-search grounding.
  • Added DLP evaluation during upload ingestion before embeddings/search indexing.
  • Added admin-configurable regex rules with defaults for U.S. SSNs and Luhn-valid credit cards.
  • Added keyword-proximity confidence shaping for regex rules.
  • Added monitor, redact, and block modes.
  • Added optional external Presidio Analyzer endpoint support.
  • Kept Presidio as a separate service integration instead of embedding it into SimpleChat.
  • Added endpoint hardening for Presidio calls, including private-host checks, DNS rebinding protection, env-backed auth, and auth-header validation.
  • Added admin settings, docs, release notes, and functional tests.

Notes

The regex model is intentionally simple. It gives deployers a lightweight starting point, but Presidio is the better fit for customers who want broader PII detection such as emails, phone numbers, IP addresses, names, or other recognizers.

I extended the same DLP path to uploads because the use case felt similar: some customers may want to scrub or block sensitive content before it is sent to model inference, embeddings, or search indexing.

Testing

  • Ran focused DLP tests for web search, uploads, regex rules, telemetry, admin settings, and Presidio endpoint behavior.
  • Ran direct Presidio functional test entrypoints.

Merge-resolution changes outside the DLP feature

This branch was merged with Development up to 4a29f67e. Three changes below
are not part of the DLP control plane but were required for the merged branch to
land green. They are called out separately so they can be reviewed on their own
terms.

1. scripts/build_release_notes_pages.py — relative link rewriting

docs/explanation/release_notes.md is the source of truth and the pages under
docs/explanation/release-notes/ are generated from it. test_docs_release_notes_integrity
requires the two to agree, so adding a release-notes section means regenerating.

The generated pages sit one directory below the source, but the generator copied
release text verbatim. Relative links such as fixes/NAME.md, which resolve
correctly from docs/explanation/, therefore pointed at files that do not exist
once a section reached a generated page — 8 broken links, caught by
test_docs_link_integrity.

rewrite_relative_links() now prefixes ../ for relative targets, skipping
absolute paths, anchors, URL schemes and Liquid expressions. This is a
pre-existing generator bug that regeneration exposed rather than introduced;
the committed pages were already stale on Development (the index covered only
up to v0.261.003 while the source had later versions).

2. Documentation for the DLP capability toggles

test_docs_app_surface_coverage fails on any unclaimed enable_* settings key.
This PR adds three: enable_dlp_control_plane, enable_upload_dlp and
enable_web_search_dlp.

  • Claimed in docs/_data/features.yml under a new data-loss-prevention entry.
  • Documented on docs/admin/security.md under Content Safety, covering the
    monitor/redact/block modes, the regex and external Presidio engines, the
    private-host allowlist, env-var-backed API key handling, and counts-only
    telemetry.
  • docs/_data/app_surface.yml regenerated with scripts/build_docs_inventory.py.

3. functions_dlp_presidio.py — urllib3 version safety

The adapter imported urllib3.util.timeout._DEFAULT_TIMEOUT, a private symbol
that only exists on urllib3 2.x, and referenced urllib3.connection.NameResolutionError,
also 2.x-only. urllib3 is not pinned and is resolved transitively through
requests, and functions_documents.py imports this module, so on urllib3 1.x
the private import raised ImportError during application start-up.

The sentinel now resolves through urllib3's public Timeout.DEFAULT_TIMEOUT,
which is is-identical to the 2.x private sentinel and to
socket._GLOBAL_DEFAULT_TIMEOUT on 1.x. Connect-timeout behavior on the DLP
request path is unchanged: an explicit timeout is still applied and only the
"leave the socket default alone" case skips settimeout(). DNS failures raise
NameResolutionError where available and its base NewConnectionError
otherwise. No urllib3 pin was added.

Covered by functional_tests/test_dlp_presidio_urllib3_compatibility.py, which
passes on both urllib3 1.26.20 and 2.7.0.

Template placement note

Development split templates/admin_settings.html into templates/admin/_panes/*.html.
The DLP card was moved verbatim into admin/_panes/content-safety.html,
immediately after the Content Safety card, matching its original position
relative to its neighbours. Left inline in the old monolith it would have
rendered outside every tab-pane. The CSRF hidden input was reapplied to the
real <form> in admin_settings.html, and three DLP tests were repointed at
the pane.

Three PR-added redirect(url_for('admin_settings')) calls — two on DLP
validation-failure paths — were updated to
url_for('frontend_admin_settings.admin_settings') after Development moved
that route into a blueprint. As merged they would have raised BuildError.

@zarg-dev

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="OCC"

Comment on lines +8 to +12
try:
from functions_appinsights import log_event
except Exception:
def log_event(message, extra=None, level=None, exceptionTraceback=False):
return None

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.

Was there an issue trying to import log_event? Why wrap it in the try block?

import re
from collections import OrderedDict

from functions_dlp_presidio import analyze_with_presidio_endpoint

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.

presidio was mentioned to be optional but here it appears to be joining the core application alongside the new dlp feature?

@Bionic711

Copy link
Copy Markdown
Collaborator

zarg-dev Paul Lizer (@paullizer) and I are discussing the implementation and will get back to you once we have feedback. Stay tuned!

Resolve conflicts between the DLP control plane / Presidio endpoint work and
Development, keeping both sides' functionality.

Notable resolutions:

* config.py: VERSION bumped on top of Development's version, and Development's
  Easy Auth logout opt-out kept.
* functions_documents.py: kept the DLP-aware enhanced-citation flag, the
  DLP-sanitized transcript embedding, and the DLP block re-raises, and moved
  Development's embedding character clamp onto the post-DLP embedding call site
  so vectors stay bounded but are never built from unredacted content.
* route_backend_chats.py: kept Development's log prefixes together with the
  DLP log redaction, and kept the DLP-approved-query-only behavior instead of
  restoring the user_message fallback.
* templates: Development split admin_settings.html into admin/_panes/*, so the
  DLP card moved into admin/_panes/content-safety.html in the same relative
  position and the CSRF hidden input was reapplied to the real form.
* route_frontend_admin_settings.py: DLP redirects now use the blueprint
  endpoint name Development introduced.
* release_notes.md: DLP entries filed under a new version section; no existing
  entries removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
functions_dlp_presidio.py imported urllib3.util.timeout._DEFAULT_TIMEOUT, a
private symbol that only exists on urllib3 2.x, and referenced
urllib3.connection.NameResolutionError, which was also added in 2.x.
requirements.txt does not pin urllib3, so it is resolved transitively through
requests. On urllib3 1.x the private import raised ImportError, and because
functions_documents.py imports functions_dlp -> functions_dlp_presidio, that
failure propagated to application start-up.

Resolve the sentinel through urllib3's public Timeout.DEFAULT_TIMEOUT instead.
It is the identical object to the 2.x private sentinel and to
socket._GLOBAL_DEFAULT_TIMEOUT on 1.x, so connect-timeout behavior on the DLP
request path is unchanged: an explicit timeout is still applied, and only the
"leave the socket default alone" case still skips settimeout(). DNS failures
now raise NameResolutionError when present and NewConnectionError, its base
class, otherwise.

Also update tests for the merge with Development:

* test_upload_dlp_ingestion_integration.py stubs the document access index and
  Search write fence modules Development added to the functions_documents
  import chain, and asserts the relocated embedding clamp still reads only
  sanitized text.
* test_web_search_current_message_only.py expects Development's [WEB_SEARCH]
  log prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Take Development through the Custom model endpoint provider work and re-file
the DLP release notes, which is the only conflict this merge produced.

* config.py: VERSION 0.261.011, above Development's 0.261.010.
* release_notes.md: DLP entries moved to a new v0.261.011 section so the
  v0.261.010 Custom model endpoint entry keeps the version it shipped under.
  Nothing was removed.
* Documented the DLP capability toggles on the Security admin page and claimed
  enable_dlp_control_plane, enable_upload_dlp and enable_web_search_dlp in
  docs/_data/features.yml, then regenerated docs/_data/app_surface.yml.
* build_release_notes_pages.py now rewrites relative release-note links when it
  emits into docs/explanation/release-notes/. The generated pages sit one level
  below the source, so links such as fixes/NAME.md previously resolved to files
  that do not exist once a section reached a generated page.
* Updated the upload DLP ingestion test harness for Development's search write
  path, which passes client timeouts and requires per-document acknowledgement.

Re-verified after merging that the DLP enforcement ordering still holds:
save_chunks and save_chunks_batch evaluate upload DLP before any embedding call
and the embedding character clamp only ever narrows the sanitized text, and
perform_web_search still refuses to fall back to the raw user message.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Take Development through the revert of the Custom model endpoint provider
(microsoft#1431 reverting microsoft#1228). Only two files conflicted, both mechanical.

* config.py: kept VERSION 0.261.011. The revert returned Development to
  0.261.009, so this is still ahead and needs no renumber.
* release_notes.md: kept the v0.261.011 DLP section and took Development's side
  of the region below it, so the reverted v0.261.010 Custom model endpoint
  section is not reinstated.
* Regenerated docs/explanation/release-notes/ so the generated pages drop the
  reverted v0.261.010 entry with the source. docs/_data/app_surface.yml was
  regenerated and is unchanged, since the revert did not alter the application
  surface this PR documents.

Re-verified the DLP enforcement paths against the reverted file contents rather
than assuming they carried over, because microsoft#1228 had touched both files:

* save_chunks and save_chunks_batch still evaluate upload DLP before any
  embedding call, and the embedding character clamp still only narrows
  sanitized_chunk_text.
* perform_web_search still has no user_message fallback, so only the
  DLP-approved query can reach the outbound search.
* Video enhanced-citation upload still gates on the caller-supplied
  enable_enhanced_citations flag that upload DLP clears.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
panel.classList.toggle('d-none', !toggle.checked);
toggle.addEventListener('change', function () {
panel.classList.toggle('d-none', !this.checked);
if (typeof markFormAsModified === 'function') {
};
dlpDefaultEngine.addEventListener('change', function () {
updateDlpEngineVisibility();
if (typeof markFormAsModified === 'function') {
if request_error_type:
raise PresidioEndpointRequestError(f"Presidio analyzer request failed: {request_error_type}") from None

if not isinstance(body, list):
from config import *
from functions_appinsights import log_event
try:
from functions_appinsights import log_event
from functions_dlp_rules import get_effective_dlp_regex_rules, scan_text_with_dlp_regex_rules

try:
from functions_appinsights import log_event
def evaluate_dlp_text(text, settings=None, context=None, surface="generic"):
"""Evaluate text against the configured DLP policy and return a safe result."""
settings = settings or {}
context = context or {}
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
last_error = None
from config import *
from functions_appinsights import log_event
try:
from functions_appinsights import log_event
Comment on lines +43 to +48
from functions_dlp import (
build_dlp_telemetry_properties,
build_upload_dlp_file_log_summary,
evaluate_upload_content,
should_emit_dlp_telemetry,
)
"""

import importlib.util
import os

def test_enforced_truncation_blocks_before_scanner_error_fail_open():
"""Protected enforced surfaces should block truncated text before scanner errors."""
import functions_dlp

def test_scanner_error_fails_closed_by_default():
"""Scanner errors must not allow web-search egress by default."""
import functions_dlp
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.

4 participants