Description
Snaps are installed and updated by snapd, not by APT: unattended-upgrades never touches
them. snapd checks the store several times a day (refresh.timer) and installs new
revisions on its own. An administrator can postpone these refreshes:
- for all snaps, with
snap refresh --hold[=<duration>](therefresh.holdsystem option); - for some snaps, with
snap refresh --hold[=<duration>] <snap>.
Without a duration, the hold lasts forever. The control fails when a hold ends more than
{{ max_hold_days }} days from now, and when the snapd service is not enabled.
snapd is installed on every Ubuntu release (fact snapd); the 20.04 and 22.04 cloud images
ship the lxd snap, Ubuntu Desktop ships Firefox and other applications as snaps. A hold that
has already ended (for example one set while building an image) passes. Snaps installed from a
local file (--dangerous, revisions x1, x2...) are never refreshed: list them with
snap list and replace them with store snaps or remove them.
Rationale
Snaps bundle their own libraries: a fix in an Ubuntu library reaches a snap only when the snap is rebuilt and refreshed. LXD, Firefox, Thunderbird and many agents ship as snaps, and some of them face the network. An indefinite hold, often set once to get through an incident and then forgotten, leaves them vulnerable for good while the APT side of the system looks up to date.
Impact
A refresh restarts the services of the refreshed snap (for example the LXD daemon, not its
containers). Use refresh.timer to move refreshes into a maintenance window, and holds of
at most {{ max_hold_days }} days to postpone them, rather than indefinite holds.
Applies when
Detected: when: snapd is installed A scanner reports the control not applicable when one is false.
Setting
| Location | snapd system options (snap get system refresh), per-snap holds |
| Default | No hold, snapd.service enabled |
| Recommended | No hold ending more than {{ max_hold_days }} days from now, snapd.service enabled |
Parameters
| Parameter | Core | Server | Server hardened | Desktop | Desktop hardened | Bounds |
|---|---|---|---|---|---|---|
max_hold_daysinteger, days. Longest accepted refresh hold, counted from now, for all snaps or for one snap. | 30 | 30 | 30 | 30 | 30 | 1 to 90 |
The value used is your override (tailoring file), else the profile's, else the one of the profile it extends, else the default. Bounds limit what an override may set.
Audit
systemctl is-enabled snapd.serviceprintsenabled.snap refresh --timeshows nohold:line, or a hold ending in at most{{ max_hold_days }}days.snap listshows no snap withheldin the Notes column. For a held snap, read the end of its hold in theholdfield ofcurl -s --unix-socket /run/snapd.socket http://localhost/v2/snaps(an indefinite hold shows a date about 300 years ahead); it must be at most{{ max_hold_days }}days ahead.
Automated: every check must pass. Scanners report each check by its id.
snapd-service
The snapd service, which runs the automatic refreshes.
unit: snapd.service
state: enabled
holds
Refresh holds for all snaps and for each snap, read from the snapd API.
script: |
command -v python3 >/dev/null || { echo "python3 is needed to read the snapd API"; exit 3; }
[ -S /run/snapd.socket ] || { echo "snapd socket missing, snapd is not running"; exit 1; }
python3 - "$CB_MAX_HOLD_DAYS" <<'EOF'
import calendar, json, socket, sys, time
def get(path):
s = socket.socket(socket.AF_UNIX)
s.settimeout(30)
s.connect("/run/snapd.socket")
s.sendall(("GET %s HTTP/1.0\r\nHost: localhost\r\n\r\n" % path).encode())
data = b""
while True:
chunk = s.recv(65536)
if not chunk:
break
data += chunk
s.close()
return json.loads(data.split(b"\r\n\r\n", 1)[1])["result"]
def until(value):
if not value:
return None
if value == "forever":
return float("inf")
return calendar.timegm(time.strptime(value[:19], "%Y-%m-%dT%H:%M:%S"))
try:
holds = [("all snaps", get("/v2/snaps/system/conf?keys=refresh").get("refresh", {}).get("hold"))]
holds += [(s["name"], s.get("hold")) for s in get("/v2/snaps")]
except Exception as e:
print("snapd API error: %s" % e)
sys.exit(3)
limit = time.time() + int(sys.argv[1]) * 86400
bad = 0
for name, value in holds:
t = until(value)
if t is None or t <= time.time():
continue
days = "forever" if t == float("inf") or t > time.time() + 36500 * 86400 else "%d days" % ((t - time.time()) // 86400)
print("%s: held for %s (%s)" % (name, days, value))
bad += t > limit
print("%d snaps, %d holds longer than %s days" % (len(holds) - 1, bad, sys.argv[1]))
sys.exit(1 if bad else 0)
EOF
Remediation
systemctl enable --now snapd.serviceif it was disabled.- Remove the long holds:
snap refresh --unholdfor all snaps,snap refresh --unhold <snap>for one snap. If a refresh must be postponed, usesnap refresh --hold=<duration>with a duration of at most{{ max_hold_days }}days. - To keep refreshes out of business hours, set a window instead, for example
snap set system refresh.timer=sat,02:00-04:00. - Run
snap refreshto catch up, during a maintenance window if snap services must not restart now.
The automated fix enables snapd.service and removes every refresh hold, bounded ones
included; it does not run snap refresh.
| Restart | none |
| Disruption | low |
Rollback
Hold the refreshes again with snap refresh --hold=<duration> [<snap>].
snapd-service
Generated from check snapd-service: the engine writes the fix for every output, it sets the expected value and the check runs again after.
holds
# Remove every hold: the check only fails when one is longer than the limit, and a fresh
# bounded hold can be set again by the administrator.
if [ -n "$(snap get system refresh.hold 2>/dev/null)" ]; then
snap refresh --unhold >/dev/null
fi
snap list 2>/dev/null | awk 'NR > 1 && $NF ~ /(^|,)held(,|$)/ { print $1 }' |
while IFS= read -r s; do
snap refresh --unhold "$s" >/dev/null || exit 1
done
Handwritten code reads parameters from CB_<NAME> environment variables. Each release turns these fixes into remediate.sh and an Ansible role per profile: dry run by default, a fix runs only when its check fails.
References
- Snapcraft documentation - Managing updates Canonical, vendor-doc, accessed 2026-10-19
- snap(8), Ubuntu manual pages Canonical, vendor-doc, accessed 2026-10-19
- Ubuntu Security documentation Canonical, vendor-doc, accessed 2026-10-19
Mappings
| Framework | ID | Relation | Note |
|---|---|---|---|
| NIST SP 800-53 Rev. 55.1.1 | SI-2 | partial | Covers the snap packages, which the APT update controls do not reach. |
How this control relates to the other framework's: equivalent, stricter, looser, partial or related.
Threats mitigated
Related controls
- related
CB-UBU-PKG-007Install security updates automatically - related
CB-UBU-PKG-010Install only the packages the system needs
People and lifecycle
| Authors |
|
| Verified | 2026-10-19 by Sn0wAlice on 20.04, Proxmox VM, Ubuntu 20.04 cloud image, kernel 5.4.0-216-generic. Passes on stock (expired image hold, lxd/core20/snapd); forever hold on all snaps and on one snap, 59-day hold on lxd and disabled snapd.service detected, 9-day hold accepted; fix unholds and enables snapd, lxd works after reboot. |
| Verified | 2026-10-19 by Sn0wAlice on 22.04, Proxmox VM, Ubuntu 22.04 cloud image, kernel 5.15.0-198-generic. Passes on stock (lxd/core22/snapd); forever hold on all snaps and on one snap, 59-day hold on lxd and disabled snapd.service detected, 9-day hold accepted; fix unholds and enables snapd, lxd works after reboot. |
| Verified | 2026-10-19 by Sn0wAlice on 24.04, Proxmox VM, Ubuntu 24.04 cloud image, kernel 6.8.0-147-generic. Passes on stock (no snap installed); forever hold on all snaps and on hello-world detected; fix removes both holds. |
| Verified | 2026-10-19 by Sn0wAlice on 26.04, Proxmox VM, Ubuntu 26.04 cloud image, kernel 7.0.0-38-generic. Passes on stock (no snap installed); forever hold on all snaps and on hello-world detected; fix removes both holds. |
| Introduced in | v0.1.0 |
| Created, updated | 2026-10-19, 2026-10-19 |
| Translations | fr |