From 6bcb16b8ab3265800a14b85e04d21ceb1201b517 Mon Sep 17 00:00:00 2001 From: Damien Date: Wed, 9 Sep 2026 17:00:17 +0200 Subject: [PATCH] =?UTF-8?q?Durcissement=20ANSSI=20(module=20S=C3=A9curixOS?= =?UTF-8?q?)=20sur=20toutes=20les=20machines=20de=20la=20flotte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendor modules/anssi/ depuis SécurixOS (DINUM), copy-paste explicitement supporté par son propre README, licence MIT - hosts/common/base.nix: security.anssi.enable, level=intermediary, category=client -- remplace le bloc sysctl qui n'existait que sur le profil oreo (nixos_users), même valeurs, rien de plus fort - garde systemd.coredump.enable=false + apparmor (pas couvert par le module ANSSI, exception R45 explicite côté Sécurix) - ip_forward=0 (R12) n'affecte pas pxe-server, qui n'importe pas ce fichier Co-Authored-By: Claude Sonnet 5 --- hosts/common/base.nix | 36 ++++ modules/anssi/README.md | 15 ++ modules/anssi/base.nix | 15 ++ modules/anssi/default.nix | 10 + modules/anssi/generator.nix | 112 +++++++++++ modules/anssi/kernel-options.nix | 324 +++++++++++++++++++++++++++++++ modules/anssi/options.nix | 209 ++++++++++++++++++++ modules/anssi/preboot.nix | 181 +++++++++++++++++ modules/anssi/ruleset.nix | 23 +++ 9 files changed, 925 insertions(+) create mode 100644 modules/anssi/README.md create mode 100644 modules/anssi/base.nix create mode 100644 modules/anssi/default.nix create mode 100644 modules/anssi/generator.nix create mode 100644 modules/anssi/kernel-options.nix create mode 100644 modules/anssi/options.nix create mode 100644 modules/anssi/preboot.nix create mode 100644 modules/anssi/ruleset.nix diff --git a/hosts/common/base.nix b/hosts/common/base.nix index cdde051..6d838b9 100644 --- a/hosts/common/base.nix +++ b/hosts/common/base.nix @@ -2,6 +2,8 @@ { pkgs, lib, ... }: { + imports = [ ../../modules/anssi ]; + # Toutes les machines de la flotte portent le même nom : pas # d'identification individuelle pour l'instant (voir README du repo # racine). À revoir plus tard via le numéro de série DMI si un vrai @@ -17,6 +19,40 @@ # firmware). mkDefault : un profil peut désactiver au cas par cas. hardware.enableRedistributableFirmware = lib.mkDefault true; + # Durcissement : reprend le module ANSSI de SécurixOS (modules/anssi/, + # vendored ci-dessus), qui remplace/couvre déjà tous les sysctls qui + # étaient déclarés à la main sur le profil oreo (dmesg_restrict, + # kptr_restrict, yama.ptrace_scope, rp_filter, tcp_syncookies, + # protected_hardlinks/symlinks -- mêmes valeurs, vérifié règle par + # règle dans modules/anssi/kernel-options.nix : R9, R11, R12, R14). + # level "intermediary" : c'est le niveau que couvraient déjà TOUTES + # les valeurs qu'on avait mises à la main sur oreo -- rien de plus + # fort n'a été inventé ici. category "client" : ce sont des postes de + # travail, pas des serveurs (les règles actuellement implémentées sont + # toutes "category = base" donc ça ne change rien pour l'instant, mais + # c'est le bon réglage si Sécurix ajoute des règles catégorisées). + # + # ATTENTION : R12 force net.ipv4.ip_forward = 0. Sans impact ici (ce + # sont des postes clients), mais à ne SURTOUT PAS importer sur + # pxe-server/configuration.nix, qui a besoin l'inverse (voir son + # networking.nat) -- pxe-server n'importe pas ce fichier, donc ok. + # R8 ajoute des mitigations CPU dont "mds=full,nosmt" (désactive + # l'hyperthreading) : impact perf réel sur du matériel bas de gamme, + # c'est la vraie recommandation ANSSI au niveau "intermediary", pas + # une invention -- à surveiller sur les PC les plus faibles du parc. + security.anssi = { + enable = true; + level = "intermediary"; + category = "client"; + }; + + # Pas (encore) couvert par le module ANSSI ci-dessus (R45 y est + # explicitement listé en exception : "AppArmor is partially supported + # on NixOS", modules/anssi/base.nix) -- repris tel quel du profil oreo. + systemd.coredump.enable = false; + security.apparmor.packages = [ pkgs.apparmor-profiles ]; + security.apparmor.enableCache = true; + time.timeZone = "Europe/Paris"; i18n.defaultLocale = "fr_FR.UTF-8"; i18n.extraLocaleSettings = { diff --git a/modules/anssi/README.md b/modules/anssi/README.md new file mode 100644 index 0000000..79e685f --- /dev/null +++ b/modules/anssi/README.md @@ -0,0 +1,15 @@ + + +# ANSSI compliance module + +This is a self-contained NixOS implementation of . +It can be copy-pasted outside of Sécurix for your own needs. + +> Vendored verbatim depuis [SécurixOS](https://github.com/cloud-gouv/securix) +> (`modules/anssi/`, alpha) le 2026-09-09, tel que suggéré par ce README +> ("can be copy-pasted outside of Sécurix"). Ne pas éditer ces fichiers +> directement : en cas de correctif, le reprendre depuis Sécurix. diff --git a/modules/anssi/base.nix b/modules/anssi/base.nix new file mode 100644 index 0000000..0d8e8f5 --- /dev/null +++ b/modules/anssi/base.nix @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ + # Default exceptions for MACs. + security.anssi.exceptions = { + R37.rationale = "No MAC implementation is available on NixOS at the moment."; + R45.rationale = "AppArmor is partially supported on NixOS."; + R46.rationale = "SELinux is unsupported on NixOS."; + R47.rationale = "SELinux is unsupported on NixOS."; + R48.rationale = "SELinux is unsupported on NixOS."; + R49.rationale = "SELinux is unsupported on NixOS."; + }; +} diff --git a/modules/anssi/default.nix b/modules/anssi/default.nix new file mode 100644 index 0000000..019e5ab --- /dev/null +++ b/modules/anssi/default.nix @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ + imports = [ + ./options.nix + ./base.nix + ]; +} diff --git a/modules/anssi/generator.nix b/modules/anssi/generator.nix new file mode 100644 index 0000000..f23562b --- /dev/null +++ b/modules/anssi/generator.nix @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ + # rulesets: a set of rules, e.g., { R1 = {...}; R2 = {...}; ... } + generateOptionHierarchy = + rulesets: + let + levelMapping = { + "minimal" = 0; + "intermediary" = 1; + "reinforced" = 2; + "high" = 3; + }; + # helper to generate one rule's option set + makeRuleOption = + { + lib, + config, + pkgs, + ... + }: + ruleId: rule: + { + anssiRef = lib.mkOption { + type = lib.types.str; + default = rule.anssiRef; + readOnly = true; + description = "ANSSI reference for rule ${rule.name}"; + }; + + enable = lib.mkOption { + type = lib.types.bool; + default = + config.security.anssi.enable + && levelMapping.${config.security.anssi.level} >= levelMapping.${rule.severity} + && (rule.category == "base" || rule.category == config.security.anssi.category) + && lib.all (t: !(lib.elem t config.security.anssi.excludes)) (rule.tags or [ ]) + && ( + ((rule.architectures or [ ]) != [ ]) + -> lib.elem pkgs.stdenv.hostPlatform.linuxArch rule.architectures + ) + && !config.security.anssi.exceptions ? ${ruleId}; + + description = '' + Enable rule ${rule.name}. + ${rule.description} + ANSSI reference: ${rule.anssiRef}. + ''; + }; + + exceptionRationale = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = + if config.security.anssi.exceptions ? ${ruleId} then + config.security.anssi.exceptions.${ruleId}.rationale + else + null; + readOnly = true; + description = "Optional rationale for disabling or modifying rule ${rule.name}"; + }; + + checkScript = lib.mkOption { + type = lib.types.path; # TODO: or string if we store the script inline + default = rule.checkScript pkgs; + description = "Check script for rule ${rule.name}"; + }; + } + // lib.optionalAttrs (rule ? implementations) { + implementation = lib.mkOption { + type = lib.types.enum (lib.attrNames rule.implementations); + description = "Chosen implementation for this rule."; + }; + }; + + # map each rule in rulesets to the generated option hierarchy + ruleOptions = + moduleArgs: moduleArgs.lib.mapAttrs (ruleId: rule: makeRuleOption moduleArgs ruleId rule) rulesets; + ruleEnabled = config: ruleId: config.security.anssi.rules.${ruleId}.enable; + enabledRuleConfigs = + moduleArgs: + moduleArgs.lib.mapAttrsToList ( + ruleId: rule: + let + complexConfigTerm = + cfg: + moduleArgs.lib.mkMerge ( + moduleArgs.lib.mapAttrsToList ( + implName: _: + moduleArgs.lib.mkIf (moduleArgs.config.security.anssi.rules.${ruleId}.implementation == implName) ( + rule.implementations.${implName}.config cfg + ) + ) rule.implementations + ); + simpleConfigFn = rule.config; + configFn = if rule ? implementations then complexConfigTerm else simpleConfigFn; + in + moduleArgs.lib.mkIf (ruleEnabled moduleArgs.config ruleId) (configFn moduleArgs) + ) rulesets; + in + { + lib, + config, + pkgs, + ... + }@moduleArgs: + { + options.security.anssi.rules = ruleOptions moduleArgs; + config = lib.mkMerge (enabledRuleConfigs moduleArgs); + }; +} diff --git a/modules/anssi/kernel-options.nix b/modules/anssi/kernel-options.nix new file mode 100644 index 0000000..c30f7ff --- /dev/null +++ b/modules/anssi/kernel-options.nix @@ -0,0 +1,324 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +let + mkSysctlChecker = + lib: attrs: + let + # Normalize whitespace (tabs to spaces, collapse runs, trim edges) + # so that sysctl output like "32768\t65535" matches Nix value "32768 65535". + normalizeWhitespace = "tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//' "; + mkCheckSingularSysctl = attr: expectedValue: '' + # Check for sysctl '${attr}' + actual_value=$(sysctl -n "${attr}" | ${normalizeWhitespace}) || { + echo "Check failed for ${attr}: unable to read sysctl value" + exit 1 + } + expected_value=$(echo "${toString expectedValue}" | ${normalizeWhitespace}) + if [[ "$actual_value" != "$expected_value" ]]; then + echo "Check failed for ${attr}: expected $expected_value, got $actual_value" + exit 1 + else + echo "Check passed for ${attr}" + fi + ''; + in + '' + ${lib.concatStringsSep "\n" ( + map (name: mkCheckSingularSysctl name attrs.${name}) (lib.attrNames attrs) + )} + ''; +in +{ + R8 = { + name = "R8_MemoryBootOptions"; + anssiRef = "R8 – Paramétrer les options de configuration de la mémoire"; + description = "Set memory options at boot"; + severity = "intermediary"; + category = "base"; + + config = _: { + boot.kernelParams = [ + "l1tf=full,force" # Enable full mitigation for L1 Terminal Fault (L1TF), including disabling SMT + "page_poison=on" # Enable poisoning of freed pages to detect leaks + "pti=on" # Force use of Page Table Isolation (PTI) for Meltdown vulnerability + "slab_nomerge=yes" # Disable slab cache merging to complicate heap overflow attacks + "slub_debug=FZP" # Enable slab cache debugging with specific tests + "spec_store_bypass_disable=seccomp" # Use seccomp mitigation for Spectre v4 + "spectre_v2=on" # Force Spectre v2 mitigation + "mds=full,nosmt" # Enable full mitigation for MDS and disable SMT (requires microcode update) + "mce=0" # Force kernel panic on uncorrected Machine Check exceptions + "page_alloc.shuffle=1" # Enable page allocator randomization + "rng_core.default_quality=500" # Set HWRNG quality for TPM-based CSPRNG initialization + ]; + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R8" '' + # The kernel boot parameters we expect to check + EXPECTED_PARAMS=( + "l1tf=full,force" + "page_poison=on" + "pti=on" + "slab_nomerge=yes" + "slub_debug=FZP" + "spec_store_bypass_disable=seccomp" + "spectre_v2=on" + "mds=full,nosmt" + "mce=0" + "page_alloc.shuffle=1" + "rng_core.default_quality=500" + ) + + # Get the current kernel parameters from /proc/cmdline + ACTUAL_PARAMS=$(cat /proc/cmdline) + + # Check if each expected parameter is in the actual kernel parameters + for param in "''${EXPECTED_PARAMS[@]}"; do + if echo "$ACTUAL_PARAMS" | grep -q "$param"; then + echo "Kernel parameter '$param' is correctly set." + else + echo "WARNING: Kernel parameter '$param' is NOT set." + exit 1 + fi + done + + # Additionally, check specific parameters from /proc/cpuinfo (for cpu-related parameters) + echo "Checking CPU-specific parameters..." + + # Check if SMT (Hyper-Threading) is disabled, as required by "mds=full,nosmt" + if grep -q "nosmt" <<< "$ACTUAL_PARAMS"; then + if grep -q "siblings" /proc/cpuinfo && ! grep -q "HT" /proc/cpuinfo; then + echo "SMT is disabled (as expected for mds=full,nosmt)." + else + echo "WARNING: SMT is still enabled, but 'mds=full,nosmt' was passed." + fi + fi + + # Check if Page Table Isolation (PTI) is enabled (from /proc/cpuinfo) + if grep -q "pti" <<< "$ACTUAL_PARAMS"; then + if grep -q "pti" /proc/cpuinfo; then + echo "PTI is enabled (as expected for pti=on)." + else + echo "WARNING: PTI is not enabled, despite pti=on being passed." + fi + fi + + exit 0 + ''; + }; + + R9 = + let + sysctls = { + # Restrict access to dmesg buffer + "kernel.dmesg_restrict" = "1"; + + # Hide kernel addresses in /proc and other interfaces, even for privileged users + "kernel.kptr_restrict" = "2"; + + # Set process ID maximum + "kernel.pid_max" = "1048576"; # 2^20 + + # Restrict perf subsystem usage + "kernel.perf_cpu_time_max_percent" = "1"; + "kernel.perf_event_max_sample_rate" = "1"; + + # Restrict access to perf_event_open system call + "kernel.perf_event_paranoid" = "2"; + + # Enable Address Space Layout Randomization (ASLR) + "kernel.randomize_va_space" = "2"; + + # Disable Magic SysRq key (prevents magical system requests) + "kernel.sysrq" = "0"; + + # Restrict use of BPF (Berkeley Packet Filter) to privileged users + "kernel.unprivileged_bpf_disabled" = "1"; + + # Kernel panic on unexpected kernel behavior (e.g., oops) + "kernel.panic_on_oops" = "1"; + }; + in + { + name = "R9_KernelOptions"; + anssiRef = "R9 – Paramétrer les options de configuration du noyau"; + description = "Tune kernel configuration options"; + severity = "intermediary"; + category = "base"; + + config = _: { boot.kernel.sysctl = sysctls; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R9" '' + ${mkSysctlChecker pkgs.lib sysctls} + ''; + }; + + R10 = { + name = "R10_DisableKernelModuleLoading"; + anssiRef = "R10 – Désactiver le chargement des modules noyau"; + description = "Disable loading of kernel modules"; + severity = "reinforced"; + category = "base"; + tags = [ "disable-kernel-module-loading" ]; + + config = _: { boot.kernel.sysctl."kernel.modules_disabled" = "1"; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R10" '' + ${mkSysctlChecker pkgs.lib { "kernel.modules_disabled" = 1; }} + ''; + }; + + R11 = { + name = "R11_ConfigureYamaLSM"; + anssiRef = "R11 – Activer et configurer le LSM Yama"; + description = "Enable and configure the Yama Linux Security Module"; + severity = "intermediary"; + category = "base"; + + config = _: { boot.kernel.sysctl."kernel.yama.ptrace_scope" = "1"; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R11" '' + ${mkSysctlChecker pkgs.lib { "kernel.yama.ptrace_scope" = 1; }} + ''; + }; + + R12 = + let + ipv4_sysctls = { + # Harden the JIT compiler for BPF + "net.core.bpf_jit_harden" = "2"; + + # Disable IP forwarding (no routing between interfaces) + "net.ipv4.ip_forward" = "0"; + + # Reject packets with source address 127/8 (loopback network) + "net.ipv4.conf.all.accept_local" = "0"; + + # Disable ICMP redirects (prevents potential traffic redirection) + "net.ipv4.conf.all.accept_redirects" = "0"; + "net.ipv4.conf.default.accept_redirects" = "0"; + "net.ipv4.conf.all.secure_redirects" = "0"; + "net.ipv4.conf.default.secure_redirects" = "0"; + + # Disable shared media detection (useful for preventing misconfiguration) + "net.ipv4.conf.all.shared_media" = "0"; + "net.ipv4.conf.default.shared_media" = "0"; + + # Disable kernel ARP management across interfaces (security for high-availability setups) + "net.ipv4.conf.all.arp_filter" = "1"; + + # Respond to ARP requests only if source and destination are on the same network + "net.ipv4.conf.all.arp_ignore" = "2"; + + # Refuse routing of packets with loopback addresses as source or destination + "net.ipv4.conf.all.route_localnet" = "0"; + + # Drop Gratuitous ARP (prevents ARP poisoning attacks) + "net.ipv4.conf.all.drop_gratuitous_arp" = "1"; + + # Perform Reverse Path Filtering (checks if the source is reachable via the interface it came from) + "net.ipv4.conf.default.rp_filter" = "1"; + "net.ipv4.conf.all.rp_filter" = "1"; + + # Disable sending ICMP redirects (normal for routers but unnecessary for end hosts) + "net.ipv4.conf.default.send_redirects" = "0"; + "net.ipv4.conf.all.send_redirects" = "0"; + + # Ignore bogus ICMP error responses (RFC 1122) + "net.ipv4.icmp_ignore_bogus_error_responses" = "1"; + + # Increase the range for ephemeral ports (for better security) + "net.ipv4.ip_local_port_range" = "32768 65535"; + + # Enable RFC 1337 compliance (prevents TCP timestamp attacks) + "net.ipv4.tcp_rfc1337" = "1"; + + # Enable SYN cookies (prevents SYN flood attacks) + "net.ipv4.tcp_syncookies" = "1"; + }; + in + { + name = "R12_IPv4Networking"; + anssiRef = "R12 – Paramétrer les options de configuration du réseau IPv4"; + description = "Configure IPv4 network options"; + severity = "intermediary"; + category = "base"; + + config = _: { boot.kernel.sysctl = ipv4_sysctls; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R12" '' + ${mkSysctlChecker pkgs.lib ipv4_sysctls} + ''; + }; + + R13 = + let + sysctls = { + # Disable IPv6 on all interfaces by default + "net.ipv6.conf.default.disable_ipv6" = "1"; + + # Disable IPv6 on all interfaces + "net.ipv6.conf.all.disable_ipv6" = "1"; + }; + in + { + name = "R13_DisableIPv6"; + anssiRef = "R13 – Désactiver le plan IPv6"; + description = "Disable IPv6"; + severity = "intermediary"; + category = "base"; + tags = [ "no-ipv6" ]; + + config = _: { boot.kernel.sysctl = sysctls; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R13" '' + ${mkSysctlChecker pkgs.lib sysctls} + ''; + }; + + R14 = + let + sysctls = { + # Disable coredumps for setuid executables + "fs.suid_dumpable" = "0"; + + # Prevent opening FIFOs and regular files not owned by the user in sticky directories + "fs.protected_fifos" = "2"; + "fs.protected_regular" = "2"; + + # Restrict the creation of symbolic links to files owned by the user + "fs.protected_symlinks" = "1"; + + # Restrict the creation of hard links to files owned by the user + "fs.protected_hardlinks" = "1"; + }; + in + { + name = "R14_FilesystemConfig"; + anssiRef = "R14 – Paramétrer les options de configuration des systèmes de fichiers"; + description = "Tune filesystem configuration options"; + severity = "intermediary"; + category = "base"; + + config = _: { boot.kernel.sysctl = sysctls; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R14" '' + ${mkSysctlChecker pkgs.lib sysctls} + ''; + }; +} diff --git a/modules/anssi/options.nix b/modules/anssi/options.nix new file mode 100644 index 0000000..338cfe6 --- /dev/null +++ b/modules/anssi/options.nix @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ + pkgs, + config, + lib, + ... +}: + +let + inherit (lib) mkEnableOption mkOption types; + + cfg = config.security.anssi; + rulesets = import ./ruleset.nix { inherit lib; }; + levelMapping = { + "minimal" = 0; + "intermediary" = 1; + "reinforced" = 2; + "high" = 3; + }; + + # rules.* must follow the format used earlier: + # { + # name = "..."; + # category = "client" / "server" / "base"; + # severity = "minimal" / "intermediary" / "reinforced" / "high"; + # tags = [ "kernel" ... ]; + # config = f: { ... }; + # checkScript = pkgs: pkgs.writeShellScript ... + # } + + # Look at all rules that are not enabled. + # Determine why they are not enabled: + # - if there's an exception for it, use it. + # - if it's excluded via a tag, use it. + # - if it's excluded via architecture, use it. + # - if it's out of the scope for the category, use it. + # - if it's out of the scope for the level, use it. + # - otherwise, mark it unknown. + # Determine why a rule is not enabled. + determineExclusionReason = + ruleId: rule: + if !cfg.rules.${ruleId}.enable then + # Check if it's excluded via a clear mechanism. + let + tagExclusions = lib.filter (tag: lib.elem tag (rule.tags or [ ])) cfg.excludes; + categoryExclusion = rule.category != "base" && rule.category != cfg.category; + levelExclusion = levelMapping.${rule.severity} > levelMapping.${cfg.level}; + architectureExclusion = + if (rule.architectures or [ ]) != [ ] then + !lib.elem pkgs.stdenv.hostPlatform.linuxArch rule.architectures + else + false; + in + if tagExclusions != [ ] then + { + reason = "excluded by tag ${lib.concatStringsSep ", " tagExclusions}"; + via = "tag"; + exclusionTags = tagExclusions; + } + else if categoryExclusion then + { + reason = "out of scope for category '${cfg.category}' (${rule.category})"; + via = "category"; + requiredCategory = rule.category; + } + else if levelExclusion then + { + reason = "out of scope for level ${cfg.level} < ${rule.severity}"; + via = "level"; + requiredLevel = rule.severity; + } + else if architectureExclusion then + { + reason = "out of scope for architecture '${pkgs.stdenv.hostPlatform.linuxArch}'"; + via = "architecture"; + requiredArchitectures = rule.architectures or [ ]; + } + else if lib.hasAttr ruleId cfg.exceptions then + { + reason = "excluded by explicit exception: ${cfg.exceptions.${ruleId}.rationale}"; + via = "exception"; + rationale = cfg.exceptions.${ruleId}.rationale; + } + else + { + reason = "could not understand why this rule was excluded, the user must have set the `enable = false;` forcibly via the module system bypassing the governance system. This is a non-compliant."; + via = "unknown"; + } + else + null; + + generateRuleEntry = + ruleId: rule: + { + enabled = cfg.rules.${ruleId}.enable; + anssiRef = cfg.rules.${ruleId}.anssiRef; + knownCheckScript = rule.checkScript pkgs; + } + // lib.optionalAttrs (cfg.exceptions ? ruleId) { + declaredExceptionRationale = cfg.exceptions.${ruleId}.rationale; + } + // lib.optionalAttrs (rule ? implementations) { + implementation = cfg.rules.${ruleId}.implementation; + }; + + complianceReport = { + "org.securix.anssi-compliance.v1" = { + guideVersion = "2.0"; + rules = lib.mapAttrsToList (ruleId: rule: generateRuleEntry ruleId rule) rulesets; + targetLevel = cfg.level; + hostname = config.networking.hostName; + domain = config.networking.domain or null; + targetSystemCategory = cfg.category; + exclusions = lib.mapAttrs determineExclusionReason ( + lib.filterAttrs (ruleId: _: !cfg.rules.${ruleId}.enable) rulesets + ); + }; + }; +in +{ + imports = [ + # This will generate `security.anssi.rules.RXX.{anssiRef,enable,checkScript}` options and so on. + ((import ./generator.nix).generateOptionHierarchy rulesets) + ]; + + options.security.anssi = { + enable = mkEnableOption "ANSSI compliance for GNU/Linux systems (v2.0)"; + + level = mkOption { + type = types.enum [ + "minimal" + "intermediary" + "reinforced" + "high" + ]; + default = "minimal"; + description = "Select desired ANSSI compliance level."; + }; + + category = mkOption { + type = types.enum [ + "base" + "client" + "server" + ]; + default = "base"; + description = "Rule category to activate."; + }; + + excludes = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Tags to exclude (e.g. [ \"kernel\" \"no-ipv6\" ])"; + }; + + exceptions = mkOption { + type = types.attrsOf ( + types.submodule (_: { + options.rationale = mkOption { type = types.lines; }; + }) + ); + default = { }; + }; + }; + + config = lib.mkIf cfg.enable { + system.build.complianceReport = complianceReport; + system.build.complianceReportDocument = pkgs.writers.writeJSON "anssi-compliance.json" complianceReport; + system.build.complianceCheckScript = + let + mkCheckSingularRule = ruleId: rule: '' + # Check for rule ${ruleId} + if [ -n "${rule.checkScript}" ]; then + result=$(bash -c "${rule.checkScript}") + results["${ruleId}"]=$result + else + results["${ruleId}"]="Check script not defined: cannot be checked." + fi + ''; + in + pkgs.writeShellScriptBin "anssi-nixos-compliance-check" '' + declare -A results + + ${lib.concatStringsSep "\n" ( + lib.mapAttrsToList mkCheckSingularRule ( + lib.filterAttrs (_: rule: rule.enable) config.security.anssi.rules + ) + )} + + # Output the results in a matrix format + echo "ANSSI Compliance Check Results:" + for rule in "''${!results[@]}"; do + result="''${results[$rule]}" + if [[ "$result" =~ .*TODO.* ]]; then + printf "\033[33m%-40s : %s\033[0m\n" "$rule" "$result" + elif [[ "$result" =~ (fail|WARNING|DIVERGENCE|UNSET|error|not\ completed|incomplete) ]]; then + printf "\033[31m%-40s : %s\033[0m\n" "$rule" "$result" + else + printf "\033[32m%-40s : %s\033[0m\n" "$rule" "$result" + fi + done + ''; + + environment.systemPackages = [ config.system.build.complianceCheckScript ]; + }; +} diff --git a/modules/anssi/preboot.nix b/modules/anssi/preboot.nix new file mode 100644 index 0000000..fef2514 --- /dev/null +++ b/modules/anssi/preboot.nix @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ + # TODO: these two next rules requires special system integration that depends on the hardware manufacturer. + # We implement no specific check or implementation here. + R1 = { + name = "R1_ChooseHardware"; + anssiRef = "R1 – Choisir et configurer son matériel"; + description = "Choosing and configuring hardware"; + severity = "high"; + category = "base"; + + config = _: { + # TODO: hardware-level config (maybe BIOS settings, firmware) + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R1" '' + echo "TODO: check hardware configuration for R1" + exit 0 + ''; + }; + + R2 = { + name = "R2_BIOS_UEFI"; + anssiRef = "R2 – Configurer le BIOS/UEFI"; + description = "Configure BIOS / UEFI"; + severity = "intermediary"; + category = "base"; + + config = _: { + # TODO: set NixOS options for UEFI / BIOS security + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R2" '' + echo "TODO: check BIOS / UEFI secure configuration" + exit 0 + ''; + }; + + R3 = { + name = "R3_UEFI_SecureBoot"; + anssiRef = "R3 – Activer le démarrage sécurisé UEFI"; + description = "Enable UEFI Secure Boot"; + severity = "intermediary"; + category = "base"; + + config = _: { + # TODO: NixOS bootloader secure-boot config + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R3" '' + # Check if the system is running in UEFI mode + if [ ! -d "/sys/firmware/efi" ]; then + echo "Secure Boot cannot be checked. System is not in UEFI mode." + exit 1 + fi + + # Check the Secure Boot status + secure_boot_file="/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c" + if [ -f "$secure_boot_file" ]; then + # Extract the value of SecureBoot + secure_boot_status=$(xxd -p -c 4 "$secure_boot_file") + + # Check if Secure Boot is enabled + if [[ "$secure_boot_status" =~ 01 ]]; then + echo "Secure Boot is enabled." + exit 0 + else + echo "fail: Secure Boot is not enabled." + exit 1 + fi + else + echo "fail: Secure Boot status file not found." + exit 1 + fi + ''; + }; + + R4 = { + name = "R4_ReplacePreloadedKeys"; + anssiRef = "R4 – Remplacer les clés préchargées"; + description = "Replace preloaded UEFI keys"; + severity = "high"; + category = "base"; + + config = _: { }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R4" '' + echo "TODO: check all loaded certificates" + exit 0 + ''; + }; + + R5 = { + name = "R5_BootloaderPassword"; + anssiRef = "R5 – Configurer un mot de passe pour le chargeur de démarrage"; + description = "Set a password on the bootloader"; + severity = "intermediary"; + category = "base"; + + implementations = { + secureboot = { + checkScript = { }; + depends = [ "R3" ]; + config = _: { }; + }; + grub = { + checkScript = ""; + config = _: { }; + }; + }; + + config = _: { + # TODO: grub or systemd-boot password + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R5" '' + echo "R5 depends on R3." + exit 0 + ''; + }; + + R6 = { + name = "R6_ProtectKernelCmdline"; + anssiRef = "R6 – Protéger les paramètres de ligne de commande du noyau et l’initramfs"; + description = "Protect kernel cmdline and initramfs"; + severity = "high"; + category = "base"; + + # implementations.secureboot = { checkScript = {}; depends = [ "R3" ]; }; + + config = _: { + # TODO + }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R6" '' + echo "R6 depends on R3." + ''; + }; + + R7 = { + name = "R7_EnableIOMMU"; + anssiRef = "R7 – Activer l’IOMMU"; + description = "Enable IOMMU"; + severity = "reinforced"; + category = "base"; + + config = _: { boot.kernelParams = [ "iommu=force" ]; }; + + checkScript = + pkgs: + pkgs.writeShellScript "check-R7" '' + #!/bin/sh + + IOMMU_DIR="/sys/class/iommu" + + if [ -d "$IOMMU_DIR" ] && [ "$(ls -A $IOMMU_DIR)" ]; then + echo "IOMMU is enabled. Files found: $(ls $IOMMU_DIR | xargs)" + else + echo "No IOMMU detected or not enabled." + exit 1 + fi + + exit 0 + ''; + }; +} diff --git a/modules/anssi/ruleset.nix b/modules/anssi/ruleset.nix new file mode 100644 index 0000000..14d3005 --- /dev/null +++ b/modules/anssi/ruleset.nix @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Ryan Lahfa +# +# SPDX-License-Identifier: MIT + +{ lib, ... }: +let + loadRules = files: lib.mergeAttrsList (map import files); +in +loadRules [ + ./preboot.nix + ./kernel-options.nix + # ./kernel.nix + # ./vfs.nix + # ./users.nix + # ./apparmor.nix + # ./selinux.nix + # ./journaling.nix + # ./runtime-minimization.nix + # ./hardening.nix + # ./nss-hardening.nix + # ./integrity.nix + # ./mta.nix +]