Quatre défauts qu'une campagne complète sur les sept systèmes du catalogue a mis au jour. La famille zypper visait un faisceau de certificats qu'openSUSE n'écrit pas, et pip s'arrêtait là, en ligne comme hors ligne. sudo remet l'environnement à zéro : sans env_keep, une installation lancée par sudo rejette l'autorité du cache là où PAM ne relit pas /etc/environment. Le journal de console n'était gardé que pour la voie installateur, si bien qu'une image cloud bloquée avant ssh ne laissait rien à lire. Et une VM de 8 Gio restait allumée après sa mesure, épuisant la mémoire de l'hôte. --- EN --- Four faults a full campaign over the catalogue's seven systems brought out. The zypper family pointed at a certificate bundle openSUSE does not write, and pip stopped there, online as well as offline. sudo resets the environment: without env_keep, an install run by sudo rejects the cache authority where PAM does not reread /etc/environment. The console log was kept for the installer path only, so a cloud image stuck before ssh left nothing to read. And an 8 GiB VM stayed up after its measurement, exhausting the host's memory. Assisted-by: Claude Opus 5
235 lines
7.6 KiB
Go
235 lines
7.6 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func jeuDeTest() RuleSet {
|
|
return RuleSet{
|
|
Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899,
|
|
}
|
|
}
|
|
|
|
// La garde qui compte : seul ce qui SORT du sous-réseau est détourné. Sans
|
|
// elle, une VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service sur
|
|
// la passerelle du /24 — part vers le cache, qui n'en sait rien faire.
|
|
func TestNftEpargneLeTraficInterne(t *testing.T) {
|
|
lignes := jeuDeTest().NftLines()
|
|
texte := strings.Join(lignes, "\n")
|
|
|
|
if !strings.Contains(texte, "ip daddr != 192.168.122.0/24") {
|
|
t.Errorf("l'exclusion du trafic interne manque :\n%s", texte)
|
|
}
|
|
for _, port := range []string{"tcp dport 80", "tcp dport 443"} {
|
|
if !strings.Contains(texte, port) {
|
|
t.Errorf("%q absent des règles", port)
|
|
}
|
|
}
|
|
if !strings.Contains(texte, "redirect to :8898") {
|
|
t.Error("le 80 ne part pas vers l'écoute HTTP")
|
|
}
|
|
if !strings.Contains(texte, "redirect to :8899") {
|
|
t.Error("le 443 ne part pas vers l'écoute TLS")
|
|
}
|
|
if !strings.Contains(texte, `iifname "virbr0"`) {
|
|
t.Error("les règles ne sont pas bornées au pont des VM")
|
|
}
|
|
}
|
|
|
|
// Chaque règle porte l'exclusion : une seule qui l'oublie suffit à couper le
|
|
// trafic interne du port qu'elle vise.
|
|
func TestChaqueRegleDeRedirectionPorteLExclusion(t *testing.T) {
|
|
for _, l := range jeuDeTest().NftLines() {
|
|
if !strings.Contains(l, "redirect to") {
|
|
continue
|
|
}
|
|
if !strings.Contains(l, "ip daddr != 192.168.122.0/24") {
|
|
t.Errorf("règle sans exclusion : %s", l)
|
|
}
|
|
}
|
|
for _, l := range jeuDeTest().IptablesLines() {
|
|
if !strings.Contains(l, "! -d 192.168.122.0/24") {
|
|
t.Errorf("règle iptables sans exclusion : %s", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le retrait est UN geste : un désarmement à moitié fait laisserait une VM
|
|
// détournée vers un cache éteint.
|
|
func TestRetraitEnUnGeste(t *testing.T) {
|
|
l := jeuDeTest().NftDeleteLine()
|
|
if !strings.Contains(l, "delete table ip "+TableName) {
|
|
t.Errorf("le retrait n'efface pas la table : %s", l)
|
|
}
|
|
if strings.Count(l, "\n") != 0 {
|
|
t.Errorf("le retrait tient sur plusieurs lignes : %q", l)
|
|
}
|
|
}
|
|
|
|
// La table porte un nom à nous : des règles ajoutées dans les chaînes
|
|
// d'autrui devraient être retrouvées une par une pour être retirées.
|
|
func TestTableNommee(t *testing.T) {
|
|
if !strings.Contains(jeuDeTest().NftLines()[0], TableName) {
|
|
t.Error("les règles ne vivent pas dans une table nommée")
|
|
}
|
|
}
|
|
|
|
// pip et npm ignorent le magasin système : poser l'autorité ne suffit pas
|
|
// pour eux, alors qu'elle suffit pour pacman et pour apt.
|
|
func TestVariablesDeLInvite(t *testing.T) {
|
|
lignes := GuestEnvLines("/etc/ssl/certs/ca-certificates.crt")
|
|
attendues := []string{"PIP_CERT=", "REQUESTS_CA_BUNDLE=", "NODE_EXTRA_CA_CERTS="}
|
|
if len(lignes) != len(attendues) {
|
|
t.Fatalf("%d variables, attendu %d", len(lignes), len(attendues))
|
|
}
|
|
texte := strings.Join(lignes, "\n")
|
|
for _, a := range attendues {
|
|
if !strings.Contains(texte, a) {
|
|
t.Errorf("%q manque :\n%s", a, texte)
|
|
}
|
|
}
|
|
for _, l := range lignes {
|
|
if !strings.HasSuffix(l, "/etc/ssl/certs/ca-certificates.crt") {
|
|
t.Errorf("la variable ne pointe pas l'autorité : %s", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le chemin ET la commande changent par famille : se tromper laisse une VM
|
|
// qui échoue sur chaque téléchargement HTTPS.
|
|
func TestCommandeDeConfiance(t *testing.T) {
|
|
cas := map[string][3]string{
|
|
"pacman": {
|
|
"/etc/ca-certificates/trust-source/anchors", "trust extract-compat",
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
},
|
|
"apt": {
|
|
"/usr/local/share/ca-certificates", "update-ca-certificates",
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
},
|
|
"dnf": {
|
|
"/etc/pki/ca-trust/source/anchors", "update-ca-trust",
|
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
},
|
|
"zypper": {
|
|
"/etc/pki/trust/anchors", "update-ca-certificates",
|
|
"/etc/ssl/ca-bundle.pem",
|
|
},
|
|
}
|
|
for famille, attendu := range cas {
|
|
dir, cmd, bundle, ok := GuestTrustCommand(famille)
|
|
if !ok {
|
|
t.Errorf("%s : famille inconnue", famille)
|
|
continue
|
|
}
|
|
if dir != attendu[0] {
|
|
t.Errorf("%s : répertoire %q, attendu %q", famille, dir, attendu[0])
|
|
}
|
|
if cmd != attendu[1] {
|
|
t.Errorf("%s : commande %q, attendu %q", famille, cmd, attendu[1])
|
|
}
|
|
if bundle != attendu[2] {
|
|
t.Errorf("%s : faisceau %q, attendu %q", famille, bundle, attendu[2])
|
|
}
|
|
}
|
|
if _, _, _, ok := GuestTrustCommand("plan9"); ok {
|
|
t.Error("une famille inconnue est pourtant acceptée")
|
|
}
|
|
}
|
|
|
|
// L'exception doit passer AVANT les détournements : nftables rend le verdict
|
|
// de la première règle qui correspond, et une exception placée après ne
|
|
// serait jamais atteinte.
|
|
func TestExceptionAvantDetournement(t *testing.T) {
|
|
r := RuleSet{
|
|
Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899,
|
|
Bypass: []string{"52:54:00:aa:bb:cc"},
|
|
}
|
|
lignes := r.NftLines()
|
|
iException, iRedirect := -1, -1
|
|
for i, l := range lignes {
|
|
if strings.Contains(l, "ether saddr @"+BypassSetName) {
|
|
iException = i
|
|
}
|
|
if iRedirect < 0 && strings.Contains(l, "redirect to :") {
|
|
iRedirect = i
|
|
}
|
|
}
|
|
if iException < 0 {
|
|
t.Fatal("aucune règle d'exception rendue")
|
|
}
|
|
if iRedirect < 0 {
|
|
t.Fatal("aucun détournement rendu")
|
|
}
|
|
if iException > iRedirect {
|
|
t.Errorf("l'exception (ligne %d) suit le détournement (ligne %d)",
|
|
iException, iRedirect)
|
|
}
|
|
}
|
|
|
|
// L'ensemble existe même vide : une règle qui viserait un ensemble absent
|
|
// ferait échouer le jeu ENTIER, donc le démarrage du service — un cache muet
|
|
// là où l'on n'a jamais demandé d'exception.
|
|
func TestEnsembleRenduMemeVide(t *testing.T) {
|
|
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899}
|
|
texte := strings.Join(r.NftLines(), "\n")
|
|
if !strings.Contains(texte, "set "+BypassSetName+" {") {
|
|
t.Error("l'ensemble n'est pas déclaré quand aucune MAC n'est exceptée")
|
|
}
|
|
if !strings.Contains(texte, "type ether_addr") {
|
|
t.Error("l'ensemble n'a pas de type")
|
|
}
|
|
if strings.Contains(texte, "elements =") {
|
|
t.Error("un ensemble vide ne doit pas déclarer d'éléments")
|
|
}
|
|
}
|
|
|
|
func TestElementsRendusQuandIlYEnA(t *testing.T) {
|
|
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899,
|
|
Bypass: []string{"52:54:00:aa:bb:cc", "52:54:00:11:22:33"}}
|
|
texte := strings.Join(r.NftLines(), "\n")
|
|
if !strings.Contains(texte,
|
|
"elements = { 52:54:00:aa:bb:cc, 52:54:00:11:22:33 }") {
|
|
t.Errorf("éléments mal rendus :\n%s", texte)
|
|
}
|
|
}
|
|
|
|
// iptables n'a pas d'ensemble nommé : la chaîne se bâtit par « -A »
|
|
// successifs, et un RETURN posé après les redirections ne serait jamais
|
|
// atteint.
|
|
func TestIptablesExceptionEnTete(t *testing.T) {
|
|
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899,
|
|
Bypass: []string{"52:54:00:aa:bb:cc"}}
|
|
lignes := r.IptablesLines()
|
|
if len(lignes) != 3 {
|
|
t.Fatalf("%d lignes rendues, trois attendues : %v", len(lignes), lignes)
|
|
}
|
|
if !strings.Contains(lignes[0], "--mac-source 52:54:00:aa:bb:cc") ||
|
|
!strings.Contains(lignes[0], "-j RETURN") {
|
|
t.Errorf("la première ligne n'est pas l'exception : %q", lignes[0])
|
|
}
|
|
for _, l := range lignes[1:] {
|
|
if !strings.Contains(l, "REDIRECT") {
|
|
t.Errorf("ligne inattendue : %q", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sans exception, le jeu iptables est celui d'avant : trois lignes là où deux
|
|
// suffisent trahirait une ligne vide rendue pour rien.
|
|
func TestIptablesSansExceptionInchange(t *testing.T) {
|
|
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
|
|
HTTPPort: 8898, TLSPort: 8899}
|
|
if n := len(r.IptablesLines()); n != 2 {
|
|
t.Errorf("%d lignes sans exception, deux attendues", n)
|
|
}
|
|
}
|