-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.serviceWhat 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.
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 daemonOne 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.
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/useras well as/homeand/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 withReadWritePaths=%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
/tmpfallback directory was created with default permissions. That path is predictable and/tmpis world-writable, so another local user could create it first and own the directory the lock, owner record and socket are made in — andFile.WriteAllTextfollows symlinks. Now created 0700, with the mode checked afterwards, becauseCreateDirectorydoes 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.
- Release packages bundle the .NET runtime, so they depend only on system libraries.
- Dependency lists in the
.deband the PKGBUILD are derived from a sweep of the published binaries —objdump -pforNEEDEDentries plus a strings sweep for anything loaded bydlopen— rather than written from memory. -
packaging/test-deb.shasserts the package contents against the tree it was built from and then launches the binaries out of the extracted package withDOTNET_ROOTscrubbed, 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.
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.
PathMaprewrites the source root so paths ship as/nexus-manager/…. -
A deterministic tree does not give a deterministic tarball.
tarrecords mtimes, ownership and entry order, andgzipstamps the time into its own header, so two identical trees still hashed differently. Sorted entries, epoch mtimes, numeric root ownership andgzip -npin it;SOURCE_DATE_EPOCHis 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.
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.
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.
SHA256SUMSships 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
hidrawanduinputaccess, which sandboxed packaging formats do not grant well. This is a real limitation rather than an oversight. -
A
Launchaction is not confirmed before it runs. The configuration is trusted the way a shell profile is trusted. If configuration ever becomes importable —.cuescreensimport is not implemented — that assumption has to be revisited before it ships, because a file from someone else could then carry an action.