NIS2, ISO 27001 & bewijsgevorderd5 min lezen

Kwartaal-access-review in één uur: accounts, sudo, SSH-keys en SSO naast de personeelslijst — met script

ISO 27001 A.5.18 en NIS2 art. 21 §2 (i) vragen dat je periodiek nakijkt wie toegang heeft en of dat nog klopt. In de praktijk wordt dat een middag spreadsheets, of het gebeurt niet. Dit script maakt van de review een uur: het legt de technische inventaris (servers, sudo, keys, IdP-accounts) naast de HR-lijst, markeert elke afwijking, en produceert het ondertekende verslag dat een auditor accepteert.

Inhoud
  1. Wat een access review moet opleveren
  2. Stap 1: de bronnen verzamelen
  3. Stap 2: één identiteit per persoon
  4. Stap 3: het script — vergelijken en afwijkingen markeren
  5. Stap 4: het oordeel (het uur)
  6. Stap 5: uitvoeren, bewijzen, ondertekenen
  7. Valkuilen
  8. Wat je hiermee nog niet hebt
  9. Zo doet monsys het
  10. FAQ

De access review is het bewijsstuk dat in bijna elke audit ontbreekt, en niet omdat niemand het belangrijk vindt. Het ontbreekt omdat de vraag "wie heeft toegang tot wat, en hoort dat zo?" vijf bronnen raakt — servers, de IdP, de VPN, de cloudconsole, HR — en niemand die vijf op één dag naast elkaar krijgt. Dit artikel automatiseert het verzamelen en het vergelijken, zodat de mens alleen nog het oordeel hoeft te vellen. Dat oordeel is het enige wat niet te scripten is; het is ook het enige wat de auditor echt wil zien.

Wat een access review moet opleveren

Voor een auditor (ISO 27001 A.5.18, NIS2 art. 21 §2 (i), CyFun Important ID.AM/PR.AC) telt het resultaat, niet de methode. Het resultaat is een document met:

  1. Datum en scope — welke systemen, welke periode.
  2. De lijst — per persoon: welke accounts, welke rechten, laatste gebruik.
  3. De afwijkingen — accounts zonder persoon, personen zonder accounts die ze zouden moeten hebben, rechten die niet bij de functie passen, keys zonder eigenaar, accounts ongebruikt > 90 dagen.
  4. De beslissing per afwijking — behouden (met reden), verwijderen (met datum), of onderzoeken (met eigenaar).
  5. Handtekening van de reviewer, en bewijs dat de verwijderingen zijn uitgevoerd.

Alles behalve punt 4 is te scripten.

Stap 1: de bronnen verzamelen

Servers. Gebruik de inventaris uit sudo en authorized_keys fleet-breed; die levert per host user, sudo, key en lastlogin-regels. Voor deze review volstaat één run:

R=/srv/access-review/$(date +%Y-Q$(( ($(date +%-m)-1)/3+1 ))); mkdir -p "$R"
while read -r h; do ssh -o BatchMode=yes -o ConnectTimeout=5 "$h" sudo /usr/local/sbin/access-inventory.sh; done < /srv/inventory/hosts.txt > "$R/servers.tsv"

IdP (SSO). Elke IdP heeft een export. Drie voorbeelden; het resultaat is telkens email,naam,rol,mfa,laatste_login:

# Keycloak (admin-cli)
kcadm.sh config credentials --server https://sso.example.be --realm master --user admin
kcadm.sh get users -r example --fields username,email,enabled,attributes -q max=1000 | jq -r '.[] | [.email, .username, (.enabled|tostring)] | @csv' > "$R/idp.csv"
# Authentik
curl -s -H "Authorization: Bearer $AK_TOKEN" 'https://sso.example.be/api/v3/core/users/?page_size=1000' \
  | jq -r '.results[] | [.email, .name, (.is_active|tostring), (.last_login // "never")] | @csv' > "$R/idp.csv"
# Microsoft Entra ID (Graph, met een app-registratie met User.Read.All)
curl -s -H "Authorization: Bearer $GRAPH_TOKEN" 'https://graph.microsoft.com/v1.0/users?$select=mail,displayName,accountEnabled,signInActivity&$top=999' \
  | jq -r '.value[] | [.mail, .displayName, (.accountEnabled|tostring), (.signInActivity.lastSignInDateTime // "never")] | @csv' > "$R/idp.csv"

