Skip to content

Hardening

codingncaffeine edited this page Sep 8, 2026 · 4 revisions

Hardening

What has actually been done to reduce what this application can be made to do, what was found when it was reviewed, and what has deliberately not been done.

The short version is in SECURITY.md. This page is the reasoning and the measurements.

The shape of the risk

Nexus Manager is not a network service. It has no listener on any port, no update checker and no telemetry. What makes it worth reviewing at all is a narrower set of capabilities:

Capability Why it exists What it means
Writes to a hidraw device It draws on the panel Access is an ACL for the logged-in user, not root
Creates a virtual keyboard on /dev/uinput A touch button presses a real key; Wayland has no XTEST While running, it can type into whatever has focus
Runs a configured command through /bin/sh A touch button launches things Whoever can write your config runs commands as you
Spawns parec, pactl, wpctl, gdbus Audio capture and media control Fixed argument arrays, never a shell
Binds a Unix socket A second launch raises the first one's window Accepts exactly one word, show

Everything after the first two rows is ordinary desktop behaviour. The first two are the ones worth understanding before you install it.

No privilege at any point

There is no setuid binary, no capability, no polkit action and no system service. The bundled systemd unit is a user unit. After installation nothing belonging to this project runs as root.

Device access comes from a udev rule that tags the panel with uaccess, which grants an ACL to the active local seat's user and revokes it at logout — rather than MODE="0666", which would hand the device to every process on the machine permanently. See Device Access on Linux.

Sandboxing the daemon

systemd-analyze security rates the shipped unit 1.3 OK. It rated 7.0 MEDIUM before this pass. Check it yourself:

systemd-analyze security --user nexus-manager.service

What was added: SystemCallFilter=@system-service, SystemCallArchitectures=native, PrivateNetwork=yes and IPAddressDeny=any, RestrictAddressFamilies narrowed to what the app actually uses, DevicePolicy=closed with only the panel and the virtual keyboard allowed through, an empty capability bounding set, LockPersonality, RestrictSUIDSGID, ProtectHostname, ProtectClock, ProtectProc=invisible and UMask=0077.

⛔ Three directives are deliberately absent

A hardening score measures the policy. It says nothing about whether the program still runs under it. Each of these improved the score and had to be reverted because the service would not start:

Directive What happens
MemoryDenyWriteExecute=true The .NET runtime JITs and needs pages that are writable and then executable. The service does not start.
SystemCallFilter=~@resources Failed to create CoreCLR, HRESULT: 0x8007054F. The runtime needs syscalls in that set.
RestrictAddressFamilies=AF_UNIX alone Device enumeration goes through udev, which needs AF_NETLINK. Without it the panel is simply never found.

They are written down here and in the unit file so that the next person to look at the score does not re-add them.

How to test a directive rather than guess:

systemd-run --user -p ProtectHome=read-only --wait --pipe nexus-manager daemon

One property per run names the culprit in minutes. Two things to watch for: systemctl is-active reports activating during a restart loop, so it is not proof of anything; and a test unit with Restart=always will grab the panel lock between probes and quietly invalidate every result after it.

What the review found

Five faults from the first pass, all in 0.0.3 and all fixed in 0.0.4, plus one that static analysis caught afterwards. They are listed because the pattern matters more than the individual bugs.

The one CodeQL found is instructive, but not for the reason first written here. The GlowPills visualizer constructed four SKRoundRect objects per bar per frame and disposed none of them — roughly 7700 native wrappers a second at 64 bands and 30 fps. cs/local-not-disposed flagged it on the first run.

It was then described on this page as an OOM-class leak. Measurement says it is not. An A/B soak — the same instrument on both arms, with the leaky build rebuilt from the same commit as a negative control — could not tell them apart:

build RSS after warm-up intervals where RSS went down
fixed +704 kB over 80 s several
leaky +896 kB over 152 s (0.35 MB/min) 7 of 25

A real leak never decreases. Both arms are flat with occasional heap-growth steps, and 2.5 minutes at an alleged 7700 leaked objects a second would have added hundreds of megabytes if the claim were true.

