#Requires -Version 5.1 <# .SYNOPSIS ADCS Health Check by certmon.de - free quick check for your Microsoft PKI (Active Directory Certificate Services). Kostenloser Schnellcheck für Ihre Microsoft-PKI (Active Directory Certificate Services). .DESCRIPTION Checks your internal Microsoft PKI for the most common causes of outages and creates an HTML report with a traffic light: - availability of the enterprise CAs - expiry, key length and signature algorithm of all CA certificates in AD - chain validation including revocation status of the CA certificates - CRLs (base and delta): expiry, overdue publication, stale copies - availability of the CDP and AIA locations (HTTP and LDAP) - NTAuth store (certificate / smart card logon) - issued certificates that expire soon and have not been renewed - certificates in the computer store of this machine The script is strictly READ-ONLY. No data is sent to 247-IT, certmon.de or anyone else. The report is only saved locally. Report and console are available in German and English (-Language). Der Check arbeitet ausschließlich lesend und überträgt keine Daten. Report und Konsole gibt es auf Deutsch und Englisch (-Language de). .PARAMETER OutputPath Path of the HTML file. Default: desktop or current folder. .PARAMETER Language Language of report and console: de or en. Default: German if the Windows display language is German, otherwise English. .PARAMETER WarningDays Remaining days at which a certificate or CRL is reported as a warning. Default: 30. .PARAMETER CriticalDays Remaining days at which it is reported as critical. Default: 7. .PARAMETER CaWarningDays Warning threshold for CA certificates. Default: 365. .PARAMETER CaCriticalDays Critical threshold for CA certificates. Default: 90. .PARAMETER ExtraCrlUrl Additional CRL URLs to check (e.g. of an offline root CA). .PARAMETER SkipDatabase Do not evaluate the CA database (expiring issued certificates). .PARAMETER SkipNetwork No HTTP requests and no online chain validation. .PARAMETER Json Additionally write a JSON file next to the report (for scripts and RMM tools). Ids in the JSON do not depend on the language. .PARAMETER NoOpen Do not open the report automatically. .PARAMETER Demo Creates a sample report with made-up data without checking the network. .EXAMPLE .\ADCS-HealthCheck.ps1 .EXAMPLE .\ADCS-HealthCheck.ps1 -OutputPath C:\Temp\pki.html -Json -NoOpen .EXAMPLE .\ADCS-HealthCheck.ps1 -Demo -Language en .NOTES Exit code: 0 = all OK, 1 = warnings, 2 = critical findings, 3 = error. (c) 247-IT, Herrenberg. https://certmon.de #> [CmdletBinding()] param( [string]$OutputPath, [ValidateSet('de', 'en')][string]$Language, [ValidateRange(1, 3650)][int]$WarningDays = 30, [ValidateRange(0, 3650)][int]$CriticalDays = 7, [ValidateRange(1, 3650)][int]$CaWarningDays = 365, [ValidateRange(0, 3650)][int]$CaCriticalDays = 90, [string[]]$ExtraCrlUrl, [switch]$SkipDatabase, [switch]$SkipNetwork, [switch]$Json, [switch]$NoOpen, [switch]$Demo ) $Script:Version = '0.2.0' $Script:Product = 'ADCS Health Check' $Script:ProductFull = 'ADCS Health Check by certmon.de' $Script:Vendor = '247-IT' $Script:SiteBase = 'https://certmon.de' $Script:CtaMail = 'mail@247-it.com' $Script:Findings = New-Object System.Collections.Generic.List[object] $Script:HttpCache = @{} $Script:SampleCerts = @{} #region DER-Helfer (wird beim Build eingebettet) $Script:DerSource = @' // Minimaler DER-Parser fuer CRLs und Zertifikatserweiterungen. // Bewusst C# 5 (Windows PowerShell 5.1 / .NET Framework csc) und ohne externe Abhaengigkeiten. using System; using System.Collections.Generic; using System.Globalization; using System.Text; namespace AdcsHc { public class Tlv { public int Tag; public int Offset; public int HeaderLength; public int Length; public int ValueOffset { get { return Offset + HeaderLength; } } public int End { get { return Offset + HeaderLength + Length; } } public bool Constructed { get { return (Tag & 0x20) != 0; } } } public class CrlInfo { public DateTime ThisUpdate; public DateTime? NextUpdate; public byte[] IssuerRaw; public int EntryCount; public bool IsDelta; public long CrlNumber = -1; public string AuthorityKeyId; } public static class Der { public static Tlv Read(byte[] d, int off) { if (d == null || off < 0 || off + 2 > d.Length) throw new FormatException("DER: Daten zu kurz"); int tag = d[off]; if ((tag & 0x1F) == 0x1F) throw new FormatException("DER: hohe Tag-Nummern nicht unterstuetzt"); int b = d[off + 1]; int len; int hl = 2; if (b < 0x80) { len = b; } else { int n = b & 0x7F; if (n == 0 || n > 4) throw new FormatException("DER: ungueltige Laengenangabe"); if (off + 2 + n > d.Length) throw new FormatException("DER: Daten zu kurz"); len = 0; for (int i = 0; i < n; i++) len = (len << 8) + d[off + 2 + i]; hl = 2 + n; } if (len < 0 || off + hl + len > d.Length) throw new FormatException("DER: Laenge ueberschreitet Daten"); Tlv t = new Tlv(); t.Tag = tag; t.Offset = off; t.HeaderLength = hl; t.Length = len; return t; } public static List Children(byte[] d, Tlv parent) { List list = new List(); int p = parent.ValueOffset; while (p < parent.End) { Tlv c = Read(d, p); list.Add(c); p = c.End; } return list; } public static byte[] Value(byte[] d, Tlv t) { byte[] r = new byte[t.Length]; Buffer.BlockCopy(d, t.ValueOffset, r, 0, t.Length); return r; } public static byte[] Whole(byte[] d, Tlv t) { byte[] r = new byte[t.HeaderLength + t.Length]; Buffer.BlockCopy(d, t.Offset, r, 0, r.Length); return r; } public static DateTime ParseTime(byte[] d, Tlv t) { string s = Encoding.ASCII.GetString(d, t.ValueOffset, t.Length); if (t.Tag == 0x17) { int yy = int.Parse(s.Substring(0, 2), CultureInfo.InvariantCulture); int year = yy >= 50 ? 1900 + yy : 2000 + yy; s = year.ToString("0000", CultureInfo.InvariantCulture) + s.Substring(2); } else if (t.Tag != 0x18) { throw new FormatException("DER: kein Zeitwert"); } s = s.TrimEnd('Z'); int dot = s.IndexOf('.'); if (dot >= 0) s = s.Substring(0, dot); string fmt = s.Length == 12 ? "yyyyMMddHHmm" : "yyyyMMddHHmmss"; return DateTime.SpecifyKind(DateTime.ParseExact(s, fmt, CultureInfo.InvariantCulture), DateTimeKind.Utc); } public static string DecodeOid(byte[] d, Tlv t) { if (t.Tag != 0x06) throw new FormatException("DER: keine OID"); StringBuilder sb = new StringBuilder(); long v = 0; bool first = true; for (int i = t.ValueOffset; i < t.End; i++) { v = (v << 7) + (d[i] & 0x7F); if ((d[i] & 0x80) == 0) { if (first) { long a = v < 40 ? 0 : (v < 80 ? 1 : 2); sb.Append(a).Append('.').Append(v - a * 40); first = false; } else { sb.Append('.').Append(v); } v = 0; } } return sb.ToString(); } public static string Hex(byte[] b) { StringBuilder sb = new StringBuilder(b.Length * 2); foreach (byte x in b) sb.Append(x.ToString("X2", CultureInfo.InvariantCulture)); return sb.ToString(); } public static CrlInfo ParseCrl(byte[] d) { Tlv outer = Read(d, 0); List oc = Children(d, outer); List tc = Children(d, oc[0]); int i = 0; if (tc[i].Tag == 0x02) i++; // version i++; // signature AlgorithmIdentifier CrlInfo info = new CrlInfo(); info.IssuerRaw = Whole(d, tc[i++]); info.ThisUpdate = ParseTime(d, tc[i++]); if (i < tc.Count && (tc[i].Tag == 0x17 || tc[i].Tag == 0x18)) info.NextUpdate = ParseTime(d, tc[i++]); if (i < tc.Count && tc[i].Tag == 0x30) { info.EntryCount = Children(d, tc[i]).Count; i++; } if (i < tc.Count && tc[i].Tag == 0xA0) { Tlv exts = Children(d, tc[i])[0]; foreach (Tlv ext in Children(d, exts)) { List ec = Children(d, ext); string oid = DecodeOid(d, ec[0]); Tlv val = ec[ec.Count - 1]; if (oid == "2.5.29.27") info.IsDelta = true; else if (oid == "2.5.29.20") { Tlv n = Read(d, val.ValueOffset); byte[] nb = Value(d, n); if (nb.Length <= 8) { long num = 0; foreach (byte x in nb) num = (num << 8) + x; info.CrlNumber = num; } } else if (oid == "2.5.29.35") { Tlv seq = Read(d, val.ValueOffset); foreach (Tlv c in Children(d, seq)) if (c.Tag == 0x80) info.AuthorityKeyId = Hex(Value(d, c)); } } } return info; } // Liefert alle URIs (GeneralName [6]) aus einer Erweiterung, z. B. CRL Distribution Points. public static List FindUris(byte[] d) { List result = new List(); if (d == null || d.Length < 2) return result; Walk(d, Read(d, 0), result); return result; } private static void Walk(byte[] d, Tlv t, List result) { if (t.Tag == 0x86) { result.Add(Encoding.ASCII.GetString(d, t.ValueOffset, t.Length)); return; } if (t.Constructed) foreach (Tlv c in Children(d, t)) Walk(d, c, result); } // Authority Information Access: liefert Paare { Methode, URI }. Methode: "caIssuers", "ocsp" oder OID. public static List ParseAia(byte[] d) { List result = new List(); if (d == null || d.Length < 2) return result; Tlv seq = Read(d, 0); foreach (Tlv ad in Children(d, seq)) { List parts = Children(d, ad); if (parts.Count < 2 || parts[1].Tag != 0x86) continue; string oid = DecodeOid(d, parts[0]); string method = oid == "1.3.6.1.5.5.7.48.2" ? "caIssuers" : (oid == "1.3.6.1.5.5.7.48.1" ? "ocsp" : oid); result.Add(new string[] { method, Encoding.ASCII.GetString(d, parts[1].ValueOffset, parts[1].Length) }); } return result; } } } '@ if ($Script:DerSource.Trim() -eq ('# @@DER_' + 'SOURCE@@')) { # im Quellstand: C# aus Nachbardatei laden $Script:DerSource = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'AdcsHc.Der.cs') -Raw } if (-not ('AdcsHc.Der' -as [type])) { Add-Type -TypeDefinition $Script:DerSource -Language CSharp } #endregion #region Texte # Alle Texte für Report und Konsole, je Sprache eine Tabelle. Beide Tabellen müssen dieselben Schlüssel und # dieselben Platzhalter ({0}, {1:N0} ...) enthalten, der Test prüft das. Geschweifte Klammern im Text verdoppeln. $Script:Texts = @{ de = @{ DateTimeFormat = 'dd.MM.yyyy HH:mm' DateFormat = 'dd.MM.yyyy' DaysExpired = 'seit {0} Tagen abgelaufen' DaysExpiredOne = 'seit 1 Tag abgelaufen' DaysToday = 'läuft heute ab' DaysTomorrow = 'läuft morgen ab' DaysLeft = 'noch {0} Tage gültig' Status_Critical = 'Kritisch' Status_Warning = 'Warnung' Status_Info = 'Info' Status_OK = 'OK' Cat_CAS = 'Zertifizierungsstellen' Cat_CA_CERTS = 'CA-Zertifikate' Cat_CRL = 'Sperrlisten (CRL)' Cat_CDP_AIA = 'CDP/AIA-Erreichbarkeit' Cat_NTAUTH = 'NTAuth' Cat_ISSUED = 'Ausgestellte Zertifikate' Cat_LOCAL_STORE = 'Lokaler Zertifikatsspeicher' Cat_GENERAL = 'Allgemein' ErrConfigNc = 'Configuration-Partition nicht ermittelbar.' ErrMissingText = 'Text "{0}" fehlt in der Sprachtabelle.' VerboseExtension = 'Erweiterung {0} nicht lesbar: {1}' VerboseNtAuth = 'NTAuth nicht lesbar: {0}' VerboseTemplates = 'Vorlagennamen nicht lesbar: {0}' VerboseCaDb = 'CA-Datenbank {0}: {1}' VerboseSampleCert = 'Beispielzertifikat {0}: {1}' SrcEnterpriseCa = 'Enterprise-CA {0}' SrcRootCas = 'Vertrauenswürdige Stamm-CAs (AD)' SrcAia = 'AIA-Container (AD)' SrcNtAuth = 'NTAuth-Speicher' FromCaCert = 'CA-Zertifikat {0}' FromIssued = 'von CA {0} ausgestellte Zertifikate' FromParam = 'Parameter -ExtraCrlUrl' CaCertutilMissing = 'certutil.exe nicht gefunden, Erreichbarkeit nicht geprüft.' CaPingOk = 'CA-Dienst auf {0} antwortet. {1} Vorlagen veröffentlicht.' CaPingFailed = 'CA-Dienst auf {0} antwortet nicht (certutil -ping, Code {1}).' CaPingFailedRec = 'Dienst "Active Directory-Zertifikatdienste" (CertSvc) auf dem CA-Server prüfen, Firewall (RPC/DCOM) und DNS-Auflösung kontrollieren.' CaNone = 'Keine Enterprise-CA im AD registriert.' CaCertMsg = '{0}. Fundort: {1}.' CaCertActive = 'Dies ist das aktuelle Zertifikat einer Enterprise-CA.' CaCertRenewRec = 'CA-Zertifikat rechtzeitig erneuern. Eine CA stellt keine Zertifikate aus, die länger gültig sind als ihr eigenes Zertifikat - Laufzeiten neuer Zertifikate verkürzen sich bereits jetzt.' CaWeakKey = 'RSA-Schlüssel mit nur {0} Bit.' CaWeakKeyRec = 'Bei der nächsten Erneuerung ein neues Schlüsselpaar mit mindestens 2048 Bit (besser 4096 Bit) erzeugen.' CaWeakHash = 'Signiert mit veraltetem Hash-Verfahren {0}.' CaWeakHashRec = 'CA auf SHA-256 umstellen und CA-Zertifikat erneuern. Moderne Clients und Browser lehnen SHA-1 teilweise ab.' CaExpiredLeftover = 'Abgelaufenes CA-Zertifikat liegt noch im AD ({0}).' CaExpiredLeftoverMulti = '{0} abgelaufene CA-Zertifikate liegen noch im AD ({1}). Abgelaufen am: {2}.' CaExpiredLeftoverRec = 'Kann nach Prüfung entfernt werden (certutil -viewdelstore), damit Clients keine veralteten Zertifikate laden.' ChainOkOne = 'Zertifikatskette gültig, Sperrstatus prüfbar (1 Zertifikat).' ChainOkMany = 'Zertifikatskette gültig, Sperrstatus prüfbar ({0} Zertifikate).' ChainError = 'Kettenprüfung: {0}' ChainRec = 'Kette auf einem Client mit "certutil -verify -urlfetch" prüfen.' ChainRevocationRec = 'Die Sperrliste der übergeordneten CA ist abgelaufen oder nicht erreichbar. Clients können dann Zertifikate dieser CA ablehnen (WLAN, VPN, LDAPS, Smartcard). CDP-Pfade und Offline-Root-CRL prüfen.' NtAuthOk = 'Aktuelles CA-Zertifikat ist im NTAuth-Speicher eingetragen.' NtAuthMissing = 'Aktuelles CA-Zertifikat fehlt im NTAuth-Speicher.' NtAuthMissingRec = 'Ohne NTAuth-Eintrag funktionieren Smartcard-Anmeldung, Windows Hello for Business (Zertifikat) und 802.1X mit Zertifikat nicht. Als Enterprise-Admin: certutil -dspublish -f NTAuthCA' IssuedDbUnreadable = 'CA-Datenbank konnte nicht gelesen werden.' IssuedDbUnreadableRec = 'Für diese Prüfung den Check als CA-Administrator ausführen oder dem Konto das Recht "Lesen" auf der CA geben.' IssuedSummary = '{0:N0} gültige ausgestellte Zertifikate, {1:N0} Inhaber/Vorlagen-Kombinationen, davon {2:N0} laufen in den nächsten {3} Tagen ab und sind noch nicht erneuert.' IssuedMore = 'Weitere {0:N0} ablaufende Zertifikate nicht einzeln aufgeführt.' IssuedNoCn = '(ohne CN)' IssuedExpiring = '{0}. Vorlage: {1}, Antragsteller: {2}, Anforderungs-ID {3}, CA {4}.' IssuedExpiringRec = 'Zertifikat erneuern oder prüfen, warum die automatische Registrierung nicht greift. Wird es nicht mehr gebraucht: bewusst auslaufen lassen.' CrlPathsUnknown = 'CDP/AIA-Pfade der ausgestellten Zertifikate nicht ermittelbar (keine Datenbankrechte und kein Zertifikat dieser CA auf diesem Rechner).' CrlPathsUnknownRec = 'Check als CA-Administrator ausführen oder URLs per -ExtraCrlUrl angeben. Sperrlisten im AD werden trotzdem geprüft.' CrlNoNextUpdate = 'Sperrliste ohne "Nächste Aktualisierung".' CrlExpired = 'Sperrliste ist {0}.' CrlLongLived = 'Sperrliste {0} (Laufzeit {1} Tage, typisch für Offline-CA).' CrlOverdue = 'Sperrliste läuft in {0:N1} Stunden ab, eine neue Version wurde noch nicht veröffentlicht.' CrlCurrent = 'Sperrliste aktuell, nächste Aktualisierung {0}.' CrlAdUnreadable = 'Sperrliste im AD nicht lesbar: {0}' CrlAdUnreadableRec = 'Sperrliste neu veröffentlichen: certutil -crl' CrlLdapMissing = 'LDAP-Sperrliste nicht im AD gefunden. Verwendet in: {0}.' CrlLdapMissingRec = 'Auf der ausstellenden CA "certutil -crl" ausführen bzw. bei einer Offline-CA die CRL mit "certutil -dspublish " ins AD laden.' CrlOrphanedHost = 'Verwaiste Sperrliste ({0}) des Servers {1}, zuletzt aktualisiert am {2}.' CrlOrphaned = 'Verwaiste Sperrliste ({0}), zuletzt aktualisiert am {1}.' CrlOrphanedRef = 'Eine neuere Version liegt an anderer Stelle, kein geprüftes Zertifikat verweist hierher.' CrlOrphanedRefUnknown = 'Eine neuere Version liegt an anderer Stelle. Welche Pfade die ausgestellten Zertifikate verwenden, war bei diesem Lauf nicht ermittelbar.' CrlOrphanedRec = 'Typisch nach einem Umzug der CA. Kann nach Prüfung mit ADSI-Edit (Konfiguration > Services > Public Key Services > CDP) entfernt werden. Vorher sicherstellen, dass keine noch gültigen Zertifikate diesen Pfad verwenden.' CrlKindBase = 'Base-CRL' CrlKindDelta = 'Delta-CRL' CrlDetail = 'Nummer {0}, {1:N0} Einträge. Ort: {2}.' CrlDetailNoNumber = '{0:N0} Einträge. Ort: {1}.' CrlUsedIn = 'Verwendet in: {0}.' CrlOfflineRec = 'Offline-CA: CA starten, neue CRL mit "certutil -crl" signieren und an alle CDP-Orte (AD und Webserver) kopieren. Termin für die nächste Erneuerung im Kalender eintragen.' CrlOnlineRec = 'Auf der CA prüfen, ob der Dienst läuft und die CRL veröffentlichen kann: "certutil -crl". Schreibrechte der CA auf die CDP-Orte kontrollieren.' CrlStale = 'Veraltete Kopie: CRL-Nummer {0}, an anderem Ort liegt bereits Nummer {1}.' CrlStaleRec = 'Veröffentlichung an diesen Ort prüfen (Kopierjob, Freigabe, Schreibrechte der CA). Clients können hier eine veraltete oder bald abgelaufene Liste laden.' CdpUnreachable = 'Sperrliste nicht abrufbar ({0}). Verwendet in: {1}.' CdpUnreachableRec = 'Webserver, DNS-Eintrag und Freigabe des CDP-Ordners prüfen. Clients ohne erreichbare Sperrliste lehnen Zertifikate ggf. ab.' CdpDeltaPlusRec = 'Delta-CRL mit "+" im Namen: Im IIS für das CDP-Verzeichnis "allowDoubleEscaping" aktivieren (Anforderungsfilterung).' CdpOk = 'Sperrliste abrufbar. Verwendet in: {0}.' CdpInvalid = 'Abgerufene Datei ist keine gültige Sperrliste. Verwendet in: {0}.' CdpInvalidRec = 'Prüfen, ob der Webserver eine Fehlerseite statt der CRL ausliefert (MIME-Typ .crl = application/pkix-crl).' CdpNoHttpObject = 'HTTP-Sperrlistenpfad' CdpNoHttp = 'Die ausgestellten Zertifikate enthalten nur LDAP-Pfade für die Sperrliste, keinen HTTP-Pfad.' CdpNoHttpRec = 'Geräte ohne LDAP-Zugriff auf die Domäne (Handys und Gäste im WLAN, VPN vor der Anmeldung, Partner) können den Sperrstatus dann nicht prüfen. Bei Bedarf einen HTTP-CDP ergänzen.' AiaUnreachable = 'CA-Zertifikat nicht abrufbar ({0}). Verwendet in: {1}.' AiaUnreachableRec = 'Clients außerhalb der Domäne (z. B. Handys im WLAN, Partner) können die Kette sonst nicht aufbauen. Datei auf dem Webserver bereitstellen.' AiaOk = 'CA-Zertifikat abrufbar ({0}, {1}). Verwendet in: {2}.' AiaExpiredRec = 'Am AIA-Ort liegt ein abgelaufenes CA-Zertifikat. Aktuelles Zertifikat der CA dort ablegen.' AiaInvalid = 'Abgerufene Datei ist kein gültiges Zertifikat. Verwendet in: {0}.' AiaInvalidRec = 'Prüfen, ob der Webserver eine Fehlerseite ausliefert (MIME-Typ .crt = application/pkix-cert).' LocalEmpty = 'Keine Zertifikate im Computer-Speicher (Eigene Zertifikate).' LocalExpired = 'Abgelaufenes Zertifikat ohne Nachfolger im Speicher (Aussteller: {0}).' LocalExpiredRec = 'Prüfen, ob ein Dienst (IIS, RDP, LDAPS) es noch verwendet. Sonst entfernen.' LocalExpiring = '{0}. Aussteller: {1}.' LocalExpiringRec = 'Zertifikat erneuern und in allen Diensten (IIS-Bindung, RDP, LDAPS) neu zuweisen.' LocalOk = '{0:N0} Zertifikate im Computer-Speicher, keines läuft in den nächsten {1} Tagen ab.' LocalForeign = '{0:N0} abgelaufene Zertifikate fremder Aussteller (z. B. von Microsoft-Diensten) wurden nicht einzeln aufgeführt.' OtherServersObject = 'Weitere Server' OtherServers = 'Geprüft wurde nur dieser Rechner. Zertifikate auf anderen Servern (IIS, RDS, Exchange, LDAPS) wurden nicht erfasst.' AdUnreadable = 'PKI-Objekte im AD konnten nicht gelesen werden: {0}' AdUnreadableRec = 'Den Check auf einem domänenangehörigen Windows-Rechner mit einem Domänenkonto ausführen.' CaListNone = 'keine gefunden' DemoTplWeb = 'Webserver' DemoTplRas = 'RAS- und IAS-Server' ReportSubtitle = 'Zustand Ihrer Microsoft-PKI' ReportCreated = 'Erstellt {0}' ReportCreatedOn = 'auf {0} · Version {1}' LabelDomain = 'Domäne' LabelOverall = 'Gesamtstatus' OverallOk = 'Keine Probleme gefunden' OverallWarn = 'Handlungsbedarf in den nächsten Wochen' OverallCrit = 'Sofortiger Handlungsbedarf' CountWarnings = 'Warnungen' CountInfo = 'Hinweise' SectionEnvironment = 'Umgebung' EnvCas = 'Enterprise-CAs' EnvCaCerts = 'CA-Zertifikate im AD' EnvUser = 'Geprüft als' ColStatus = 'Status' ColObject = 'Objekt' ColFinding = 'Befund' ColDate = 'Datum' Recommendation = 'Empfehlung: {0}' CtaTitle = 'Dieser Check ist eine Momentaufnahme.' CtaText = 'Sperrlisten und Zertifikate laufen jeden Tag weiter ab. Der {0} überwacht Ihre PKI dauerhaft, prüft auch die Zertifikate auf allen Servern und warnt per E-Mail oder Teams, bevor WLAN, VPN oder RDP ausfallen. Einmallizenz statt Abo, läuft komplett in Ihrem Netz.' CtaButton = 'Auf die Warteliste setzen' CtaContact = 'Fragen oder Hilfe bei einem Befund: {0}' Footer = 'Der Check arbeitet nur lesend. Es wurden keine Daten übertragen; dieser Report liegt nur auf Ihrem Rechner. Freeware, alle Angaben ohne Gewähr.' ConsoleReadOnly = 'Der Check arbeitet nur lesend. Es werden keine Daten übertragen.' Step1 = '[1/7] Lese PKI-Objekte aus dem Active Directory ...' Step2 = '[2/7] Prüfe Erreichbarkeit der CAs ...' Step3 = '[3/7] Prüfe CA-Zertifikate und Ketten ...' Step4 = '[4/7] Prüfe NTAuth ...' Step5 = '[5/7] Werte CA-Datenbank aus ...' Step6 = '[6/7] Prüfe Sperrlisten, CDP und AIA ...' Step7 = '[7/7] Prüfe lokalen Zertifikatsspeicher ...' ConsoleResult = 'Ergebnis: {0} kritisch, {1} Warnungen, {2} OK' ConsoleReport = 'Report: {0}' ConsoleJson = 'JSON: {0}' ConsoleError = 'Fehler: {0}' } en = @{ DateTimeFormat = 'yyyy-MM-dd HH:mm' DateFormat = 'yyyy-MM-dd' DaysExpired = 'expired {0} days ago' DaysExpiredOne = 'expired 1 day ago' DaysToday = 'expires today' DaysTomorrow = 'expires tomorrow' DaysLeft = 'valid for another {0} days' Status_Critical = 'Critical' Status_Warning = 'Warning' Status_Info = 'Info' Status_OK = 'OK' Cat_CAS = 'Certification authorities' Cat_CA_CERTS = 'CA certificates' Cat_CRL = 'Revocation lists (CRL)' Cat_CDP_AIA = 'CDP/AIA availability' Cat_NTAUTH = 'NTAuth' Cat_ISSUED = 'Issued certificates' Cat_LOCAL_STORE = 'Local certificate store' Cat_GENERAL = 'General' ErrConfigNc = 'Could not determine the configuration partition.' ErrMissingText = 'Text "{0}" is missing from the language table.' VerboseExtension = 'Extension {0} not readable: {1}' VerboseNtAuth = 'NTAuth not readable: {0}' VerboseTemplates = 'Template names not readable: {0}' VerboseCaDb = 'CA database {0}: {1}' VerboseSampleCert = 'Sample certificate {0}: {1}' SrcEnterpriseCa = 'Enterprise CA {0}' SrcRootCas = 'Trusted Root CAs (AD)' SrcAia = 'AIA container (AD)' SrcNtAuth = 'NTAuth store' FromCaCert = 'CA certificate {0}' FromIssued = 'certificates issued by CA {0}' FromParam = 'parameter -ExtraCrlUrl' CaCertutilMissing = 'certutil.exe not found, availability not checked.' CaPingOk = 'CA service on {0} is responding. {1} templates published.' CaPingFailed = 'CA service on {0} is not responding (certutil -ping, code {1}).' CaPingFailedRec = 'Check the "Active Directory Certificate Services" service (CertSvc) on the CA server, the firewall (RPC/DCOM) and DNS resolution.' CaNone = 'No enterprise CA registered in AD.' CaCertMsg = '{0}. Found in: {1}.' CaCertActive = 'This is the current certificate of an enterprise CA.' CaCertRenewRec = 'Renew the CA certificate in time. A CA never issues certificates that outlive its own certificate, so new certificates are already getting shorter lifetimes.' CaWeakKey = 'RSA key with only {0} bits.' CaWeakKeyRec = 'Generate a new key pair with at least 2048 bits (4096 recommended) at the next renewal.' CaWeakHash = 'Signed with the outdated hash algorithm {0}.' CaWeakHashRec = 'Switch the CA to SHA-256 and renew the CA certificate. Modern clients and browsers partly reject SHA-1.' CaExpiredLeftover = 'Expired CA certificate is still published in AD ({0}).' CaExpiredLeftoverMulti = '{0} expired CA certificates are still published in AD ({1}). Expired on: {2}.' CaExpiredLeftoverRec = 'Can be removed after review (certutil -viewdelstore) so that clients do not load outdated certificates.' ChainOkOne = 'Certificate chain is valid, revocation status can be checked (1 certificate).' ChainOkMany = 'Certificate chain is valid, revocation status can be checked ({0} certificates).' ChainError = 'Chain validation: {0}' ChainRec = 'Check the chain on a client with "certutil -verify -urlfetch".' ChainRevocationRec = 'The CRL of the parent CA has expired or cannot be reached. Clients may then reject certificates of this CA (Wi-Fi, VPN, LDAPS, smart card). Check the CDP locations and the offline root CRL.' NtAuthOk = 'Current CA certificate is present in the NTAuth store.' NtAuthMissing = 'Current CA certificate is missing from the NTAuth store.' NtAuthMissingRec = 'Without an NTAuth entry, smart card logon, Windows Hello for Business (certificate trust) and 802.1X with certificates will fail. As Enterprise Admin: certutil -dspublish -f NTAuthCA' IssuedDbUnreadable = 'Could not read the CA database.' IssuedDbUnreadableRec = 'For this check, run it as CA administrator or grant the account "Read" permission on the CA.' IssuedSummary = '{0:N0} valid issued certificates, {1:N0} subject/template combinations; {2:N0} of them expire within the next {3} days and have not been renewed yet.' IssuedMore = '{0:N0} more expiring certificates are not listed individually.' IssuedNoCn = '(no CN)' IssuedExpiring = '{0}. Template: {1}, requester: {2}, request ID {3}, CA {4}.' IssuedExpiringRec = 'Renew the certificate or find out why autoenrollment does not kick in. If it is no longer needed, let it expire deliberately.' CrlPathsUnknown = 'Could not determine the CDP/AIA paths of the issued certificates (no database permission and no certificate of this CA on this machine).' CrlPathsUnknownRec = 'Run the check as CA administrator or pass the URLs with -ExtraCrlUrl. CRLs in AD are checked anyway.' CrlNoNextUpdate = 'CRL has no "Next Update" field.' CrlExpired = 'CRL {0}.' CrlLongLived = 'CRL {0} (validity period {1} days, typical for an offline CA).' CrlOverdue = 'CRL expires in {0:N1} hours and no newer version has been published yet.' CrlCurrent = 'CRL is current, next update {0}.' CrlAdUnreadable = 'CRL in AD cannot be read: {0}' CrlAdUnreadableRec = 'Republish the CRL: certutil -crl' CrlLdapMissing = 'LDAP CRL not found in AD. Used in: {0}.' CrlLdapMissingRec = 'Run "certutil -crl" on the issuing CA. For an offline CA, publish the CRL to AD with "certutil -dspublish ".' CrlOrphanedHost = 'Orphaned CRL ({0}) of server {1}, last updated {2}.' CrlOrphaned = 'Orphaned CRL ({0}), last updated {1}.' CrlOrphanedRef = 'A newer version exists elsewhere and no checked certificate points here.' CrlOrphanedRefUnknown = 'A newer version exists elsewhere. Which paths the issued certificates use could not be determined in this run.' CrlOrphanedRec = 'Typical after moving the CA to another server. Can be removed after review with ADSI Edit (Configuration > Services > Public Key Services > CDP). Make sure first that no valid certificate still uses this path.' CrlKindBase = 'Base CRL' CrlKindDelta = 'Delta CRL' CrlDetail = 'Number {0}, {1:N0} entries. Location: {2}.' CrlDetailNoNumber = '{0:N0} entries. Location: {1}.' CrlUsedIn = 'Used in: {0}.' CrlOfflineRec = 'Offline CA: start the CA, sign a new CRL with "certutil -crl" and copy it to all CDP locations (AD and web server). Put the next renewal date in your calendar.' CrlOnlineRec = 'Check on the CA that the service is running and can publish the CRL: "certutil -crl". Verify the CA has write access to the CDP locations.' CrlStale = 'Stale copy: CRL number {0}, another location already has number {1}.' CrlStaleRec = 'Check publishing to this location (copy job, share, CA write permissions). Clients may load an outdated or soon-to-expire list from here.' CdpUnreachable = 'CRL cannot be downloaded ({0}). Used in: {1}.' CdpUnreachableRec = 'Check the web server, the DNS record and the share of the CDP folder. Clients that cannot reach the CRL may reject certificates.' CdpDeltaPlusRec = 'Delta CRL with "+" in its name: enable "allowDoubleEscaping" for the CDP directory in IIS (request filtering).' CdpOk = 'CRL can be downloaded. Used in: {0}.' CdpInvalid = 'Downloaded file is not a valid CRL. Used in: {0}.' CdpInvalidRec = 'Check whether the web server returns an error page instead of the CRL (MIME type .crl = application/pkix-crl).' CdpNoHttpObject = 'HTTP CRL path' CdpNoHttp = 'The issued certificates only contain LDAP paths for the CRL, no HTTP path.' CdpNoHttpRec = 'Devices without LDAP access to the domain (phones and guests on Wi-Fi, VPN before logon, partners) cannot check the revocation status. Add an HTTP CDP if needed.' AiaUnreachable = 'CA certificate cannot be downloaded ({0}). Used in: {1}.' AiaUnreachableRec = 'Clients outside the domain (e.g. phones on Wi-Fi, partners) cannot build the chain otherwise. Publish the file on the web server.' AiaOk = 'CA certificate can be downloaded ({0}, {1}). Used in: {2}.' AiaExpiredRec = 'The AIA location serves an expired CA certificate. Put the current CA certificate there.' AiaInvalid = 'Downloaded file is not a valid certificate. Used in: {0}.' AiaInvalidRec = 'Check whether the web server returns an error page (MIME type .crt = application/pkix-cert).' LocalEmpty = 'No certificates in the computer store (Personal).' LocalExpired = 'Expired certificate without a successor in the store (issuer: {0}).' LocalExpiredRec = 'Check whether a service (IIS, RDP, LDAPS) still uses it. Otherwise remove it.' LocalExpiring = '{0}. Issuer: {1}.' LocalExpiringRec = 'Renew the certificate and reassign it in all services (IIS binding, RDP, LDAPS).' LocalOk = '{0:N0} certificates in the computer store, none expires within the next {1} days.' LocalForeign = '{0:N0} expired certificates from other issuers (e.g. Microsoft services) are not listed individually.' OtherServersObject = 'Other servers' OtherServers = 'Only this machine was checked. Certificates on other servers (IIS, RDS, Exchange, LDAPS) were not included.' AdUnreadable = 'Could not read the PKI objects from AD: {0}' AdUnreadableRec = 'Run the check on a domain-joined Windows machine with a domain account.' CaListNone = 'none found' DemoTplWeb = 'Web Server' DemoTplRas = 'RAS and IAS Server' ReportSubtitle = 'State of your Microsoft PKI' ReportCreated = 'Created {0}' ReportCreatedOn = 'on {0} · version {1}' LabelDomain = 'Domain' LabelOverall = 'Overall status' OverallOk = 'No problems found' OverallWarn = 'Action needed in the coming weeks' OverallCrit = 'Immediate action required' CountWarnings = 'Warnings' CountInfo = 'Notes' SectionEnvironment = 'Environment' EnvCas = 'Enterprise CAs' EnvCaCerts = 'CA certificates in AD' EnvUser = 'Checked as' ColStatus = 'Status' ColObject = 'Object' ColFinding = 'Finding' ColDate = 'Date' Recommendation = 'Recommendation: {0}' CtaTitle = 'This check is a snapshot.' CtaText = 'CRLs and certificates keep expiring every day. {0} watches your PKI continuously, also checks the certificates on all your servers and alerts you by email or Teams before Wi-Fi, VPN or RDP go down. One-time license instead of a subscription, runs entirely inside your network.' CtaButton = 'Join the waitlist' CtaContact = 'Questions or need help with a finding? {0}' Footer = 'The check is read-only. No data was transmitted; this report exists only on your computer. Freeware, provided as is without any warranty.' ConsoleReadOnly = 'The check is read-only. No data is transmitted.' Step1 = '[1/7] Reading PKI objects from Active Directory ...' Step2 = '[2/7] Checking CA availability ...' Step3 = '[3/7] Checking CA certificates and chains ...' Step4 = '[4/7] Checking NTAuth ...' Step5 = '[5/7] Reading the CA database ...' Step6 = '[6/7] Checking CRLs, CDP and AIA ...' Step7 = '[7/7] Checking the local certificate store ...' ConsoleResult = 'Result: {0} critical, {1} warnings, {2} OK' ConsoleReport = 'Report: {0}' ConsoleJson = 'JSON: {0}' ConsoleError = 'Error: {0}' } } function Resolve-Language { # Sprache aus Parameter, sonst aus der Anzeigesprache von Windows (de -> Deutsch, sonst Englisch). param([string]$Requested) if ($Requested) { return $Requested.ToLowerInvariant() } $ui = '' try { $ui = (Get-UICulture).TwoLetterISOLanguageName } catch { } if ($ui -eq 'de') { return 'de' } return 'en' } function Set-Language { param([string]$Lang) if (-not $Script:Texts.ContainsKey($Lang)) { $Lang = 'en' } $Script:Lang = $Lang $Script:Text = $Script:Texts[$Lang] $cultureName = @{ 'de' = 'de-DE'; 'en' = 'en-US' }[$Lang] try { $Script:Culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName) } catch { $Script:Culture = [Globalization.CultureInfo]::InvariantCulture } $Script:SiteUrl = '{0}/{1}/?utm_source=report&utm_medium=tool&utm_campaign=healthcheck-{2}' -f $Script:SiteBase, $Lang, $Script:Version $Script:CtaUrl = $Script:SiteUrl + '#waitlist' } function T { # Liefert den Text zum Schlüssel in der aktuellen Sprache, optional mit Werten für {0}, {1} ... param( [Parameter(Mandatory)][string]$Key, [Parameter(ValueFromRemainingArguments = $true)][object[]]$Values ) $fmt = $Script:Text[$Key] if ($null -eq $fmt) { throw ($Script:Text['ErrMissingText'] -f $Key) } if ($null -eq $Values -or $Values.Count -eq 0) { return $fmt } return [string]::Format($Script:Culture, $fmt, [object[]]$Values) } Set-Language (Resolve-Language $Language) #endregion #region Hilfsfunktionen $Script:SeverityRank = @{ 'Critical' = 3; 'Warning' = 2; 'Info' = 1; 'OK' = 0 } $Script:Categories = @('CAS', 'CA_CERTS', 'CRL', 'CDP_AIA', 'NTAUTH', 'ISSUED', 'LOCAL_STORE', 'GENERAL') function Add-Finding { # Id und Category sind sprachunabhängige Kennungen (für JSON und Auswertungen), Message/Recommendation lokalisiert. param( [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][ValidateSet('CAS', 'CA_CERTS', 'CRL', 'CDP_AIA', 'NTAUTH', 'ISSUED', 'LOCAL_STORE', 'GENERAL')][string]$Category, [Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][ValidateSet('OK', 'Info', 'Warning', 'Critical')][string]$Status, [Parameter(Mandatory)][string]$Message, [string]$Recommendation = '', $Date = $null ) $dateText = '' $dateUtc = $null if ($Date) { $dateText = Format-Date $Date $dateUtc = ([datetime]$Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) } # Texte beginnen teils mit einer Laufzeitangabe ("noch 5 Tage gültig") - ersten Buchstaben groß schreiben. if ($Message.Length -gt 0) { $Message = $Message.Substring(0, 1).ToUpper($Script:Culture) + $Message.Substring(1) } $Script:Findings.Add([pscustomobject]@{ Id = $Id Category = $Category Object = $Object Status = $Status Rank = $Script:SeverityRank[$Status] Message = $Message Recommendation = $Recommendation Date = $dateText DateUtc = $dateUtc }) } function Format-Date { param($Date) if ($null -eq $Date) { return '' } return ([datetime]$Date).ToLocalTime().ToString((T 'DateTimeFormat'), [Globalization.CultureInfo]::InvariantCulture) } function Get-DaysLeft { param([datetime]$Date) return [int][math]::Floor(($Date.ToUniversalTime() - [datetime]::UtcNow).TotalDays) } function Get-ExpiryStatus { param([int]$DaysLeft, [int]$Warn, [int]$Crit) if ($DaysLeft -lt 0) { return 'Critical' } if ($DaysLeft -le $Crit) { return 'Critical' } if ($DaysLeft -le $Warn) { return 'Warning' } return 'OK' } function Format-DaysText { param([int]$DaysLeft) if ($DaysLeft -eq -1) { return (T 'DaysExpiredOne') } if ($DaysLeft -lt 0) { return (T 'DaysExpired' (-$DaysLeft)) } if ($DaysLeft -eq 0) { return (T 'DaysToday') } if ($DaysLeft -eq 1) { return (T 'DaysTomorrow') } return (T 'DaysLeft' $DaysLeft) } function Get-ShortName { param([string]$DistinguishedName) if ($DistinguishedName -match 'CN=([^,]+)') { return $Matches[1] } return $DistinguishedName } function ConvertTo-DerBytes { # Akzeptiert DER oder PEM/Base64 und liefert DER-Bytes. param([byte[]]$Bytes) # Komma-Operator: verhindert, dass PowerShell das Byte-Array in object[] entpackt. if ($null -eq $Bytes -or $Bytes.Length -lt 2) { return , $Bytes } if ($Bytes[0] -eq 0x30) { return , $Bytes } $text = [Text.Encoding]::ASCII.GetString($Bytes) $text = $text -replace '-----[^-]+-----', '' -replace '\s', '' try { return , [Convert]::FromBase64String($text) } catch { return , $Bytes } } function Get-CertUrls { param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) $cdp = @(); $aia = @(); $ocsp = @() foreach ($ext in $Cert.Extensions) { try { if ($ext.Oid.Value -eq '2.5.29.31') { $cdp += @([AdcsHc.Der]::FindUris($ext.RawData)) } elseif ($ext.Oid.Value -eq '1.3.6.1.5.5.7.1.1') { foreach ($pair in [AdcsHc.Der]::ParseAia($ext.RawData)) { if ($pair[0] -eq 'caIssuers') { $aia += $pair[1] } elseif ($pair[0] -eq 'ocsp') { $ocsp += $pair[1] } } } } catch { Write-Verbose (T 'VerboseExtension' $ext.Oid.Value $_) } } return [pscustomobject]@{ Cdp = $cdp; Aia = $aia; Ocsp = $ocsp } } function Get-SubjectKeyId { param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) foreach ($ext in $Cert.Extensions) { if ($ext.Oid.Value -eq '2.5.29.14') { try { $t = [AdcsHc.Der]::Read($ext.RawData, 0) return [AdcsHc.Der]::Hex([AdcsHc.Der]::Value($ext.RawData, $t)) } catch { return $null } } } return $null } function Get-HttpBytes { param([string]$Url, [int]$TimeoutMs = 10000) if ($Script:HttpCache.ContainsKey($Url)) { return $Script:HttpCache[$Url] } $resp = $null $result = $null try { $req = [System.Net.WebRequest]::Create($Url) $req.Timeout = $TimeoutMs if ($req -is [System.Net.HttpWebRequest]) { $req.ReadWriteTimeout = $TimeoutMs $req.UserAgent = "ADCS-HealthCheck/$Script:Version" } $resp = $req.GetResponse() $ms = New-Object System.IO.MemoryStream $resp.GetResponseStream().CopyTo($ms) $status = 200 if ($resp -is [System.Net.HttpWebResponse]) { $status = [int]$resp.StatusCode } $result = [pscustomobject]@{ Ok = $true; Status = $status; Bytes = $ms.ToArray(); Error = $null } } catch { $ex = $_.Exception while ($ex.InnerException -and -not ($ex -is [System.Net.WebException])) { $ex = $ex.InnerException } $code = $null if ($ex -is [System.Net.WebException] -and $ex.Response) { $code = [int]$ex.Response.StatusCode } $result = [pscustomobject]@{ Ok = $false; Status = $code; Bytes = $null; Error = $ex.Message } } finally { if ($resp) { $resp.Close() } } $Script:HttpCache[$Url] = $result return $result } function Get-KeyInfo { param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) $alg = $Cert.PublicKey.Oid.Value $size = $null try { if ($alg -eq '1.2.840.113549.1.1.1') { # RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } - direkt aus DER, unabhängig von der .NET-Version $raw = $Cert.PublicKey.EncodedKeyValue.RawData $seq = [AdcsHc.Der]::Read($raw, 0) $mod = [AdcsHc.Der]::Children($raw, $seq)[0] $len = $mod.Length if ($raw[$mod.ValueOffset] -eq 0) { $len-- } $size = $len * 8 } } catch { } return [pscustomobject]@{ Algorithm = $alg; Size = $size } } #endregion #region Active Directory function Search-Ad { param([string]$Base, [string]$Filter, [string[]]$Properties, [string]$Scope = 'OneLevel') $entry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$Base") $searcher = New-Object System.DirectoryServices.DirectorySearcher($entry, $Filter, $Properties) $searcher.SearchScope = $Scope $searcher.PageSize = 500 try { return @($searcher.FindAll()) } finally { $searcher.Dispose(); $entry.Dispose() } } function Get-AdPkiInventory { $rootDse = New-Object System.DirectoryServices.DirectoryEntry('LDAP://RootDSE') $configNC = [string]$rootDse.Properties['configurationNamingContext'].Value $defaultNC = [string]$rootDse.Properties['defaultNamingContext'].Value $rootDse.Dispose() if (-not $configNC) { throw (T 'ErrConfigNc') } $pks = "CN=Public Key Services,CN=Services,$configNC" $inv = [ordered]@{ ConfigNC = $configNC DefaultNC = $defaultNC EnrollmentServices = @() CaCerts = @{} NTAuthThumbprints = @() CdpCrls = @() TemplateNames = @{} } function Add-CaCert([hashtable]$Store, [byte[]]$Raw, [string]$Source) { if ($null -eq $Raw -or $Raw.Length -lt 10) { return $null } try { $c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, $Raw) } catch { return $null } if (-not $Store.ContainsKey($c.Thumbprint)) { $Store[$c.Thumbprint] = [pscustomobject]@{ Cert = $c; Sources = New-Object System.Collections.Generic.List[string] } } if (-not $Store[$c.Thumbprint].Sources.Contains($Source)) { $Store[$c.Thumbprint].Sources.Add($Source) } return $c } foreach ($r in Search-Ad -Base "CN=Enrollment Services,$pks" -Filter '(objectClass=pKIEnrollmentService)' -Properties @('cn', 'dnshostname', 'cacertificate', 'certificatetemplates')) { $cn = [string]$r.Properties['cn'][0] $dns = [string]$r.Properties['dnshostname'][0] $certs = @() foreach ($raw in $r.Properties['cacertificate']) { $c = Add-CaCert $inv.CaCerts ([byte[]]$raw) (T 'SrcEnterpriseCa' $cn) if ($c) { $certs += $c } } $current = $certs | Sort-Object NotBefore -Descending | Select-Object -First 1 $inv.EnrollmentServices += [pscustomobject]@{ Name = $cn DnsHostName = $dns Config = "$dns\$cn" Cert = $current TemplateCount = @($r.Properties['certificatetemplates']).Count } } foreach ($r in Search-Ad -Base "CN=Certification Authorities,$pks" -Filter '(objectClass=certificationAuthority)' -Properties @('cn', 'cacertificate')) { foreach ($raw in $r.Properties['cacertificate']) { [void](Add-CaCert $inv.CaCerts ([byte[]]$raw) (T 'SrcRootCas')) } } foreach ($r in Search-Ad -Base "CN=AIA,$pks" -Filter '(objectClass=certificationAuthority)' -Properties @('cn', 'cacertificate')) { foreach ($raw in $r.Properties['cacertificate']) { [void](Add-CaCert $inv.CaCerts ([byte[]]$raw) (T 'SrcAia')) } } try { $nt = New-Object System.DirectoryServices.DirectoryEntry("LDAP://CN=NTAuthCertificates,$pks") foreach ($raw in $nt.Properties['cACertificate']) { $c = Add-CaCert $inv.CaCerts ([byte[]]$raw) (T 'SrcNtAuth') if ($c) { $inv.NTAuthThumbprints += $c.Thumbprint } } $nt.Dispose() } catch { Write-Verbose (T 'VerboseNtAuth' $_) } foreach ($r in Search-Ad -Base "CN=CDP,$pks" -Filter '(objectClass=cRLDistributionPoint)' -Properties @('distinguishedname', 'certificaterevocationlist', 'deltarevocationlist') -Scope 'Subtree') { $dn = [string]$r.Properties['distinguishedname'][0] foreach ($attr in @('certificaterevocationlist', 'deltarevocationlist')) { foreach ($raw in $r.Properties[$attr]) { $bytes = [byte[]]$raw if ($bytes.Length -lt 10) { continue } $inv.CdpCrls += [pscustomobject]@{ Dn = $dn; Attribute = $attr; Bytes = $bytes } } } } try { foreach ($r in Search-Ad -Base "CN=Certificate Templates,$pks" -Filter '(objectClass=pKICertificateTemplate)' -Properties @('cn', 'displayname', 'mspki-cert-template-oid')) { $name = [string]$r.Properties['displayname'][0] if (-not $name) { $name = [string]$r.Properties['cn'][0] } $inv.TemplateNames[[string]$r.Properties['cn'][0]] = $name $oid = [string]$r.Properties['mspki-cert-template-oid'][0] if ($oid) { $inv.TemplateNames[$oid] = $name } } } catch { Write-Verbose (T 'VerboseTemplates' $_) } return [pscustomobject]$inv } #endregion #region Prüfungen function Test-CaReachability { param($Inventory) $certutil = Get-Command certutil.exe -ErrorAction SilentlyContinue foreach ($ca in $Inventory.EnrollmentServices) { if (-not $certutil) { Add-Finding -Id 'CA_PING_SKIPPED' -Category 'CAS' -Object $ca.Name -Status 'Info' -Message (T 'CaCertutilMissing') continue } $null = & certutil.exe -config $ca.Config -ping 2>&1 if ($LASTEXITCODE -eq 0) { Add-Finding -Id 'CA_PING_OK' -Category 'CAS' -Object $ca.Name -Status 'OK' ` -Message (T 'CaPingOk' $ca.DnsHostName $ca.TemplateCount) } else { Add-Finding -Id 'CA_PING_FAILED' -Category 'CAS' -Object $ca.Name -Status 'Critical' ` -Message (T 'CaPingFailed' $ca.DnsHostName $LASTEXITCODE) ` -Recommendation (T 'CaPingFailedRec') } } } function Test-CaCertificates { param($Inventory) $activeThumbs = @($Inventory.EnrollmentServices | Where-Object { $_.Cert } | ForEach-Object { $_.Cert.Thumbprint }) $weakSig = @{ '1.2.840.113549.1.1.4' = 'MD5' '1.2.840.113549.1.1.5' = 'SHA-1' '1.3.14.3.2.29' = 'SHA-1' '1.2.840.10045.4.1' = 'SHA-1 (ECDSA)' } $expired = New-Object System.Collections.Generic.List[object] foreach ($entry in $Inventory.CaCerts.Values) { $c = $entry.Cert $name = Get-ShortName $c.Subject $sources = ($entry.Sources -join ', ') $days = Get-DaysLeft $c.NotAfter $isActive = $activeThumbs -contains $c.Thumbprint if ($days -lt 0 -and -not $isActive) { $expired.Add($entry) continue } $status = Get-ExpiryStatus $days $CaWarningDays $CaCriticalDays $id = 'CA_CERT_OK' $msg = T 'CaCertMsg' (Format-DaysText $days) $sources $rec = '' if ($status -ne 'OK') { $id = 'CA_CERT_EXPIRING' if ($days -lt 0) { $id = 'CA_CERT_EXPIRED' } $rec = T 'CaCertRenewRec' if ($isActive) { $msg += ' ' + (T 'CaCertActive') } } Add-Finding -Id $id -Category 'CA_CERTS' -Object $name -Status $status -Date $c.NotAfter -Message $msg -Recommendation $rec $key = Get-KeyInfo $c if ($key.Size -and $key.Size -lt 2048) { Add-Finding -Id 'CA_CERT_WEAK_KEY' -Category 'CA_CERTS' -Object $name -Status 'Warning' ` -Message (T 'CaWeakKey' $key.Size) -Recommendation (T 'CaWeakKeyRec') } if ($weakSig.ContainsKey($c.SignatureAlgorithm.Value)) { Add-Finding -Id 'CA_CERT_WEAK_HASH' -Category 'CA_CERTS' -Object $name -Status 'Warning' ` -Message (T 'CaWeakHash' $weakSig[$c.SignatureAlgorithm.Value]) -Recommendation (T 'CaWeakHashRec') } } # Abgelaufene, nicht mehr aktive CA-Zertifikate: eine Zeile je CA-Name (nach Erneuerungen gibt es oft mehrere) foreach ($g in ($expired | Group-Object { Get-ShortName $_.Cert.Subject })) { $items = @($g.Group | Sort-Object { $_.Cert.NotAfter } -Descending) $srcList = @($items | ForEach-Object { $_.Sources } | ForEach-Object { $_ } | Sort-Object -Unique) -join ', ' if ($items.Count -eq 1) { $msg = T 'CaExpiredLeftover' $srcList } else { $dates = ($items | ForEach-Object { $_.Cert.NotAfter.ToLocalTime().ToString((T 'DateFormat'), [Globalization.CultureInfo]::InvariantCulture) }) -join ', ' $msg = T 'CaExpiredLeftoverMulti' $items.Count $srcList $dates } Add-Finding -Id 'CA_CERT_EXPIRED_LEFTOVER' -Category 'CA_CERTS' -Object $g.Name -Status 'Info' -Date $items[0].Cert.NotAfter ` -Message $msg -Recommendation (T 'CaExpiredLeftoverRec') } } function Test-CaChains { param($Inventory) if ($SkipNetwork) { return } foreach ($ca in $Inventory.EnrollmentServices) { if (-not $ca.Cert) { continue } $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online $chain.ChainPolicy.RevocationFlag = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot $chain.ChainPolicy.UrlRetrievalTimeout = [TimeSpan]::FromSeconds(15) [void]$chain.Build($ca.Cert) $problems = @($chain.ChainStatus | Where-Object { $_.Status -ne 'NoError' }) if ($problems.Count -eq 0) { $msg = T 'ChainOkMany' $chain.ChainElements.Count if ($chain.ChainElements.Count -eq 1) { $msg = T 'ChainOkOne' } Add-Finding -Id 'CA_CHAIN_OK' -Category 'CA_CERTS' -Object $ca.Name -Status 'OK' -Message $msg continue } $critical = @('UntrustedRoot', 'PartialChain', 'NotTimeValid', 'Revoked', 'RevocationStatusUnknown', 'OfflineRevocation', 'NotSignatureValid') $status = 'Warning' foreach ($p in $problems) { if ($critical -contains [string]$p.Status) { $status = 'Critical' } } $text = ($problems | ForEach-Object { '{0} ({1})' -f $_.Status, ($_.StatusInformation.Trim()) }) -join '; ' $rec = T 'ChainRec' if ($text -match 'Revocation') { $rec = T 'ChainRevocationRec' } Add-Finding -Id 'CA_CHAIN_ERROR' -Category 'CA_CERTS' -Object $ca.Name -Status $status -Message (T 'ChainError' $text) -Recommendation $rec } } function Test-NTAuth { param($Inventory) foreach ($ca in $Inventory.EnrollmentServices) { if (-not $ca.Cert) { continue } if ($Inventory.NTAuthThumbprints -contains $ca.Cert.Thumbprint) { Add-Finding -Id 'NTAUTH_OK' -Category 'NTAUTH' -Object $ca.Name -Status 'OK' -Message (T 'NtAuthOk') } else { Add-Finding -Id 'NTAUTH_MISSING' -Category 'NTAUTH' -Object $ca.Name -Status 'Warning' ` -Message (T 'NtAuthMissing') -Recommendation (T 'NtAuthMissingRec') } } } function Test-IssuedCertificates { param($Inventory) $Script:SampleCerts = @{} if ($SkipDatabase) { return } foreach ($ca in $Inventory.EnrollmentServices) { $view = $null try { $view = New-Object -ComObject CertificateAuthority.View $view.OpenConnection($ca.Config) $cols = @('Request.RequestID', 'CommonName', 'CertificateTemplate', 'NotAfter', 'Request.RequesterName') $view.SetResultColumnCount($cols.Count) foreach ($col in $cols) { $view.SetResultColumn($view.GetColumnIndex(0, $col)) } $view.SetRestriction($view.GetColumnIndex(0, 'Request.Disposition'), 1, 0, 20) # 20 = ausgestellt $view.SetRestriction($view.GetColumnIndex(0, 'NotAfter'), 8, 0, [datetime]::Now) # >= jetzt $rows = $view.OpenView() $list = New-Object System.Collections.Generic.List[object] while ($rows.Next() -ne -1) { $h = @{} $colEnum = $rows.EnumCertViewColumn() while ($colEnum.Next() -ne -1) { $h[$colEnum.GetName()] = $colEnum.GetValue(1) } $list.Add($h) if ($list.Count -ge 250000) { break } } } catch { Add-Finding -Id 'ISSUED_DB_UNREADABLE' -Category 'ISSUED' -Object $ca.Name -Status 'Info' ` -Message (T 'IssuedDbUnreadable') -Recommendation (T 'IssuedDbUnreadableRec') Write-Verbose (T 'VerboseCaDb' $ca.Config $_) continue } # Erneuerungen erkennen: pro Zertifikatsinhaber + Vorlage zählt nur das am längsten gültige Zertifikat. $latest = @{} $maxId = 0 foreach ($h in $list) { $cn = [string]$h['CommonName'] if (-not $cn) { $cn = [string]$h['Request.RequesterName'] } $key = '{0}|{1}' -f $cn, $h['CertificateTemplate'] if (-not $latest.ContainsKey($key) -or ([datetime]$h['NotAfter']) -gt ([datetime]$latest[$key]['NotAfter'])) { $latest[$key] = $h } if ([int]$h['Request.RequestID'] -gt $maxId) { $maxId = [int]$h['Request.RequestID'] } } $expiring = @($latest.Values | Where-Object { (Get-DaysLeft ([datetime]$_['NotAfter'])) -le $WarningDays } | Sort-Object { [datetime]$_['NotAfter'] }) Add-Finding -Id 'ISSUED_SUMMARY' -Category 'ISSUED' -Object $ca.Name -Status 'Info' ` -Message (T 'IssuedSummary' $list.Count $latest.Count $expiring.Count $WarningDays) $shown = 0 foreach ($h in $expiring) { if ($shown -ge 50) { Add-Finding -Id 'ISSUED_MORE' -Category 'ISSUED' -Object $ca.Name -Status 'Info' ` -Message (T 'IssuedMore' ($expiring.Count - $shown)) break } $shown++ $na = [datetime]$h['NotAfter'] $days = Get-DaysLeft $na $tpl = [string]$h['CertificateTemplate'] if ($Inventory.TemplateNames.ContainsKey($tpl)) { $tpl = $Inventory.TemplateNames[$tpl] } $cn = [string]$h['CommonName'] if (-not $cn) { $cn = T 'IssuedNoCn' } Add-Finding -Id 'ISSUED_CERT_EXPIRING' -Category 'ISSUED' -Object $cn -Status (Get-ExpiryStatus $days $WarningDays $CriticalDays) -Date $na ` -Message (T 'IssuedExpiring' (Format-DaysText $days) $tpl $h['Request.RequesterName'] $h['Request.RequestID'] $ca.Name) ` -Recommendation (T 'IssuedExpiringRec') } # Ein aktuelles Zertifikat holen, um die CDP/AIA-URLs dieser CA zu kennen. if ($maxId -gt 0) { try { $v2 = New-Object -ComObject CertificateAuthority.View $v2.OpenConnection($ca.Config) $v2.SetResultColumnCount(1) $v2.SetResultColumn($v2.GetColumnIndex(0, 'RawCertificate')) $v2.SetRestriction($v2.GetColumnIndex(0, 'Request.RequestID'), 1, 0, $maxId) $r2 = $v2.OpenView() if ($r2.Next() -ne -1) { $ce = $r2.EnumCertViewColumn() if ($ce.Next() -ne -1) { $b64 = ([string]$ce.GetValue(1)) -replace '-----[^-]+-----', '' -replace '\s', '' $bytes = [Convert]::FromBase64String($b64) $Script:SampleCerts[$ca.Config] = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, $bytes) } } } catch { Write-Verbose (T 'VerboseSampleCert' $ca.Config $_) } } } } function Get-UrlsToCheck { param($Inventory) $urls = @{} # URL -> Herkunftsangaben; Issued = URL stammt aus einem ausgestellten Zertifikat function Add-Url([string]$Url, [string]$Kind, [string]$From, [bool]$Issued) { if (-not $Url) { return } $k = "$Kind|$Url" if (-not $urls.ContainsKey($k)) { $urls[$k] = [pscustomobject]@{ Url = $Url; Kind = $Kind; From = New-Object System.Collections.Generic.List[string]; Issued = $false } } if (-not $urls[$k].From.Contains($From)) { $urls[$k].From.Add($From) } if ($Issued) { $urls[$k].Issued = $true } } foreach ($entry in $Inventory.CaCerts.Values) { $c = $entry.Cert if ((Get-DaysLeft $c.NotAfter) -lt 0) { continue } $u = Get-CertUrls $c $from = T 'FromCaCert' (Get-ShortName $c.Subject) foreach ($x in $u.Cdp) { Add-Url $x 'CDP' $from $false } foreach ($x in $u.Aia) { Add-Url $x 'AIA' $from $false } } foreach ($ca in $Inventory.EnrollmentServices) { if (-not $ca.Cert) { continue } $sample = $Script:SampleCerts[$ca.Config] if (-not $sample) { # Ersatz: ein Zertifikat dieser CA im Computer-Speicher dieses Rechners try { $sample = Get-ChildItem Cert:\LocalMachine\My -ErrorAction Stop | Where-Object { $_.Issuer -eq $ca.Cert.Subject } | Sort-Object NotBefore -Descending | Select-Object -First 1 } catch { $sample = $null } } if ($sample) { $u = Get-CertUrls $sample $from = T 'FromIssued' $ca.Name foreach ($x in $u.Cdp) { Add-Url $x 'CDP' $from $true } foreach ($x in $u.Aia) { Add-Url $x 'AIA' $from $true } } else { Add-Finding -Id 'CRL_PATHS_UNKNOWN' -Category 'CRL' -Object $ca.Name -Status 'Info' ` -Message (T 'CrlPathsUnknown') -Recommendation (T 'CrlPathsUnknownRec') } } foreach ($x in $ExtraCrlUrl) { Add-Url $x 'CDP' (T 'FromParam') $false } return @($urls.Values) } function Get-CrlStatus { # Bewertet eine Sperrliste. Langlebige CRLs (Offline-Root) werden nach Resttagen bewertet, # kurzlebige (Online-CA) danach, ob die nächste Veröffentlichung überfällig ist. # Code = Befund-Id. param($Info) if (-not $Info.NextUpdate) { return [pscustomobject]@{ Status = 'Warning'; Code = 'CRL_NO_NEXT_UPDATE'; Text = (T 'CrlNoNextUpdate'); Days = $null } } $next = [datetime]$Info.NextUpdate $validity = ($next - [datetime]$Info.ThisUpdate).TotalDays $remaining = ($next - [datetime]::UtcNow).TotalDays $days = [int][math]::Floor($remaining) if ($remaining -lt 0) { return [pscustomobject]@{ Status = 'Critical'; Code = 'CRL_EXPIRED'; Text = (T 'CrlExpired' (Format-DaysText $days)); Days = $days } } if ($validity -ge 30) { $status = Get-ExpiryStatus $days $WarningDays $CriticalDays $code = 'CRL_OK' if ($status -ne 'OK') { $code = 'CRL_EXPIRING' } return [pscustomobject]@{ Status = $status; Code = $code; Text = (T 'CrlLongLived' (Format-DaysText $days) ([int]$validity)); Days = $days } } if ($remaining -lt ($validity * 0.2)) { return [pscustomobject]@{ Status = 'Warning'; Code = 'CRL_OVERDUE'; Text = (T 'CrlOverdue' ($remaining * 24)); Days = $days } } return [pscustomobject]@{ Status = 'OK'; Code = 'CRL_OK'; Text = (T 'CrlCurrent' (Format-Date $next)); Days = $days } } function Test-Crls { param($Inventory, $Urls) $crls = New-Object System.Collections.Generic.List[object] foreach ($item in $Inventory.CdpCrls) { try { $info = [AdcsHc.Der]::ParseCrl((ConvertTo-DerBytes $item.Bytes)) $crls.Add([pscustomobject]@{ Source = 'LDAP'; Location = $item.Dn; Info = $info }) } catch { Add-Finding -Id 'CRL_AD_UNREADABLE' -Category 'CRL' -Object (Get-ShortName $item.Dn) -Status 'Warning' ` -Message (T 'CrlAdUnreadable' $_.Exception.Message) -Recommendation (T 'CrlAdUnreadableRec') } } $adDns = @($Inventory.CdpCrls | ForEach-Object { $_.Dn.ToLowerInvariant() }) $referenced = @{} # LDAP-DN (klein) -> Herkunft der Referenz $httpCdpCount = 0 foreach ($u in @($Urls | Where-Object { $_.Kind -eq 'CDP' })) { $from = $u.From -join ', ' if ($u.Url -match '^ldap://[^/]*/([^?]+)') { $dn = [Uri]::UnescapeDataString($Matches[1]) $referenced[$dn.ToLowerInvariant()] = $from if ($adDns -notcontains $dn.ToLowerInvariant()) { Add-Finding -Id 'CRL_LDAP_MISSING' -Category 'CRL' -Object $u.Url -Status 'Critical' ` -Message (T 'CrlLdapMissing' $from) -Recommendation (T 'CrlLdapMissingRec') } continue } if ($u.Url -notmatch '^https?://') { continue } $httpCdpCount++ if ($SkipNetwork) { continue } $resp = Get-HttpBytes $u.Url if (-not $resp.Ok) { $rec = T 'CdpUnreachableRec' if ($u.Url -match '\+' -and $resp.Status -eq 404) { $rec = T 'CdpDeltaPlusRec' } $code = $resp.Status if (-not $code) { $code = $resp.Error } Add-Finding -Id 'CDP_UNREACHABLE' -Category 'CDP_AIA' -Object $u.Url -Status 'Critical' ` -Message (T 'CdpUnreachable' $code $from) -Recommendation $rec continue } try { $info = [AdcsHc.Der]::ParseCrl((ConvertTo-DerBytes $resp.Bytes)) $crls.Add([pscustomobject]@{ Source = 'HTTP'; Location = $u.Url; Info = $info }) Add-Finding -Id 'CDP_OK' -Category 'CDP_AIA' -Object $u.Url -Status 'OK' -Message (T 'CdpOk' $from) } catch { Add-Finding -Id 'CDP_INVALID' -Category 'CDP_AIA' -Object $u.Url -Status 'Critical' ` -Message (T 'CdpInvalid' $from) -Recommendation (T 'CdpInvalidRec') } } $hasIssuedCertUrls = @($Urls | Where-Object { $_.Kind -eq 'CDP' -and $_.PSObject.Properties['Issued'] -and $_.Issued }).Count -gt 0 if ($hasIssuedCertUrls -and $httpCdpCount -eq 0) { Add-Finding -Id 'CDP_NO_HTTP' -Category 'CDP_AIA' -Object (T 'CdpNoHttpObject') -Status 'Info' ` -Message (T 'CdpNoHttp') -Recommendation (T 'CdpNoHttpRec') } # Neueste CRL-Nummer je CA-Schlüssel und Typ (Base/Delta) $maxNumber = @{} foreach ($c in $crls) { if (-not $c.Info.AuthorityKeyId -or $c.Info.CrlNumber -lt 0) { continue } $k = '{0}|{1}' -f $c.Info.AuthorityKeyId, $c.Info.IsDelta if (-not $maxNumber.ContainsKey($k) -or $c.Info.CrlNumber -gt $maxNumber[$k]) { $maxNumber[$k] = $c.Info.CrlNumber } } # Verwaiste Kopien: im AD, von keinem geprüften Zertifikat referenziert und an anderer Stelle gibt es eine neuere Version. # Typisch nach Umzug der CA auf einen anderen Server. Nur Aufräum-Hinweis, kein Ausfallrisiko. # Entscheidung pro Ort (DN): nicht referenziert und mindestens eine Liste dort ist veraltet -> ganzer Ort ist Altlast. $orphanDns = @{} foreach ($c in $crls) { if ($c.Source -ne 'LDAP') { continue } if ($referenced.ContainsKey($c.Location.ToLowerInvariant())) { continue } $k = '{0}|{1}' -f $c.Info.AuthorityKeyId, $c.Info.IsDelta if ($maxNumber.ContainsKey($k) -and $c.Info.CrlNumber -ge 0 -and $c.Info.CrlNumber -lt $maxNumber[$k]) { $orphanDns[$c.Location] = $true } } $orphans = @{} foreach ($c in $crls) { if ($c.Source -ne 'LDAP' -or -not $orphanDns.ContainsKey($c.Location)) { continue } $c | Add-Member -NotePropertyName Orphan -NotePropertyValue $true -Force if (-not $orphans.ContainsKey($c.Location)) { $orphans[$c.Location] = New-Object System.Collections.Generic.List[object] } $orphans[$c.Location].Add($c) } foreach ($dn in $orphans.Keys) { $caHost = '' if ($dn -match '^CN=[^,]+,CN=([^,]+),CN=CDP,') { $caHost = $Matches[1] } $last = ($orphans[$dn] | Sort-Object { $_.Info.ThisUpdate } -Descending | Select-Object -First 1).Info.ThisUpdate $kinds = ($orphans[$dn] | ForEach-Object { if ($_.Info.IsDelta) { 'Delta' } else { 'Base' } } | Sort-Object -Unique) -join ' + ' if ($caHost) { $msg = T 'CrlOrphanedHost' $kinds $caHost (Format-Date $last) } else { $msg = T 'CrlOrphaned' $kinds (Format-Date $last) } if ($hasIssuedCertUrls) { $msg += ' ' + (T 'CrlOrphanedRef') } else { $msg += ' ' + (T 'CrlOrphanedRefUnknown') } Add-Finding -Id 'CRL_ORPHANED' -Category 'CRL' -Object $dn -Status 'Info' -Date $last -Message $msg -Recommendation (T 'CrlOrphanedRec') } # Einzelbewertung foreach ($c in $crls) { if ($c.PSObject.Properties['Orphan']) { continue } $issuer = Get-ShortName (New-Object System.Security.Cryptography.X509Certificates.X500DistinguishedName (, $c.Info.IssuerRaw)).Name $kind = T 'CrlKindBase' if ($c.Info.IsDelta) { $kind = T 'CrlKindDelta' } $st = Get-CrlStatus $c.Info $rec = '' if ($st.Status -ne 'OK') { if ($c.Info.NextUpdate -and ((([datetime]$c.Info.NextUpdate) - ([datetime]$c.Info.ThisUpdate)).TotalDays -ge 30)) { $rec = T 'CrlOfflineRec' } else { $rec = T 'CrlOnlineRec' } } if ($c.Info.CrlNumber -ge 0) { $detail = T 'CrlDetail' $c.Info.CrlNumber $c.Info.EntryCount $c.Location } else { $detail = T 'CrlDetailNoNumber' $c.Info.EntryCount $c.Location } $msg = $st.Text + ' ' + $detail if ($c.Source -eq 'LDAP' -and $referenced.ContainsKey($c.Location.ToLowerInvariant())) { $msg += ' ' + (T 'CrlUsedIn' $referenced[$c.Location.ToLowerInvariant()]) } Add-Finding -Id $st.Code -Category 'CRL' -Object ("{0} - {1} ({2})" -f $issuer, $kind, $c.Source) -Status $st.Status -Date $c.Info.NextUpdate ` -Message $msg -Recommendation $rec # Veraltete Kopie an einem genutzten Ort $k = '{0}|{1}' -f $c.Info.AuthorityKeyId, $c.Info.IsDelta if ($c.Info.AuthorityKeyId -and $c.Info.CrlNumber -ge 0 -and $maxNumber.ContainsKey($k) -and $c.Info.CrlNumber -lt $maxNumber[$k]) { Add-Finding -Id 'CRL_STALE_COPY' -Category 'CRL' -Object $c.Location -Status 'Warning' ` -Message (T 'CrlStale' $c.Info.CrlNumber $maxNumber[$k]) -Recommendation (T 'CrlStaleRec') } } } function Test-AiaUrls { param($Urls) if ($SkipNetwork) { return } foreach ($u in @($Urls | Where-Object { $_.Kind -eq 'AIA' -and $_.Url -match '^https?://' })) { $from = $u.From -join ', ' $resp = Get-HttpBytes $u.Url if (-not $resp.Ok) { $code = $resp.Status if (-not $code) { $code = $resp.Error } Add-Finding -Id 'AIA_UNREACHABLE' -Category 'CDP_AIA' -Object $u.Url -Status 'Warning' ` -Message (T 'AiaUnreachable' $code $from) -Recommendation (T 'AiaUnreachableRec') continue } try { [byte[]]$bytes = ConvertTo-DerBytes $resp.Bytes $c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, $bytes) $days = Get-DaysLeft $c.NotAfter $msg = T 'AiaOk' (Get-ShortName $c.Subject) (Format-DaysText $days) $from if ($days -lt 0) { Add-Finding -Id 'AIA_EXPIRED' -Category 'CDP_AIA' -Object $u.Url -Status 'Warning' -Message $msg -Recommendation (T 'AiaExpiredRec') } else { Add-Finding -Id 'AIA_OK' -Category 'CDP_AIA' -Object $u.Url -Status 'OK' -Message $msg } } catch { Add-Finding -Id 'AIA_INVALID' -Category 'CDP_AIA' -Object $u.Url -Status 'Warning' ` -Message (T 'AiaInvalid' $from) -Recommendation (T 'AiaInvalidRec') } } } function Test-LocalStore { # CaSubjects: Aussteller-DNs der eigenen PKI. Abgelaufene Zertifikate fremder Aussteller (z. B. von # Microsoft-Diensten wie App-Proxy-Connector oder PolicyKeyService) sind meist Rauschen und werden nur gezählt. param([string[]]$CaSubjects = @()) try { $certs = @(Get-ChildItem Cert:\LocalMachine\My -ErrorAction Stop) } catch { return } $computer = $env:COMPUTERNAME if ($certs.Count -eq 0) { Add-Finding -Id 'LOCAL_STORE_EMPTY' -Category 'LOCAL_STORE' -Object $computer -Status 'Info' -Message (T 'LocalEmpty') Add-OtherServersHint return } $bySubject = $certs | Group-Object Subject $problems = 0 $skippedForeign = 0 foreach ($g in $bySubject) { $newest = $g.Group | Sort-Object NotAfter -Descending | Select-Object -First 1 $days = Get-DaysLeft $newest.NotAfter $name = Get-ShortName $newest.Subject if (-not $name) { $name = $newest.Thumbprint } if ($days -lt 0) { if ($CaSubjects -notcontains $newest.Issuer) { $skippedForeign++; continue } Add-Finding -Id 'LOCAL_CERT_EXPIRED' -Category 'LOCAL_STORE' -Object $name -Status 'Info' -Date $newest.NotAfter ` -Message (T 'LocalExpired' (Get-ShortName $newest.Issuer)) -Recommendation (T 'LocalExpiredRec') $problems++ continue } $st = Get-ExpiryStatus $days $WarningDays $CriticalDays if ($st -ne 'OK') { Add-Finding -Id 'LOCAL_CERT_EXPIRING' -Category 'LOCAL_STORE' -Object $name -Status $st -Date $newest.NotAfter ` -Message (T 'LocalExpiring' (Format-DaysText $days) (Get-ShortName $newest.Issuer)) -Recommendation (T 'LocalExpiringRec') $problems++ } } if ($problems -eq 0) { Add-Finding -Id 'LOCAL_STORE_OK' -Category 'LOCAL_STORE' -Object $computer -Status 'OK' -Message (T 'LocalOk' $certs.Count $WarningDays) } if ($skippedForeign -gt 0) { Add-Finding -Id 'LOCAL_FOREIGN_EXPIRED' -Category 'LOCAL_STORE' -Object $computer -Status 'Info' -Message (T 'LocalForeign' $skippedForeign) } Add-OtherServersHint } function Add-OtherServersHint { Add-Finding -Id 'LOCAL_OTHER_SERVERS' -Category 'LOCAL_STORE' -Object (T 'OtherServersObject') -Status 'Info' -Message (T 'OtherServers') } #endregion #region Demo-Daten function Add-DemoFindings { # Erfundene Umgebung für Screenshots und Website. Nutzt dieselben Texte wie die echten Prüfungen. $now = Get-Date $issuing = 'CONTOSO-ISSUING-CA' $fromIssued = T 'FromIssued' $issuing Add-Finding -Id 'CA_PING_OK' -Category 'CAS' -Object $issuing -Status 'OK' -Message (T 'CaPingOk' 'pki01.contoso.local' 14) Add-Finding -Id 'CA_CERT_OK' -Category 'CA_CERTS' -Object 'CONTOSO-ROOT-CA' -Status 'OK' -Date $now.AddDays(2900) ` -Message (T 'CaCertMsg' (Format-DaysText 2900) ((T 'SrcRootCas') + ', ' + (T 'SrcAia'))) Add-Finding -Id 'CA_CERT_EXPIRING' -Category 'CA_CERTS' -Object $issuing -Status 'Warning' -Date $now.AddDays(214) ` -Message ((T 'CaCertMsg' (Format-DaysText 214) (@((T 'SrcEnterpriseCa' $issuing), (T 'SrcAia'), (T 'SrcNtAuth')) -join ', ')) + ' ' + (T 'CaCertActive')) ` -Recommendation (T 'CaCertRenewRec') Add-Finding -Id 'CA_CERT_WEAK_HASH' -Category 'CA_CERTS' -Object $issuing -Status 'Warning' -Message (T 'CaWeakHash' 'SHA-1') -Recommendation (T 'CaWeakHashRec') Add-Finding -Id 'CA_CERT_EXPIRED_LEFTOVER' -Category 'CA_CERTS' -Object 'OLD-ROOT-CA' -Status 'Info' -Date $now.AddDays(-800) ` -Message (T 'CaExpiredLeftover' (T 'SrcAia')) -Recommendation (T 'CaExpiredLeftoverRec') Add-Finding -Id 'CRL_EXPIRING' -Category 'CRL' -Object ('CONTOSO-ROOT-CA - {0} (HTTP)' -f (T 'CrlKindBase')) -Status 'Critical' -Date $now.AddDays(5) ` -Message ((T 'CrlLongLived' (Format-DaysText 5) 180) + ' ' + (T 'CrlDetail' 11 0 'http://pki.contoso.com/CertEnroll/CONTOSO-ROOT-CA.crl')) ` -Recommendation (T 'CrlOfflineRec') Add-Finding -Id 'CRL_OK' -Category 'CRL' -Object ('{0} - {1} (LDAP)' -f $issuing, (T 'CrlKindBase')) -Status 'OK' -Date $now.AddDays(6) ` -Message ((T 'CrlCurrent' (Format-Date $now.AddDays(6))) + ' ' + (T 'CrlDetail' 412 37 'CN=CONTOSO-ISSUING-CA,CN=pki01,CN=CDP,CN=Public Key Services,CN=Services,CN=Configuration,DC=contoso,DC=local')) Add-Finding -Id 'CRL_STALE_COPY' -Category 'CRL' -Object 'http://pki.contoso.com/CertEnroll/CONTOSO-ISSUING-CA.crl' -Status 'Warning' ` -Message (T 'CrlStale' 409 412) -Recommendation (T 'CrlStaleRec') Add-Finding -Id 'CDP_UNREACHABLE' -Category 'CDP_AIA' -Object 'http://pki.contoso.com/CertEnroll/CONTOSO-ISSUING-CA+.crl' -Status 'Critical' ` -Message (T 'CdpUnreachable' 404 $fromIssued) -Recommendation (T 'CdpDeltaPlusRec') Add-Finding -Id 'AIA_OK' -Category 'CDP_AIA' -Object 'http://pki.contoso.com/CertEnroll/pki01_CONTOSO-ISSUING-CA.crt' -Status 'OK' ` -Message (T 'AiaOk' $issuing (Format-DaysText 214) $fromIssued) Add-Finding -Id 'NTAUTH_OK' -Category 'NTAUTH' -Object $issuing -Status 'OK' -Message (T 'NtAuthOk') Add-Finding -Id 'ISSUED_SUMMARY' -Category 'ISSUED' -Object $issuing -Status 'Info' -Message (T 'IssuedSummary' 1284 402 2 30) Add-Finding -Id 'ISSUED_CERT_EXPIRING' -Category 'ISSUED' -Object 'rds-gw.contoso.com' -Status 'Critical' -Date $now.AddDays(4) ` -Message (T 'IssuedExpiring' (Format-DaysText 4) (T 'DemoTplWeb') 'CONTOSO\admin' 8812 $issuing) -Recommendation (T 'IssuedExpiringRec') Add-Finding -Id 'ISSUED_CERT_EXPIRING' -Category 'ISSUED' -Object 'wlan-radius.contoso.local' -Status 'Warning' -Date $now.AddDays(19) ` -Message (T 'IssuedExpiring' (Format-DaysText 19) (T 'DemoTplRas') 'CONTOSO\NPS01$' 7310 $issuing) -Recommendation (T 'IssuedExpiringRec') Add-Finding -Id 'LOCAL_STORE_OK' -Category 'LOCAL_STORE' -Object 'PKI01' -Status 'OK' -Message (T 'LocalOk' 6 30) Add-OtherServersHint } #endregion #region Report function ConvertTo-Html5 { param([string]$Text) return [System.Net.WebUtility]::HtmlEncode($Text) } function New-HtmlReport { param($Environment, $Findings) $crit = @($Findings | Where-Object Status -eq 'Critical').Count $warn = @($Findings | Where-Object Status -eq 'Warning').Count $ok = @($Findings | Where-Object Status -eq 'OK').Count $info = @($Findings | Where-Object Status -eq 'Info').Count $overall = 'OK'; $overallText = T 'OverallOk' if ($warn -gt 0) { $overall = 'Warning'; $overallText = T 'OverallWarn' } if ($crit -gt 0) { $overall = 'Critical'; $overallText = T 'OverallCrit' } $cls = @{ 'OK' = 'ok'; 'Warning' = 'warn'; 'Critical' = 'crit'; 'Info' = 'info' } $overallClass = $cls[$overall] $e = @{} # HTML-kodierte Texte für das Gerüst foreach ($k in @('ReportSubtitle', 'LabelDomain', 'LabelOverall', 'CountWarnings', 'CountInfo', 'SectionEnvironment', 'EnvCas', 'EnvCaCerts', 'EnvUser', 'ColStatus', 'ColObject', 'ColFinding', 'ColDate', 'CtaTitle', 'CtaButton', 'Footer', 'Status_Critical', 'Status_OK')) { $e[$k] = ConvertTo-Html5 (T $k) } $ctaText = [string]::Format((ConvertTo-Html5 (T 'CtaText')), 'ADCS Monitor') $ctaContact = [string]::Format((ConvertTo-Html5 (T 'CtaContact')), ('{0}' -f $Script:CtaMail)) $ctaUrl = ConvertTo-Html5 $Script:CtaUrl $siteUrl = ConvertTo-Html5 $Script:SiteUrl $sb = New-Object System.Text.StringBuilder [void]$sb.Append(@" ADCS Health Check - $(ConvertTo-Html5 $Environment.Domain)

