Appearance
Known limitations
What this release does not do. Stated plainly, because a security product that hides its gaps is more dangerous than one that has none.
Every gap in a table below cites either an open ticket in the tracker at task.erpona.app (workspace DLP, project MyDLP), or the decision that accepts it, usually ADR-0039, which says why and what would reopen it. tools/check_register_tickets.py checks this in CI, and with --online also checks that each ticket is still open. Until DLP-1174 this paragraph said every item was a ticket, while 56 rows cited only closed tickets, keys the tracker never had, or nothing. CI has no tracker token, so the offline check reads a snapshot of the keys the tracker lacks and the highest it had (tools/tracker_absent_keys.txt). A row citing a ticket filed after that snapshot fails as a predicted id until tools/check_register_tickets.py --refresh is run and the snapshot is committed in the same change (DLP-1666).
1. Enforcement
An endpoint that has been away a long time enforces stale rules rather than none. A policy bundle carries an expiry - seven days by default - and an endpoint that has not heard a newer one keeps enforcing what it last verified, reporting itself as Degraded with the reason and marking every incident it raises evaluated_with_stale_policy (DLP-262). It used to discard the bundle and load nothing, so a laptop away for a fortnight came back protecting nothing: it did not degrade, it disarmed, silently from the point of view of the person carrying it. Only an endpoint that is offline goes stale: the server re-signs every tenant's bundle before it expires (DLP-1232), which until 2026-09-28 it did not, so a tenant quiet for a week offered a newly enrolled endpoint no policy at all.
The trade is deliberate and it is not free. Expiry is a staleness marker, not a trust one - the signature still verifies and the tenant signed exactly these rules - so the choice is between a definite loss of protection and a speculative risk that the rules have since changed. The second is the lesser harm. What it costs: a rule withdrawn at the console does not stop being enforced on an endpoint that never hears about it.
There is deliberately no outer limit (ADR-0018, DLP-277). An endpoint offline for a year enforces a year-old bundle, because the argument above does not weaken with time: at ninety days the loss of protection is still definite and the staleness risk is still speculative, and a cliff there is the same cliff moved further out. What does change is how loudly the endpoint says so - once a bundle is past its expiry by four of its own lifetimes, and never sooner than thirty days, the endpoint reports Impaired rather than Degraded and its warning says it has not accepted a policy change in that time. On the seven-day default that is thirty-seven days after the bundle was issued. The control is find the machine, not disarm it, and the console's endpoint list is where that separation is read.
A bundle that is wrong rather than stale - a bad signature, another tenant, another device, a rollback - is still discarded exactly as before, and a test asserts each of those separately so the two cases cannot be collapsed.
The agent prevents one thing, on one platform, when asked. Since #387 a Linux endpoint can deny an open() under a directory the policy bundle marks with enforce_opens, and the opening process receives EPERM. Everything else on every platform is still monitor-only: a policy that says block on any other channel produces an incident whose action is audit, and the incident records both.
Enforcement is off unless a path asks for it, needs CAP_SYS_ADMIN, and costs about 6x on every open in scope - so most endpoints are enforcing nothing, which is the intended state and not a fault.
The clipboard is still not read, on either platform, and the reason is structural. The clipboard is per-session: the agent runs as a LocalSystem service in session 0 on Windows and as a systemd unit with no desktop on Linux, and in both cases the clipboard it can see is its own, permanently empty one. Reading a person's clipboard needs a process inside their session, and so does collecting a typed justification (#471).
The channel between such a process and the agent exists as of #317 - agent/core/src/session_link.rs, a unix socket at 0600 and a named pipe with a DACL naming only SYSTEM, administrators and interactive users - and so does the agent's side of it: a connected session agent's copies are scanned, evaluated against policy and queued as clipboard events.
The process that sits on the other end is dlp-agent session, and on Windows it is now joined to both halves: it connects over the named pipe, is told what it may do, reads the clipboard and reports copies when a policy asks for them. Identity comes from Windows - the agent impersonates the pipe client just long enough to ask who it is - not from anything the client sends.
One platform gap remains.
The Windows channel has run on a physical desktop. Its first run on DESKTOP-MNVLKOM found that the service's pipe inherited Administrators as owner, while the client correctly accepts only LocalSystem. DLP-1249 now names LocalSystem in the descriptor without weakening that check. On 2026-09-29 the service self-updated to 0.439.0 and the same session processes that had started under 0.428.0 reconnected after the restart; the service reported Connected { claimed_user: "Asaf" } and no further peer-identification error. DLP-1248 keeps the session process alive with capped backoff while the service is absent. The setup program starts it for the person installing (DLP-1247). When an MSI installs or upgrades the service, the service starts it, as each person, in every session already signed in, and an Event Log warning (3011) names a session it could not start. The capability follows the live connection, so the endpoint view reports clipboard unavailable, with the reason, whenever no session agent is connected. Hosted Windows CI runs again since 2026-10-01, but its session-pipe tests do not pass yet (DLP-1489, Windows CI status); it is no longer the only evidence for this channel.
Linux reads no clipboard. dlp-agent session connects and runs there, and there is nothing behind it: reading a clipboard needs an X11 or Wayland client, which is not implemented. The subcommand says so and reports nothing.
A copy is attributed to the process that owned the clipboard, and the attribution can still fail (DLP-181). The owner is the window that last set the clipboard, so it is the process the copy came out of - which is the question an analyst has, and a different one from which window had focus when the change was noticed. After a copy-and-paste the focused window is often the application the data was pasted into, so the two are never merged: the owner is used when it can be established, the foreground window is offered only as a hint that says in the event that it is one, and nothing is guessed.
Three ways it fails, all of them reported rather than left blank: the clipboard can be set with no owner window, the owner can close between the copy and the lookup, and the pid can be reused before the service resolves it. The last is why the session agent pins the owner's creation time and the service checks it against the same handle every other field comes off - a pid Windows has since handed to something else describes nothing rather than describing the wrong program. That guard now runs against a real Windows kernel (DLP-324): CI's windows-agent job describes the test's own process by its own handle, then describes the same pid with a creation time one tick out and asserts that nothing about the process comes back - which is the branch that decides whether an incident names the right program.
What is still unobserved is narrower than it was, and it is the clipboard half. GetClipboardOwner needs a window station with a desktop and somebody copying something; a build runner has neither, and windows-stress.yml records that boundary. So: the process identity underneath the attribution is exercised on Windows, the owner lookup on top of it is not, and that remainder is DLP-1176's ground.
A typed justification is collected on both platforms - Linux since DLP-113, Windows since DLP-258. The channel had carried a Prompt message since #317 and nothing sent one, so an answer that arrived was read and dropped - which meant a require_justification policy recorded [not_asked] on every event of every fleet, including the ones with somebody sitting at the machine. Both halves now exist on both: the agent sends the question and waits, dlp-agent session puts it in front of the person and sends back what they chose.
Windows took longer for one reason worth recording. The serving thread blocks in a read for as long as the session agent is quiet, which on an idle desktop is nearly always, so a question sent from that thread would wait for the person to copy something first. Unix gets its writing half from UnixStream::try_clone; a named pipe needs DuplicateHandle, and the duplicate is owned by a pump thread while the original stays with the reader. Exactly one thread ever writes, which is what keeps two messages from interleaving into something the peer cannot parse.
Three outcomes, and they are deliberately not the same fact:
| What happened | Recorded |
|---|---|
| A reason and a category, and the reason passes the same checks the agent applies to any justification | [external_party] ... - consent |
| A live session agent, and nobody answered before the deadline | [no_answer] |
| No session agent, or one that was killed while the question was open | [not_asked], with which |
That last row is the one that matters and is why the two are separated: if killing the session agent produced silence that read as "nobody answered", require_justification would quietly become audit for anyone able to kill a process running as themselves - which is everyone, on their own machine. See Threat model §3.6.
What the person sees depends on the platform. On Windows the question is a native dialog in their session (session_ui::ask, DLP-195): standard Win32 controls, so keyboard focus, tab order and screen-reader metadata come from Windows. On Linux dlp-agent session is a terminal program, and the question appears where it runs.
Two sentences here used to say otherwise. "On Windows the prompt is not carried yet" contradicted the paragraph above it, which describes the DuplicateHandle pump that carries it (session_link/windows_impl.rs). "What the person sees is a console, not a dialog" stopped being true on Windows with DLP-195. Neither dialog nor pipe has run with a person at a keyboard (CI has none). The graphical setup program starts the session agent at every sign-in (DLP-1126). The MSI's entry is written and has not been installed on Windows (DLP-1214), and a scripted install from the standalone download registers none. The service still serves one session at a time (DLP-1125).
Two things about that channel are worth knowing before it is used:
- The switch is a policy naming the clipboard channel, not a setting. Reading what somebody copied is the most invasive thing in the product, so turning it on is an act somebody performs, reviews and can point to. Removing the policy turns it off on the next check-in. A broad policy with an empty channel scope does not turn it on - somebody writing "block card numbers leaving this company" has not asked to read everybody's clipboard.
- A copy that matches no detector never leaves the agent's memory. It is scanned and dropped: no queue, no disk, no network. That is the difference between this and a keylogger, and there is a test for it.
Windows warns; Linux does not. On Windows a warning now goes only to the session an event came from (DLP-1127). The clipboard and a browser upload name their session. The file-system channels' events carry none, so since DLP-1125 they report Warn as partial: shown only while exactly one person is signed in and their session is active, and recorded as not delivered on a machine with several (until then they reported it unavailable, DLP-1465). Since #317 a Windows endpoint shows the logged-in user a message box for a warn or require_justification policy, through WTSSendMessageW. It never waits for an answer, so the event says the person was told and never that they agreed, and an event on a machine where nobody is logged in carries not_delivered rather than failed. On the first physical host that message box never appeared: the service read session 0's notification state, took it for "something is full screen", and withheld every one (DLP-1251, fix released in bb2f40e7). Since then the service's own box has appeared on both physical hosts, about five seconds after the write, with its text read back and checked (DLP-1342, cases T70/T71 and R17). Each time a session agent was connected and did not confirm (DLP-1315); the box with no session agent connected at all has not been watched, and is what DLP-1251 still waits on. A warning raised while a box is open queues, and queued boxes appear 60 s apart (DLP-1379). So not_delivered has also meant the agent withheld it, and the console no longer calls it harmless. Since DLP-1276 it also counts them: the Overview has a row for the week's warnings nobody saw, the incident queue filters to them, and a device row carries its own count. A count is not a cause, and the row says where to look (the device's session agent), not which of the reasons it was. On Linux nothing is shown: that needs a process in the user's session, the same one the clipboard channel waits on, so a warn policy still clamps to audit there.
This is still not a kernel component. fanotify denies from userspace, which is why it was possible at all. The released Windows agent still has no active minifilter and prevents no file operation, but the earlier refusal has been reversed: ADR-0069 authorises a phased thin driver. The repository's B1 monitor remains unsigned, development-only and outside the production installer; B2 blocking cannot start until B1's Windows attribution, reliability and performance gates pass. The original blast-radius and review-surface reasoning remains as history in ADR-0004, because those risks now define the release gates rather than a permanent refusal.
What a Windows minifilter would actually cost is now priced, in SPIKE-004, and it is worse than SPIKE-001 assumed. Attestation signing - the cheap path - is refused by Microsoft for filter drivers on Windows Server 2016 and later, and those versions load only HLK-tested drivers. Since a DLP product that cannot run on a file server is not one, the requirement is the full Hardware Compatibility Program: a test lab, and a submission per Windows release. The money is small; the standing commitment is not.
An organisation that needs prevention today should know this before deploying. It is visible in the product - on every endpoint's capability matrix, on every clamped incident, and in the overview's "actions the endpoint could not enforce" figure - rather than only in this document.
A justification records why; it gates nothing. RequireJustification now asks (#471): the agent prompts, checks the answer is an answer rather than a keypress, coalesces one answer across a burst, and carries what was typed onto the incident where an analyst reads it. What it does not do is stop anything - there is no enforcer, so the operation proceeds whatever the person types or does not type, and the prompt says so in those words rather than implying a gate. A prompt that looks like a question and is actually a notification teaches people to dismiss prompts, and the next one matters.
Two consequences worth stating: a fleet agent runs as a service with no session, so in most deployments today there is nobody to ask - the event records [not_asked] with the reason rather than inventing consent. The graphical prompt exists on Windows, but it runs in the session agent, as the clipboard reader does, and nothing starts that agent for each signed-in person yet (DLP-1125).
One channel is enforced; every other one reports. On Linux, an open under a directory the bundle marks with enforce_opens can be denied, by the kernel, with the process receiving EPERM (#387). That is the first control in the product rather than a record of one. It needs CAP_SYS_ADMIN, it is off unless a path asks for it, and it costs about 6x on every open in scope - so it is a scalpel, not a setting to turn on everywhere. Everything below this paragraph still reports and does not prevent.
Two residual gaps worth stating. A verdict that never arrives suspends the process that opened the file; a watchdog outside the decision path withdraws enforcement after two seconds and the kernel then allows everything waiting - but if the whole agent is stopped with SIGSTOP, no thread runs, including that watchdog, and openers stay suspended until the agent is continued or killed. They remain killable throughout (fanotify's permission wait is TASK_KILLABLE), so the remedy is a signal rather than a reboot. And nothing is quarantined on this channel: denying an open does not move a file.
External devices are named, not stopped, and not all of them are named. A phone or tablet over MTP/PTP and a burnt optical disc are now identified on Linux and reported on channels of their own, so a policy can say "not to a phone" - the largest real gap, because a phone on USB is not a block device and was therefore invisible to the removable-storage path (#474). None of it is prevented. The open enforcer (#387) adjudicates opens under a marked directory; a write to a phone is not an open of a marked file, so stopping one still needs the write-side work in #390. Bluetooth is not detected at all on either platform. On Windows an optical disc is now seen (#317) - GetDriveTypeW reports the drive and the disc is classified onto its own channel. A phone is now detected as attached on Windows too, through the Portable Devices API, but a transfer to it is still invisible: the file goes over a COM interface and never touches the filesystem, so identifying the device perfectly changes nothing about seeing the data. mobile_device therefore stays unavailable as a channel on Windows, and the presence list is reported separately so the two cannot be confused. The capability matrix says so per platform rather than borrowing one platform's row for the other.
A deletion is reported from memory, not from the file. When a file the endpoint had already found something in is deleted, the event carries what was remembered - detector, category, count - and no preview, because the file is gone and there is nothing to redact from (#364). Two consequences: a file deleted before it was ever scanned produces nothing, and a cache cleared or lost between the scan and the deletion loses the link. Both are the honest answer rather than a guess.
file_open now has something behind it, on Linux only. The contract defined it (#499) so that an adjudicated open is reported as an open rather than borrowed onto file_copy - a copy is a read plus a write somewhere else and this product cannot see the second half. The enforcer landed in #387 and fills it in on Linux. On Windows and macOS the row still says unavailable with the reason, and an agent-core test asserts that an endpoint enforcing nothing never claims it can block.
A browser upload is inspected only where the extension is loaded, and only on Linux. DLP-235 wired the channel through to a policy: an upload of sensitive data to an unapproved destination raises an incident naming the destination origin, and the extension holds the upload open until the agent answers, so this is the second channel on Linux that can actually prevent rather than only record. What that does not cover is everything without the extension in it. An upload from a browser that has not loaded it, from a browser this product has no extension for, or from anything that is not a browser - curl, a sync client, an application posting on its own - is invisible on this channel, and the capability matrix reports browser_upload unavailable on any endpoint where no host has connected rather than implying otherwise. And it says which browsers (DLP-237): the matrix reported any connected host as whole coverage, so a machine running Chrome with the extension and Firefox without it showed "browser uploads: blocking" while half the uploads leaving it were invisible. The row now carries a partial_reason naming what is covered and what is running beside it uncovered, and the console marks it partly covered. For Firefox, the manifest variant, the add-on id, the agent's host registration and the enterprise policy template are built (DLP-1207). The add-on is not yet signed by Mozilla and has not run in a managed Firefox, so until then Firefox stays listed as uncovered on every endpoint where it runs.
The extension still has to be loaded by hand, and the reason is a signature rather than an omission. The native-messaging manifest a browser reads to find the agent now exists on both platforms: written by the session agent at login on Linux, and on Windows written beside the binary by dlp-agent install and pointed at by HKLM\SOFTWARE\{Google\Chrome,Microsoft\Edge,Chromium}\NativeMessagingHosts\app.mydlp.agent. Both are removed at uninstall - on Windows by reading the path back out of the key that names it - and both are reported by dlp-agent doctor. deploy/browser/ holds the force-install policy for Chrome and Edge, by Group Policy, by Intune and as a .reg (DLP-236). A browser also starts a host with the calling extension's origin as its first argument rather than with a subcommand, which until DLP-236 exited on a usage error: that command line is now recognised, its arguments discarded rather than parsed, and an integration test starts the host exactly as Chrome does.
What is missing is a packaged, signed extension with a published id: packaging is signed with the same arrangement as the rest of the product, which needs a code-signing identity this repository does not hold (DLP-1203), and a store id needs an organisational account. So every policy file takes the id as a parameter - windows-forcelist.reg ships a placeholder no browser can resolve, deliberately, so an unedited import installs nothing rather than something - and none of it has been exercised against a real browser. The Windows registration writes real registry keys through reg.exe and has not been observed on a Windows host; what is tested everywhere is the decision half: which keys, which value, where the manifest goes, and what removal deletes. Until an extension id exists, an install configures no ids, writes no manifest, registers nothing, and says so in its own notes rather than silently - and the capability matrix reports the channel unavailable.
And within a browser that has loaded it, a file built in JavaScript is not seen. The content script watches the three gestures that put a file into a web application - a file picker, a drag onto a drop zone, and a paste (DLP-271). A page that builds a Blob in JavaScript and posts it with fetch or XMLHttpRequest, with no file and no gesture involved, produces nothing to watch and is not inspected. That one is deliberately not pursued: catching it means rewriting a page's networking from the extension, which breaks sites, is detectable and defeatable by the page itself, and is exactly the behaviour a security team refuses to deploy.
Two smaller edges travel with the gestures that are watched. A drop or paste is held for the length of the verdict and then handed back to the page, so the application's own handler runs a moment later than it otherwise would - that delay is what makes blocking possible at all, since the event is synchronous and the answer is not. And a drop carrying both files and dragged text is replayed carrying only its files, because reading the dragged text would put this extension inside page content, which is the line it does not cross.
Three further limits travel with it. Only the first 512 KiB of a file is scanned - anything past it is not, the event says the scan was partial, and a partial scan that found nothing is not a statement that the file is clean. The agent records the verdict it returned, not that the browser obeyed it: enforcement happens inside the extension, and an endpoint cannot confirm from outside that it did. And an upload nobody answers is allowed - if the agent is not running, or does not answer within the bounded wait, the extension proceeds, because a browser that hangs on every upload is a browser whose extension is removed within a day.
Tracked as DLP-128 (print), DLP-130 (e-mail), DLP-236 and DLP-1207 (browser deployment) and DLP-227 (network share).
2. Platform coverage
| Gap | Detail | Tracked as |
|---|---|---|
| Windows is not exercised on Windows at all | CI is written to build the agent natively on windows-latest, run the agent crates' tests there, assert the reported capability matrix, and run the enrollment-to-incident flow end to end (#317). None of those jobs has passed yet. No GitHub-hosted job ran from 2026-09-15 to 2026-10-01, because the account's billing failed (DLP-1215). Since then the native build passes, the agent library's tests run with 5 of 1149 failing, and the end-to-end job stops before enrolment (Windows CI status). The self-hosted runner mydlp-host (DLP-1225) runs the Linux jobs, and it is not Windows. What does run is Linux tests of every platform decision declared outside cfg, the x86_64-pc-windows-gnu cross-compile, and a few binaries under Wine. When the jobs run, they will still be one runner image with no domain, Group Policy or endpoint protection, a loopback server over http, and no fleet | DLP-449 |
| macOS unsupported | Contract exists; no implementation. Endpoint Security Framework work needs a notarised system extension and an Apple entitlement | Accepted: ADR-0039 §A1 |
| Print and e-mail | E-mail is not implemented on any platform, and needs a gateway or client integration. Print is observed on Windows from outside the spooler (DLP-1141) - job metadata, never the document's content - and not on Linux, which needs a CUPS backend. Publishing a policy for one is not silent any more (DLP-270): the console and the publish response say that no endpoint reports being able to observe the channel and that the rule will not fire, with the count - read from the capability matrix every agent sends at each check-in. It warns and never refuses, because a policy written the day before the fleet is upgraded is legitimate | DLP-1178 (e-mail, per ADR-0017); Linux print: Accepted: ADR-0039 §A2 |
| Connection policies block by application and address, and an application is found by path | A connection policy (ADR-0057) keeps named applications from connecting to addresses, on Windows, without reading anything sent. WFP names an application by its full path, so curl.exe is covered where the agent finds it: the system folders, PowerShell's and the PATH. A copy elsewhere or a renamed one is not covered. Hostnames are not accepted, because WFP matches addresses. A refused connection is recorded once a minute per application and destination, so a retry loop shows as one event a minute and not each attempt. The filters have not yet run on a Windows host (DLP-2072) | DLP-2072 |
| Browser upload: only where the extension is loaded | The channel reaches a policy and can stop an upload (DLP-235), from a picker, a drop zone or a paste (DLP-271) - but only from inside a browser running the managed extension. Anything else that posts a file - another browser, curl, a sync client, or a page that builds a Blob and posts it with fetch - is invisible on it, and only the first 512 KiB is scanned. Linux and Windows both follow the live host; Windows registration and policy templates exist (DLP-236), but a signed published id still needs the organisation's external signing and store identity (DLP-1203) | DLP-1203 (the signed extension id); other clients: Accepted: ADR-0039 §A3 |
| A file over the size limit is not inspected | Each monitored path has a scan limit (max_file_bytes, 32 MiB by default). A text file past it is read at its two ends only, the first 1.5 MiB and the last 512 KiB, and reported partially scanned with what was read (DLP-1500): a card in the middle of a 40 MiB log is not found, and the file says so. Any other file past it (an archive, a PDF, an Office document, which cannot be read in pieces) is not read at all. On the first physical host a file padded past the limit carried a card number through, and was logged findings=0, the same words as a clean scan (DLP-1270). It is now said and counted: the agent logs a file could not be fully inspected; it is not known to be clean, with the reason. The health it reports carries a running count and the latest file's name and reason, and the console marks the endpoint Files not inspected. The same applies to a format the endpoint cannot read and to content out of reach inside a file. Below the file limit, the detectors on an endpoint read 2 MiB of a file's text, which keeps a scan inside its time budget. A plain text file with more is read at its two ends, the first 1.5 MiB and the last 512 KiB, the same rule as a file over the limit, and reported partially scanned with what was read (DLP-1597; before it, a card on the last line of a 2-32 MiB log was missed while the same card in a 40 MiB one was found). A document's extracted text (a DOCX, a PDF) with more than 2 MiB is read from its start only, and reported as only the first 2 MiB of N MiB of text was read, not as clean (DLP-1368). A scan that spends its whole time budget before every detector has run is reported partially scanned: the scan ran out of its time budget before N detector(s) ran, and is counted under timed out (DLP-1530). Until then the budget's end was not said at all: on YUSIF a 17,500-row card export reported nothing and was not marked partial, and here the same file spent the budget with twenty-one detectors unrun. Which detectors that budget drops was decided by the bundle's alphabetical order until DLP-1629: on YUSIF a valid card on the first line of a 1.7 MiB text file, under a block policy, raised no incident six times out of six, because core.payment_card came twenty-fifth. The detectors a policy reads, by id, through a classification it binds, through a data category or through a total across every detector, now run first and are never dropped for the whole scan's budget. Each is still held to its own budget (300 ms on an endpoint), and a file where one runs out is reported partially scanned as before. Since DLP-1633 that file is read once more, 30 seconds later, one file per turn of the agent's loop, with 5 seconds for each policy detector. If that pass also runs short, the agent logs a warning and the file waits for its next change. There are at most 50 such passes a day and 256 files waiting; past the daily cap a file waits for the next day, and past the waiting bound it is not queued, which is logged. Acting on it is opt-in: the built-in template Files the endpoint could not inspect (mydlp.inspection-gap) is adopted as a classification, and a policy on it audits, warns on or blocks such files like any other policy, with an incident. Nothing does so by default, because such a policy also catches large media and archives that hold nothing, and that is the tenant's trade to make. Without one, padding a file past the limit is visible but not stopped. Since DLP-1279 each endpoint also keeps the week's count by cause across restarts and updates, and the Overview sums it for the fleet. Encrypted content is named as that, from the scan's own recognition of a password-protected ZIP entry, Office file or PDF, or an AES-encrypted 7z (entries only or header too, DLP-1297), and is never reported clean. Other encrypted forms, such as a RAR with a password, are not recognised: they count as could not be opened or a format this endpoint does not read, not as encrypted | DLP-1270, DLP-1279, DLP-1297, DLP-1500, DLP-1530, DLP-1597, DLP-1629, DLP-1633; Accepted: ADR-0039 §A5 |
| A file the antivirus will not let the agent read is not inspected | When an anti-malware product refuses the read (Windows ERROR_VIRUS_INFECTED, 225, or ERROR_VIRUS_DELETED, 226), the file is recorded as not inspected under its own cause, held back by the antivirus (blocked_by_antivirus), not read past and never counted clean (DLP-1596). Until then it was logged scan failed and then processed, findings=0, and recorded nowhere else. Any other failed read is now recorded too, under another reason. What such a file holds stays unknown: the agent does not work around a security tool. Seen on YUSIF with Microsoft Defender; the code is matched from the error, and has not been run against an EDR in block-access mode | DLP-1596; Accepted: ADR-0039 §A19 |
| A file OneDrive downloads back into a synced folder is told from an upload only by its content | The agent cannot see which process wrote a file. When OneDrive downloads a file it had made online-only, that write looked like the person uploading it, and on YUSIF it opened a duplicate incident. Since DLP-1554, a synced file whose content is byte for byte what the agent last read at that same path is reported as a discovery sighting, joining the incident that holds the content without counting. So a download back is not an upload, and neither is a person saving the same bytes again. Different content, or a path the agent has not read before (a new install, or the 200,000-path scan cache having forgotten it), is still reported as an upload, so a file first seen as it downloads still counts as uploaded by whoever is signed in | DLP-1554; which process wrote a file: DLP-1683 |
| The product folder's permissions are set by the running service, not by the installer | Since DLP-1556 the service restricts C:\ProgramData\MyDLP at every start (SYSTEM and Administrators write, Users read, no inheritance), and removes displaced copies of the session launcher (dlp-session.exe.replaced-*) once nothing holds them, at start and at every health report. Until the service first starts after an install, the folder keeps ProgramData's grant letting Users create files in it. Anything a user created there before that, such as YUSIF's probe folder, is left in place, owned by them. The ACL change is cross-compiled and runs through the same secure_readable already applied to agent-public, but it has not been observed on a host: Windows CI is blocked (DLP-1489). Since DLP-1671 the same call also makes Administrators the owner of the folder and of the state directory, and adds an OWNER RIGHTS entry with no rights. Before it, the installing user owned them and, as their owner, kept the right to rewrite their permissions after losing administrator rights. The state directory's start-up audit now reports any other owner as unsafe, and the device as not compliant, until it is put right. If Windows refuses the owner change, the permissions are still applied and the audit says who the owner is. This is cross-compiled too, and not yet observed on a host; DLP-1580's T02 (a standard user writing into the state directory is refused) is where it is observed | DLP-1556, DLP-1671; agent.toml's own ACL and the staged installer copy: DLP-1690; observed on a host: DLP-1580 |
| A file its owner made private is read through backup semantics; an EFS-encrypted file is not | On YUSIF a standard user hid a card file by granting only themselves, or by denying SYSTEM read. Since DLP-1560 the service enables SeBackupPrivilege at start and reads files with backup semantics, which reaches past a DACL, read only (ADR-0046). Verified on YUSIF on agent 1.30.0 (2026-10-04): both hidden files produced incidents. Where the privilege is missing (a hardened host, or the agent run by hand) the file is counted as not inspected under whose permissions shut the agent out, not other. An EFS-encrypted file stays unreadable to the service, which has no key for it. dlp-agent scan run by the file's owner does read it, because Windows decrypts for that person: on YUSIF it reported two EFS files the service could not read (DLP-1604). So EFS content is checked only when its owner runs a scan, and only then. A synced folder such as OneDrive that is watched only because a policy covers synced folders is not walked by a discovery scan, the service's or this one. The service reads its files as they change, and the command lists it under Synced folders. A synced folder inside a monitored path is walked with that path | DLP-1560, DLP-1604; Accepted: ADR-0039 §A20 |
| A card number in a file name is masked only where a detector validates it, and stored rows from before the mask keep it | Since DLP-1559 the agent masks validated values (a Luhn-valid card, an IBAN, a checksummed national identifier) in a file's path and name, the paths it was seen at, its destination and its not-read sentence, before the event leaves the endpoint, keeping the last four. A number no validator confirms is sent as written. Since DLP-1563 the server masks the same values at ingest, so an older agent's new events are masked too. Events stored before that, and the incidents they opened, keep their clear names until an operator runs dlp-management-api --mask-stored-names --apply, which masks them and records an audit entry per tenant (without --apply it is a dry run that only counts). On production it was run on 2026-10-05 with the owner's approval: 12 events and 4 incidents in one organisation were masked, and a second run found nothing left (DLP-1563). A self-hosted installation runs it itself. Since DLP-1561 the agent's log masks the same values; lines an earlier agent wrote stay in its rotated files until rotation removes them. Two files in one folder whose names differ only in a masked number with the same last four read as one place on the incident | DLP-1559, DLP-1561, DLP-1563; Accepted: ADR-0039 §A21 |
A case-sensitive folder is recognised only by agents with DLP-1572 | Windows paths are compared without case (DLP-1470), so card.txt and CARD.txt are one place. A folder with Windows' per-directory case sensitivity turned on (fsutil file setCaseSensitiveInfo, used by WSL and some developer tools) holds them as two files. Since DLP-1572 the agent asks each folder and marks the event, and the server then compares that path exactly: two incidents and two places where the content differs. Events from earlier agents, and folders the agent could not ask (a filesystem without the query), are compared without case as before. Observed on a Windows host on 2026-10-08 (agent 1.87.0, DLP-2054): card.txt and CARD.txt became two incidents with a place each, and deleting one marked only that one gone | Accepted: ADR-0039 §A25 |
| Why a file left a path is known only from the NTFS change journal | The watcher reports a deletion and a move out of the watched folders the same way. Since DLP-1539 the Windows agent asks the change journal which it was, and the incident location says deleted (deleted outright), in the Recycle Bin (Explorer's delete, still recoverable) or moved out of watched folders (the file still exists on the endpoint). Where the journal cannot say (FAT or exFAT, a network share, a journal that was not readable, a record not found in the window read), the location says no longer here, never deleted. Every removal reported by an older agent says no longer here for the same reason, since deleted was all it could send. The Windows journal read has been cross-compiled and its decision tested, but it has not run on a Windows host: Windows CI is blocked (DLP-1489) | DLP-1539, DLP-1489 |
| A folder renamed, moved or deleted as one is followed only through files the endpoint already knows | The watcher names a folder that is renamed, moved or deleted, and nothing inside it. Until DLP-1531 the files below it stayed current at a path that no longer existed. Now each file the agent's scan cache remembers below the folder is reported gone with it, and a folder renamed or moved into place is walked and its files read, so the server pairs each with its old path as a move. Limits: a file the cache has forgotten (it holds 200,000 paths) is not reported; the walk reads at most 2,000 files of an arriving folder; the server pairs a move only within thirty seconds, so the last files of a very large folder can read as removed in one place and new in another; a folder moved out of the monitored paths is said as moved out only where the change journal records it, like a single file (DLP-1539); and a rename made while the agent was stopped is picked up by the change journal per file, not as a folder | DLP-1531; Accepted: ADR-0039 §A22 |
| A full walk is slow on a large tree, and a walk after a journal gap starts again if the agent restarts | When the change journal cannot say what changed while the agent was stopped, every monitored path is walked, and the reconciling walk re-walks them on its interval. Until DLP-1609 and DLP-1610 the first ran whole before the agent's loop began (on YUSIF, 42 minutes with no check-in and no live event read, while the console said healthy), and the second stopped at 20,000 files and still logged reconciled. Both are now walked a slice of 0.4 s at a time between live events and check-ins, to the end, with a progress line each minute, and reconciled is said only of a finished walk. The volume's journal checkpoint advances only when its gap walk ends, so a restart in the middle walks the gap again. The files the journal names after a restart or an update are read the same way, a slice at a time (DLP-1609, reopened): read before the loop, 1,671 documents kept YUSIF from checking in for twelve minutes and the update watchdog rolled a working release back. Since DLP-1634 the checkpoint also advances while the agent runs: every five minutes, if nothing has waited to be read for a whole interval, so a restart replays only the last few minutes rather than everything since the previous start. A volume whose start-up reconcile did not complete is not advanced until the next start. A crash within those minutes still re-reads what they held, which is the safe direction. A walk is as fast as reading each file; DLP-1611 removed the cache's per-file full scans, which made tiny files cost tens of milliseconds each on a large cache | DLP-1609, DLP-1610, DLP-1611 |
| A file that exists for about a second or less is not read | A created or changed file waits until it has stopped changing for one second before it is read, so a file still being written is read once, complete (DLP-1271). A file deleted inside that second is never read: on YUSIF a card file deleted after 0 to 1,000 ms left no event and no log line, while one deleted after 2 s or more was read and reported. That is the staging pattern of dropping a file into a watched or synced folder and taking it away within the second. Since DLP-1529 it is said and counted: the agent logs a file appeared and was gone before it could be inspected, with the path and how long it lived, and counts it as not inspected under gone before they could be read, with the file's name and lifetime in the latest-file report. A rename is not counted, because its content is read where it went. Neither is an empty file, nor a name an application uses for its own short life (~$, ~WRL, .tmp, .crdownload, .part, .partial): Office makes and deletes those on every save, and counting them would make the signal always on. The content of a vanished file is still not read, and nothing says what it held. The window is wider under load: behind a bulk copy the agent fell minutes behind the file system on YUSIF, and a file moved or deleted in that time was never read. Since DLP-1603 its lifetime is timed from when the watcher saw it, so the record says the two minutes it lived and not the 13 ms it once claimed, and the agent logs inspection is behind the file system once a minute while it is more than 30 s behind. Since DLP-1615 the health report carries the backlog (events waiting and how long the oldest waited), and the endpoint's component list shows File inspection: N s behind, M waiting past half a minute. What still waits is reading under load: identical duplicates are read one by one, in arrival order, ahead of a distinct file that arrived after them | DLP-1271, DLP-1529, DLP-1603, DLP-1615; Accepted: ADR-0039 §A26 |
| An unlabelled card list padded with ten times as many numbers that cannot be cards is not reported | A card number among at least ten numbers of its shape that are outside every card scheme's issuer range, ten times as many as valid cards, counts only beside a word about cards (DLP-1714). That removed GovDocs1's FDIC tables, id columns and binary noise as card incidents. It is also a way to hide a deliberate list: pad it with enough numbers starting 1, 7, 8 or 9 and leave out every word about cards. Look-alikes inside an issuer range do not count, so the padding DLP-1655 used does not work, and a real export with an id column beside its card column has as many valid cards as ids. A deliberate, unlabelled, padded list is missed. The same holds for a card written four or more times inside a page's HTML tags with no word about cards, which is how a page repeats an id in its links (DLP-1733) | DLP-1714, DLP-1733; Accepted: ADR-0039 §A24 |
| A junction or symbolic link inside a monitored folder is not followed | Discovery and the watcher do not follow links: following them turns a scan of one folder into a scan of the disk, and lets anybody point the agent at what it should not read. Until DLP-1281 a link was dropped with no trace, the one gap that said nothing: on the first physical host, a card written through a junction left no log line while a file beside it raised an incident. Each link is now reported as not inspected, naming the link and its target, and counted as behind a link that was not followed. A volume mount point is reported the same way, its target given as the volume's GUID path; until DLP-1395 it was silently skipped, because its target was misread as a folder beside the link. A link whose target is itself monitored is not reported, because it is read there. Nor is one of Windows' own compatibility junctions (My Videos in Documents, Application Data in a profile, and the rest) when it points at exactly the folder of its own profile Windows points it at: it holds nothing, and what is written through it lands in a folder that is watched or not in its own right (DLP-1715). The same name pointing anywhere else is reported. The bytes behind the link are still not read. Whether a standard user can create a junction with mklink /J inside a monitored folder has not been verified on a host; if they can, this is a standard-user way past file monitoring that is now visible and still not stopped | DLP-1281; Accepted: ADR-0039 §A23 |
| The user an event names is the signed-in person unless a service owns the file | Without a live B1 minifilter, the released agent cannot see which process wrote a file (DLP-1683), so a file event names the person signed in at the time. Since DLP-1682 a file whose owner is SYSTEM, LOCAL SERVICE, NETWORK SERVICE or a per-service account (S-1-5-80-...) names that service instead, and the person's groups are not applied to it: on YUSIF a file a scheduled task wrote as SYSTEM had been blamed on the signed-in person and counted in their history. A file owned by another person still names whoever is signed in, because a moved file keeps its first owner and the owner cannot simply be named instead; since DLP-1704 the event says so (user.attribution: file_owner when the owner is the person signed in, session when it is someone else or unknown, service), and the incident's timeline reads "User inferred from the signed-in session; the file's owner is ...". A lab-loaded B1 driver instead supplies a generation-verified requestor PID/session, but it has not passed the production gates. An inferred user still counts in the person's prior-incident history at full weight. The fallback was cross-compiled and has not yet been observed on a host | DLP-1683; ADR-0069 |
| Network share: the local side only | A file this machine writes to a mounted cifs, smb3, nfs or nfs4 path is reported on network_share with the server and share named (DLP-227). Three limits, each stated because an operator would otherwise draw conclusions from the channel's silence. A write by another client to the same share is invisible - inotify on a network filesystem reports what the local kernel did, and the protocol has no change notification a client is obliged to receive. A read-only mount is left on its original channel, because it cannot be a destination. On Windows (DLP-1145) a UNC path is recognised by its own shape, \\tsclient as a Remote Desktop client drive, and mapped letters through WNetGetConnectionW. The service runs as LocalSystem, though, and a person's own mapped drive belongs to their logon session, so it is not seen: a write through S: reaches the channel only when S:'s target is itself a monitored UNC root | DLP-227, DLP-1145 |
| Clipboard: Windows only, and never with a person at a keyboard | dlp-agent session reads the clipboard on Windows and reports copies when a policy names the channel (#317). The capability follows the live connection, so an endpoint with nobody signed in reports it unavailable rather than claiming it. What has not happened is a real desktop session - CI has no keyboard. On Linux the channel and the session agent exist and nothing reads an X11 or Wayland clipboard behind them | DLP-1125 |
| Removable storage is watched, not controlled | Removable mounts are detected and identified (DLP-FEAT-019), so a write to a USB stick is reported on the RemovableStorage channel. The capability matrix says monitor and warn on Windows and monitor only on Linux; neither released path can block. The Linux open enforcer (#387) does not change this: it adjudicates an open() under a marked directory, and a write to a stick is neither. The development B1 Windows minifilter can attribute the write in a lab but has no deny callback. ADR-0069 authorises phased B2 work only after B1's Windows stability, attribution and performance gates pass. Windows' own write-control policy is reported per endpoint meanwhile (DLP-1149) | ADR-0069 |
| The mount list refreshes on a miss, at most every 2 seconds | A file on a device the agent does not recognise provokes a re-read, rate-limited - so a stick attached and written to between check-ins is attributed within seconds rather than within a check-in interval. The window is the interval, not zero: the first file written in that window is still reported on file_create against the local disk. A device removed produces no file to miss on and is still noticed on the check-in cadence. Unconditional per-file lookup remains rejected - it would parse the mount table and walk sysfs for every file a discovery scan touches | Accepted: ADR-0039 §A4 |
| Removable storage has run with a real stick on one Windows machine, and never on Linux | On 2026-09-28, on one physical Windows 11 machine (DESKTOP-MNVLKOM), two real USB sticks were picked up without a restart, and a synthetic card written to each raised a removable_storage incident with the action clamped to warn (INC-000020, INC-000026), within 30 s of the write. Those incidents named only the drive letter (E:\), not the device. Since DLP-1267 the agent records the stick's vendor, model and serial, and a later run on the same host named the device on the incident (JetFlash, Transcend 16GB; cases T09/T29 on DLP-1342). The warning was not shown to the person on the first run (DLP-1249, DLP-1251); whether it was shown for a write to the stick on the later run is not recorded. On Linux the classification is unit-tested against realistic sysfs trees, and a loop device at /media/usbtest is correctly not classified as removable; a real device is untested there | DLP-1176, DLP-1267 |
| File copy not attributed | Observed as create-plus-read; distinguishing a copy needs a kernel hook | ADR-0010; Accepted: ADR-0037 |
| The Windows service has not run on a managed fleet | dlp-agent service registers a ServiceMain, a control handler and reports START_PENDING/RUNNING/STOPPED; the CI job written to install it through the WiX MSI, upgrade it while running and check start and removal has not yet passed on windows-latest (Windows CI status). By hand, on two physical Windows 11 PCs, it was installed, ran, and updated itself in place about 25 times on one of them (DLP-1342). What that is not is a fleet: two machines, neither tested with a domain, Group Policy, endpoint protection beside it, or a reboot | DLP-449 |
| The endpoint speaks English only, whatever the person's language | By the owner's decision of 2026-10-07 (DLP-1897), every agent surface is shown in English: notices, the prompt, the About page, setup and uninstall. A message from the prompt library is shown in its English text, which the console requires; the Azerbaijani, Turkish and Russian texts written beside it are stored, signed into the bundle and not shown. The console's prompt editor says so (DLP-2030). Lifting the decision is one constant, AGENT_UI_LANGUAGE in agent/core/src/localisation.rs, and the per-person rule it overrides (DLP-1135) is still there and tested | DLP-1847 (the agent UI epic, whose decision 3 records it; DLP-1897 built it) |
Fixed, and kept as a lesson rather than a gap: the Windows agent did not compile for two releases. A field added to RemovableMount in #474 reached the Linux arm of scan() and not the Windows arm, and every CI gate ran on Linux. Two gates now cover the class: a Windows cross-compile check on the lint job and a native windows-latest build (#317). It was a row in the table above until DLP-1174, where it read as an open gap.
2.1 Enforcement: Linux denies an open, and Windows is decided against
SPIKE-001 prototyped a fanotify enforcer in spikes/ and measured it: +76 µs on the median open, fail-open on crash, and a hang that suspends callers but leaves them killable. It then shipped in #387. A Linux endpoint denies an open() under a path marked enforce_opens, with CAP_SYS_ADMIN, and nowhere else. §1 above and Agent capability matrix § Open enforcement on Linux state the conditions and what the endpoint reports. It is the only prevention of a file operation on any platform. The Linux agent is frozen (CLAUDE.md §9), so it is kept working and not extended. This section said until DLP-1173 that the enforcer was a prototype linked by nothing that ships, while §1 described it shipping.
Windows enforcement now has a thin development prototype: the B1 minifilter observes post-operation metadata and a pure B2 helper can decide only from complete cached facts. The helper is deliberately not wired into an I/O callback, the driver is not production-packaged, and no Windows lab evidence has qualified blocking. ADR-0069 records the reversed product decision and the Driver Verifier, HLK, performance, compatibility, altitude, signing and rollout gates. So the honest statement is Windows enforcement is scheduled and partly prototyped, but not qualified, packaged or active. The capability matrix therefore continues to report file-system monitoring rather than blocking.
2.3 The Windows service is written, not witnessed
Until #375 the agent had no service entry point at all: every published sc.exe create MyDLPAgent binPath= "...\dlp-agent.exe run" instruction created a service that could not start, because run is a console process that never calls StartServiceCtrlDispatcherW and so never reports SERVICE_RUNNING. The Service Control Manager waited for a status it could not receive and killed the process at the start timeout.
dlp-agent service is that entry point. What can be shown from here:
| Claim | Evidence |
|---|---|
| It compiles and links for Windows | cargo check --target x86_64-pc-windows-gnu --all-targets -p dlp-agent -p dlp-agent-core |
| The SCM entry points are really imported, from a DLL already in the table | objdump -p target/x86_64-pc-windows-gnu/debug/dlp-agent.exe lists StartServiceCtrlDispatcherW, RegisterServiceCtrlHandlerExW, SetServiceStatus, RegisterEventSourceW, ReportEventW and DeregisterEventSource under ADVAPI32.dll. No new DLL and no new crate |
| Which controls each state accepts, and that stop, shutdown and pre-shutdown all end the run | Unit tests in agent/dlp-agent/src/service.rs, run on Linux |
| The console mode still works | dlp-agent run is unchanged; on a non-Windows host dlp-agent service refuses and names systemd instead |
What is not shown by the one hosted Windows image:
- that a stop drains the queue and reports
Stoppedinside the stop timeout, including the case where the drain outlives the wait hint and depends on the rising checkpoint to buy more time; - that the agent is running after a reboot without anyone logging in;
- that recovery and Event Log rendering behave the same under domain policy and endpoint protection rather than the clean hosted runner image.
The remaining claims require fleet and reboot qualification, not another cross-compile assertion.
2.2 Named-entity recognition is not shipped
Presidio and an NER model would add name, address and free-text entity detection. Neither is integrated, and as of September 2026 this is a decision rather than a gap.
SPIKE-002 chose the Apache-2.0 GLiNER PII model over spaCy's OntoNotes-trained ones on licence grounds and said a licence result is not a quality result. SPIKE-008 then measured the quality, on 1,580 documents and 5,310 gold spans, and recommends not adopting either model. GLiNER is the better of the two and is still 100x the cost of spaCy at 2.8 GB resident - against dlp-detector-engine's measured 13.9 MiB/s it runs at 0.21 KB/s - while its person precision cannot be lifted past 0.78 at any threshold because 84% of its genuine false positives are contract role nouns (Borrower, Lender, policyholder). On the two structured groups where it would overlap what we already ship, its recall is 0.29 and 0.24 against regex-plus-checksum detectors that do not guess.
So the detector catalogue is pattern, checksum, keyword, EDM and document-fingerprint based, and Detector catalogue states each detector's precision posture. Names and postal addresses in free text are not detected, and that is what a customer should be told. A customer who needs them can supply their own model through the DetectorKind::External contract and accept its cost on their endpoints; we do not ship one.
3. Content handling
The limitations in this section are pinned by tests.
packages/detector-engine/tests/documented_limitations.rsasserts that each one is still true - that an image is reported as needing OCR rather than read as empty, that a malformed compound file is reported rather than read as empty (a.docitself is read sinceDLP-1161), that.raris not inspected (.7zand tar are, sinceDLP-1177), and that document fingerprinting does not run.Those tests are meant to fail one day. When somebody implements OCR the file breaks, and they are told at that moment that a document claims otherwise. A failure there is not a regression; it is the notification that a limitation is over, and the fix is to delete the test and the row together.
This exists because seven rows in this document were found stale in a single day - three claiming the product could not do something it had done for releases, four made untrue by work in the same session. A corrected sentence goes stale again; a test does not.
| Gap | What happens instead | Tracked as |
|---|---|---|
| Images and scanned documents | Read, on an endpoint that has the helper (DLP-231). Tesseract with aze, tur, eng and rus runs inside the DLP-230 sandbox - private namespaces, an irreversible drop to nobody, rlimits, a deadline and a seccomp filter - is never linked into the agent, and a standalone image is routed to it. Findings are flagged ocr_derived with reduced confidence. At poor scan quality the digits do not survive - on a badly scanned page two thirds of the characters come back, the prose is readable and a card number is not, which docs/TESTING.md records with the measured figures and the cost per page. An image inside a document is read too, and stops counting as unread once it has been; the languages come from the tenant's own settings through the signed bundle, so an endpoint configured by hand in Baku and one imaged in London read the same things. The build needs libtesseract and is Linux-only - Windows reports the sandbox unavailable and runs no OCR at all. That is a decision, not an omission: the Windows sandbox is an AppContainer, a job object and a restricted token, none of which can be verified from a Linux host, and a sandbox nobody has watched work is one that may silently not be one - worse than none, by ADR-0012's own rule. The contract already carries the isolation strength a mechanism provides, so the weaker Windows fallback cannot later be reported as the same thing the Linux sandbox gives | Windows OCR: ADR-0012; scan quality: Accepted: ADR-0039 §A5 |
| Archive contents are read under limits, not exhaustively | A ZIP's entries are extracted and scanned (DLP-FEAT-015) - compressing a file before sending it does not defeat the scanner. What is bounded is how far: entries, total and per-entry decompressed bytes, nesting depth, declared expansion ratio, and retained text: on an endpoint, 512 entries and three levels of nesting. Anything a limit cut short is reported as a partial inspection with the reason (stopped after 512 entries, archives nested deeper than 3 were not opened) and counted as read only in part. Until DLP-1365 and DLP-1367 that was not true: a card four zips deep, or after 512 empty entries, was reported clean on a physical host. ZIP, tar, gzip, bzip2, xz and zstd (a .tar.gz, .tar.bz2, .tar.xz or .tar.zst, or one compressed file) and 7z are read under the same limits (DLP-1177, DLP-1477), and a Microsoft cabinet's stored, MSZIP and LZX folders are too (DLP-2230, DLP-2274); an entry that is a document, such as a PDF, is read by the same extractor as on disk (DLP-1386), and one in a format the endpoint does not read is named; a 7z whose decoder would allocate a dictionary above 64 MiB, or an xz stream that would need more than 96 MiB, is refused unread. .rar is not read | Accepted: ADR-0039 §A5 |
| Classification labels are read and agent labels are additive | FileCondition::labels remains case-insensitive (DLP-133). Windows reads the MyDLP ADS plus documented Microsoft MSIP_Labels/OOXML properties, and an optional apply_label writes only the MyDLP ADS. A different existing agent label is preserved unless replace_existing: true; customer/Purview labels are never overwritten or removed. The signed taxonomy can declare exact Microsoft and SharePoint term coordinates, but this is read-only interpretation, not a Microsoft/Graph write grant (ADR-0020). Missing/foreign tenant and conflicting GUID observations fail closed. No MIP SDK agreement/licensed tenant, protected-coauthoring LabelInfo reader or repository writer is present; last human writer is unknown. Linux has no provider-specific reader and reports no label rather than guessing. A real Purview tenant is still needed for end-to-end provider validation | DLP-1009; ADR-0020 |
| NER and semantic classification | DetectorKind::External contract exists and stays unused. Not blocked any more - declined: SPIKE-008 measured both candidate models and neither is adoptable, GLiNER on cost (0.21 KB/s against the engine's 13.9 MiB/s) and precision (role nouns), spaCy on being structurally unable to express two of the four entity types a DLP product is asked about | Declined: SPIKE-008 |
| Document fingerprinting | Works end to end. Wired into the scan path (DLP-45): a pasted paragraph from a protected document is detected in an unrelated file, thresholded on protected-corpus coverage so a 40% excerpt is not diluted by surrounding text, and a candidate shorter than eight shingles is reported as too short to judge rather than as clean. The server side is built too (DLP-244) - documents are imported, a corpus is built and versioned, and fingerprint_corpus::for_bundle puts it in the signed bundle, with tests/fingerprint_corpus.sh asserting that not a word of the source documents reaches the database, the bundle or the audit log. | Accepted: ADR-0039 §A5 |
| Exact Data Match | Delivered end to end since DLP-267: a dataset a published policy references travels in the signed bundle as packed keyed tags, the HMAC key reaches the endpoint on its own authenticated request and lands in the keystore beside the device key, and a record in a document raises an incident naming the dataset. tests/edm_delivery.sh proves it against a real agent, including the negative - no source value in the bundle, the database, the audit log or the event. Key rotation keeps an old/new overlap until every source is re-imported and every active endpoint has the replacement bundle; source-free automatic re-fingerprinting remains impossible. | Accepted: ADR-0039 §A6, ADR-0071 |
| A scan can run out of time before the end of a file | Every detector has a per-detector budget - 300 ms on the endpoint's live file-event path - and a detector that reaches it stops there. The result is marked timed_out and the file is reported as partially scanned, never as clean: what was found before the deadline is real, and nothing is claimed about the rest. It is reachable in practice, because an EDM set is expensive enough to hit it on an ordinary document: measured at about 5.9 s per megabyte with eight fields against a 116 ms baseline (Testing). Making EDM cheap enough not to is DLP-256 | Accepted: ADR-0039 §A5 |
3.0 Document extraction covers OOXML, ODF, RTF, PDF and the legacy compound files, not the long tail
DOCX, XLSX and PPTX are extracted on the endpoint (DLP-FEAT-011), in pure Rust, with no service to operate. ODF - .odt, .ods, .odp - and RTF are too, since DLP-226: ODF is the same ZIP-of-XML shape and goes through the same container reader, the same budget and the same markup stripper, and RTF is a tokeniser with a bounded group depth. PDF content streams are read, including Flate-compressed ones, which is most real PDFs. A page's text is read through its fonts' /ToUnicode maps where they have one (DLP-1620). A stream that is mostly binary and has no BT text object is not read (DLP-2295), but a binary stream that contains a BT token is still read, and so is a stream that is about half printable with no BT; an address-shaped run in such bytes can still be reported as an email. A standard-14 face with no map, Helvetica, Times or Courier as a PDF names it without embedding it, is read as WinAnsi with the face's advances (DLP-2167). A Type1 font with no /Encoding is StandardEncoding by the specification, and is read as WinAnsi here: an accented letter in such a font, and the apostrophe and grave bytes, can come out as a different character. A font with a /Differences array or any other encoding is read as it was before.
The pre-2007 binary formats - .doc, .xls, .ppt - and Outlook .msg are read since DLP-1161 (cfb_text), through the compound-file reader already in the tree. A .msg is read field by field: subject, body, sender, recipients, and each attachment through the same extractor one level deeper. A saved .eml is read the same way since DLP-1366: its MIME parts are decoded (base64, quoted-printable) and each attachment goes through the extractor; a part in an encoding it does not decode makes the file partially read, not clean. Before that, its attachments reached the detectors still encoded.
Outlook's winmail.dat, the TNEF stream (application/ms-tnef), is read since DLP-2229, recognised by its signature whatever its name. Its plain-text body (attBody) is read, and each attachment's data (attAttachData) goes through the extractor one level deeper, as a .msg attachment does. Its TNEF checksums are not verified, so a damaged checksum does not stop data being read. A plain-text body held only in its MAPI properties (PidTagBody, Unicode or String8, in attMsgProps) is read since DLP-2293. When the message also holds a compressed RTF body, the RTF's text is not read beside it, so the same text is not read twice, but an object embedded in that RTF body is still read. A plain-text body that cannot be decoded is named, and the RTF body is read as the body in its place. A body held only as compressed RTF in its MAPI properties (PidTagRtfCompressed, in attMsgProps) is decompressed and read since DLP-2253; its own CRC is verified, and a body that fails it is reported and not read. An object embedded in that RTF body is read one level down, as an RTF document's objects are, since DLP-2294; past the sixteen-object cap, nested past the depth limit, or in a form that does not unwrap, it is named and not read. Not read, and reported as a partial reading: a message with no plain-text body and no compressed RTF body, whose body may be held in other MAPI properties (attMsgProps, not decoded); and an attachment whose data is held only in its MAPI properties is read when it is binary (PR_ATTACH_DATA_BIN), an embedded message (read as a TNEF stream, the form the MS-OXTNEF section 2.1.3.4 gives it) or an OLE storage or stream (read as a compound file) since DLP-2254; any other object is named as not read by its interface, and an attachment with no data in either place is named as such. A stream that ends inside an attribute is read up to the cut and reported as cut short.
The Office formats are read by recovering the text runs from their main stream, which carries the words and numbers typed as text, and has two stated limits: the order is the stream's, not necessarily the reading order; and UTF-16 text is recovered for Latin, Greek, Cyrillic, Hebrew and Arabic scripts, not CJK. A number an .xls stores as a binary cell, which is how a typed card number usually is, is read from the workbook's NUMBER, RK and MULRK records and written out as its digits (DLP-1308); until then it was not text, and a card column read as clean. A document saved with a password is refused and reported as encrypted.
Plain text is read in UTF-8 and in UTF-16, big- or little-endian, as a file and as an archive entry (DLP-1358). Until then a UTF-16 file, which is what Windows PowerShell 5.1's > and Notepad's "UTF-16 LE" write, was typed as binary and reported as not inspected. With a byte-order mark any script is read; without one, UTF-16 is recognised only where most characters are in Latin-1 (a NUL in every other byte), so BOM-less Cyrillic or Arabic UTF-16 is still reported as not inspected rather than read. UTF-32 is read too, by its mark or its shape (DLP-1476).
Text in a legacy single-byte code page is decoded as that code page since DLP-1674: a file with no mark that is not valid UTF-8 has its code page guessed by chardetng, the detector Firefox uses for unlabelled pages. Windows-1254 and ISO-8859-9 (Turkish, and older Azerbaijani exports), Windows-1252 and Windows-1251 come out in their own letters. Until then such a file was read as UTF-8 and every byte above 0x7F became U+FFFD, so "FİN kodu" read "F\uFFFDN kodu" and the Azerbaijani and Turkish identity detectors, which need their label, missed in silence. The guess is a guess: a few lines of text with no letter that tells two code pages apart can be decoded in the wrong one, and Azerbaijani's ə, which Windows-1254 lacks, has no single-byte code page. Valid UTF-8 is never second-guessed.
Exercised end to end in packages/detector-engine/tests/office_documents.rs and packages/detector-engine/tests/odf_and_rtf.rs: a real file through type detection, extraction and the real detector pack. The table above used to list these as unsupported, two paragraphs from this sentence, for long enough that a customer reading it would have concluded their Word documents were never scanned. Under-claiming is the same failure as over-claiming with the sign flipped, and the test is what stops the claim and the code drifting apart again.
What is not covered, and is the whole of what a service like Tika would buy:
| Format | Why not |
|---|---|
CJK text in .doc/.xls/.ppt | The legacy formats are read by text-run recovery (DLP-1161), and CJK runs are not recovered. An .xls cell's stored number is read (DLP-1308), and so is the cached numeric result of a formula cell (DLP-1591); a formula's string result is read only as text-run recovery finds it, and a formula is never evaluated, so a workbook saved without cached results shows none |
| Scanned images, and images inside documents, on an endpoint without the OCR helper | Read by OCR where the endpoint has the helper (the Images row above, DLP-231); no Windows endpoint has it. Without it the image is not read and is reported as not inspected, never as clean (DLP-1270, DLP-1279) |
| Encrypted or password-protected documents | Cannot be read at all, by anything. Four forms are recognised on the endpoint: a ZIP (or OOXML package) with an encrypted entry, an Office document saved with a password, a PDF that needs a password to open, and an AES-encrypted 7z (DLP-1297). A PDF protected only by an owner password opens without one in any viewer, so it is read and is not encrypted (DLP-1606). They are reported as their own coverage state, encrypted, which a classification's coverage element can match (DLP-1130). Anything else unreadable is reported as unscannable |
PDF with DCTDecode streams, and filters other than the five lossless ones | A DCTDecode stream is a JPEG: counted as an image and read by OCR where the endpoint has it. LZWDecode streams are decoded since DLP-1309. Since DLP-1588 a stream's whole filter chain is applied in order, with ASCIIHexDecode, ASCII85Decode and RunLengthDecode as well as Flate and LZW, so a page under [/ASCII85Decode /FlateDecode], which PostScript-to-PDF converters write, is read. A stream under JBIG2, CCITTFax, JPX or Crypt is counted as unread and the document reported partly scanned |
Within OOXML, extraction is selective: the body, headers, footers (including a workbook's print header and a Word watermark's text), notes, comments, the text boxes on a slide master or layout (not their placeholder prompts), shared strings and document properties. Styles, themes and relationship parts are skipped deliberately - they carry no user content, and reading them would spend the budget on markup. A detector match hidden only in a style definition is therefore not found, which is the intended trade.
3.1 Archive inspection covers ZIP and deflate only
Archives are inspected (DLP-FEAT-015): an archive's entries are decompressed into a bounded in-memory buffer and scanned, so compressing a file no longer defeats the scanner. ZIP, tar, gzip, 7z and CAB are read, since DLP-1177 (CAB since DLP-2230) under one walker and one set of limits, and each opens the others when nested. Nothing is written to disk, which is why a path-traversal entry name is reported rather than defended against - there is no extraction directory to escape from.
What is not covered:
- Not RAR. A
.raris reported as unread: the reference unrar licence is not a permissive open-source one, and no decoder has been assessed underdocs/LICENSE_POLICY.md. A.tar.bz2,.tar.xzor.tar.zstis read sinceDLP-1477, by pure-Rust decoders under licences already admitted (bzip2-rs,lzma-rust2's xz reader with a 96 MiB memory limit, andruzstd), its output counted against the same budget as a ZIP's. Inside a ZIP, an entry compressed with the bzip2 or zstd method is still not read, for the reason below. - A 7z decoder allocates what the file asks for, so the file is asked first.
sevenz-rust2has no memory limit of its own, and an LZMA dictionary declared in a header is reserved and zero-filled before a byte is decoded. Every dictionary, including the one that decodes a compressed archive header, is read before decoding, and above 64 MiB the archive is refused unread. A solid 7z block decodes an entry whole whether or not it is read, so an entry larger than the budget left is not started. - CAB reads stored, MSZIP and LZX folders (
DLP-2230,DLP-2274). Each folder is one stream, decoded a block at a time, and its files are read in offset order; the bytes between two files are decoded too, so they count against the budget. A folder compressed with Quantum, or with any other method this build does not read, is not decoded: its files are named as refusals, so the archive is reported partial. A cabinet that continues into another, or a file whose folder lies in one, is refused by name, since its parts cannot be joined from one file. MSZIP blocks inflate with theflate2already in the build; LZX blocks withlzxd(MIT OR Apache-2.0, no dependencies). The tests build cabinets from the published layout. The one LZX cabinet is a single-frame sample from the WiX test data. No cabinet with a multi-frame LZX folder has been read, so how the window carries from one frame to the next is not verified against real data. - Encrypted archives, deliberately. The
zipcrate is built without AES, zstd and bzip2: each is a decoder reached by untrusted input, and a scanner cannot read inside an encrypted archive whatever decoder it carries. An encrypted archive is reported with the coverage stateencrypted(read from the central directory's flags,DLP-1130), which is itself worth a policy. - The limits are a ceiling on evidence, not only on work. An archive that hits the text budget is scanned up to that budget and reported as truncated; a detector match past the cut-off is not found. The report says so rather than presenting a partial scan as a complete one.
3.2 Country packs detect passport MRZ text, not every printed passport number
The Azerbaijan and Türkiye packs identify a passport from its ICAO 9303 machine-readable-zone country code. They do not match a bare printed passport number: public descriptions of the document do not provide a sufficiently stable, validated number format, and a broad alphanumeric expression would be an employee-id detector wearing a passport label. Image-only documents still depend on OCR being installed and configured.
Tenant pack updates are additive. The import API refuses omission of an existing detector rather than guessing that omission means deletion; disabling a detector remains an explicit, separately audited action. There is no bulk "retire removed rules" operation for a pack yet.
4. Server-side
| Gap | Impact | Tracked as |
|---|---|---|
| Audit retention moves entries, it does not delete them | retention_days.audit now has an effect (DLP-55): aged entries are exported to DLP_AUDIT_ARCHIVE_DIR, read back off the storage, chain-verified, signed, and only then pruned. Three things follow. With no archive directory configured nothing is removed at all, which is the safe default and means the table still grows on such a deployment. The archive holds the same rows the log held, so it needs the same access controls as the database. And the product does not delete archives and does not read them back - a subject access request covering an analyst's own activity reaches the database only, and a policy requiring deletion after a period needs a deletion policy on the archive storage. POST /settings/retention refuses an audit window shorter than 30 days (DLP-699). A shorter value already stored, which only a direct database write can now produce, is clamped to 30 and recorded in the audit.archived entry so the clamp is visible | Accepted: ADR-0039 §A7 |
| Retention deletes on a fixed interval, not on a schedule | Retention is enforced (DLP-2): a background pass deletes aged events and evidence, never open incidents, never material under a legal hold (DLP-703, which each pass counts as kept) and never the audit log. It runs on an interval, so "deleted within N days" is accurate to that interval, not to the hour | Accepted: ADR-0039 §A7 |
| Subject access is an export, not a workflow | POST /api/v1/subject-access/{username} returns everything the system holds that identifies one person: events, incidents, devices, the exception requests they raised, and a record of who looked. It needs its own permission, subject_access:export, and a justification of at least five characters sent in the request body, and it writes subject_access.exported before anything is disclosed. It is not behind the behaviour consent switch: DLP-766 took it out from behind it, because a data subject's right to their data does not depend on whether individuals may be named in analytics. Settings → Obligations → Subject access produces the export as a JSON file (DLP-938). What it is not is a case management workflow: there is no request register, no clock against the one-month deadline, no identity verification of the requester, and no erasure or rectification. Those remain the controller's process | Accepted: ADR-0039 §A15 |
| Rate limiting applies after signature verification | Per-device token buckets are enforced (DLP-TASK-063), but the bucket is keyed on the proven device identity, so the Ed25519 verification and nonce insert happen before the limit does. Keying on the claimed identity instead would let an attacker exhaust another endpoint's budget by putting its device id in a header, which is worse. nginx's per-address limit is the outer defence against an unauthenticated flood | Accepted: ADR-0039 §A8 |
| Enrollment is not rate-limited per device | There is no device identity to key on before enrollment completes. Enrollment tokens are single-tenant, expiring and use-counted, and nginx limits per address | Accepted: ADR-0039 §A8 |
| Ingestion quotas are per device, never a tenant pool | Event volume, bytes and stored footprint are capped per device (DLP-SEC-016). There is deliberately no tenant-wide cap: it would let one compromised endpoint eat the shared allowance and throttle its innocent colleagues. A tenant's ceiling is therefore its device count times the per-device quota, and enrollment is what bounds the device count | Accepted: ADR-0039 §A8 |
| The storage footprint is a row count, not bytes on disk | stored_events counts a device's rows, not the space they occupy. Event rows are bounded in size and redacted, so the count tracks the footprint closely, but a deployment that cares about exact bytes should size the quota from measured row size | Accepted: ADR-0039 §A8 |
| The footprint counter can lag between retention passes | It is incremented after events commit, so a crash in between loses an increment, and retention deletions are not subtracted until the next pass reconciles. It is therefore accurate to a retention interval, not to the second | Accepted: ADR-0039 §A8 |
| A forgotten password needs an administrator | There is no self-service "forgot password". The server sends no email at all, so it has no channel to prove somebody owns an address. An administrator issues a one-time reset link from Users & roles (DLP-795), valid 24 hours, and hands it over by whatever channel they trust; the person chooses the new password themselves, and redeeming it signs out every session they had. The same is true of an invitation that lapsed: it is re-issued, not re-sent (DLP-794). An organisation whose only Owner forgets their password still needs a second Owner, or somebody with shell access to the server to run --bootstrap for the same organisation and address, which overwrites that account's password (and nothing else about it) and records tenant.bootstrapped. That password is held to the same rule as one chosen in the console (DLP-1103) | Accepted: ADR-0039 §A9 |
| Federation and provisioning are proven against a simulated provider, not a commercial one | Signing in with the organisation's identity provider works (DLP-28): Authorization Code with PKCE, the ID token verified against the provider's published keys, the role taken from a group claim, and the session minted by the same code path as a password sign-in. The provider can also provision over SCIM 2.0 (DLP-1187, ADR-0041). It creates accounts before anybody signs in, closes them when they are deactivated or deleted there (revoking their sessions), and sets roles from pushed groups through the same mappings. Provisioning never gives Owner and never reopens an account an administrator disabled. Both are driven by suites against a simulated provider, not by Entra ID or Okta themselves. SCIM filters are eq only, with no bulk and no ETag. Only the name, the address and group membership are kept, so a department or title the provider sends is not synchronised. A deleted account is disabled and kept, not erased. One provider per tenant, and the implicit and hybrid flows are refused rather than configurable | DLP-1181 |
| Audit events reach a SIEM as RFC 5424 or CEF, one message in flight per destination, and at most 100 messages per connection | Each delivery pass opens one TLS connection per destination and reuses it for up to 100 messages (FRAMES_PER_CONNECTION), closing it when the pass ends, so one destination drains at no more than 100 events per 5 s tick, about 20 events/s. Measured on one destination, debug build, one host, one API process, one row in flight: 20.11 events/s for a 10,000-row backlog with a collector that answers after 50 ms (497.2 s, 101 connections, no errors); 19.14 events/s at 500 ms (600 rows, 31.4 s, 7 connections); 20.47 events/s at 2 s (300 rows, 14.7 s, 4 connections); 20.53 events/s at zero delay (2,000 rows, 97.4 s, 21 connections). Before the reuse, each message had its own connection and a 10,000-row backlog at 50 ms drained at 14.89 events/s and a 300-row backlog at 2 s at 0.49 events/s. The 10,000-row backlog was measured only at 50 ms (about 8 minutes); the other delays ran on backlogs of 300 to 2,000 rows, so a 10,000-row backlog at 2 s is extrapolated, not run, and delays beyond 2 s were not measured. A failing message holds the later events of its destination behind it (head-of-line), which is the cost of never dropping one. A collector that accepts and never answers holds only its own queue: a write made meanwhile returns without waiting for it (tests/syslog_categories.sh, section 2). | Accepted: DLP-2205. Throughput measured under DLP-2208; connection reuse per pass is DLP-2224 |
| Seven integration kinds, and no e-mail or bucket delivery | Webhook delivery (DLP-43), syslog over TLS as RFC 5424 (DLP-44) or CEF (DLP-1773), Splunk HTTP Event Collector (DLP-1773), and Slack (DLP-1179) and Teams (DLP-1773) incoming webhooks deliver every incident automatically, with retries, a dead-letter record and health reporting. Jira and ServiceNow (DLP-53) work differently on purpose: nothing is sent automatically and an analyst escalates on demand, because a tracker is where work is assigned to a person and a ticket per detection is a queue nobody reads. What a ticket may carry is an allowlist of already-redacted fields, checked when the integration is saved. A Slack or Teams URL is the credential, so it is stored as a secret and only its host is listed, audited or named in an error. There is no e-mail delivery, because the server sends no mail at all, and no delivery of incidents to a bucket. siem, email and s3 are no longer kinds in the schema; a SIEM takes syslog or a webhook | Accepted: ADR-0040 |
| Active time is built and has not run on a Windows host | Measured only where an Owner turns it on (ADR-0043, PRIVACY_MODEL §47). What is on main: the server's switch, ingest, storage and figures (DLP-1243); the Behaviour page reading time where it is measured, and saying so, with operations otherwise (DLP-1244); and the endpoint's sampling in the person's session, the hour fold and the upload (DLP-1245, DLP-1250). Not yet shown: an hour of use on a physical PC reaching the Behaviour page, which is DLP-1250's acceptance. Browser hosts: the extension's report, the agent's checks, the Windows native-messaging transport and the one-record handoff are joined in code (DLP-1246, DLP-1256), but have not run in a managed browser on a physical Windows host | DLP-1241 |
| Cloud coverage is SharePoint discovery, proven against a recorded Graph | A file that never touches a managed endpoint was invisible. A SharePoint site can now be a discovery target (DLP-1193, DLP-1194, DLP-1195). It is read through Graph with Sites.Selected and no write scope, every token is checked against the customer's Microsoft tenant, and the whole library is scanned with the shared engine, with each finding's exposure taken from its sharing: an anonymous link, a guest, or an organisation-wide link. It has been exercised only against a recorded Graph (tests/m365_discovery.sh), never a live tenant. A change in a SharePoint site wakes a scan through Graph change notifications, where DLP_PUBLIC_URL is reachable by Microsoft (DLP-1196). A scan made from a target reads only what changed since the last one and carries the rest forward, with a whole read at least weekly (DLP-1201). OneDrive is not subscribed to, so it changes only on its schedule. Every person's OneDrive is covered too, behind an Owner's switch that is off by default, with no target for one person's drive (DLP-1197). A finding raises an incident only through a published cloud_storage policy, and joins an open endpoint incident for the same content hash rather than raising a second (DLP-1198, ADR-0042). Such an incident is an audit, never a block. Teams and mail remain out of scope (ADR-0017) | Accepted: ADR-0039 §A18 |
| Discovery scans a mounted share and an S3 bucket (and, since DLP-1778, a PostgreSQL database the operator provides, sampled and read only; no other database engine) | Since DLP-675 each organisation reaches only the share roots and credential variables its operator assigned (DLP_TENANT_DISCOVERY_SCOPE); before that, any directory outside nine host paths and any DLP_DISCOVERY_ variable could be named by any organisation. Both kinds are in the console's Targets section (DLP-578, DLP-594). Endpoint discovery works, and a share mounted on the server is walked, scanned and inventoried by exposure (DLP-29). It repeats on an interval in hours (DLP-232) - the next run is queued the moment the previous one ends and waits until it is due, so there is no path that could queue one per worker tick - and a second run can say what appeared or changed rather than repeating the inventory. An S3-compatible bucket is scanned too (DLP-233): listed, read and detected over hand-written SigV4 with no SDK, exercised against a real MinIO on every push (tests/s3_discovery.sh), and read-only by construction - the module issues one HTTP method, GET, and contains no write, delete or lifecycle call at all. Where each of them stops is different. A share must be mounted on the server, because this product holds no file-server credentials of its own, and a schedule stops after three consecutive failures rather than retrying a decommissioned target forever, and says so. A bucket is named by an s3://bucket/prefix?endpoint=…®ion=… target and a credential reference (env:DLP_DISCOVERY_…), never a key; inline: is refused (DLP-630). An object larger than 64 MB is skipped rather than truncated. The caps of 200,000 objects and six hours apply across pages rather than per page, and mark the inventory partial when they bite. One object that cannot be read is logged and skipped, while a bucket that cannot be listed fails the scan rather than reporting it empty. A bucket answers neither owner nor permissions, so every object finding carries exposure unknown, which severity treats as high - an S3 finding's severity therefore says nothing about whether the bucket is public, and the exposure filter that is the point of a share inventory tells an operator nothing about a bucket. Unrelated to any of this, and named the same: s3 was once an integration kind, meaning delivery of incidents to a bucket, and was removed from the schema without being built (ADR-0040). A finding can be worked (DLP-234): acknowledged or marked remediated, with a reason, audited by id rather than by count, and a finding marked remediated that a later scan finds again at the same path with the same categories comes back and says so. What is deliberately absent is quarantine and delete - this product does not move or delete a customer's files, because a discovery scanner with write access to a file server is a larger risk than the data it found, and the two states stay unreachable at the API rather than merely hidden in the console | Accepted: ADR-0039 §A14 |
| No Helm chart | deploy/kubernetes/ now holds manifests, applied to a real cluster and verified by 41 assertions. Nothing packages them as a chart, and the guide no longer claims otherwise | Accepted: ADR-0039 §A10 |
| The manifests are verified on kind, not on a production cluster | kind's CNI enforces NetworkPolicy, so the deny-by-default rules were exercised. A managed cluster differs in ingress, storage class and CNI; a cluster whose CNI does not enforce NetworkPolicy will apply these objects and silently ignore them | DLP-1186 |
| Updates are signed and staged, not yet reversible or proved on Windows | The agent verifies each update against the keys it was enrolled with, and stages it under a .partial name before msiexec runs (Updates, claim by claim). The MSI now admits the watchdog's rollback and no other downgrade, and no rollback has run on Windows. No agent has yet installed its own update on a Windows host either. An install the setup program made updates from the agent binary by its own in-place install (DLP-1253), and the server publishes and queues each new release by itself for groups set to follow the latest (DLP-1254). Both are asserted here, and neither has run on a Windows host. This row said until DLP-1174 that the updater did not exist | DLP-1120, DLP-447 |
| The dashboard's reference layout is drawn from the aggregates that exist | GET /api/v1/overview computes ten aggregates from stored rows, and the Dashboard (DLP-97) renders those. Three panels of the reference composition need dimensions the endpoint does not return - incidents per day by severity, open incidents by severity beyond critical, agent-version currency, destination breakdown and destination-by-classification. The console renders the honest neighbouring figure and states the window it covers rather than inventing the missing one, so a panel there is narrower than the reference, never wrong | Accepted: ADR-0039 §A10 |
| Capacity is measured, and measured on a contended host | docs/CAPACITY.md records 3,385 events/s ingestion-only (p99 630 ms) and 2,622 events/s with every event matching a policy (p99 283 ms) on one host, one API process and one PostgreSQL - while that host was running tens of cores' worth of unrelated work on 16 cores. They are floors. Two API replicas and a soak of hours are not measured By arithmetic from those floors the 10,000-endpoint criterion clears at 600 events/endpoint/hour and not at 3,600; it has not been run as 10,000 endpoints on a quiet host | DLP-1180 |
| Ingestion: a request costs more transactions than its batch | Fixed for the batch itself (DLP-110): a batch is one transaction rather than one commit per event, and an event no policy matched no longer costs a correlation read either. Measured together, 965 to 271 transactions for 500 events - a 72% reduction, with nothing now scaling per event. What remains is per-request: a replay-protection nonce insert, two rate-limiter updates and the stored-events counter, about ten transactions per request whatever it carries. Worth batching next; not a bottleneck now | DLP-1180 |
| Event worker not separated | Correlation runs inline in the ingestion path, after the batch commits, and only for events a policy matched - so it is off the cost of ordinary traffic (DLP-110). It is retried and logged on failure, but a dedicated worker would still be more robust at volume for the events that do correlate | DLP-1180 |
| Baselines model no seasonality beyond a month | A person's baseline is a rolling 30-day window, and volume is compared against their busiest single day in it rather than their average - so one month-end is already inside the window and the next is not treated as a departure. Day-of-week patterns, quarter-end, and annual cycles are not modelled at all. A finance team's quarter-end will look unusual the first time it happens after deployment | Accepted: ADR-0039 §A10 |
| A baseline needs 7 active days and 20 events | Below that a person is not compared at all, which is the correct answer for a new joiner and means the signal is absent for the first week of any deployment. The API reports "no baseline" rather than "no deviation", so the absence is visible rather than reading as reassurance | Accepted: ADR-0039 §A10 |
| Deviation is computed only when a policy already matched | It costs four queries, and paying them for every file write on every endpoint would be the most expensive thing in the product for a factor worth five points. An operation no policy objected to is never compared against a baseline | Accepted: ADR-0039 §A10 |
5. Security
| Gap | Compensating control | Tracked as |
|---|---|---|
| Device key on disk on Linux and macOS | Windows seals the key with DPAPI at machine scope (DLP-SEC-007); on Linux and macOS it remains a 0600 file, written atomically. A local administrator can read it; server-side revocation is the answer | Accepted: ADR-0039 §A2 |
| A permissive state directory is corrected, not prevented | The agent restricts its state directory at start-up and reports what it found (DLP-TASK-071), so an operator learns the device key was exposed. It cannot undo the exposure: a key readable by every local user may already have been copied, which is why the report says to consider revoking the device rather than treating the repair as the end of it | Accepted: ADR-0039 §A11 |
| Windows verification is written and has not run | tests/windows_verification.ps1 and tests/windows_msi_installer.ps1 -Integration are wired into CI on windows-latest. They cover enrollment, the private-key and state-directory ACLs, Windows paths and monitoring, offline buffering, revocation, MSI rollback, install, upgrade, downgrade and uninstall, and the service lifecycle. They have not yet passed: no GitHub-hosted job ran from 2026-09-15 to 2026-10-01 (DLP-1215), and since then the end-to-end job stops before enrolment (DLP-1492, Windows CI status). The self-hosted runner mydlp-host (DLP-1225) is Linux. The MSI script's static half (26 assertions against Package.wxs) does run, under pwsh on Linux. Even when the scripts run, they are one runner image, not a deployment: no domain, Group Policy or endpoint protection, a loopback server over plain http, and no reboot | DLP-449 |
| Audit anchoring needs an operator-chosen timestamp authority, and cannot stop stamps being deleted | The chain detects a removed, reordered or edited entry, and not somebody who recomputes the whole tail. Signed checkpoints (DLP-SEC-018) catch that, if a copy was kept outside the database. With DLP_AUDIT_TSA_URL set, every checkpoint is also stamped automatically by an RFC 3161 timestamp authority (DLP-1185). Nobody can obtain a stamp dated in the past, so a rewrite cannot carry the old stamps; the server verifies each token and lists them for openssl ts -verify. It is off by default, because which authority to trust is the operator's decision. Somebody with the database can still delete stamps. That hides a stamp without forging one, and shows as a gap in the series, and copies of the tokens kept elsewhere close it | Accepted: ADR-0039 §A16 |
| The bundle signing key is software until an operator moves it | A new server seals it in the database. An operator can move it to a secret manager (env:) or into a PKCS#11 token, where it never leaves and can only sign (DLP-239, asserted by tests/pkcs11_signing.sh against SoftHSM); cloud KMS services cannot hold it, having no Ed25519 (ADR-0016). This row said until DLP-1184 that HSM support did not exist | Accepted: ADR-0039 §A17 |
| Signing-key retirement requires fleet convergence | Signed trust-set rotation is implemented (DLP-159): old signs {old,new}, then new signs {new}, with an independent anti-rollback floor. The retirement endpoint refuses while any active endpoint has not acknowledged the overlap. The server deliberately cannot make an endpoint that missed it trust an unrelated new key; an agent predating v0.148.5 keeps checking in but must be upgraded (or revoked if decommissioned) before retirement can proceed | Accepted: ADR-0039 §A11 |
| Agent packages not code-signed | The console shows a SHA-256, which protects against a corrupted transfer, not a compromised one. An unsigned agent also means a SmartScreen warning at every Windows install. The protected HSM release stage is implemented in , but no production certificate or successful signing record exists; code alone cannot change the package claim | DLP-1175 |
| mTLS optional | Ed25519 request signing works everywhere and is always on. mTLS is supported where TLS terminates in the application but is not the only control | Accepted: ADR-0039 §A11 |
| Group Policy rollout is written down, not walked in a domain | Rolling the Windows agent out with Group Policy covers packaging, enrolment-token issuance and rotation, and staged rings, each step checked against the MSI, the ADMX and the token API. Nobody has yet run it against a real domain. The MSI takes the enrolment token from the Group Policy setting when none is given (DLP-1191), so the startup script passes no token on a command line. That route is asserted against the WiX source and the agent, and has not been installed on a Windows host; what Group Policy Software Installation does with an assigned package after the agent upgrades itself has not been observed | DLP-449 |
| An enrolment token set in a GPO is readable by every domain user | Registry policy travels through SYSVOL, which grants Authenticated Users read. This is a property of Group Policy rather than of this product, and the setting exists anyway because the alternative is the same exposure in a deployment script with no warning. The ADMX help text says so where an administrator is standing when they type it, and AGENT_INSTALL.md §9 says what to use instead: a short-lived token minted for one imaging run | Accepted: ADR-0039 §A11 |
| A service cannot see whether anybody is using the machine | GetLastInputInfo is documented as session-specific, so the service's session-0 value is not the person's activity. The interactive session agent now reports only its idle seconds over the authenticated session link; the service treats a missing report as unknown, expires it after 90 seconds, and takes the minimum across connected sessions so Fast User Switching/RDP cannot make an active desktop look idle. discovery_require_idle_secs still defaults off, and native Windows laptop validation remains outstanding. The battery rule has no such problem: GetSystemPowerStatus is machine-wide and is on by default at 30% | DLP-1176 |
| Neither power rule has been observed on a Windows laptop | The decision is a pure function of (mains, charge, idle) and is covered from both directions, including the 255 the firmware uses for "unknown" - read as a number that is a machine at 255% charge, which never pauses. What has not been watched is GetSystemPowerStatus and GetLastInputInfo returning real values on real hardware, or a walk pausing and resuming on a laptop somebody unplugs | DLP-1176 |
| A state directory that stops being writable is noticed at the next health report, not at once | SQLite goes on writing to file handles it already holds, so a queue whose directory loses write permission, or is moved away, keeps accepting events. Until DLP-1183 nothing noticed until the agent restarted and could not open its queue. Each health report now creates, writes and removes a probe file in the state directory, and reports the directory as Degraded by name when it cannot, because the next start, a certificate rotation and the scan cache all need a new file there. Between two health reports it is not yet known. A full volume and a corrupted file were already noticed on the write itself | Accepted: ADR-0039 §A11 |
| A device certificate issued before v0.129 is inert | Until DLP-286 the csr_pem an agent sent was accepted and ignored, so every device certificate certified a key generated on the server and discarded. An endpoint holding one cannot present it for mTLS: it has no matching private key. Signed-request authentication was unaffected throughout - it verifies against agents.device_public_key, which is the key the endpoint really holds, so those devices are and were fully functional. Current agents send a real PKCS#10 request and get a usable certificate; an existing endpoint gets one at its next rotation, which is automatic in the last third of the certificate's life. An agent on an older build still enrols and still gets an inert certificate, and the server logs which devices those are | Accepted: ADR-0039 §A11 |
| Windows elevation is observed elevated, never as a standard user | TokenElevation is queried off the process's own token and the decision logic is unit-tested. ci.yml's windows-agent job is written to run those tests natively on windows-latest, and windows-e2e to add that a GitHub runner is elevated. Neither has passed: windows-agent first ran the agent library's tests natively on 2026-10-02, with 5 failures this page does not depend on, and windows-e2e stops before enrolment (Windows CI status). Which elevation branches that run exercised has not been checked, so the elevated path is not claimed as run on a Windows kernel. The standard-user path is further off still - TokenElevation returning false and every decision hanging off it - because a hosted runner has no non-elevated session to produce it (DLP-356) | DLP-449 |
5.0 A machine renamed before the upgrade stays orphaned
The hardware id no longer moves when a machine is renamed, and a re-enrolling agent migrates its own record (#376). What that cannot do is recover a machine that was already renamed under the old scheme: its record was orphaned at that point, the legacy id the agent now computes is derived from its current host name, and the orphaned row is keyed on the old one. Those records have to be revoked by hand.
The hostname() source also changed, from $HOSTNAME and /etc/hostname to /proc/sys/kernel/hostname - the kernel's own value. On a host where those disagreed, the legacy id computed today will not match the one the previous agent computed, and the migration will miss. /etc/hostname and the kernel value agree on any normally configured machine, which is why this is a limitation rather than a blocker.
5.0.1 Tamper resistance is visibility, not prevention
An agent can still be stopped. kill -9, systemctl stop, ending the process in Task Manager - all of them work, and that is deliberate (#347).
The alternative was considered and rejected. A process that resists being killed, restarts itself from somewhere unexpected, or hides from the process list is indistinguishable from malware, would be treated as malware by whatever endpoint protection is already on the machine, and would make the product impossible to remove by an administrator entitled to remove it. The line the product draws is: hard to stop by accident or casually, always removable by an authorised administrator with an audit record.
What exists instead:
| The restart | systemd Restart=always on Linux, Service Control Manager recovery actions on Windows. Both are visible where an administrator already looks and both can be switched off by one |
| The record | The agent leaves a marker while running and removes it on a clean shutdown, so a kill is reported at the next start as agent.interrupted. A clean shutdown leaves its time instead, and is reported at the next start as agent.stopped with how long it lasted (DLP-1613). Since DLP-1770 the next start says why, where it can tell: a different version started next (an update), the machine booted after the stop (a shutdown), or neither (unexplained, counted apart on Endpoints). It is inferred, not observed: a person who stops the service and then restarts the machine reads as a shutdown, and one who stops it just before an update reads as the update. A stop shorter than the organisation's listing cutoff (fifteen minutes by default, DLP-2177) is not listed as silent while it lasts; it is on record from the next start |
| The alert | The server classifies silence: explained (uninstalled, revoked, decommissioned) or not. Unexplained silence is surfaced by hostname on Administration → System health |
| The clean exit | SIGTERM is handled, so systemctl stop is not reported as tampering. Ordinary operations must not produce alerts, or the alert gets switched off |
What this does not cover:
- An attacker with administrator rights who stops the service and has time. They can uninstall it with an authorisation id they invented, and the console will show a clean removal. What they cannot do is make it silent - the removal is in the audit log with the id they used, along with how many queued events it delivered and how many it discarded (DLP-275). An uninstall flushes the queue before wiping it, because an endpoint that has been offline is the one most likely to be holding the events somebody is about to ask about; a flush that fails never blocks the removal, and the count that was lost is reported locally and on the notice rather than disappearing with the file.
- A machine taken off the network entirely. It reports as unexplained silence, which is correct, and is indistinguishable from a laptop in a bag until someone looks at the machine.
- The Windows service has not been verified on Windows. The WiX MSI owns registration, delayed start, recovery actions and removal, and a CI job is written to exercise that lifecycle on one hosted image. The job has not yet passed (Windows CI status). The service is deliberately not marked protected (PPL): that needs an anti-malware certificate and would make the agent un-stoppable even by an administrator, which crosses the line above.
- The gap is measured from the last heartbeat, which is written once a minute, so a reported gap overstates by up to a minute. Overstating is the safer error - it cannot hide a real one.
5.0.2 An alert is raised, not delivered
Sequence gaps and prolonged endpoint silence are detected, audited, counted and surfaced on Administration → System health (#351), and exported on the metrics port (#424) so a Prometheus can alert on them without anyone watching a page.
Before DLP-1771 a gap did not always mean an event was lost. An event whose id was already in the agent's queue (the same file saved again with the same content while its first report waited) was dropped on purpose, and the sequence number taken for it was dropped too, so the server opened a gap for an observation it already had. One Windows endpoint showed 544 such "missing" events in two days. The agent now gives the number back. Gaps opened before the update stay open on System health until resolved there. Other causes of a gap (a restart or an update mid-queue) were not ruled out by that change, and a gap opened by a current agent is still worth reading as a loss until shown otherwise.
The product now delivers incidents to a webhook (INT-001): queued on creation, posted to an https endpoint, signed so the receiver can tell it came from here, retried with backoff, and the integration's health says whether alerts are arriving at all.
What it still does not deliver: sequence gaps and endpoint silence. Those are detected, audited, counted and surfaced on Administration → System health (#351) and exported on the metrics port (#424), and nothing pushes them anywhere. For a fleet nobody watches out of hours that remains the difference between a control and a record - the record is worth having, because it is what an investigation reads afterwards, but it should not be mistaken for a control. Syslog (DLP-44) and Slack (DLP-1179) deliver as well, and Jira and ServiceNow escalate on demand (DLP-53). There is no e-mail delivery, and no delivery of an incident to a bucket: siem, email and s3 were removed from the schema rather than built (ADR-0040). The removed s3 meant delivering an incident to a bucket; it is not the S3 discovery scanning of §4, which works.
The three thresholds in play, and why they differ:
| When | For | |
|---|---|---|
| Listed as silent | 15 min by default; tenant-set, never under 15 min (DLP-2177) | Worth a look when you next open the console |
| Alerting on silence | 30 min by default; tenant-set, never under 30 min and never before listing (DLP-2177) | No longer explicable by a lunch or a commute |
| Sequence gap | Immediately, on the batch that skips | Events are already lost; waiting adds nothing |
5.1 The SBOM describes the repository, not a built artefact
tools/generate_sbom.py produces CycloneDX and SPDX from the lockfiles and the source tree. It answers "what did this build declare it depends on". It does not answer "what is inside the binary we shipped" - a dependency that is in the lockfile but compiled out by a feature flag still appears, and anything statically vendored without lockfile metadata does not.
Every component the lockfiles can vouch for now carries its checksum, and the generator fails the build rather than emitting a document that silently lacks them. What the checksums verify is the source artefact each dependency was fetched as, not the compiled output.
5.2 Several pinned actions are majors behind
Pinning the workflows to commits surfaced how stale some of them are: actions/checkout is pinned at the v4 line while v7.0.1 exists, and gitleaks-action at v2 while v3.0.0 exists. The pins are at the refs that were in use, so nothing changed behaviour - but a pin that is silently three majors behind is worse than a tag, because a tag at least moves.
.github/dependabot.yml opens a pull request when one of them releases, so the update is a deliberate, reviewable change. Nothing has been bumped here: bumping a major version of an action blind, on a repository whose CI runs cannot be observed from this environment, would be changing what runs in CI without being able to see the result.
5.3 The dashboard's Zod schemas are not generated
JSON Schema is emitted from the Rust contract types and CI fails if the committed files drift from them (DOC-003). The dashboard's Zod definitions, which validate every response at runtime, are still written by hand and mirror the same types independently.
So there are two hand-maintained descriptions of one contract, and nothing compares them. A field renamed in Rust is caught by the schema check; the same rename in the Zod definition is caught only by someone noticing. Closing it properly means generating TypeScript from the schema and diffing it against the Zod types.
5.4 LDAP sign-in reads the directory only when someone signs in
LDAP console sign-in (DLP-2160, ADR-0073) checks the password, the account's state and its groups at sign-in and at a step-up. Between those, nothing asks the directory: an account disabled, deleted or moved out of every mapped group in AD keeps its live session until that session ends or meets a step-up. Disabling the MyDLP account ends its sessions at once, and is the containment step meanwhile.
It speaks LDAPS only, not StartTLS. It has been run against a real slapd carrying the AD attributes, not against Active Directory or Samba. AD constructs tokenGroups and returns it only to a base-scope read by a caller allowed to read it; the test directory stores it. So that the service account may read it is an operator step no run here has shown.
6.1 Fuzzing runs weekly now, but the long campaign has not run yet
Six targets cover the hostile-input boundaries. CI smoke-runs every one on each pull request, and a scheduled workflow (.github/workflows/fuzz-campaign.yml, #414) runs each for four hours every Sunday - five for regex_compile, which executes an order of magnitude fewer iterations per second than the others - carrying its corpus forward between runs so week three starts where week two stopped.
Four hours, not the six this section used to claim. A GitHub-hosted job is terminated at six hours of execution, and timeout-minutes cannot raise that ceiling, only lower it. The workflow as first written asked for 480 minutes and gave regex_compile a 720-minute budget, so every scheduled campaign would have been killed by the platform partway through - after restoring the corpus cache and before saving anything. The budget is now built downwards from the limit rather than upwards from what would be nice, and the fuzzing slice is clamped so that a campaign which would overrun is shortened loudly, in the log, rather than quietly reporting a figure that does not mean what it says.
The scheduled run has still not been observed. It is committed and the script it calls has been exercised locally, target by target, but nobody has read a run - so there is still no long-campaign evidence, and the longest campaign on record remains the ten-minute one below. What to check on the first one, and why the second run matters more than the first, is in DLP-254 (deleted in the 2026-09-20 re-plan, readable by numeric id 817); reading the runs at all is DLP-448.
Until #414 the coverage of each target was unknown, so "no crash" could have meant the fuzzer never got past the first branch. Measured, per target, with the corpus each has accumulated:
| Target | Exec/sec | Coverage of the module it drives |
|---|---|---|
regex_compile | 238 | safe_regex.rs - 93.5% of regions |
extract_any | 15 438 | extract.rs - 86.4% |
extract_pdf | 6 971 | extract.rs - 61.9% |
archive_inspect | 3 773 | archive.rs - 52.9% |
detect_type | 46 848 | extract.rs - 20.4% |
bundle_deserialise | 3 368 | engine.rs - 12.2% (was 0.0%, #439) |
That last row was the finding, and it is fixed in v0.12.3 (#439). bundle_deserialise guards everything it tests behind if let Ok(..) = serde_json::from_str(..), and its only committed seed stopped parsing when min_acceptable_version was added to PolicyBundlePayload. So 1.5 million executions per minute were exercising serde's rejection path and nothing else, and the 14 884 957 runs, no crash recorded below was a real figure with a conclusion that did not follow.
The corpus is now generated from the real types and the real detector packs by cargo run -p dlp-detector-engine --example generate_fuzz_seeds, never hand-written - hand-written JSON is exactly how this happened. From an empty corpus the target now reaches 12.2% of engine.rs, 39.5% of contracts/detector.rs and 24.3% of contracts/policy.rs in sixty seconds. The throughput drop from 24 164 to 3 368 executions per second is the clearest signal: it is doing real parsing work instead of rejecting at the first byte.
Four tests in seeds_parse keep it that way, including one asserting the corpus is not empty - an empty corpus would make the others vacuous, which is how the original defect could have hidden from the very test meant to catch it.
This is why coverage was measured rather than assumed. The pre-authentication bundle parser is the highest-value fuzzing surface in the product, and it had never been fuzzed.
The ten-minute campaign, which is still the longest on record:
| Target | Runs | Result |
|---|---|---|
detect_type | 28 707 907 | no crash |
bundle_deserialise | 14 884 957 | no crash, but see #439 |
extract_any | 9 311 332 | no crash |
extract_pdf | 5 881 915 | no crash |
archive_inspect | 2 473 860 | no crash |
regex_compile | 239 997 | no crash |
regex_compile executes far fewer runs than the rest because each one now compiles the pattern - the fix for what fuzzing found there. Fewer, deeper runs is the right trade for that target, and the scheduled campaign gives it twice the slice of the others to compensate.
6.2 A server outage signs the analyst out
App.tsx decides from a failed /api/v1/auth/me whether the analyst is signed out or the server is unwell, and both branches of its catch do the same thing. The comment in the second branch states the intended behaviour - "a server problem is not a logout" - and the code does the opposite of it.
So during an API outage every signed-in analyst is dropped onto the login screen. Their cookie is still valid and their credentials are fine, but the screen they are looking at exists to collect credentials, so that is what they conclude is wrong. During an incident this is the worst available time for a security team to believe it has been locked out.
Found while writing the component tests in #354, which cover it as two skipped tests in apps/dashboard/src/pages/session.test.tsx. Tracked as #429.
Fixed in #429. The decision moved out of App.tsx into apps/dashboard/src/lib/session.ts, which distinguishes the two cases the catch used to merge: a 401 is a definitive answer and signs the analyst out at once, while a dropped connection, a 5xx, a 408 or a 429 produce a fourth session state - signed in, cannot currently confirm. That state renders SessionOutage instead of the login form, keeps the cookie, retries on a widening backoff (1s to 30s, seven attempts), and then says it has stopped rather than spinning indefinitely. The two tests named above are no longer skipped, and four more cover the dropped connection, the wording, the recovery and the exhausted backoff.
What remains: the console checks the session only at load and immediately after sign-in, so "signed in, then the server went away" cannot arise while the shell is on screen, and the in-shell banner the ticket suggested has no reachable scenario to render in. A periodic re-check, and the banner that would then be worth having, are tracked as #485.
Fixed in #485. useSession now re-checks /auth/me every 60 seconds while a session is confirmed, and once more whenever a hidden tab becomes visible again; the interval is suspended while the tab is hidden. The three things a tab could not previously learn about itself all arrive on that check: a revoked session reaches the login form on its own rather than at the next reload, a changed role rebuilds the rail and the can() gates, and a server that has gone away is reported once for the whole console instead of as a separate ErrorState on each panel. The rule from #429 is unchanged - only a 401 ends the session.
This is also what made the in-shell banner reachable, so it is now built: losing contact with the shell already on screen keeps the shell and adds a strip (SessionBanner) saying contact is lost and that what is on screen predates it. The full-screen SessionOutage panel is still what a reload straight into an outage gets, because there is no shell to keep in that case. Four tests in apps/dashboard/src/pages/session.test.tsx cover the banner, the 401, the permission refresh and the visibility check, all on fake timers.
6.3 What the property tests found, and where those defects went
Generated-input tests cover redaction, normalisation, the validators and the risk model (#353): 49 properties. Four failed on the day they were written, from three distinct defects - two sharing a root cause. All four were left in the tree asserting the behaviour the code claims, marked #[ignore] with a ticket number, rather than softened to match what the code did; relaxing a property to make a suite green deletes the only record that the two disagree.
All three are now fixed, and all 49 properties hold.
| What did not hold | Fixed in | Ticket |
|---|---|---|
EmailStyle previews were not length-capped - everything after the first @ was copied verbatim, so a 3,002-character value produced a 3,002-character "preview" holding 500 copies of the secret, while the same value under Full was capped to 76 | v0.12.1 | #425 |
| NFKC-produced whitespace escaped collapsing, so a whitespace run could survive normalisation and normalising twice differed from normalising once | v0.10.2 | #426 |
line_of panicked on a byte offset inside a multi-byte character - on the scanning path, from a helper that only produces a diagnostic | v0.12.2 | #427 |
#425 now refuses to treat a value as an address unless it looks like one. split_once('@') succeeded on any value containing an at-sign, which is how a document became an "e-mail address" whose "domain" was the rest of the file. The domain is capped at 48 characters and truncated from the front, keeping the tail: dropping the end of mail.example.com.attacker.net would render it as mail.example.com, turning a preview into a false statement about where data went, which is worse than an over-long preview because someone would act on it. The local-part mask is fixed-width, where it used to be proportional and therefore published the length - enough to separate a@example.com from administrator@example.com.
#426 now expands first and passes every produced character through one gate. The forward/reverse pairing is enumerated over the whole of Unicode by the_nfkc_whitespace_pairing_is_enumerated_in_both_directions: 52 characters are not whitespace but produce some under NFKC, and none go the other way.
#427 clamps down to the nearest character boundary rather than requiring one. Clamping and not asserting, because of what the function is for: it produces a line number for a report, and killing a scan of a customer's machine because a diagnostic helper was handed an awkward offset is a denial of service on the thing the product protects. Rounding down is deliberate - rounding up would move a match at the end of a line onto the next one.
The other 45 are worth stating too, because they are the guarantees the engine rests on: offsets always map back to a character boundary inside the original, the offset map never goes backwards, no invisible character survives normalisation, every correctly constructed Luhn / TCKN / IBAN value validates and every single-character change to one does not, and the risk score always lands in 0-100 with a factor breakdown that reconstructs it.
7. Clean-room comparison with OpenDLP and MyDLP (the GPL project)
Written from public documentation. No code, schema, rule content or architectural artefact was copied; both are GPL-family and would impose copyleft obligations on a proprietary product.
| Capability | OpenDLP / historical MyDLP | This product |
|---|---|---|
| Agentless network scanning | Yes, over SMB with credentials | Partly. The server scans discovery targets: a share mounted on the server with the server's own access, or an S3 bucket with a credential it holds. It does not connect over SMB with credentials itself, and endpoint-resident discovery runs on each endpoint |
| Endpoint agent | Windows, deployed over SMB | Rust, cross-platform, enrolled with a short-lived token and a device identity |
| Network enforcement | ICAP and SMTP interception | No. Designed for, not built |
| Detection | Regex plus some validators | Regex, keywords, entropy, checksums, EDM, with overlap arbitration and explainable confidence |
| Policy | Rule lists | Scope, boolean condition trees, exceptions, conflict strategy, schedules, signed distribution |
| Multi-tenancy | No | Enforced in the schema with composite foreign keys |
| Audit | Application logs | Append-only, hash-chained, verifiable |
| Redaction | Stores matched content | Matched values never leave the endpoint |
| Licence | GPL family | Proprietary, built on permissively licensed components only |
The comparison is a feature reference, not a design input. Old implementation decisions are not treated as secure design patterns.
8. Things this product deliberately will not do
Not gaps. Constraints.
- Keylogging, credential collection or interception
- Covert screenshots
- Hidden persistence or security-tool evasion
- Surveillance beyond what a published policy describes
See Privacy model §3.
9. Claims this documentation does not make
- Not "fully secure". See Threat model §7 for residual risks accepted in this release.
- Not "zero false positives". Every detector states its precision posture in Detector catalogue, and the low-precision ones say so.
- Not "complete DLP coverage". §1 and §2 above are the honest scope.
- Not "licence-free". Every permissive licence carries obligations; they are discharged in Third-party notices.
- Not "AI-driven". The per-user baselines (DLP-94) are a rolling statistical summary of what a person has done in a 30-day window, compared against by set membership and a maximum. There is no model, nothing is learned, and nothing is predicted. The API returns
"method": "rolling_statistical_baseline"so that no console or datasheet built on it can say otherwise.