mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
Move standalone repository docs from the root into docs/ and add a documentation index that links the moved docs and the code-local runbooks. Update Markdown links, source comments, and the root entry points to the new locations. Keep README.md and AGENTS.md at the root as required entry points. Assisted-by: DeepSeek Harness (deepseek-flash)
129 lines
4.6 KiB
Nix
129 lines
4.6 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
options,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.modules.security.audit;
|
|
inherit (config.services) openssh;
|
|
|
|
# Audit trail for login sessions, so that a stolen deployment key leaves a
|
|
# record of what each session did; see docs/SECURITY.md ("Audit trail").
|
|
# `auditctl -R` aborts at the first watch whose path is missing, leaving
|
|
# auditing off, so every watched path must exist when the rules load.
|
|
|
|
# Secrets are symlinks into a per-deploy generation under the agenix ramfs,
|
|
# which a per-file watch would lose on the next deploy; watch the ramfs tree.
|
|
agenixSecrets = lib.optionals (options ? age) (lib.attrValues config.age.secrets);
|
|
hasAgenix = agenixSecrets != [ ];
|
|
credentialDirs = lib.optional hasAgenix config.age.secretsMountPoint;
|
|
credentialFiles = lib.unique (
|
|
map (secret: secret.path) (lib.filter (secret: !secret.symlink) agenixSecrets)
|
|
# The host key is also agenix's identity: reading it decrypts every secret.
|
|
++ lib.optionals openssh.enable (map (key: key.path) openssh.hostKeys)
|
|
++ lib.optionals hasAgenix config.age.identityPaths
|
|
);
|
|
|
|
identityFiles = map (name: "/etc/${name}") [
|
|
"passwd"
|
|
"shadow"
|
|
"group"
|
|
"gshadow"
|
|
"sudoers"
|
|
];
|
|
|
|
watchedDirs = credentialDirs ++ [
|
|
"/etc/ssh"
|
|
"/etc/pam.d"
|
|
"/etc/nix"
|
|
"/etc/audit"
|
|
"/var/log/audit"
|
|
];
|
|
watchedFiles = credentialFiles ++ identityFiles ++ [ "/nix/var/nix/profiles/system" ];
|
|
|
|
watchFile =
|
|
key: perm: path:
|
|
"-a always,exit -F path=${path} -F perm=${perm} -k ${key}";
|
|
watchDir =
|
|
key: perm: dir:
|
|
"-a always,exit -F dir=${dir} -F perm=${perm} -k ${key}";
|
|
# A b64-only syscall rule misses 32-bit binaries.
|
|
arches = [ "b64" ] ++ lib.optional pkgs.stdenv.hostPlatform.isx86_64 "b32";
|
|
perArch = rule: map (arch: "-a always,exit -F arch=${arch} ${rule}") arches;
|
|
in
|
|
{
|
|
options.modules.security.audit.enable = lib.mkEnableOption ''
|
|
the Linux audit trail (auditd) for login sessions: every command run as
|
|
root, access to decrypted secrets and SSH host keys, and changes to SSH,
|
|
account, PAM, Nix trust and audit configuration. Logs stay in
|
|
/var/log/audit on the host
|
|
'';
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
# auditctl -R splits fields on whitespace; it does not shell-unquote paths.
|
|
assertion = lib.all (path: builtins.match ".*[[:space:]].*" path == null) (
|
|
watchedDirs ++ watchedFiles
|
|
);
|
|
message = "modules.security.audit: audited paths cannot contain whitespace in the native auditctl rule file.";
|
|
}
|
|
];
|
|
|
|
security.auditd.enable = true;
|
|
security.audit = {
|
|
enable = true;
|
|
backlogLimit = lib.mkDefault 8192;
|
|
# The first matching exit rule sets the key: specific watches go first.
|
|
rules = lib.mkBefore (
|
|
# Evaluated at fork: tasks without a login uid (everything systemd
|
|
# starts) get no audit context, so no per-syscall cost.
|
|
[ "-a never,task -F auid=unset" ]
|
|
++ map (watchFile "credential-access" "rwa") credentialFiles
|
|
++ map (watchDir "credential-access" "rwa") credentialDirs
|
|
++ [ (watchDir "ssh-config" "wa" "/etc/ssh") ]
|
|
++ map (watchFile "identity" "wa") identityFiles
|
|
++ [
|
|
(watchDir "identity" "wa" "/etc/pam.d")
|
|
(watchDir "nix-trust" "wa" "/etc/nix")
|
|
(watchFile "nix-trust" "wa" "/nix/var/nix/profiles/system")
|
|
(watchDir "audit-config" "wa" "/etc/audit")
|
|
(watchDir "audit-config" "wa" "/var/log/audit")
|
|
]
|
|
++ perArch "-S init_module,finit_module,delete_module -k kernel-module"
|
|
++ perArch "-S execve,execveat -F euid=0 -F auid!=unset -k root-session"
|
|
++ [
|
|
"-a always,exit -S all -F exit=-EACCES -F auid!=unset -k access-denied"
|
|
"-a always,exit -S all -F exit=-EPERM -F auid!=unset -k access-denied"
|
|
]
|
|
);
|
|
};
|
|
|
|
security.auditd.settings = {
|
|
log_file = "/var/log/audit/audit.log";
|
|
max_log_file_action = "rotate";
|
|
max_log_file = 50; # MiB
|
|
num_logs = 5;
|
|
};
|
|
|
|
# Parents of watches that may not exist yet on a fresh system.
|
|
systemd.tmpfiles.rules = [
|
|
"d /var/log/audit 0700 root root -"
|
|
"d /nix/var/nix/profiles 0755 root root -"
|
|
];
|
|
|
|
# A watch binds to the inode present at load time, so load after the
|
|
# bind mounts (preservation), tmpfiles and the agenix ramfs.
|
|
systemd.services.audit-rules-nixos = {
|
|
after = [
|
|
"local-fs.target"
|
|
"systemd-tmpfiles-setup.service"
|
|
]
|
|
++ lib.optional hasAgenix "agenix-install-secrets.service";
|
|
unitConfig.RequiresMountsFor = lib.unique (watchedDirs ++ map dirOf watchedFiles);
|
|
};
|
|
};
|
|
}
|