Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nixos/apparmor: policy activation tristate and policy path support #356796

Merged
merged 5 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions nixos/doc/manual/release-notes/rl-2505.section.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@
| virtualBoxOVA | virtualbox-vagrant.box | nixos-image-vagrant-virtualbox-25.05pre-git-x86_64-linux.ova |
| vmwareImage | nixos-25.05pre-git-x86_64-linux.vmdk | nixos-image-vmware-25.05pre-git-x86_64-linux.vmdk |

- `security.apparmor.policies.<name>.enforce` and `security.apparmor.policies.<name>.enable` were removed.
Configuring the state of apparmor policies must now be done using `security.apparmor.policies.<name>.state` tristate option.

- the notmuch vim plugin now lives in a separate output of the `notmuch`
package. Installing `notmuch` will not bring the notmuch vim package anymore,
add `vimPlugins.notmuch-vim` to your (Neo)vim configuration if you want the
Expand Down
282 changes: 176 additions & 106 deletions nixos/modules/security/apparmor.nix
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
{ config, lib, pkgs, ... }:
{
config,
lib,
pkgs,
...
}:
let
inherit (builtins) attrNames head map match readFile;
inherit (lib) types;
inherit (config.environment) etc;
cfg = config.security.apparmor;
mkDisableOption = name: lib.mkEnableOption name // {
default = true;
example = false;
};
enabledPolicies = lib.filterAttrs (n: p: p.enable) cfg.policies;
enabledPolicies = lib.filterAttrs (n: p: p.state != "disable") cfg.policies;
buildPolicyPath = n: p: lib.defaultTo (pkgs.writeText n p.profile) p.path;

# Accessing submodule options when not defined results in an error thunk rather than a regular option object
# We can emulate the behavior of `<option>.isDefined` by attempting to evaluate it instead
# This is required because getting isDefined on a submodule is not possible in global module asserts.
submoduleOptionIsDefined = value: (builtins.tryEval value).success;
in

{
imports = [
(lib.mkRemovedOptionModule [ "security" "apparmor" "confineSUIDApplications" ] "Please use the new options: `security.apparmor.policies.<policy>.enable'.")
(lib.mkRemovedOptionModule [ "security" "apparmor" "profiles" ] "Please use the new option: `security.apparmor.policies'.")
(lib.mkRemovedOptionModule [
"security"
"apparmor"
"confineSUIDApplications"
] "Please use the new options: `security.apparmor.policies.<policy>.state'.")
(lib.mkRemovedOptionModule [
"security"
"apparmor"
"profiles"
] "Please use the new option: `security.apparmor.policies'.")
apparmor/includes.nix
apparmor/profiles.nix
];
Expand Down Expand Up @@ -42,22 +56,39 @@ in
description = ''
AppArmor policies.
'';
type = types.attrsOf (types.submodule ({ name, config, ... }: {
options = {
enable = mkDisableOption "loading of the profile into the kernel";
enforce = mkDisableOption "enforcing of the policy or only complain in the logs";
profile = lib.mkOption {
description = "The policy of the profile.";
type = types.lines;
apply = pkgs.writeText name;
type = types.attrsOf (
types.submodule {
options = {
state = lib.mkOption {
description = "How strictly this policy should be enforced";
type = types.enum [
"disable"
"complain"
"enforce"
];
# should enforce really be the default?
# the docs state that this should only be used once one is REALLY sure nothing's gonna break
default = "enforce";
};

profile = lib.mkOption {
description = "The profile file contents. Incompatible with path.";
type = types.lines;
};

path = lib.mkOption {
description = "A path of a profile file to include. Incompatible with profile.";
type = types.nullOr types.path;
default = null;
};
};
};
}));
default = {};
}
);
default = { };
};
includes = lib.mkOption {
type = types.attrsOf types.lines;
default = {};
default = { };
description = ''
List of paths to be added to AppArmor's searched paths
when resolving `include` directives.
Expand All @@ -66,7 +97,7 @@ in
};
packages = lib.mkOption {
type = types.listOf types.package;
default = [];
default = [ ];
description = "List of packages to be added to AppArmor's include path";
};
enableCache = lib.mkEnableOption ''
Expand All @@ -90,13 +121,20 @@ in
};