ADCS Health Check by certmon.de

$($e.ReportSubtitle) · $($e.LabelDomain) $(ConvertTo-Html5 $Environment.Domain)
$(ConvertTo-Html5 (T 'ReportCreated' $Environment.Created))
$(ConvertTo-Html5 (T 'ReportCreatedOn' $Environment.Computer $Script:Version))
$($e.LabelOverall)
$(ConvertTo-Html5 (T ('Status_' + $overall)))
$(ConvertTo-Html5 $overallText)
$crit
$($e.Status_Critical)
$warn
$($e.CountWarnings)
$ok
$($e.Status_OK)
$info
$($e.CountInfo)

$($e.SectionEnvironment)

$($e.LabelDomain)$(ConvertTo-Html5 $Environment.Domain)
$($e.EnvCas)$(ConvertTo-Html5 $Environment.CaList)
$($e.EnvCaCerts)$($Environment.CaCertCount)
$($e.EnvUser)$(ConvertTo-Html5 $Environment.User)
"@) $present = @($Findings | Select-Object -ExpandProperty Category -Unique) foreach ($cat in @($Script:Categories + ($present | Where-Object { $Script:Categories -notcontains $_ }))) { $items = @($Findings | Where-Object Category -eq $cat | Sort-Object @{ Expression = 'Rank'; Descending = $true }, Object) if ($items.Count -eq 0) { continue } [void]$sb.Append("

