Durcissement ANSSI (module SécurixOS) sur toutes les machines de la flotte
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
|
||||
# ANSSI compliance module
|
||||
|
||||
This is a self-contained NixOS implementation of <https://messervices.cyber.gouv.fr/guides/recommandations-de-securite-relatives-un-systeme-gnulinux>.
|
||||
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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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.";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
{
|
||||
imports = [
|
||||
./options.nix
|
||||
./base.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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 ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# SPDX-FileCopyrightText: 2026 Ryan Lahfa <ryan.lahfa@numerique.gouv.fr>
|
||||
#
|
||||
# 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
|
||||
]
|
||||
Reference in New Issue
Block a user