Why this one self-limits, and the earlier one did not. Thousands of small managed wrappers a second is heavy gen0 pressure: the collector runs constantly, finalizers execute, and the native memory behind them is reclaimed. The leak that actually OOM-killed the editor was the opposite shape — fewer but larger native allocations that never generated enough managed pressure to trigger a collection at all. Object count is not the danger; the ratio of native bytes to managed pressure is.

The fix still ships: an undisposed IDisposable in a per-frame path is a real defect, the non-allocating overload DrawRoundRect(SKRect, rx, ry, paint) is strictly better, and it costs nothing. But the honest description is avoidable allocation and finalizer churn, not a memory leak users would notice.

The lesson worth keeping is the one about the write-up rather than the code: the severity was asserted from a static finding and published before anything measured it.

  • The bundled systemd unit could never start the daemon. ProtectHome= covers /run/user as well as /home and /root, so the directory holding the single-instance lock was read-only. The daemon then reported "the panel is already being driven by another instance" — naming a process that did not exist. Fixed with ReadWritePaths=%t.
  • A corrupt configuration crashed the application with a core dump. One interrupted autosave left it unstartable. It now reports the parse error, moves the unreadable file aside rather than letting the next save overwrite it, and starts from a discovered configuration.
  • The /tmp fallback directory was created with default permissions. That path is predictable and /tmp is world-writable, so another local user could create it first and own the directory the lock, owner record and socket are made in — and File.WriteAllText follows symlinks. Now created 0700, with the mode checked afterwards, because CreateDirectory does not alter a directory that already exists.
  • Lock failures were reported as contention. Three unrelated causes — a permissions fault, a sandbox denial and genuine contention — all produced the same message. A failure path is a diagnostic; giving one sentinel three meanings makes the program confidently wrong.
  • A launched program could deadlock or pin a thread. The launcher read one pipe to the end before the other, so a child filling stderr hung both, and since the read only returns when the child exits, launching anything long-lived held a worker for its lifetime.

⛔ Every one of those passed every other check. The package inventory, the dependency sweep, the launch tests and the hardening score were all green while the service could not boot. That is why packaging/test-deb.sh now starts the packaged unit and requires it to reach its ready state, verified against the broken unit as a negative control.

Supply chain

  • Release packages bundle the .NET runtime, so they depend only on system libraries.
  • Dependency lists in the .deb and the PKGBUILD are derived from a sweep of the published binaries — objdump -p for NEEDED entries plus a strings sweep for anything loaded by dlopen — rather than written from memory.
  • packaging/test-deb.sh asserts the package contents against the tree it was built from and then launches the binaries out of the extracted package with DOTNET_ROOT scrubbed, so a bundle that silently fell back to a developer toolchain would be caught. It also starts the packaged systemd unit and requires it to reach its ready state.

Builds are deterministic — but not yet reproducible across machines

Be precise about this, because the two get conflated and only one of them is true here.

What holds: on one machine with one toolchain, two builds of the same commit produce a byte-identical release tarball. CI asserts it on every push by building twice and comparing, so it cannot quietly regress.

What does not hold yet: a build on CI and a build on a workstation, from the same commit, produce different tarballs. Measured — CI produced ec3138bb…, the workstation 4646bfa2…. The remaining difference is the .NET runtime that a self-contained publish bundles: its version tracks the installed SDK and runtime patch, and two machines rarely have exactly the same one. global.json pins the SDK feature band, which narrows this but does not close it.

So the honest claim is: you can verify a release if you match the toolchain, and not otherwise. That is weaker than "anyone can rebuild and compare", which is what a reproducible-builds badge would imply, and it is not claimed here.

Two things had to be fixed even to get determinism, and the first is the one people miss:

  • The compiler embedded absolute build paths. Every assembly carried the path it was built from — enough that the same source built in two directories produced different binaries, and enough to leak the developer's username and directory layout into a public artifact and into any stack trace a user pasted into a bug report. PathMap rewrites the source root so paths ship as /nexus-manager/….
  • A deterministic tree does not give a deterministic tarball. tar records mtimes, ownership and entry order, and gzip stamps the time into its own header, so two identical trees still hashed differently. Sorted entries, epoch mtimes, numeric root ownership and gzip -n pin it; SOURCE_DATE_EPOCH is honoured so an older release can be reproduced exactly.