config = lib.mkIf cfg.enable {
assertions = map (policy:
{ assertion = match ".*/.*" policy == null;
message = "`security.apparmor.policies.\"${policy}\"' must not contain a slash.";
# Because, for instance, aa-remove-unknown uses profiles_names_list() in rc.apparmor.functions
# which does not recurse into sub-directories.
}
) (attrNames cfg.policies);
assertions = lib.concatLists (
lib.mapAttrsToList (policyName: policyCfg: [
{
assertion = builtins.match ".*/.*" policyName == null;
message = "`security.apparmor.policies.\"${policyName}\"' must not contain a slash.";
# Because, for instance, aa-remove-unknown uses profiles_names_list() in rc.apparmor.functions
# which does not recurse into sub-directories.
}
{
assertion = lib.xor (policyCfg.path != null) (submoduleOptionIsDefined policyCfg.profile);
message = "`security.apparmor.policies.\"${policyName}\"` must define exactly one of either path or profile.";
}
]) cfg.policies
);

environment.systemPackages = [
pkgs.apparmor-utils
Expand All @@ -105,67 +143,81 @@ in
environment.etc."apparmor.d".source = pkgs.linkFarm "apparmor.d" (
# It's important to put only enabledPolicies here and not all cfg.policies
# because aa-remove-unknown reads profiles from all /etc/apparmor.d/*
lib.mapAttrsToList (name: p: { inherit name; path = p.profile; }) enabledPolicies ++
lib.mapAttrsToList (name: path: { inherit name path; }) cfg.includes
lib.mapAttrsToList (name: p: {
inherit name;
path = buildPolicyPath name p;
}) enabledPolicies
++ lib.mapAttrsToList (name: path: { inherit name path; }) cfg.includes
);
environment.etc."apparmor/parser.conf".text = ''
environment.etc."apparmor/parser.conf".text =
''
${if cfg.enableCache then "write-cache" else "skip-cache"}
cache-loc /var/cache/apparmor
Include /etc/apparmor.d
'' +
lib.concatMapStrings (p: "Include ${p}/etc/apparmor.d\n") cfg.packages;
''
+ lib.concatMapStrings (p: "Include ${p}/etc/apparmor.d\n") cfg.packages;
# For aa-logprof
environment.etc."apparmor/apparmor.conf".text = ''
'';
environment.etc."apparmor/apparmor.conf".text = '''';
# For aa-logprof
environment.etc."apparmor/severity.db".source = pkgs.apparmor-utils + "/etc/apparmor/severity.db";
environment.etc."apparmor/logprof.conf".source = pkgs.runCommand "logprof.conf" {
header = ''
[settings]
# /etc/apparmor.d/ is read-only on NixOS
profiledir = /var/cache/apparmor/logprof
inactive_profiledir = /etc/apparmor.d/disable
# Use: journalctl -b --since today --grep audit: | aa-logprof
logfiles = /dev/stdin

parser = ${pkgs.apparmor-parser}/bin/apparmor_parser
ldd = ${pkgs.glibc.bin}/bin/ldd
logger = ${pkgs.util-linux}/bin/logger

# customize how file ownership permissions are presented
# 0 - off
# 1 - default of what ever mode the log reported
# 2 - force the new permissions to be user
# 3 - force all perms on the rule to be user
default_owner_prompt = 1

custom_includes = /etc/apparmor.d ${lib.concatMapStringsSep " " (p: "${p}/etc/apparmor.d") cfg.packages}

[qualifiers]
${pkgs.runtimeShell} = icnu
${pkgs.bashInteractive}/bin/sh = icnu
${pkgs.bashInteractive}/bin/bash = icnu
${config.users.defaultUserShell} = icnu
'';
footer = "${pkgs.apparmor-utils}/etc/apparmor/logprof.conf";
passAsFile = [ "header" ];
} ''
cp $headerPath $out
sed '1,/\[qualifiers\]/d' $footer >> $out
'';

boot.kernelParams = [ "apparmor=1" "security=apparmor" ];
environment.etc."apparmor/logprof.conf".source =
pkgs.runCommand "logprof.conf"
{
header = ''
[settings]
# /etc/apparmor.d/ is read-only on NixOS
profiledir = /var/cache/apparmor/logprof
inactive_profiledir = /etc/apparmor.d/disable
# Use: journalctl -b --since today --grep audit: | aa-logprof
logfiles = /dev/stdin

parser = ${pkgs.apparmor-parser}/bin/apparmor_parser
ldd = ${pkgs.glibc.bin}/bin/ldd
logger = ${pkgs.util-linux}/bin/logger

# customize how file ownership permissions are presented
# 0 - off
# 1 - default of what ever mode the log reported
# 2 - force the new permissions to be user
# 3 - force all perms on the rule to be user
default_owner_prompt = 1

custom_includes = /etc/apparmor.d ${
lib.concatMapStringsSep " " (p: "${p}/etc/apparmor.d") cfg.packages
}

[qualifiers]
${pkgs.runtimeShell} = icnu
${pkgs.bashInteractive}/bin/sh = icnu
${pkgs.bashInteractive}/bin/bash = icnu
${config.users.defaultUserShell} = icnu
'';
footer = "${pkgs.apparmor-utils}/etc/apparmor/logprof.conf";
passAsFile = [ "header" ];
}
''
cp $headerPath $out
sed '1,/\[qualifiers\]/d' $footer >> $out
'';

boot.kernelParams = [
"apparmor=1"
"security=apparmor"
];

systemd.services.apparmor = {
after = [
"local-fs.target"
"systemd-journald-audit.socket"
];
before = [ "sysinit.target" "shutdown.target" ];
before = [
"sysinit.target"
"shutdown.target"
];
conflicts = [ "shutdown.target" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
Description="Load AppArmor policies";
Description = "Load AppArmor policies";
DefaultDependencies = "no";
ConditionSecurity = "apparmor";
};
Expand All @@ -176,39 +228,57 @@ in
etc."apparmor/parser.conf".source
etc."apparmor.d".source
];
serviceConfig = let
killUnconfinedConfinables = pkgs.writeShellScript "apparmor-kill" ''
set -eu
${pkgs.apparmor-bin-utils}/bin/aa-status --json |
${pkgs.jq}/bin/jq --raw-output '.processes | .[] | .[] | select (.status == "unconfined") | .pid' |
xargs --verbose --no-run-if-empty --delimiter='\n' \
kill
'';
commonOpts = p: "--verbose --show-cache ${lib.optionalString (!p.enforce) "--complain "}${p.profile}";
in {
Type = "oneshot";
RemainAfterExit = "yes";
ExecStartPre = "${pkgs.apparmor-utils}/bin/aa-teardown";
ExecStart = lib.mapAttrsToList (n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --add ${commonOpts p}") enabledPolicies;
ExecStartPost = lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
ExecReload =
# Add or replace into the kernel profiles in enabledPolicies
# (because AppArmor can do that without stopping the processes already confined).
lib.mapAttrsToList (n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --replace ${commonOpts p}") enabledPolicies ++
# Remove from the kernel any profile whose name is not
# one of the names within the content of the profiles in enabledPolicies
# (indirectly read from /etc/apparmor.d/*, without recursing into sub-directory).
# Note that this does not remove profiles dynamically generated by libvirt.
[ "${pkgs.apparmor-utils}/bin/aa-remove-unknown" ] ++
# Optionally kill the processes which are unconfined but now have a profile loaded
# (because AppArmor can only start to confine new processes).
lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
ExecStop = "${pkgs.apparmor-utils}/bin/aa-teardown";
CacheDirectory = [ "apparmor" "apparmor/logprof" ];
CacheDirectoryMode = "0700";
};
serviceConfig =
let
killUnconfinedConfinables = pkgs.writeShellScript "apparmor-kill" ''
set -eu
${pkgs.apparmor-bin-utils}/bin/aa-status --json |
${pkgs.jq}/bin/jq --raw-output '.processes | .[] | .[] | select (.status == "unconfined") | .pid' |
xargs --verbose --no-run-if-empty --delimiter='\n' \
kill
'';
commonOpts =
n: p:
"--verbose --show-cache ${
lib.optionalString (p.state == "complain") "--complain "
}${buildPolicyPath n p}";
in
{
Type = "oneshot";
RemainAfterExit = "yes";
ExecStartPre = "${pkgs.apparmor-utils}/bin/aa-teardown";
ExecStart = lib.mapAttrsToList (
n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --add ${commonOpts n p}"
) enabledPolicies;
ExecStartPost = lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
ExecReload =
# Add or replace into the kernel profiles in enabledPolicies
# (because AppArmor can do that without stopping the processes already confined).
lib.mapAttrsToList (
n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --replace ${commonOpts n p}"
) enabledPolicies
++
# Remove from the kernel any profile whose name is not
# one of the names within the content of the profiles in enabledPolicies
# (indirectly read from /etc/apparmor.d/*, without recursing into sub-directory).
# Note that this does not remove profiles dynamically generated by libvirt.
[ "${pkgs.apparmor-utils}/bin/aa-remove-unknown" ]
++
# Optionally kill the processes which are unconfined but now have a profile loaded
# (because AppArmor can only start to confine new processes).
lib.optional cfg.killUnconfinedConfinables killUnconfinedConfinables;
ExecStop = "${pkgs.apparmor-utils}/bin/aa-teardown";
CacheDirectory = [
"apparmor"
"apparmor/logprof"
];
CacheDirectoryMode = "0700";
};
};
};

meta.maintainers = with lib.maintainers; [ julm grimmauld ];
meta.maintainers = with lib.maintainers; [
julm
grimmauld
];
}
2 changes: 1 addition & 1 deletion nixos/tests/all-tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ in {
apfs = runTest ./apfs.nix;
appliance-repart-image = runTest ./appliance-repart-image.nix;
appliance-repart-image-verity-store = runTest ./appliance-repart-image-verity-store.nix;
apparmor = handleTest ./apparmor.nix {};
apparmor = handleTest ./apparmor {};
archi = handleTest ./archi.nix {};
aria2 = handleTest ./aria2.nix {};
armagetronad = handleTest ./armagetronad.nix {};
Expand Down
Loading
Loading