refactor(nixpak): split policy checks into per-app suites (#493)

* refactor(nixpak): split policy checks into per-app suites

Make hardening/tests/policy.nix a small loader that auto-discovers one suite per app under hardening/tests/policy. Shared checks live in policy-lib.nix and common expectations are generated by mkAppPolicy; app files only declare differences and special cases. The loader also discovers wrappers from hardening/nixpaks and fails when wrapper/suite inventory or check names diverge.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): reject duplicate per-app policy checks

mkAppPolicy merges generated and custom checks, so an extraChecks key could silently override a standard assertion. Fail explicitly when a per-app check name is defined twice.

Assisted-by: DeepSeek Harness (deepseek-flash)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-01 10:05:15 +08:00
1 parent 7a00fead7d
commit 090a7b622b
13 files changed
+335 -221

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ All browsers get read-only NSS certificates, printing, native notifications and
- WeChat uses the native nixpkgs-patched package with nixpak as the only bwrap layer. Build/policy
pass; interactive Wayland validation is still pending.
- Evidence and repeat commands: [runtime validation](tests/runtime-validation.md),
[policy test](tests/policy.nix).
[policy test](tests/policy.nix), which auto-loads every suite under [tests/policy](tests/policy/).
## Code map
+170
View File
@@ -0,0 +1,170 @@
{ pkgs, mkNixPak }:
let
inherit (pkgs) lib;
load = name: (pkgs.callPackage (../nixpaks + "/${name}.nix") { inherit mkNixPak; }).config;
# Wrapper files are the source of truth for the app inventory. The test
# loader compares this with the per-app suites under ./policy.
appNames = map (lib.removeSuffix ".nix") (
builtins.attrNames (
lib.filterAttrs (
name: type: type == "regular" && name != "default.nix" && lib.hasSuffix ".nix" name
) (builtins.readDir ../nixpaks)
)
);
apps = map load appNames;
mountStrings =
app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev));
forbiddenDbus =
argument:
lib.any (name: lib.hasInfix name argument) [
"org.freedesktop.portal.Usb"
"org.freedesktop.portal.RemoteDesktop"
"org.gnome.Shell.Screencast"
]
|| argument == "--talk=org.freedesktop.portal.Desktop";
withoutSecretPortal =
calls:
calls
// {
"org.freedesktop.portal.Desktop" = lib.filter (
arg: !lib.hasInfix "org.freedesktop.portal.Secret" arg
) calls."org.freedesktop.portal.Desktop";
};
forbiddenMount =
path:
path == "/dev"
|| lib.hasPrefix "/dev/video" path
|| lib.hasPrefix "/dev/bus/usb" path
|| path == "/run/dbus"
|| path == "/run/dbus/system_bus_socket";
hasPrivateTmp =
app:
let
tmpPath = lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null app.bubblewrap.bind.rw;
in
builtins.isList tmpPath && builtins.length tmpPath == 2;
hasDbusFragment = app: fragment: lib.any (argument: lib.hasInfix fragment argument) app.dbus.args;
networkCheck =
app: mode:
if mode == "host" then
app.bubblewrap.network && !app.pasta.enable
else if mode == "pasta" then
app.bubblewrap.network
&& app.pasta.enable
&& app.pasta.mode == "isolate"
&& lib.elem "--no-map-gw" app.pasta.args
else if mode == "none" then
!app.bubblewrap.network && !app.pasta.enable
else
throw "unknown network mode for ${app.flatpak.appId}: ${mode}";
mediaCheck =
app: expected:
if expected then
app.bubblewrap.sockets.pulse && app.portalGroups.camera && app.portalGroups.screenCast
else
!app.bubblewrap.sockets.pulse
&& !app.bubblewrap.sockets.pipewire
&& !app.portalGroups.camera
&& !app.portalGroups.screenCast
&& !(hasDbusFragment app "org.freedesktop.portal.Camera")
&& !(hasDbusFragment app "org.freedesktop.portal.ScreenCast");
secretPortalCheck =
app: expected:
if expected then
(app.browserPermissions.secretPortal or false)
&& lib.any (
argument:
argument
== "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Secret.RetrieveSecret@/org/freedesktop/portal/desktop"
) app.dbus.args
else
!(app.browserPermissions.secretPortal or false)
&& !(hasDbusFragment app "org.freedesktop.portal.Secret");
trayCheck =
app: expected:
if expected then
app.desktopIntegration.tray
&& (app.dbus.policies."org.freedesktop.DBus" or null) == "talk"
&& (app.dbus.policies."org.kde.StatusNotifierWatcher" or null) == "talk"
else
!app.desktopIntegration.tray;
mkAppPolicy =
name: spec:
let
app = load name;
allowedVideoDevices = spec.allowedVideoDevices or [ ];
forbiddenMountFor = path: forbiddenMount path && !lib.elem path allowedVideoDevices;
checks = {
"${name}/clearEnv" = app.bubblewrap.clearEnv;
"${name}/dbus" = app.dbus.enable;
"${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire;
"${name}/noForbiddenDbus" = !lib.any forbiddenDbus app.dbus.args;
"${name}/noBroadKeyring" =
!lib.any (argument: argument == "--talk=org.freedesktop.secrets") app.dbus.args;
"${name}/noForbiddenMounts" = !lib.any forbiddenMountFor (mountStrings app);
"${name}/privateTmp" = hasPrivateTmp app;
"${name}/network" = networkCheck app spec.network;
"${name}/x11" = app.bubblewrap.sockets.x11 == (spec.x11 or false);
"${name}/media" = mediaCheck app (spec.media or false);
"${name}/mpris" = app.desktopIntegration.mpris == (spec.mpris or false);
"${name}/notifications" = app.desktopIntegration.notifications == (spec.notifications or true);
"${name}/tray" = trayCheck app (spec.tray or false);
"${name}/browserpass" =
(app.browserPermissions.browserpass or false) == (spec.browserpass or false);
"${name}/personalFiles" =
(app.browserPermissions.personalFiles or false) == (spec.personalFiles or false);
"${name}/secretPortal" = secretPortalCheck app (spec.secretPortal or false);
};
extraChecks = lib.mapAttrs' (checkName: value: {
name = "${name}/${checkName}";
inherit value;
}) (spec.extraChecks or { });
duplicates = lib.attrNames (lib.intersectAttrs checks extraChecks);
in
if duplicates != [ ] then
throw "duplicate nixpak policy checks for ${name}: ${lib.concatStringsSep ", " duplicates}"
else
checks // extraChecks;
globalChecks =
let
paths = map (app: app.bubblewrap.bind.rw) apps;
tmpPaths = map (
bind:
lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null bind
) paths;
in
{
appTmpIsolation =
lib.all (path: builtins.isList path && builtins.length path == 2) tmpPaths
&& lib.length (lib.unique (map builtins.head tmpPaths)) == builtins.length apps;
};
in
{
inherit
lib
appNames
load
mountStrings
withoutSecretPortal
mkAppPolicy
globalChecks
;
}
+33 -218
View File
@@ -1,221 +1,36 @@
# Evaluate with the same pkgs/mkNixPak as the wrappers. Returns true or identifies
# the violated policy boundary; does not build or launch an application.
{ pkgs, mkNixPak }:
# Evaluate with the same pkgs/mkNixPak as the wrappers. Every file under
# ./policy is imported as one app policy suite. Wrapper files are auto-discovered
# from ../nixpaks, so a wrapper and its suite must be added together.
{
pkgs,
mkNixPak,
mylib,
}:
let
inherit (pkgs) lib;
load = name: (pkgs.callPackage (../nixpaks + "/${name}.nix") { inherit mkNixPak; }).config;
firefox = load "firefox";
chromium = load "chromium";
chrome = load "google-chrome";
restrictedBrowsers = map load [
"brave-origin"
"microsoft-edge"
];
chromiumFamily = map load [
"chromium"
"google-chrome"
"brave-origin"
"microsoft-edge"
];
browsers = map load [
"firefox"
"chromium"
"google-chrome"
"brave-origin"
"microsoft-edge"
];
nonBrowserApps = map load [
"qq"
"telegram-desktop"
"zoom"
"wechat"
];
browserAndChatApps =
browsers
++ map load [
"qq"
"telegram-desktop"
];
apps = browsers ++ nonBrowserApps;
# Inspect effective proxy arguments, not just capability switches: an extra
# direct service grant could otherwise bypass a disabled portal group.
forbiddenDbus =
argument:
lib.any (name: lib.hasInfix name argument) [
"org.freedesktop.portal.Usb"
"org.freedesktop.portal.RemoteDesktop"
"org.gnome.Shell.Screencast"
]
|| argument == "--talk=org.freedesktop.portal.Desktop";
withoutSecretPortal =
calls:
calls
// {
"org.freedesktop.portal.Desktop" = lib.filter (
arg: !lib.hasInfix "org.freedesktop.portal.Secret" arg
) calls."org.freedesktop.portal.Desktop";
};
mountStrings =
app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev));
forbiddenMount =
path:
path == "/dev"
|| lib.hasPrefix "/dev/video" path
|| lib.hasPrefix "/dev/bus/usb" path
|| path == "/run/dbus"
|| path == "/run/dbus/system_bus_socket";
checks = {
personalBrowserCapabilitiesMatch =
firefox.browserPermissions.media == chromium.browserPermissions.media
&& firefox.browserPermissions.personalFiles == chromium.browserPermissions.personalFiles
&& firefox.browserPermissions.browserpass == chromium.browserPermissions.browserpass
&&
(removeAttrs firefox.portalGroups [ "secret" ]) == (removeAttrs chromium.portalGroups [ "secret" ])
&& firefox.desktopIntegration == chromium.desktopIntegration;
personalBrowserPortalCallsMatch =
withoutSecretPortal firefox.dbus.rules.call == withoutSecretPortal chromium.dbus.rules.call;
personalBrowserHostLoopback = lib.all (app: app.bubblewrap.network && !app.pasta.enable) [
firefox
chromium
];
workBrowserIsolatedLoopback =
chrome.bubblewrap.network
&& chrome.pasta.enable
&& chrome.pasta.mode == "isolate"
&& lib.elem "--no-map-gw" chrome.pasta.args;
zoomDrmSysfs = lib.elem "/sys/class/drm" (load "zoom").bubblewrap.bind.ro;
zoomCameraDevices =
let
zoom = load "zoom";
in
lib.all (dev: lib.elem dev zoom.bubblewrap.bind.dev) [
"/dev/video0"
"/dev/video1"
];
fhsTrayIntegration =
lib.all
(
app:
app.desktopIntegration.tray
&& app.dbus.policies."org.freedesktop.DBus" == "talk"
&& app.dbus.policies."org.kde.StatusNotifierWatcher" == "talk"
&& app.dbus.policies."org.kde.StatusNotifierItem-2-1" == "own"
&& app.dbus.policies."org.kde.StatusNotifierItem-2-2" == "own"
)
(
map load [
"wechat"
"zoom"
]
);
appTmpIsolation =
let
tmpPath =
app:
lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null app.bubblewrap.bind.rw;
paths = map tmpPath apps;
in
lib.all (path: builtins.isList path && builtins.length path == 2) paths
&& lib.length (lib.unique (map builtins.head paths)) == builtins.length apps;
workBrowserMedia =
chrome.portalGroups.camera
&& chrome.portalGroups.screenCast
&& chrome.bubblewrap.sockets.pulse
&& chrome.gpu.enable
&& lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Camera.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args
&& lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.ScreenCast.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args;
secretPortalScope =
lib.all (
app:
app.browserPermissions.secretPortal
&& lib.any (
arg:
arg
== "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Secret.RetrieveSecret@/org/freedesktop/portal/desktop"
) app.dbus.args
&& !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args
) chromiumFamily
&& !firefox.browserPermissions.secretPortal
&& lib.all (
app:
!(app.browserPermissions.secretPortal or false)
&& !lib.any (arg: lib.hasInfix "org.freedesktop.portal.Secret" arg) app.dbus.args
) nonBrowserApps;
mediaScope =
lib.all
(
app:
app.browserPermissions.media
&& app.bubblewrap.sockets.pulse
&& app.portalGroups.camera
&& app.portalGroups.screenCast
)
[
firefox
chromium
chrome
];
restrictedBrowserMedia = lib.all (
app:
!app.browserPermissions.media
&& !app.bubblewrap.sockets.pulse
&& !app.bubblewrap.sockets.pipewire
&& !app.portalGroups.camera
&& !app.portalGroups.screenCast
&& !app.desktopIntegration.mpris
&& !lib.any (
arg:
lib.hasInfix "org.freedesktop.portal.Camera" arg
|| lib.hasInfix "org.freedesktop.portal.ScreenCast" arg
|| lib.hasInfix "org.mpris.MediaPlayer2" arg
) app.dbus.args
&& !lib.any (path: lib.hasPrefix "/dev/snd" path) (mountStrings app)
) restrictedBrowsers;
personalFileScope =
firefox.browserPermissions.personalFiles
&& chromium.browserPermissions.personalFiles
&& !chrome.browserPermissions.personalFiles;
browserpassScope =
lib.all (app: app.browserPermissions.browserpass) [
firefox
chromium
chrome
]
&& lib.all (app: !app.browserPermissions.browserpass) (
map load [
"brave-origin"
"microsoft-edge"
]
);
deniedServices = lib.all (app: !lib.any forbiddenDbus app.dbus.args) apps;
noBroadKeyring = lib.all (
app: !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args
) apps;
deniedDevicesAndSystemBus =
let
zoomVideoDevices = [
"/dev/video0"
"/dev/video1"
];
forbiddenMountFor =
app:
if app.flatpak.appId == "us.zoom.Zoom" then
path: forbiddenMount path && !lib.elem path zoomVideoDevices
else
forbiddenMount;
in
lib.all (app: !lib.any (forbiddenMountFor app) (mountStrings app)) apps;
filteredEnvironmentAndMedia = lib.all (
app:
app.bubblewrap.clearEnv
&& app.dbus.enable
&& !app.bubblewrap.sockets.pipewire
&& !app.bubblewrap.sockets.x11
) browserAndChatApps;
};
ctx = import ./policy-lib.nix { inherit pkgs mkNixPak; };
suitePaths = mylib.scanPaths ./policy;
suiteNames = map (path: lib.removeSuffix ".nix" (baseNameOf (toString path))) suitePaths;
suites = map (path: import path ctx) suitePaths;
merge =
acc: suite:
let
duplicates = lib.attrNames (lib.intersectAttrs acc suite);
in
if duplicates != [ ] then
throw "duplicate nixpak policy checks: ${lib.concatStringsSep ", " duplicates}"
else
acc // suite;
checks = ctx.globalChecks // lib.foldl' merge { } suites;
missing = lib.subtractLists suiteNames ctx.appNames;
orphans = lib.subtractLists ctx.appNames suiteNames;
failures = builtins.filter (name: !(checks.${name} or false)) (builtins.attrNames checks);
problems =
lib.optional (missing != [ ]) "missing policy suites: ${lib.concatStringsSep ", " missing}"
++ lib.optional (orphans != [ ]) "orphan policy suites: ${lib.concatStringsSep ", " orphans}"
++ lib.optional (failures != [ ]) "failed checks:\n - ${lib.concatStringsSep "\n - " failures}";
in
lib.all (name: checks.${name} || throw "nixpak policy check failed: ${name}") (
builtins.attrNames checks
)
if problems == [ ] then
true
else
throw "nixpak policy check failed:\n${lib.concatStringsSep "\n" problems}"
+10
View File
@@ -0,0 +1,10 @@
{ mkAppPolicy, ... }:
mkAppPolicy "brave-origin" {
network = "pasta";
media = false;
mpris = false;
notifications = true;
personalFiles = false;
browserpass = false;
secretPortal = true;
}
+10
View File
@@ -0,0 +1,10 @@
{ mkAppPolicy, ... }:
mkAppPolicy "chromium" {
network = "host";
media = true;
mpris = true;
notifications = true;
personalFiles = true;
browserpass = true;
secretPortal = true;
}
+31
View File
@@ -0,0 +1,31 @@
{
lib,
load,
mkAppPolicy,
withoutSecretPortal,
...
}:
let
firefox = load "firefox";
chromium = load "chromium";
in
mkAppPolicy "firefox" {
network = "host";
media = true;
mpris = true;
notifications = true;
personalFiles = true;
browserpass = true;
secretPortal = false;
extraChecks = {
capabilitiesMatchChromium =
firefox.browserPermissions.media == chromium.browserPermissions.media
&& firefox.browserPermissions.personalFiles == chromium.browserPermissions.personalFiles
&& firefox.browserPermissions.browserpass == chromium.browserPermissions.browserpass
&&
(removeAttrs firefox.portalGroups [ "secret" ]) == (removeAttrs chromium.portalGroups [ "secret" ])
&& firefox.desktopIntegration == chromium.desktopIntegration;
portalCallsMatchChromium =
withoutSecretPortal firefox.dbus.rules.call == withoutSecretPortal chromium.dbus.rules.call;
};
}
+10
View File
@@ -0,0 +1,10 @@
{ mkAppPolicy, ... }:
mkAppPolicy "google-chrome" {
network = "pasta";
media = true;
mpris = true;
notifications = true;
personalFiles = false;
browserpass = true;
secretPortal = true;
}
+10
View File
@@ -0,0 +1,10 @@
{ mkAppPolicy, ... }:
mkAppPolicy "microsoft-edge" {
network = "pasta";
media = false;
mpris = false;
notifications = true;
personalFiles = false;
browserpass = false;
secretPortal = true;
}
+8
View File
@@ -0,0 +1,8 @@
{ mkAppPolicy, ... }:
mkAppPolicy "qq" {
network = "pasta";
media = true;
mpris = true;
notifications = true;
tray = true;
}
@@ -0,0 +1,8 @@
{ mkAppPolicy, ... }:
mkAppPolicy "telegram-desktop" {
network = "pasta";
media = true;
mpris = true;
notifications = true;
tray = true;
}
+9
View File
@@ -0,0 +1,9 @@
{ mkAppPolicy, ... }:
mkAppPolicy "wechat" {
network = "pasta";
media = true;
mpris = false;
notifications = false;
x11 = true;
tray = true;
}
+28
View File
@@ -0,0 +1,28 @@
{
lib,
load,
mkAppPolicy,
...
}:
let
zoom = load "zoom";
in
mkAppPolicy "zoom" {
network = "pasta";
media = true;
mpris = false;
notifications = false;
x11 = true;
tray = true;
allowedVideoDevices = [
"/dev/video0"
"/dev/video1"
];
extraChecks = {
drmSysfs = lib.elem "/sys/class/drm" zoom.bubblewrap.bind.ro;
videoDevices = lib.all (dev: lib.elem dev zoom.bubblewrap.bind.dev) [
"/dev/video0"
"/dev/video1"
];
};
}
@@ -1,9 +1,14 @@
{ inputs, outputs, ... }:
{
inputs,
mylib,
outputs,
...
}:
let
pkgs = outputs.nixosConfigurations.ai-niri.pkgs;
in
import ../../../../hardening/tests/policy.nix {
inherit pkgs;
inherit pkgs mylib;
mkNixPak = inputs.nixpak.lib.nixpak {
inherit pkgs;
inherit (pkgs) lib;