Each release ships a SHA256SUMS file. It proves a download was not corrupted. It does not prove authenticity, and it is not a signature.

Continuous integration

Every push and pull request, plus a weekly run so that a newly published advisory is found without waiting for a commit:

Job What it proves
build Compiles with warnings as errors, runs the DSP self-test, renders a real frame through SkiaSharp, and runs the editor's full view self-test under xvfb
audit dotnet list package --vulnerable --include-transitive and the deprecated-package check
reproducible Builds the release tarball twice and fails if the checksums differ
codeql CodeQL for C# with the security-and-quality query set

⛔ The audit job reads the output rather than the exit status. dotnet list package --vulnerable exits 0 even when it finds vulnerabilities, so a check written the obvious way is permanently green and can never fail — which is worse than having no check at all, because it would be believed.

Static analysis, triaged rather than counted

CodeQL's security-and-quality set returns roughly 190 findings on this codebase. None carry a security severity. That is not the same as none mattering, and the first pass here dismissed them by category and shipped — which is how the two below reached a release.

The four error-severity findings. Three are false positives: i * 2 * Math.PI where i is 0, 1 or 2, and two multiplications whose operand is already Math.Clamped. The fourth was real, and not for the reason the rule gave. Width = Columns * 202 + 24 takes Columns from a user-editable dashboard.json with no bounds; a negative value asks the toolkit for a width of −582, which throws during the first layout pass and leaves the editor hung at startup. CodeQL called it a possible overflow, which it is not at any plausible value — the rule was wrong about the mechanism and right about the line. Clamped in the setter, since that is what the deserialiser calls.

The 117 broad-catch findings. Nearly all are correct: disposal, cancelling a token, awaiting a pump that has just been cancelled, killing a process that has already exited, setting brightness on a device that may not be there. Four were not, and they share one shape — a catch that turns "I could not tell" into a definite claim:

  • DashboardLayout.Save swallowed every write failure. That file holds the renames, the group order, the hidden sensors and the Home pins, so a failed write meant the arrangement was silently lost on exit.
  • FindMprisPlayer returned null both for "no player is running" and for "could not ask", and the caller reported the first. A missing busctl told the user no player was running while one was playing.
  • Fixing that exposed LastError never being cleared, so it meant the first thing that ever went wrong rather than what just happened.
  • MonitorRunning returned false when it could not tell, reporting a suspended monitor when the truth was that pactl had not run.

That shape is worth naming because it caused three separate misdiagnoses during this work: a permissions fault, a sandbox denial and genuine contention all surfaced as "the panel is already being driven by another instance", sending the reader to stop a process that did not exist. A failure path is a diagnostic. Give the sentinel one meaning and report every other cause as itself.

The remaining ~110 broad catches are documented cleanup and best-effort paths and are left as they are.

Not done, and worth doing

Stated plainly rather than left for someone to discover:

  • Releases are not signed. There is no GPG signature on tags or artifacts and no build provenance attestation. SHA256SUMS ships with each release and the AUR package pins the tarball's hash, which protects against a corrupted download; neither establishes authenticity if the release itself were replaced. Reproducibility narrows this — anyone can rebuild the tag and compare — but it is not a substitute for a signature.
  • Release artifacts are built on a workstation, not in CI. CI proves the tree builds and is reproducible, but the bytes that get uploaded are still produced by hand.
  • No portal or Flatpak confinement. The application needs raw hidraw and uinput access, which sandboxed packaging formats do not grant well. This is a real limitation rather than an oversight.
  • A Launch action is not confirmed before it runs. The configuration is trusted the way a shell profile is trusted. If configuration ever becomes importable — .cuescreens import is not implemented — that assumption has to be revisited before it ships, because a file from someone else could then carry an action.