Skip to content

Dev - #379

Merged
neamul602 merged 41 commits into
mainfrom
dev
Nov 2, 2026
Merged

Dev#379
neamul602 merged 41 commits into
mainfrom
dev

Conversation

@neamul602

Copy link
Copy Markdown
Contributor

Brings dev to main: the File DLP work of epic #324. That's 15 PRs, all merged into dev with CI green.

What changes on devices: nothing, until the backend sends a policy with a files module enabled. The backend contract was merged in BrainStation-23/Compliance-OS#562, #566 and #572. Clipboard DLP is unchanged.

File DLP (Linux; manual upload proxy on any OS)

PR What
#364, #365 modules.files contract (decoded tolerantly), the file scan engine (internal/dlp/filescan), privacy guard; gosec fixes
#366, #367, #368 Linux monitoring of USB/SD drives and SMB/NFS/sshfs shares with fanotify notification events (audit only); sandboxed dlp-scan workers (Landlock, no new privileges, no network, unprivileged); emergency off switch; tests runnable without root
#369 CI runs the root-only end-to-end tests with sudo
#370 Dedicated sentinelgo-scan account for the workers
#371 Legacy Office (.doc, .xls, .ppt) and files embedded in Office documents
#372 Per-policy file_filters (types, sizes)
#373, #374 Upload inspection proxy, phase 1 (sentinelgo dlp proxy); per-device CA; no connections to loopback or link-local addresses
#375 Published coverage and bypass list (docs/dlp-file-coverage.md)
#376, #377, #378 Copy correlation: after-write action (record / quarantine / delete), read-only untrusted USB drives, process taint (off by default)

The CHANGELOG's [Unreleased] section has the user-facing details.

Known open points

  • BrainStation-23/Compliance-OS#570: the backend rejects file events without a sha256 (files too large, timed out, or failed to scan), so those events are dropped server-side until it's relaxed.
  • SonarCloud flags S5144 on uploadproxy/proxy.go. It's mitigated in uploadproxy: never connect a client to loopback or link-local addresses #374 (guardDial), but the finding needs to be marked accepted in SonarCloud.
  • Not yet tested on a real Ubuntu desktop with real USB sticks and shares, nor the proxy in real browsers.

Issues this completes

#326, #334 (Linux; macOS copy events come with #332), #335 (decided: option C, continued in #344), #338, #341 (agent side)

🤖 Generated with Claude Code

