Gate a systemd sandbox exposure score before deployment

John Burns

A service-unit review often ends with a useful but non-enforcing observation: systemd-analyze security reports an exposure score, the reviewer notes it, and the change still moves on. That is reasonable for discovery, but it does not make a sandbox regression visible to a pull request or deployment job. A unit can lose NoNewPrivileges=, regain access to host devices, or widen its filesystem access without a syntax error.

The useful boundary is an exposure budget. This guide uses systemd-analyze security --offline=yes --threshold= to make a candidate unit fail before it is installed or restarted. The check runs against a file in the review workspace; it does not contact the service manager, reload a unit, or start the service. The result is a small CI gate that distinguishes a valid unit file from one that also meets the team’s agreed sandbox target.

The validation here used systemd 257.13 on Debian 13, x86-64. A minimal Type=oneshot fixture scored 9.4 and a restricted version scored 6.2. The important finding was that the threshold is an integer on a 0–100 scale, while the displayed exposure score is shown with one decimal place. A threshold of 94 accepted the 9.4 fixture; 9 rejected it. Treat that conversion as a tested behavior of this systemd release and confirm it on the oldest systemd version in the fleet before making it a required check.

Start with an offline review

systemd-analyze security reviews the sandbox-related service settings that systemd understands and estimates an overall exposure level from 0.0 to 10.0. A lower number means that more of the reviewed controls are set. It is not an application security assessment. It cannot prove that the program handles its input safely, that its credentials are appropriately scoped, or that its network peer is trustworthy.

Offline mode is useful in a change pipeline because it reads the file named on the command line. It avoids the common mistake of evaluating the currently installed service when the change under review is a different, uninstalled copy.

Create a small fixture in a disposable directory first:

# minimal.service
[Unit]
Description=Offline exposure threshold fixture

[Service]
Type=oneshot
ExecStart=/usr/bin/true

Validate the unit structure and then inspect its reviewed controls:

systemd-analyze verify ./minimal.service
systemd-analyze security --offline=yes ./minimal.service

verify and security answer different questions. verify checks unit-file structure and reports invalid directives or dependency problems it can identify. A successful verify result does not mean the unit is constrained. The offline security command reports each control it assessed and ends with the overall level.

In the isolated validation, both commands accepted the fixture structurally, while the security review ended with:

→ Overall exposure level for minimal.service: 9.4 UNSAFE :-{

That is a useful baseline rather than a reason to paste a universal hardening profile into every service. /usr/bin/true has no application requirements. A real service may need a state directory, a certificate, a device, a network family, or a local socket. Establish those requirements before changing its unit.

Make the budget explicit

The --threshold= option changes the command’s exit status when the reported exposure is above the permitted level. It is designed for an automated boundary: the output remains available for review, while the nonzero result prevents the next pipeline step from treating the unit as acceptable.

Use a threshold that represents a reviewed policy, not an arbitrary race toward zero. For the 9.4 baseline fixture, this check fails:

systemd-analyze security --offline=yes --threshold=9 ./minimal.service

On the tested systemd 257.13 host, it returned exit status 1. The first attempt at this test used --threshold=10, expecting a 10.0-scale limit; it also returned 1. Raising the value to 94 returned 0 for the same 9.4 result. That is why a pipeline should record both its systemd version and one known pass/fail pair rather than assuming the command-line number has the same representation as the display.

A shell check can preserve the report and make the decision obvious:

set -eu
unit=./candidate.service
threshold=62

systemd-analyze verify "$unit"
systemd-analyze security --offline=yes --threshold="$threshold" "$unit"

Do not hide the exit status behind a wrapper that discards it. In particular, a transcript tool may record a failing command while returning its own successful status unless configured to propagate the child result. Test the wrapper with a deliberately failing threshold before relying on it in CI.

Improve a fixture without claiming a universal profile

The following controls are deliberately suitable for the disposable /usr/bin/true fixture. They are not a drop-in policy for a network service, database, container runtime, hardware agent, or language runtime with just-in-time compilation.

# restricted.service
[Unit]
Description=Offline exposure threshold fixture

[Service]
Type=oneshot
ExecStart=/usr/bin/true
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
UMask=0077

NoNewPrivileges=yes prevents the service and its children from gaining privileges through mechanisms such as set-user-ID executables. ProtectSystem=strict, ProtectHome=yes, and PrivateTmp=yes reduce ordinary filesystem exposure, but can break an application with undeclared writes. Prefer a narrow ReadWritePaths= exception or a managed StateDirectory= when a service needs persistent writable state.

PrivateDevices=yes and the kernel protection settings are usually inappropriate for software that manages devices, containers, cgroups, or kernel interfaces. RestrictAddressFamilies=AF_UNIX is only correct for this local fixture. A service that reaches a TCP or UDP peer must allow the required address families, then have its egress policy reviewed separately. MemoryDenyWriteExecute=yes can conflict with runtimes that generate executable code.

The restricted fixture produced this result in the same isolated run:

→ Overall exposure level for restricted.service: 6.2 MEDIUM :-|

The matching threshold check was:

systemd-analyze security --offline=yes --threshold=62 ./restricted.service

It returned zero on the tested release. A threshold of 6 returned 1. The score improved, but it did not make the service safe. The remaining report still identified controls that this minimal example intentionally did not address, including running as a non-root identity, capability bounding, namespace restrictions, process visibility, and network access. That list is the review queue; it is not a mandate to enable every setting.

Put the gate in the right part of the change

Run the offline review before the deployment phase that copies a unit into a system location, reloads systemd, or restarts a process. A CI job can analyze a repository-owned candidate unit directly. A configuration-management change can analyze its rendered unit in a temporary workspace before the host-specific rollout begins.

Keep three decisions visible in the change review:

  1. The measured exposure level and the exact systemd version.
  2. The threshold selected for this service and the operational reason for any remaining exposure.
  3. A staging test of the real program after the restrictions are applied.

The third item cannot be replaced by a score. After a candidate clears the offline gate, use an authorized staging environment to confirm startup, intended reads and writes, required sockets, health checks, and failure handling. If the service needs an exception, add the smallest one that restores the required behavior, rerun the check, and record why it is necessary.

An offline threshold turns a systemd sandbox review into a repeatable stop condition. Use the analyzer to catch a regression in the unit file, use verify to catch syntax and structure errors, and use staging to prove that the intended service still works. Those three checks are complementary; none is a substitute for the others.

Sources