$(ConvertTo-Html5 (T ('Cat_' + $cat)))

") foreach ($f in $items) { $rec = '' if ($f.Recommendation) { $rec = "
$(ConvertTo-Html5 (T 'Recommendation' $f.Recommendation))
" } [void]$sb.Append("") } [void]$sb.Append('
$($e.ColStatus)$($e.ColObject)$($e.ColFinding)$($e.ColDate)
$(ConvertTo-Html5 (T ('Status_' + $f.Status)))$(ConvertTo-Html5 $f.Object)$(ConvertTo-Html5 $f.Message)$rec$(ConvertTo-Html5 $f.Date)
') } [void]$sb.Append(@"

$($e.CtaTitle)

$ctaText
$($e.CtaButton)
$ctaContact
"@) return $sb.ToString() } #endregion #region Hauptprogramm function Get-DefaultOutputPath { $dir = $null try { $dir = [Environment]::GetFolderPath('Desktop') } catch { } if (-not $dir -or -not (Test-Path -LiteralPath $dir)) { $dir = (Get-Location).Path } return (Join-Path $dir ('ADCS-HealthCheck_{0:yyyyMMdd_HHmm}.html' -f (Get-Date))) } function Invoke-AdcsHealthCheck { if (-not $OutputPath) { $OutputPath = Get-DefaultOutputPath } $exitCode = 0 $now = Get-Date $envInfo = [ordered]@{ Domain = '' Computer = [Environment]::MachineName User = [Environment]::UserDomainName + '\' + [Environment]::UserName Created = Format-Date $now CreatedUtc = $now.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) CaList = '-' CaCertCount = 0 } Write-Host "$Script:ProductFull $Script:Version - $Script:Vendor" -ForegroundColor Cyan Write-Host (T 'ConsoleReadOnly') -ForegroundColor DarkGray if ($Demo) { $envInfo.Domain = 'contoso.local (DEMO)' $envInfo.CaList = 'CONTOSO-ISSUING-CA' $envInfo.CaCertCount = 4 $envInfo.Computer = 'PKI01' $envInfo.User = 'CONTOSO\admin' Add-DemoFindings } else { try { $envInfo.Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Name } catch { $envInfo.Domain = $env:USERDNSDOMAIN } $inv = $null try { Write-Host (T 'Step1') $inv = Get-AdPkiInventory $envInfo.CaList = (@($inv.EnrollmentServices | ForEach-Object { $_.Name }) -join ', ') if (-not $envInfo.CaList) { $envInfo.CaList = T 'CaListNone' } $envInfo.CaCertCount = $inv.CaCerts.Count } catch { Add-Finding -Id 'AD_UNREADABLE' -Category 'GENERAL' -Object 'Active Directory' -Status 'Critical' ` -Message (T 'AdUnreadable' $_.Exception.Message) -Recommendation (T 'AdUnreadableRec') } if ($inv) { if ($inv.EnrollmentServices.Count -eq 0) { Add-Finding -Id 'CA_NONE' -Category 'CAS' -Object $envInfo.Domain -Status 'Info' -Message (T 'CaNone') } Write-Host (T 'Step2'); Test-CaReachability -Inventory $inv Write-Host (T 'Step3'); Test-CaCertificates -Inventory $inv; Test-CaChains -Inventory $inv Write-Host (T 'Step4'); Test-NTAuth -Inventory $inv Write-Host (T 'Step5'); Test-IssuedCertificates -Inventory $inv Write-Host (T 'Step6') $urls = Get-UrlsToCheck -Inventory $inv Test-Crls -Inventory $inv -Urls $urls Test-AiaUrls -Urls $urls } Write-Host (T 'Step7') $caSubjects = @() if ($inv) { $caSubjects = @($inv.CaCerts.Values | ForEach-Object { $_.Cert.Subject } | Sort-Object -Unique) } Test-LocalStore -CaSubjects $caSubjects } $findings = $Script:Findings.ToArray() $html = New-HtmlReport -Environment ([pscustomobject]$envInfo) -Findings $findings [IO.File]::WriteAllText($OutputPath, $html, (New-Object System.Text.UTF8Encoding($true))) $crit = @($findings | Where-Object Status -eq 'Critical').Count $warn = @($findings | Where-Object Status -eq 'Warning').Count $okCount = @($findings | Where-Object Status -eq 'OK').Count if ($crit) { $exitCode = 2 } elseif ($warn) { $exitCode = 1 } if ($Json) { # Sprachunabhängig auswertbar über id, category und status; message/recommendation in der gewählten Sprache. $jsonPath = [IO.Path]::ChangeExtension($OutputPath, '.json') $items = @($findings | ForEach-Object { [ordered]@{ id = $_.Id; category = $_.Category; status = $_.Status; object = $_.Object message = $_.Message; recommendation = $_.Recommendation; date = $_.DateUtc } }) $payload = [ordered]@{ product = $Script:Product vendor = $Script:Vendor version = $Script:Version language = $Script:Lang exitCode = $exitCode summary = [ordered]@{ critical = $crit; warning = $warn; ok = $okCount; info = @($findings | Where-Object Status -eq 'Info').Count } environment = $envInfo findings = $items } [IO.File]::WriteAllText($jsonPath, ($payload | ConvertTo-Json -Depth 5), (New-Object System.Text.UTF8Encoding($false))) Write-Host (T 'ConsoleJson' $jsonPath) } $color = 'Green' if ($crit) { $color = 'Red' } elseif ($warn) { $color = 'Yellow' } Write-Host '' Write-Host (T 'ConsoleResult' $crit $warn $okCount) -ForegroundColor $color Write-Host (T 'ConsoleReport' $OutputPath) if (-not $NoOpen -and $env:OS -eq 'Windows_NT') { try { Start-Process -FilePath $OutputPath } catch { } } $Script:ExitCode = $exitCode } # Beim Dot-Sourcing (Tests) nur Funktionen laden, nicht ausführen. if ($MyInvocation.InvocationName -ne '.') { try { $Script:ExitCode = 3 Invoke-AdcsHealthCheck | Out-Null exit $Script:ExitCode } catch { Write-Host (T 'ConsoleError' $_.Exception.Message) -ForegroundColor Red exit 3 } } #endregion