Cloud en overige. aws iam list-users, az ad user list, de VPN-server (wg show peers, of de OpenVPN-CA-index), de database (\du in PostgreSQL). Elk als csv in $R/. Voor de eerste review neem je de drie belangrijkste; de rest volgt.

HR. Een csv met email,naam,functie,in_dienst_sinds,uit_dienst. Dit is het enige bestand dat je niet kunt genereren; vraag het aan HR met een vaste deadline per kwartaal. Zonder HR-lijst is een access review een inventaris, geen review.

Stap 2: één identiteit per persoon

Serveraccounts heten jpeeters, de IdP zegt jan.peeters@example.be, HR zegt Peeters, Jan. Een mapping-bestand is onvermijdelijk — maak hem één keer, hou hem bij:

# /srv/access-review/identities.csv — email,server_user,idp_user,cloud_user
jan.peeters@example.be,jpeeters,jan.peeters,jan.peeters
deploy@example.be,deploy,,                     # service-account: eigenaar staat in owners.csv
ci@example.be,ci-runner,,ci-runner

Service-accounts krijgen een eigenaar in owners.csv (account,eigenaar_email,doel,vervaldatum). Een service-account zonder eigenaar is per definitie een afwijking.

Stap 3: het script — vergelijken en afwijkingen markeren

sudo tee /usr/local/sbin/access-review.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# access-review.sh <review-map>  — verwacht servers.tsv, idp.csv, hr.csv, identities.csv, owners.csv
set -euo pipefail
R=$1; OUT=$R/findings.tsv; : > "$OUT"
f() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$OUT"; }   # type, subject, detail, suggested action

# Hulptabellen
awk -F, 'NR>1 {print $2}' "$R/identities.csv" | grep -v '^$' | sort -u > /tmp/known-server-users
awk -F, 'NR>1 && $5=="" {print tolower($1)}' "$R/hr.csv" | sort -u > /tmp/hr-active
awk -F, 'NR>1 && $5!="" {print tolower($1)"\t"$5}' "$R/hr.csv" | sort -u > /tmp/hr-left
awk -F, 'NR>1 {print $1}' "$R/owners.csv" | sort -u > /tmp/owned-svc

# 1. Serveraccounts die aan niemand gekoppeld zijn (geen identiteit, geen service-eigenaar)
awk -F'\t' '$2=="user" {print $3}' "$R/servers.tsv" | sort -u | while read -r u; do
  grep -qx "$u" /tmp/known-server-users || grep -qx "$u" /tmp/owned-svc || f orphan-account "$u" "op $(awk -F'\t' -v u="$u" '$2=="user" && $3==u {print $1}' "$R/servers.tsv" | sort -u | paste -sd, -)" "koppel aan persoon/eigenaar of verwijder"
done

# 2. Personen uit dienst met nog actieve toegang
while IFS=$'\t' read -r mail left; do
  su=$(awk -F, -v m="$mail" 'tolower($1)==m {print $2}' "$R/identities.csv")
  [ -n "$su" ] && grep -qP "\tuser\t$su\t" "$R/servers.tsv" && f left-but-active "$mail" "serveraccount $su, uit dienst $left" "VERWIJDER vandaag"
  grep -qi "^\"\?$mail" "$R/idp.csv" && grep -qi "$mail.*true" "$R/idp.csv" && f left-but-active "$mail" "IdP-account actief, uit dienst $left" "DEACTIVEER vandaag"
done < /tmp/hr-left

# 3. Sudo op hosts waar de functie dat niet vraagt (lijst van rollen die sudo mogen: ops.csv)
awk -F'\t' '$2=="sudo" {print $3"\t"$1}' "$R/servers.tsv" | sort -u | while IFS=$'\t' read -r u h; do
  mail=$(awk -F, -v u="$u" '$2==u {print $1}' "$R/identities.csv")
  role=$(awk -F, -v m="$mail" 'tolower($1)==m {print $3}' "$R/hr.csv")
  case "$role" in *sysadmin*|*devops*|*ops*|"") ;; *) f sudo-outside-role "$u" "sudo op $h, functie: $role" "bevestig of beperk tot commando's" ;; esac
done

# 4. Keys zonder comment of van verboden type
awk -F'\t' '$2=="key" && ($5 ~ /^(RSA 1024|DSA)/ || $5 ~ /no comment|geen comment|^[A-Z0-9]+ *$/) {print $3"\t"$1"\t"$5}' "$R/servers.tsv" | sort -u \
  | while IFS=$'\t' read -r u h k; do f weak-or-unowned-key "$u@$h" "$k" "vervang door ed25519 met comment, of verwijder"; done

# 5. Ongebruikt > 90 dagen (server: lastlogin uit journal; IdP: laatste login)
cut90=$(date -d '-90 days' +%F)
awk -F'\t' '$2=="user" {print $3}' "$R/servers.tsv" | sort -u | while read -r u; do
  last=$(awk -F'\t' -v u="$u" '$2=="lastlogin" && $3==u {print $4}' "$R/servers.tsv" | sort | tail -1)
  [ -z "$last" ] && f unused-90d "$u" "geen SSH-login in de journal-historie" "verwijder of documenteer waarom nodig" && continue
  [[ "${last:0:10}" < "$cut90" ]] && f unused-90d "$u" "laatste login ${last:0:10}" "verwijder of documenteer"
done

# 6. IdP-accounts zonder MFA (kolom afhankelijk van je export; hier: 5e kolom = mfa)
awk -F, 'NR>1 && tolower($5)=="false" {print $1}' "$R/idp.csv" 2>/dev/null | while read -r m; do f no-mfa "$m" "IdP-account zonder MFA" "MFA afdwingen"; done

sort -u "$OUT" -o "$OUT"
echo "$(wc -l < "$OUT") findings → $OUT"
column -t -s $'\t' "$OUT" | head -40
EOF
sudo chmod 0755 /usr/local/sbin/access-review.sh
sudo /usr/local/sbin/access-review.sh "$R"

De output is een tsv met vier kolommen: type, subject, detail, voorgestelde actie. Dat is de lijst die de reviewer doorloopt — en meestal zijn het er tussen de vijf en de dertig, niet honderden.

Stap 4: het oordeel (het uur)

Open findings.tsv in een spreadsheet of editor en vul per regel een vijfde kolom in: keep:<reden>, remove:<datum>, of investigate:<eigenaar>. Regels:

  • left-but-active is nooit keep. Verwijderen, vandaag, en de verwijdering loggen (stap 5).
  • orphan-account krijgt een eigenaar of verdwijnt. "Weet niemand" is remove.
  • sudo-outside-role met keep vereist een reden in één zin ("on-call rotatie Q4"). Volgend kwartaal komt hij terug.
  • unused-90d met keep vereist een geplande gebruiksdatum. Anders remove; een account is in tien seconden opnieuw aangemaakt.
  • no-mfa is remove voor de toegang, niet voor het account: forceer MFA bij de volgende login.

Bewaar het als findings-reviewed.tsv. Dat bestand is de review.

Stap 5: uitvoeren, bewijzen, ondertekenen

# Verwijderingen uitvoeren en loggen — per regel met remove:
awk -F'\t' '$5 ~ /^remove/' "$R/findings-reviewed.tsv" | while IFS=$'\t' read -r type subj detail action decision; do
  case $type in
    orphan-account|unused-90d|left-but-active)
      u=${subj%%@*}
      for h in $(awk -F'\t' -v u="$u" '$2=="user" && $3==u {print $1}' "$R/servers.tsv" | sort -u); do
        ssh "$h" "sudo userdel -r '$u' 2>&1" && echo "$(date -Is) removed $u from $h ($type)" >> "$R/actions.log"
      done ;;
    weak-or-unowned-key) echo "$(date -Is) TODO manual: remove key $detail for $subj" >> "$R/actions.log" ;;
  esac
done

# Verslag: findings + beslissingen + acties, gehasht en ondertekend (zie de bewijs-how-to voor de sleutel)
( cd "$R" && sha256sum servers.tsv idp.csv hr.csv findings.tsv findings-reviewed.tsv actions.log > MANIFEST.sha256 )
ssh-keygen -Y sign -f /etc/evidence/key -n access-review "$R/MANIFEST.sha256"
echo "$(date -Is) access-review $(basename "$R") door $(whoami): $(wc -l < "$R/findings.tsv") findings, $(grep -c remove "$R/findings-reviewed.tsv") verwijderd" | sudo tee -a /srv/inventory/access-reviews.log

Het kwartaal-verslag bestaat nu uit: de inventaris (wat er was), de findings (wat afweek), de beslissingen (wat je ervan vond), de acties (wat je deed), een manifest met hashes en een handtekening. Dat is exact wat ISO 27001 A.5.18 bedoelt met "review at planned intervals" en wat een auditor wil zien.

Valkuilen

  • HR-lijst komt niet. Zonder hr.csv kun je afwijking 2 en 3 niet berekenen. Spreek de deadline af in de kalender van HR, niet die van IT; en laat het script luid falen als het bestand ouder is dan 30 dagen.
  • Gedeelde accounts. deploy met acht keys is één account met acht personen erachter. Het script ziet één eigenaar. Splits ze (persoonlijke accounts + sudo-regel), anders is de review per definitie onvolledig.
  • Review zonder uitvoering. Een lijst met dertig remove waarvan er in het volgende kwartaal nog 28 bestaan, is erger dan geen review: het bewijst dat je het wist. actions.log is er daarom.
  • Alleen servers. De databaserol met superuser, de S3-bucket-policy, de GitHub-org-owner: toegang zit overal. Begin met drie bronnen, voeg er elk kwartaal één toe, en schrijf in de scope wat je (nog) niet dekt.
  • Privacy. hr.csv bevat persoonsgegevens; de review-map ook. Beperkte toegang, bewaartermijn afspreken (drie jaar is gangbaar), en niet in een gedeelde chat plakken.

Wat je hiermee nog niet hebt

  • Continu in plaats van per kwartaal. Een account dat op dag 2 na de review wordt aangemaakt, valt 88 dagen buiten beeld. De dagelijkse diff uit de fleet-how-to vangt dat voor servers; voor de IdP heb je een webhook of een dagelijkse export nodig.
  • Eén identiteit over alle systemen. identities.csv is handwerk en veroudert. Bij dertig medewerkers gaat dat; bij driehonderd niet.
  • Bewijs dat de reviewer het las. Een handtekening over het manifest bewijst integriteit, niet aandacht. Sommige auditors vragen per finding een paraaf; een decided_by-kolom met datum is de pragmatische versie.

Zo doet monsys het

monsys verzamelt de serverkant continu (accounts, sudo, keys, laatste login per host) en koppelt via de SSO-integratie IdP-accounts aan dashboard-gebruikers. Het access-review-rapport (ISO 27001 A.5.18) genereert per kwartaal automatisch: alle accounts met rol, MFA-status en laatste activiteit, alle scoped role-assignments, de SSO-configuratie zonder secrets, en alle toegangsmutaties uit het audit-log in de periode — Ed25519-ondertekend, byte-stabiel, offline verifieerbaar. De beoordeling blijft mensenwerk; de beslissingen leg je vast in het rapport, en verwijderingen die via monsys lopen (agent-tokens, dashboard-gebruikers) staan als bewijs in het volgende.

FAQ

Hoe vaak moet een access review?

ISO 27001 zegt "at planned intervals"; de gangbare interpretatie en wat auditors verwachten is elk kwartaal voor beheerderstoegang en minstens jaarlijks voor gewone gebruikers. Bij elke uitdiensttreding daarnaast een gerichte check binnen 24 uur.

Wat als HR geen lijst kan leveren?

Dan begin je met wat je hebt (de IdP als beste benadering van "wie werkt hier") en schrijf je expliciet in de scope dat de HR-koppeling ontbreekt. Een auditor waardeert een eerlijke scope meer dan een review die volledigheid suggereert.

Moet elke finding een beslissing hebben?

Ja. Een finding zonder beslissing is een open punt dat de auditor als "niet gereviewd" telt. investigate:<eigenaar> is een geldige beslissing, mits er in de volgende review een uitkomst staat.

Geschreven door het monsys-team — sysadmins die dit dagelijks doen.

Zelf gedaan? Laat monsys het bijhouden.

Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.