Neamul Haque and others added 30 commits November 2, 2026 12:07
Phase 1 of the File DLP epic (#324), agent side, with no OS hooks yet, so
nothing is enforced on files and device behaviour doesn't change.

#326, contract (draft, agent side):
- The bundle reads modules.files (channels, audit_only, limits,
  on_unscannable/on_timeout, egress apps, trusted devices/shares, messages)
  and the file fields of policy exceptions.
- Both are decoded tolerantly: a files module or file exception the agent
  can't read is turned off and reported as a compile error, and the
  clipboard keeps working. source_apps keeps its old strictness.
- Defaults: audit-only when audit_only is missing; fail open unless
  on_unscannable/on_timeout say "block"; limits clamped.
- File channels don't count as enforced channels, so a files-only bundle
  leaves DLP idle instead of starting session helpers. The file engine is
  built lazily, so helpers never pay for it.
- Events gain optional file/destination/process/scan fields; clipboard
  events keep their exact JSON.

#327, internal/dlp/filescan:
- The file type comes from the content. ASCII-only magic numbers ("BM",
  "MZ") are checked only after the text test, so a CSV starting "BMW" is
  still scanned.
- Extractors: text in UTF-8, UTF-16 LE/BE with or without BOM, and Latin-1;
  OOXML (docx/xlsx/pptx text parts, cells and paragraphs kept apart, field
  codes and formulas skipped); ODF; RTF; zip archives, entry by entry.
- The text is scanned in chunks that overlap. Cuts are made at a newline
  or space, and each chunk keeps only matches that start before the next
  chunk, so a match across a boundary is neither missed nor counted twice.
- Limits: file size, text budget, deadline, archive depth/entries/total
  bytes/compression ratio. The zip central directory's entry count is
  checked before archive/zip allocates anything, and spill buffers grow
  with what is read, not with what an entry claims.
- Explicit statuses: scanned, truncated, timeout, unscannable:<reason>.
  PDF and legacy OLE have no extractor yet (unsupported_type).
  Encrypted and IRM-protected OOXML are detected.
- Microsoft sensitivity label names come from docProps/custom.xml.
- Bounded LRU verdict cache keyed by file identity, size, times and policy
  version; timeouts aren't cached; invalidated on close-after-write.
- Fuzz targets (FuzzScan, FuzzRTF) and benchmarks per format.

#341: FileName gives an event nothing, the base name or the full path,
by evidence mode. A structural test forbids filescan from logging,
printing, networking or touching files beyond its spill file.

Refs #324 #326 #327 #341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

File DLP groundwork: modules.files contract, scan engine, privacy guard (#324)
- zip.go: sizes and offsets from zip headers go through toInt64, which
  refuses values over MaxInt64 instead of converting them. All limit
  checks are now int64 comparisons (G115 x3).
- cfb.go: UTF-16 marker bytes are built with AppendUint16 (G115).
- zip.go: the archive entry name is only a label in results, never a path
  anything is written to; annotated (G305 false positive).

No behaviour change: the existing guards already rejected negative and
oversized values; this makes it explicit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 2 of the File DLP epic (#324): #333 (fanotify), #328 (sandboxed
scan workers) and the fail-safes of #329 that apply to audit-only. When a
bundle enables file.removable or file.network_share, files written there
are scanned and matches recorded as "monitored" events. Nothing is
blocked.

Design: fanotify notification events (FAN_CLOSE_WRITE on mount marks), so
the kernel never waits for the agent; a slow or dead agent can't hang the
machine. Blocking (permission events, timers, watchdog) is left for when
audit data exists, as the epic's rollout rule says.

- internal/dlp/scanworker: `sentinelgo dlp-scan` workers parse files,
  never the root service. The service passes the file's descriptor over
  a socket pair (SCM_RIGHTS); the worker never opens a path. As root,
  workers run as an unprivileged account (sentinelgo-scan, else nobody)
  in a new network namespace, falling back without one where the kernel
  refuses. Each worker sets no_new_privs and Landlock (no file access by
  path; no TCP from ABI 4) on every thread via AllThreadsSyscall, plus
  RLIMIT_FSIZE=0, NOFILE, AS, CORE and a Go memory limit. Every scan has
  a deadline: a stuck worker is killed and replaced. Five failures in ten
  minutes disable scanning (reported, not retried forever). Cancelling a
  scan's context ends its wait at once.
- internal/dlp/filedlp: the Decider (OS-agnostic) applies the policies
  for the channel. It honours per-policy exceptions (apps, trusted
  devices, trusted shares, source apps), caches results by file identity
  and policy version, and records events with file, destination, process
  and scan details by evidence mode. Unscannable or timed-out files are
  recorded only when on_unscannable/on_timeout ask.
- Linux enforcer: classifies mounts from mountinfo and sysfs. Removable
  means a USB/MMC bus or the removable flag, with dm/md followed to the
  devices under them (LUKS sticks). Network means cifs/smb3/nfs/sshfs,
  with the share host taken from the source (IPv6 aware). It marks them,
  re-marking on mount changes and policy changes. Bounded queue: a full
  queue drops and counts. Event fds are closed on every path. The agent's
  own writes are ignored. When the writer has already exited, the user
  comes from the file's owner.
- Emergency switch: dlp-files-off in the config directory removes every
  mark within seconds, without the backend.
- Manager: a files-only bundle enforces with a file hook and stays idle
  without one (every platform but Linux). The clipboard is unchanged.
  Status gains file channel capabilities and a files health block.

Tests: decider unit tests; mount/sysfs classification against fixtures;
worker pool tests running the test binary as a real sandboxed worker
(scan, concurrency, stuck worker, crash loop, cancel, sandbox probe).
Root-only end-to-end tests run real fanotify on a tmpfs "stick": an
event, no event for clean files or while switched off, recovery, stop
when the channel turns off, and stop during an in-flight scan.

Refs #324 #328 #329 #333

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts under -race

- Pool.acquire: scans waiting for a worker were never woken when starting
  one failed (or a worker died), so they waited until their context ended,
  forever with context.Background. Slots freeing up now signal waiters,
  who retry. Regression test: 8 scans against a pool whose workers can't
  start all return.
- Tests that run real workers skip in cgo builds (CI's -race step): the
  sandbox needs AllThreadsSyscall, which cgo builds lack, and a worker
  refuses to run unsandboxed. Releases are always CGO_ENABLED=0, and CI's
  regular test step still runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File DLP on Linux: audit-only monitoring of USB and network shares (#324)
SonarCloud measured 58% coverage on #366's new code: CI isn't root, so
the fanotify end-to-end tests skip there. Now covered without root:

- Seams for fanotify_init, the mount marks and the mountinfo path. A
  test runs the real loops with a pipe as the "group" and writes
  kernel-format event records into it: marking (including a mark that
  fails), events mapped to a mount and decided, files elsewhere,
  directories, our own writes, overflow/unreadable counters, the
  emergency switch and stopping.
- The "fanotify not permitted / unavailable" paths and their capability
  reasons.
- The scan worker's serve loop in-process over a real socket pair
  (policy, scans with a passed descriptor, no policy, a bad policy,
  unknown messages, a clean exit), frame limits and the offsets cap.

Fix found by the new test: after the monitor stopped, Health() returned
nil when no worker had ever started, so the status lost its files block
instead of reporting "stopped". It now remembers that a start was tried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
filedlp: test the Linux monitor without root (coverage for #366)
#367's SonarCloud gate measured 46.7% on its new lines: mostly the real
fanotify_init/fanotify_mark wrappers, which the loop tests replace with
fakes. This test calls them for real. As a normal user (CI) the kernel
refuses, which still runs every line and checks the failure mapping; as
root it marks and unmarks a directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alls

filedlp: test the real fanotify calls (coverage for #367)
The fanotify end-to-end tests and the sandbox probe need root (a tmpfs
that plays a USB stick, fanotify, workers dropping to nobody), so they
skipped in CI and only ran on developer machines. Hosted runners allow
sudo: a new step runs filedlp and scanworker as root, fails if those
tests skipped instead of running, and adds their coverage to the Ubuntu
report SonarCloud reads. Root gets its own build cache so later steps,
run as the runner user, never meet root-owned cache files.

Refs #340 #333 #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workers fell back to nobody, which other software shares. The service now
creates a sentinelgo-scan system account (no home, nologin shell, own
group) the first time it starts workers as root, so every install path
gets it: install.sh, -install, packages and devices that only self-update.
If useradd is missing or fails (a directory-managed system), workers keep
falling back to nobody, logged once. -uninstall and install.sh uninstall
remove the account. useradd, userdel and nologin are resolved from fixed
paths (binpath), never PATH.

Refs #339 #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ci: run the file DLP end-to-end tests as root on Linux
…ents

.doc, .xls and .ppt were reported unsupported_type, and files embedded in
docx/xlsx/pptx weren't opened.

- cfbfile.go: a bounded OLE compound file reader (header, FAT and DIFAT,
  directory tree, mini stream). Every sector number is range-checked and
  every chain bounded by the file's sector count; streams are capped at
  max_file_bytes.
- .doc: text through Word's piece table (8-bit and UTF-16 pieces); field
  codes dropped and results kept, like instrText in docx; paragraph and
  cell marks as breaks; fEncrypted means password_protected.
- .xls (BIFF8): the shared string table, including strings continued in
  CONTINUE records with their own 8/16-bit flag; LABEL and STRING cells;
  NUMBER, RK, MULRK and cached FORMULA values (whole numbers as digits, so
  a card number stored as a number is found); FILEPASS means
  password_protected. Excel 5/95 (Book) stays unsupported.
- .ppt: TextChars and TextBytes atoms; CryptSession10 or EncryptedSummary
  means password_protected.
- OLE packages (Package, Ole10Native) are unwrapped and scanned.
- Encrypted OOXML is detected from the parsed directory; the stream-name
  search stays as the fallback for files that don't parse.
- Embedded files (word/xl/ppt/embeddings) are scanned as entries. Ones
  with findings or protected by a password/RMS are recorded; unreadable
  objects (equations, drawings) aren't, so everyday documents don't become
  unscannable.

Tests build real compound files (mini stream and regular sectors) for
each format, encryption flags, packages, embeddings and malformed files
(loops, bad sectors, oversized streams). Fuzz seeds now include compound
files; 90 s of fuzzing found no crashers, and the slowest saved input
scans in 0.2 ms.

Refs #327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DLP scan workers run as a dedicated sentinelgo-scan account
The backend's merged contract (Compliance-OS#562) lets a policy carry
file_filters {types, min_bytes, max_bytes}, and the agent ignored them, so a
policy meant for some file types applied to every file.

- Types are lowercase extensions or detected types: a policy matches a file
  by its extension, its detected type (ooxml.xlsx) or the detected type's
  last part (xlsx), so a renamed file is still caught by its content.
  Archive entries are filtered on their own name and type.
- Sizes filter before the scan: a file outside every policy's size range is
  not read at all.
- file_filters are decoded tolerantly: unreadable ones are reported as a
  compile error and the policy applies to every file (a bad filter must not
  silence a policy, nor reject the clipboard's bundle).

Refs #326

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DLP: honour a file policy's file_filters (backend contract #326)
filescan: read legacy Office files and files embedded in Office documents
Uploads go with option C (#335): a local TLS-inspecting proxy. This is
phase 1 from #344, the explicit proxy: a client pointed at it has its
uploads to the hosts in scope decrypted and each uploaded file decided by
the file policies (channel file.upload); everything else is tunnelled
untouched. No OS redirect yet, and the service doesn't run it.

- uploadproxy.CA: a per-device ECDSA CA, its key a root-only file
  written atomically, never leaving the device; renewed before expiry;
  MaxPathLenZero. 24-hour host certificates, cached.
- uploadproxy.Proxy: CONNECT (HTTP/1.1 and HTTP/2 to the client and the
  server) and plain-HTTP proxying. Servers are verified with the system
  roots (a test forbids InsecureSkipVerify). It never chains to an
  environment proxy. A request on an intercepted connection can't be
  steered to another host (400). HostScope lists hosts and *.subdomains on
  443/80.
- uploadproxy.Inspector: the body goes to a root-only spill file up to
  max_file_bytes (taken from the same policy the scanner uses). Multipart
  file parts are decided one by one and plain fields together, other
  bodies whole, and the request is forwarded byte for byte. A body over the
  limit is decided as too large and the rest streams on unread. Scanning is
  by the sandboxed workers (the proxy only splits multipart parts).
- filedlp.Decider.Decide returns a verdict. It blocks only when the hook
  can (the Linux monitor can't), the module isn't audit-only, and a
  matching policy says block (or on_unscannable/on_timeout do). The event
  action is then blocked; the user sees the block message with detector
  ids, never matches. Uploads skip the verdict cache (spill inodes are
  reused). Events carry destination.host.
- sentinelgo dlp proxy -hosts …: runs it by hand for testing (loopback
  only, -ca-out to export the CA, events as JSON lines).

Refs #344 #335

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows has no Unix modes, so the CA key file was readable by anyone the
config directory let in (CI caught it: -rw-rw-rw-). The key's temp file
now gets winsec's ACL (SYSTEM, Administrators, the service's account)
before the key is written, and creating the CA fails if it can't be
restricted. The spill directory gets the same ACL, inherited by its
files. Mode checks in the tests are Unix-only; winsec has its own tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File DLP uploads, phase 1: the upload inspection proxy (#344)
SonarCloud (S5144, SSRF) flagged that the proxy dials whatever a client
asks for. A forward proxy must, but this one runs as root: a local client
could use it to reach what it couldn't reach itself (root-only firewall
rules, a link-local metadata service). A dialer Control hook now checks
the address actually dialled (after DNS, so names and rebinding are
covered) and refuses loopback, link-local, unspecified and multicast
addresses, for tunnels and inspected requests alike. Private ranges stay
allowed: intranet upload destinations are legitimate. Tests opt in to
local targets (their sites run on 127.0.0.1).

Refs #344

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uploadproxy: never connect a client to loopback or link-local addresses
The bypass list as signed off on #338: the threat model first (accidental
leaks, not a determined insider), where file DLP runs today, every vector
with its status, the interim mitigations next to the rows that have them
(MTP: Compliance-OS#576), and the re-review rule with the date of the last
review. The admin tampering row is held back until the health alert
(Compliance-OS#525) exists, as the sign-off asks.

Refs #338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: publish what file DLP covers and what it doesn't (#338)
…bilities (#334)

Backend Compliance-OS#570/#572 added modules.files.copy_correlation; this
is the agent side, part 1.

- policy.CopyCorrelation: scan_on_close, on_close_violation,
  process_taint, taint_window_seconds, os_copy_events, untrusted_devices.
  Decoded tolerantly; defaults keep today's behaviour; unreadable settings
  fall back to the defaults and are reported.
- on_close_violation for files found to violate a block policy (or block
  fail modes) after they were written: record, quarantine or delete. A
  monitor policy never touches a file; the action happens once per file.
  Audit-only does nothing and records would_have. The upload proxy's
  audit-only blocks record would_have: blocked too.
- Linux remediator: checks that the path still names the scanned file
  (device and inode) before moving or deleting it. Quarantine copies from
  the hook's descriptor into a root-only dlp-quarantine folder (1 GB cap,
  entries kept 30 days), then removes the original. Errors never carry the
  path.
- events: actions quarantined and deleted; top-level would_have and
  correlation, as the backend's ingest reads them.
- capabilities.copy_correlation in the status (an object inside
  capabilities); Linux reports scan_on_close and
  os_copy_events: unsupported:not_on_linux. scan_on_close: false stops the
  Linux monitor.
- CI's root step must now also run TestEnforcer_QuarantineEndToEnd.

Refs #334

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neamul Haque and others added 8 commits November 2, 2026 17:32
untrusted_devices = read_only | block_writes on Linux: a removable drive
whose device neither the files module nor a removable-channel policy
trusts is remounted read-only, so nothing can be written to it.

- A bind remount of that mount point only, keeping its flags (nosuid,
  nodev, noexec, atime) read with statfs: dropping nosuid on a stick would
  let its set-user-ID programs run with privilege.
- One event per drive: blocked, scan.status not_scanned, no hash, the
  device's ids; monitored with would_have blocked while audit-only.
- Fail open: writable again when the setting goes back to allow, the
  device becomes trusted, the emergency switch is on, or monitoring stops.
  An already read-only drive is left alone; a failed remount is logged
  once and never claimed as a block.
- The mount loop now classifies once and serves both the marks (only
  with scan_on_close) and device control; the monitor runs for either.
- capabilities.copy_correlation.untrusted_devices: supported on Linux.
- CI's root step must also run TestEnforcer_UntrustedDeviceEndToEnd.

Refs #334

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File DLP copy correlation, part 1: settings, after-write action, capabilities (#334)
File DLP copy correlation, part 2: untrusted USB drives read-only (#334)
process_taint (Linux, off by default): a file written to a monitored drive
that can't be judged on its own (unreadable, or an archive) is judged by
the files its writer read just before.

- A second fanotify group gets FAN_OPEN notifications for the mount holding
  the home folders, only while taint is on and a destination is watched; it
  is opened the first time it's needed. Its own reader means a flood of
  opens can only overflow its queue, never the destination events.
- Each open under /home costs a readlink and an fstat; the path and
  identity go into an in-memory store (32 per process, 4096 processes,
  taint_window_seconds). Nothing is scanned at open time.
- At a destination event that matched nothing and couldn't be judged on its
  own, up to 8 sources (newest first, opened after the process started, so
  a reused pid inherits nothing) are reopened with O_NOFOLLOW, checked to
  be the same file, and scanned through the cache. A match replaces the
  fail-mode events with correlated ones (correlation {method: taint,
  source_sha256, source_name}, verdict_source correlation), and the
  after-write action applies. A readable file that matched nothing is never
  judged by its writer's earlier reads.
- capabilities.copy_correlation.process_taint: supported on Linux. Health
  reports processes tracked and open events lost to overflow.
- Measured (privileged container, 100,000 opens while a drive is mounted):
  the workload took 1.05 s instead of 0.83 s (~28%), the agent used ~5 us
  of CPU per open, no events were lost.
- CI's root step must also run TestEnforcer_TaintEndToEnd.

Refs #334

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows can't rename a file that is open, so the test's destination file
was never there and the fake scanner got nil. Write it under its final
name, then open it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File DLP copy correlation, part 3: process taint (#334)
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c7f0d66a-fec3-40b4-b984-bb294c9a2446
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Comment thread internal/dlp/uploadproxy/proxy.go Fixed
Both came up on #379 (dev -> main).

- CodeQL go/allocation-size-overflow (high), scanworker writeMsg: the
  frame buffer was sized 4+len(body) with no bound when sending, although
  the comment said frames were capped. writeMsg now refuses a frame over
  the protocol's largest limit (9 MB) before allocating.
- SonarCloud S5144 (SSRF) in Proxy.connect: tunnels dialled the client's
  CONNECT target string. The connect-time guard (#374) already refused
  local addresses, but out of SonarCloud's sight. vetTarget now checks the
  port (1-65535), resolves the host itself, keeps only allowed addresses
  (allowedIP, shared with guardDial) and returns an IP literal, which is
  what gets dialled. guardDial still checks the dialled address.

Refs #344 #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neamul Haque and others added 2 commits November 2, 2026 18:22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix the release PR's CodeQL and SonarCloud findings
@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

@neamul602
neamul602 merged commit 58cd867 into main Nov 2, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants