From d326341488798ca7db6ffdf9e5be618eacd4991c Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Fri, 24 Jul 2026 15:32:18 +0200 Subject: [PATCH] Initial commit: BizTalk IIS inventory with DOCX reporting --- .editorconfig | 13 + .gitea/workflows/build.yml | 27 + .gitignore | 8 + BizTalkIisEnvironmentInventory.sln | 25 + Dokumentation.md | 273 +++++ Readme.md | 155 +++ deployment/run-inventory.cmd | 47 + scripts/build-release.cmd | 41 + scripts/package-release.cmd | 24 + src/BizTalkIisEnvironmentInventory/App.config | 20 + .../BizTalkIisEnvironmentInventory.csproj | 27 + .../Collectors/BizTalkCollector.cs | 260 +++++ .../Collectors/CertificateCollector.cs | 315 ++++++ .../Collectors/IisCollector.cs | 705 +++++++++++++ .../Collectors/SecurityCollector.cs | 262 +++++ .../Collectors/SystemCollector.cs | 219 ++++ .../Configuration/CollectorOptions.cs | 86 ++ .../Infrastructure/CommandLineOptions.cs | 111 ++ .../Infrastructure/FileLogger.cs | 107 ++ .../Infrastructure/SafeCollector.cs | 72 ++ .../Infrastructure/SensitiveDataSanitizer.cs | 78 ++ .../Models/InventoryModels.cs | 314 ++++++ src/BizTalkIisEnvironmentInventory/Program.cs | 216 ++++ .../Properties/AssemblyInfo.cs | 14 + .../Reporting/DocxReportWriter.cs | 977 ++++++++++++++++++ ...izTalkIisEnvironmentInventory.Tests.csproj | 28 + .../Program.cs | 352 +++++++ 27 files changed, 4776 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 BizTalkIisEnvironmentInventory.sln create mode 100644 Dokumentation.md create mode 100644 Readme.md create mode 100644 deployment/run-inventory.cmd create mode 100644 scripts/build-release.cmd create mode 100644 scripts/package-release.cmd create mode 100644 src/BizTalkIisEnvironmentInventory/App.config create mode 100644 src/BizTalkIisEnvironmentInventory/BizTalkIisEnvironmentInventory.csproj create mode 100644 src/BizTalkIisEnvironmentInventory/Collectors/BizTalkCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Collectors/CertificateCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Collectors/IisCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Collectors/SecurityCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Collectors/SystemCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Configuration/CollectorOptions.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Infrastructure/CommandLineOptions.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Infrastructure/FileLogger.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Infrastructure/SafeCollector.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Models/InventoryModels.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Program.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Properties/AssemblyInfo.cs create mode 100644 src/BizTalkIisEnvironmentInventory/Reporting/DocxReportWriter.cs create mode 100644 tests/BizTalkIisEnvironmentInventory.Tests/BizTalkIisEnvironmentInventory.Tests.csproj create mode 100644 tests/BizTalkIisEnvironmentInventory.Tests/Program.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f2608c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.md] +end_of_line = lf +trim_trailing_whitespace = false + diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3203b59 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build und Test + +on: + push: + pull_request: + +jobs: + build: + runs-on: windows + steps: + - name: Repository auschecken + uses: actions/checkout@v4 + + - name: Release bauen und Tests ausführen + shell: cmd + run: scripts\build-release.cmd + + - name: Deployment-Ordner erzeugen + shell: cmd + run: scripts\package-release.cmd + + - name: Deployment-Artefakt bereitstellen + uses: actions/upload-artifact@v4 + with: + name: BizTalkIisEnvironmentInventory-deploy + path: artifacts\BizTalkIisEnvironmentInventory-deploy + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1216b13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +obj/ +artifacts/ +.vs/ +*.user +*.suo +*.log + diff --git a/BizTalkIisEnvironmentInventory.sln b/BizTalkIisEnvironmentInventory.sln new file mode 100644 index 0000000..89ef161 --- /dev/null +++ b/BizTalkIisEnvironmentInventory.sln @@ -0,0 +1,25 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkIisEnvironmentInventory", "src\BizTalkIisEnvironmentInventory\BizTalkIisEnvironmentInventory.csproj", "{8A730DB4-98CE-41E8-AF0B-CDA46A8B20EF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkIisEnvironmentInventory.Tests", "tests\BizTalkIisEnvironmentInventory.Tests\BizTalkIisEnvironmentInventory.Tests.csproj", "{4C31A71F-F6CA-449B-9A41-E6259C467416}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8A730DB4-98CE-41E8-AF0B-CDA46A8B20EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A730DB4-98CE-41E8-AF0B-CDA46A8B20EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A730DB4-98CE-41E8-AF0B-CDA46A8B20EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A730DB4-98CE-41E8-AF0B-CDA46A8B20EF}.Release|Any CPU.Build.0 = Release|Any CPU + {4C31A71F-F6CA-449B-9A41-E6259C467416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C31A71F-F6CA-449B-9A41-E6259C467416}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C31A71F-F6CA-449B-9A41-E6259C467416}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C31A71F-F6CA-449B-9A41-E6259C467416}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal + diff --git a/Dokumentation.md b/Dokumentation.md new file mode 100644 index 0000000..9922368 --- /dev/null +++ b/Dokumentation.md @@ -0,0 +1,273 @@ +# Technische Dokumentation + +## 1. Ziel und Abgrenzung + +Das Tool dokumentiert den für den Frankfurt-Aufbau relevanten IIS-/BizTalk-Bestand auf den BEW-Systemen `ACC` und `PROD`. Es ersetzt eine fehleranfällige manuelle Aufnahme und benötigt trotz eingeschränkter PowerShell-Umgebung keine PowerShell-Ausführung. + +Die Anwendung ist: + +- lokal und read-only +- auf .NET Framework 4.7.2 ausgelegt +- ohne BizTalk-ExplorerOM-/OperationsOM-Buildabhängigkeit +- ohne `Microsoft.Web.Administration`-Deploymentabhängigkeit +- fehlertolerant je Datenquelle +- für Gitea-Build und Artefaktbereitstellung strukturiert + +Die Anwendung ist **kein Backup-Werkzeug**. Sie dokumentiert, was für einen kontrollierten Transfer vorhanden sein muss. Webinhalte, Zertifikate, private Schlüssel und IIS-Konfigurationsschlüssel müssen über freigegebene Backup-/Exportprozesse übertragen werden. + +## 2. Architektur + +```text +Administrative cmd.exe auf ACC oder PROD + | + +-- run-inventory.cmd + | + +-- BizTalkIisEnvironmentInventory.exe + | + +-- SystemCollector + | +-- Win32_OperatingSystem + | +-- Win32_ServerFeature + | +-- Registry (.NET Framework) + | + +-- IisCollector + | +-- applicationHost.config (XML, read-only) + | +-- Webverzeichnisse und Dateimetadaten + | +-- NTFS-ACL der Web-Stämme + | + +-- CertificateCollector + | +-- LocalMachine\My + | +-- CAPI/CNG-Metadaten und Key-Datei-ACL + | + +-- SecurityCollector + | +-- Win32_Service + | +-- secedit.exe /export + | + +-- BizTalkCollector + | +-- Registry/Uninstall + | +-- Dateiversionen + | +-- root\MicrosoftBizTalkServer + | + +-- DocxReportWriter + +-- Office Open XML (DOCX/ZIP) + +-- atomarer Dateiwechsel +``` + +Alle Collector-Abschnitte laufen nacheinander, weil sie lokale I/O- und WMI-Ressourcen verwenden und teilweise voneinander abhängen. Zertifikate werden beispielsweise gegen die vorher gelesenen IIS-Bindings markiert. Eine Exception beendet nur den betroffenen Abschnitt. Die übrigen Collectoren laufen weiter. + +## 3. Datenquellen + +### 3.1 Windows und Rollen + +`Win32_ServerFeature` liefert installierte Rollen/Features. Der Report beschränkt die Ausgabe auf IIS-, Web-, HTTP-, WAS-, ASP.NET-, .NET-, MSMQ- und COM+-relevante Einträge. Betriebssystemdaten stammen aus `Win32_OperatingSystem`. + +Auf Client-Windows oder Systemen ohne `Win32_ServerFeature` wird der Abschnitt als teilweise markiert. Das ist kein falscher leerer Bestand. + +### 3.2 IIS + +Standardpfad: + +```text +%WINDIR%\System32\inetsrv\config\applicationHost.config +``` + +Die XML-Datei wird mit `FileShare.ReadWrite | FileShare.Delete` geöffnet, damit eine laufende IIS-Verwaltung die Aufnahme nicht unnötig blockiert. Es werden erfasst: + +- Section-Deklarationen und globale Belegung +- Protected-Configuration-Provider +- Application Pools und ProcessModel-Identitäten +- Sites, Bindings, SSL-Thumbprints +- Anwendungen und virtuelle Verzeichnisse +- physische Pfade + +Der SHA-256-Wert im Report gehört zu einer **bereinigten logischen Konfiguration**. Vor dem Hashing werden sensible Attribute und verschlüsselte XML-Nutzdaten ersetzt. Er dient dem Vergleich ACC/PROD oder Vorher/Nachher, nicht als Hash der Originaldatei. + +### 3.3 Webinhalte + +Jeder physische Web-Stamm wird iterativ begangen. Dadurch entsteht keine tiefe Methodenrekursion. Schutzgrenzen: + +| Einstellung | Default | Wirkung | +| --- | ---: | --- | +| `MaxFilesPerApplication` | 10000 | Maximale Zahl im Word-Dateimanifest; Gesamtzahl und Gesamtgröße werden weiter gezählt. | +| `MaxContentDepth` | 30 | Maximale Verzeichnistiefe. | +| `IncludeFileHashes` | `false` | SHA-256 nur bei ausdrücklicher Aktivierung. | + +Reparse Points/Junctions werden nicht verfolgt. Das verhindert Schleifen und ein unbemerktes Verlassen des Web-Stamms. Sie erscheinen als Hinweis. Zugriffsfehler einzelner Dateien oder Verzeichnisse machen das Manifest sichtbar unvollständig, stoppen aber nicht die Site-Aufnahme. + +Empfehlung für die erste ACC-/PROD-Aufnahme: + +```cmd +run-inventory.cmd ACC C:\IIS-Doku\ACC +run-inventory.cmd PROD C:\IIS-Doku\PROD +``` + +SHA-256 für jede Webdatei erst in einem zweiten Lauf aktivieren, wenn ein genauer Inhaltsvergleich benötigt wird: + +```cmd +BizTalkIisEnvironmentInventory.exe --environment ACC --output C:\IIS-Doku\ACC-Hash --include-file-hashes +``` + +### 3.4 Zertifikate und private Schlüssel + +Der Collector öffnet `LocalMachine\My` read-only. Für jedes Zertifikat werden unter anderem Thumbprint, Gültigkeit, Issuer, Algorithmus, Key Usage und IIS-Nutzung dokumentiert. + +Bei privatem Schlüssel werden – soweit die ACL es zulässt – nur folgende Informationen gelesen: + +- CAPI-/CNG-Provider +- eindeutiger Containername +- Provider-Exportpolicy +- Pfad der Containerdatei +- NTFS-ACL der Containerdatei + +Der Code ruft weder `X509Certificate2.Export` noch `CngKey.Export` auf. Es gelangt kein Schlüsselmaterial in Log oder DOCX. `Exportierbar = Ja` bedeutet lediglich, dass ein separater, autorisierter Export technisch möglich sein sollte. + +### 3.5 Dienstkonten und lokale Sicherheit + +Anwendungspoolkonten stammen aus der IIS-Konfiguration. Relevante BizTalk-/IIS-/SSO-/MSMQ-Windows-Dienste werden über `Win32_Service` gelesen. + +Für lokale User Rights und grundlegende Security-/Audit-Policy startet die .NET-Anwendung: + +```text +secedit.exe /export /areas USER_RIGHTS SECURITYPOLICY +``` + +Das ist ein signiertes Windows-Bordmittel und keine PowerShell-Ausführung. Der Prozess hat einen Timeout von 60 Sekunden. Die temporäre INF-Datei wird nach dem Parsen gelöscht. Scheitert `secedit`, bleibt der Rest des Reports verwendbar. + +### 3.6 BizTalk + +Der Collector benötigt keine BizTalk-Assembly. Er verwendet: + +- `HKLM\SOFTWARE\Microsoft\BizTalk Server\3.0` in 32-/64-Bit-Registry-View +- Windows-Uninstall-Einträge +- Dateiversionen zentraler BizTalk-Programme und Assemblies +- WMI-Namespace `root\MicrosoftBizTalkServer` + +Geprüfte Klassen: + +- `MSBTS_GroupSetting` +- `MSBTS_Host` +- `MSBTS_HostInstance` +- `MSBTS_ReceivePort` +- `MSBTS_ReceiveLocation` +- `MSBTS_SendPort` +- `MSBTS_Orchestration` +- `MSBTS_Server` + +Je Klasse wird die Instanzzahl dokumentiert. Klassenfehler erscheinen einzeln als Finding und nicht als irreführende Null. + +## 4. Secret-Bereinigung + +XML-Attribute mit typischen Fragmenten wie `password`, `secret`, `token`, `connectionString`, `privateKey`, `validationKey` oder `decryptionKey` werden durch `[ENTFERNT]` ersetzt. Inhalte von `EncryptedData`, `CipherData` und `CipherValue` werden ebenfalls nicht übernommen. + +Zusätzlich gelten: + +- Logmeldungen enthalten keine XML-Dumps. +- Registrywerte mit sensitiven Namen werden nicht ausgegeben. +- Alle Berichtswerte werden über einen sicheren XML-Writer in WordprocessingML geschrieben. +- Das DOCX enthält keine Makros, externen Links, Skripte, Fonts oder Tracking-Ressourcen. + +Der Report enthält dennoch Infrastrukturinformationen und ACLs und muss entsprechend der internen Schutzklasse gespeichert werden. + +## 5. Berechtigungen + +Für eine möglichst vollständige Aufnahme ist eine lokale administrative `cmd.exe` empfohlen. + +Ohne Erhöhung funktionieren häufig: + +- IIS-XML-Topologie +- öffentlich lesbare Webinhalte +- Zertifikatsmetadaten +- Registry-Grunddaten + +Erhöhte Rechte können erforderlich sein für: + +- ACL geschützter Webverzeichnisse +- private Key-Container/ACLs +- `secedit /export` +- einzelne BizTalk-WMI-Klassen +- Dateien unter geschützten Installationspfaden + +Fehlende Rechte werden im Log und DOCX ausgewiesen. Das Tool verändert keine ACL und fordert keine zusätzlichen Rechte an. + +## 6. Resilienz und Betriebsverhalten + +- WMI-Timeout je Query, Default 30 Sekunden +- Collector-Isolation durch zentralen Safe-Runner +- iterative Dateibegehung mit Maximalgrenzen +- Reparse-Point-Schutz +- geteiltes Lesen aktiver Konfigurations-/Webdateien +- Fehlerisolation je Datei, Verzeichnis, Zertifikat und WMI-Klasse +- atomare DOCX-Ausgabe über temporäre Datei und Replace/Move +- eindeutige Zeitstempel in Report- und Logdateien +- sichtbarer Fortschritt auf der Konsole +- Exitcode `1` bei verwertbarem Teilreport + +Bei sehr großen Webverzeichnissen kann `--skip-content-manifest` verwendet werden. Bei produktiver Last sollten Datei-Hashes außerhalb der Hauptbetriebszeit berechnet werden. + +## 7. Vergleich ACC und PROD + +Empfohlenes Vorgehen: + +1. Deployment unverändert nach ACC und PROD kopieren. +2. Auf beiden Systemen als lokaler Administrator ausführen. +3. Prüfen, dass Exitcode `0` erreicht oder jeder Teilfehler begründet ist. +4. DOCX-Berichte sicher auf den Dokumentationsarbeitsplatz übertragen. +5. Vergleichen: + - acht erwartete Anwendungen vorhanden + - App-Pool-Modus, 32-Bit und Dienstidentitäten + - Bindings und Zertifikatsablauf + - Webpfade, Dateizahlen und Änderungsstände + - NTFS-/Private-Key-ACLs + - User Rights der Dienstkonten + - BizTalk-Produkte, Binärversionen und WMI-Komponenten +6. Abweichungen fachlich als beabsichtigt oder als Migrationslücke klassifizieren. + +Die DOCX-Dateien können mit Word-Bordmitteln durchsucht und verglichen werden. Für einen maschinellen Diff kann optional in einer Folgestufe ein JSON-Export ergänzt werden; die aktuelle Übergabe ist bewusst auf die gewünschte Word-Dokumentation beschränkt. + +## 8. Build, Test und Gitea + +```cmd +scripts\build-release.cmd +``` + +Das Skript: + +1. findet MSBuild über `vswhere.exe` oder `PATH` +2. baut Solution und Tests in `Release` +3. führt den abhängigen Konsolen-Testläufer aus +4. führt den eingebauten `--self-test` aus + +Tests decken ab: + +- Optionsnormalisierung +- Secret-Bereinigung +- IIS-XML-/Content-Parsing +- secedit-INF-Parsing +- DOCX-Paketstruktur und XML-Injection-Schutz + +Auf Nicht-Windows-Buildsystemen wird zusätzlich ein .NET-10-Testziel für die plattformneutralen Tests +(Optionsparser, Secret-Bereinigung und DOCX-Paketvalidierung) angeboten. Die Windows-spezifischen IIS- und +`secedit`-Parsertests werden im normalen `net472`-Lauf auf dem Windows-Runner ausgeführt. + +Die Gitea-Workflowdatei `.gitea/workflows/build.yml` erwartet einen Windows-Runner mit Visual Studio Build Tools und .NET Framework 4.7.2 Developer Pack. + +## 9. Bekannte Grenzen + +- Das Tool exportiert keine Websites, Dateien, Zertifikate oder Keys. +- Windows Feature-Namen kommen lokalisiert aus WMI. +- Effektive delegierte IIS-Konfiguration aus jeder einzelnen `web.config` wird nicht vollständig aufgelöst; die Dateien stehen im Inhaltsmanifest. +- ACLs werden am Web-Stamm und an Key-Dateien dokumentiert, nicht an jeder Webdatei. +- Gruppenverschachtelungen in Active Directory werden nicht aufgelöst. +- Private Keys in HSM/KSP können keinen Dateipfad besitzen. +- WMI-Komponentenzahlen sind eine Bestandsaufnahme, kein Laufzeitmonitoring. +- Der Report ist eine Momentaufnahme; aktive Deployments können sich während des Laufs ändern. + +## 10. Wiederholbarkeit + +Für vergleichbare Ergebnisse auf ACC und PROD: + +- dieselbe EXE-/Config-Version verwenden +- denselben Berechtigungskontext verwenden +- Dateihashes auf beiden Systemen gleich aktivieren oder deaktivieren +- Aufnahme nicht während eines Deployments ausführen +- DOCX und Log gemeinsam archivieren +- Datum, Umgebung und Servername im generierten Dateinamen unverändert lassen diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..0c7f2b4 --- /dev/null +++ b/Readme.md @@ -0,0 +1,155 @@ +# BizTalk IIS Environment Inventory + +`BizTalk IIS Environment Inventory` erzeugt pro BizTalk-2020-Umgebung eine Microsoft-Word-Dokumentation (`.docx`) des lokalen IIS- und BizTalk-Servers. Das Tool ist für die BEW-Umgebungen `ACC` und `PROD` vorgesehen und funktioniert ohne PowerShell. + +Es ist eine C#-Konsolenanwendung für .NET Framework 4.7.2. Auf dem Zielserver werden weder Microsoft Word/Office noch BizTalk-SDK-DLLs oder `Microsoft.Web.Administration` benötigt. Die Anwendung erzeugt das DOCX direkt als standardkonformes Office-Open-XML-Paket und liest lokale Windows-/IIS-Daten, WMI, Registry, Zertifikatsspeicher und NTFS-ACLs ausschließlich lesend. + +## Erfasster Umfang + +- installierte IIS-/WAS-/HTTP-/MSMQ- und .NET-Serverrollen +- `applicationHost.config`, globale IIS-Sections und Konfigurationsprovider +- Application Pools inklusive effektiver Identitäten +- Sites, Bindings, Anwendungen und virtuelle Verzeichnisse +- Webinhaltsmanifest mit Pfad, Größe und Änderungszeit; SHA-256 optional +- NTFS-Berechtigungen der Web-Stammverzeichnisse +- Zertifikate aus `LocalMachine\My`, IIS-Zuordnung, Ablauf und Enhanced Key Usage +- Private-Key-Metadaten, Exportpolicy, Provider, Container und ACL +- relevante Windows-Dienste und deren Dienstkonten +- lokale User Rights und Security-/Audit-Policy über `secedit.exe` +- BizTalk-Installation, Produkt-/Dateiversionen und zentrale WMI-Klassen +- Fehler, fehlende Rechte und unvollständige Daten als sichtbare Findings + +Der in der Aufgabe genannte Scope wird standardmäßig geprüft: + +- `heat_archive` +- `heat_bankdata` +- `heat_bbill` +- `heat_caccount` +- `heat_invoice` +- `heat_meterchange` +- `heat_meterlist` +- `heat_meterreading` + +Fehlt eine erwartete Anwendung, erscheint ein Hinweis im Bericht. Die Liste kann in `BizTalkIisEnvironmentInventory.exe.config` über `ExpectedApplications` angepasst oder durch einen leeren Wert deaktiviert werden. + +## Sicherheitsprinzip + +Das Word-Dokument enthält absichtlich: + +- keine Kennwörter +- keine Connection Strings oder Tokens +- keine entschlüsselbaren IIS-Konfigurationsblobs +- keine privaten Schlüssel/PFX-Dateien + +Für die Migration werden stattdessen Provider, Containername, Fingerprint, Exportpolicy und ACL dokumentiert. Zertifikate und IIS-Verschlüsselungsschlüssel müssen anschließend über den freigegebenen betrieblichen Schlüsseltransfer übertragen werden. Der Report selbst ist kein Key-Backup. + +## Schnellstart auf ACC und PROD + +Deployment-Ordner auf den jeweiligen BizTalk-Server kopieren und eine administrative `cmd.exe` öffnen. + +ACC: + +```cmd +run-inventory.cmd ACC C:\IIS-Doku\ACC +``` + +PROD: + +```cmd +run-inventory.cmd PROD C:\IIS-Doku\PROD +``` + +Die Ausgabe besteht pro Lauf aus: + +```text +IIS-Dokumentation-ACC-SERVERNAME-20260724-143000.docx +IIS-Dokumentation-ACC-SERVERNAME-20260724-143000.log +``` + +Das DOCX ist vollständig offline und kann anschließend auf einem Arbeitsplatz mit Microsoft Word, LibreOffice oder einer anderen OOXML-kompatiblen Anwendung geöffnet, durchsucht, gedruckt oder als PDF gespeichert werden. Auf dem BizTalk-Server wird keine dieser Anwendungen benötigt. Während des Laufs zeigt die Konsole jeden Collector, Laufzeit, Fehler und den finalen Dateipfad. + +## Voraussetzungen + +Zielserver: + +- Windows Server mit IIS/BizTalk Server 2020 +- .NET Framework 4.7.2 oder neueres 4.x +- lokale Ausführung auf dem zu dokumentierenden Server +- administrative Eingabeaufforderung empfohlen + +Build-Host: + +- Visual Studio 2022 Build Tools oder Visual Studio 2022 +- .NET Framework 4.7.2 Developer Pack +- MSBuild im `PATH` oder über `vswhere.exe` auffindbar + +Build und Tests: + +```cmd +scripts\build-release.cmd +``` + +Deployment-Paket: + +```cmd +scripts\package-release.cmd +``` + +Ergebnis: + +```text +artifacts\BizTalkIisEnvironmentInventory-deploy\ +``` + +## Direkter Aufruf + +```cmd +BizTalkIisEnvironmentInventory.exe --environment ACC --output C:\IIS-Doku\ACC +``` + +Optionen: + +| Option | Bedeutung | +| --- | --- | +| `--environment NAME` | Umgebung, beispielsweise `ACC` oder `PROD`. | +| `--output PFAD` | Ausgabeordner für DOCX und Log. | +| `--include-file-hashes` | Berechnet SHA-256 für manifestierte Webdateien; erhöht Laufzeit und I/O. | +| `--skip-content-manifest` | Erfasst Pfade und ACLs, aber keine rekursive Dateiliste. | +| `--iis-config DATEI` | Liest eine alternative `applicationHost.config`, vor allem für Offline-Tests. | +| `--self-test` | Prüft Parser, Secret-Bereinigung und DOCX-Paketstruktur ohne Serverzugriffe. | + +Exitcodes: + +| Code | Bedeutung | +| --- | --- | +| `0` | Alle Collector-Abschnitte erfolgreich. | +| `1` | Bericht wurde erzeugt, mindestens ein Abschnitt war unvollständig. | +| `2` | Fataler Fehler; Aufruf, Ausgabeordner oder Berichtserzeugung prüfen. | + +## Repository-Struktur + +```text +. +├── .gitea/workflows/build.yml +├── deployment/run-inventory.cmd +├── scripts/ +│ ├── build-release.cmd +│ └── package-release.cmd +├── src/BizTalkIisEnvironmentInventory/ +├── tests/BizTalkIisEnvironmentInventory.Tests/ +├── BizTalkIisEnvironmentInventory.sln +├── Dokumentation.md +└── Readme.md +``` + +Die technische Architektur, Berechtigungen, Datenquellen und Betriebsgrenzen sind in [Dokumentation.md](Dokumentation.md) beschrieben. + +## Base64-Transport per certutil + +Wenn das Repository als `BizTalkIisEnvironmentInventory-source.zip.txt` geliefert wird: + +```cmd +certutil.exe -decode BizTalkIisEnvironmentInventory-source.zip.txt BizTalkIisEnvironmentInventory-source.zip +``` + +Danach die ZIP-Datei mit Windows Explorer oder `tar.exe` entpacken. Der SHA-256-Wert der ZIP-Datei wird bei der Übergabe separat genannt. diff --git a/deployment/run-inventory.cmd b/deployment/run-inventory.cmd new file mode 100644 index 0000000..8b56fa9 --- /dev/null +++ b/deployment/run-inventory.cmd @@ -0,0 +1,47 @@ +@echo off +setlocal EnableExtensions + +set "APPDIR=%~dp0" +set "EXE=%APPDIR%BizTalkIisEnvironmentInventory.exe" + +if not exist "%EXE%" ( + echo FEHLER: Anwendung fehlt: "%EXE%" + exit /b 2 +) + +if "%~1"=="" goto :usage + +set "ENVIRONMENT=%~1" +if "%~2"=="" ( + set "OUTPUT=%CD%\IIS-Dokumentation\%ENVIRONMENT%" +) else ( + set "OUTPUT=%~2" +) + +echo ============================================================ +echo BizTalk IIS Environment Inventory +echo Umgebung: %ENVIRONMENT% +echo Ausgabe: %OUTPUT% +echo Modus: read-only +echo ============================================================ +echo. + +"%EXE%" --environment "%ENVIRONMENT%" --output "%OUTPUT%" +set "EXITCODE=%ERRORLEVEL%" + +echo. +if "%EXITCODE%"=="0" ( + echo Erfassung vollstaendig abgeschlossen. +) else if "%EXITCODE%"=="1" ( + echo Erfassung mit Teilfehlern abgeschlossen. DOCX und Log pruefen. +) else ( + echo Erfassung konnte nicht abgeschlossen werden. +) +echo ExitCode: %EXITCODE% +exit /b %EXITCODE% + +:usage +echo Aufruf: +echo run-inventory.cmd ACC C:\IIS-Doku\ACC +echo run-inventory.cmd PROD C:\IIS-Doku\PROD +exit /b 2 diff --git a/scripts/build-release.cmd b/scripts/build-release.cmd new file mode 100644 index 0000000..a782b13 --- /dev/null +++ b/scripts/build-release.cmd @@ -0,0 +1,41 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "SOLUTION=%ROOT%\BizTalkIisEnvironmentInventory.sln" + +if not defined MSBUILD ( + set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + if exist "%VSWHERE%" ( + for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) do ( + set "MSBUILD=%%i" + goto :found_msbuild + ) + ) +) + +:found_msbuild +if not defined MSBUILD ( + for /f "tokens=*" %%i in ('where msbuild 2^>nul') do ( + set "MSBUILD=%%i" + goto :found_msbuild_path + ) +) + +:found_msbuild_path +if not defined MSBUILD ( + echo FEHLER: MSBuild fehlt. Visual Studio 2022 Build Tools und .NET Framework 4.7.2 Developer Pack installieren. + exit /b 2 +) + +echo Verwende MSBuild: %MSBUILD% +"%MSBUILD%" "%SOLUTION%" /m /restore /p:Configuration=Release /p:Platform="Any CPU" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre Tests aus ... +"%ROOT%\tests\BizTalkIisEnvironmentInventory.Tests\bin\Release\net472\BizTalkIisEnvironmentInventory.Tests.exe" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre Self-Test der Anwendung aus ... +"%ROOT%\src\BizTalkIisEnvironmentInventory\bin\Release\net472\BizTalkIisEnvironmentInventory.exe" --self-test +exit /b %ERRORLEVEL% diff --git a/scripts/package-release.cmd b/scripts/package-release.cmd new file mode 100644 index 0000000..ec31066 --- /dev/null +++ b/scripts/package-release.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "BIN=%ROOT%\src\BizTalkIisEnvironmentInventory\bin\Release\net472" +set "ARTIFACTS=%ROOT%\artifacts" +set "DEPLOY=%ARTIFACTS%\BizTalkIisEnvironmentInventory-deploy" + +call "%ROOT%\scripts\build-release.cmd" +if errorlevel 1 exit /b %ERRORLEVEL% + +if exist "%DEPLOY%" rmdir /s /q "%DEPLOY%" +mkdir "%DEPLOY%" + +copy "%BIN%\BizTalkIisEnvironmentInventory.exe" "%DEPLOY%\" >nul +copy "%BIN%\BizTalkIisEnvironmentInventory.exe.config" "%DEPLOY%\" >nul +if exist "%BIN%\BizTalkIisEnvironmentInventory.pdb" copy "%BIN%\BizTalkIisEnvironmentInventory.pdb" "%DEPLOY%\" >nul +copy "%ROOT%\deployment\run-inventory.cmd" "%DEPLOY%\" >nul +copy "%ROOT%\Readme.md" "%DEPLOY%\" >nul +copy "%ROOT%\Dokumentation.md" "%DEPLOY%\" >nul + +echo Deployment-Ordner erstellt: +echo %DEPLOY% +exit /b 0 diff --git a/src/BizTalkIisEnvironmentInventory/App.config b/src/BizTalkIisEnvironmentInventory/App.config new file mode 100644 index 0000000..eecdf64 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/App.config @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/BizTalkIisEnvironmentInventory/BizTalkIisEnvironmentInventory.csproj b/src/BizTalkIisEnvironmentInventory/BizTalkIisEnvironmentInventory.csproj new file mode 100644 index 0000000..bf1b081 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/BizTalkIisEnvironmentInventory.csproj @@ -0,0 +1,27 @@ + + + Exe + net472 + BizTalkIisEnvironmentInventory + BizTalkIisEnvironmentInventory + 7.3 + false + true + true + true + AnyCPU + false + + + + + + + + + + + + + + diff --git a/src/BizTalkIisEnvironmentInventory/Collectors/BizTalkCollector.cs b/src/BizTalkIisEnvironmentInventory/Collectors/BizTalkCollector.cs new file mode 100644 index 0000000..94cb06f --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Collectors/BizTalkCollector.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management; +using Microsoft.Win32; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Infrastructure; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Collectors +{ + /// + /// Erfasst BizTalk-Installation, Registrymetadaten, Binärversionen und WMI-Komponentenzahlen. + /// + internal sealed class BizTalkCollector + { + private readonly CollectorOptions options; + + /// + /// Initialisiert den BizTalk-Collector. + /// + /// WMI-Timeout. + public BizTalkCollector(CollectorOptions options) + { + this.options = options; + } + + /// + /// Erfasst lokale BizTalk-Komponenten ohne ExplorerOM-Abhängigkeit. + /// + /// BizTalk-Zielmodell. + /// Liste nicht fataler Auffälligkeiten. + public void Collect(BizTalkInventory target, IList findings) + { + ReadBizTalkRegistry(target); + ReadInstalledProducts(target); + ReadComponentVersions(target, findings); + ReadBizTalkWmi(target, findings); + } + + /// + /// Liest nicht sensible Werte aus dem BizTalk-Hauptschlüssel in beiden Registry Views. + /// + /// BizTalk-Zielmodell. + private static void ReadBizTalkRegistry(BizTalkInventory target) + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var key = baseKey.OpenSubKey(@"SOFTWARE\Microsoft\BizTalk Server\3.0", false)) + { + if (key == null) + { + continue; + } + + foreach (var name in key.GetValueNames().OrderBy(item => item, StringComparer.OrdinalIgnoreCase)) + { + if (SensitiveDataSanitizer.IsSensitiveName(name)) + { + target.RegistryValues.Add(new NameValueRecord(view + " / " + name, "[ENTFERNT]")); + continue; + } + + var value = key.GetValue(name); + target.RegistryValues.Add(new NameValueRecord( + view + " / " + name, + Convert.ToString(value, CultureInfo.InvariantCulture))); + } + } + } + } + + /// + /// Liest BizTalk-bezogene Einträge aus den Windows-Uninstall-Schlüsseln. + /// + /// BizTalk-Zielmodell. + private static void ReadInstalledProducts(BizTalkInventory target) + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var uninstall = baseKey.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + false)) + { + if (uninstall == null) + { + continue; + } + + foreach (var childName in uninstall.GetSubKeyNames()) + { + using (var child = uninstall.OpenSubKey(childName, false)) + { + var displayName = Convert.ToString(child == null ? null : child.GetValue("DisplayName")); + if (displayName.IndexOf("BizTalk", StringComparison.OrdinalIgnoreCase) < 0 + && displayName.IndexOf("Enterprise Single Sign-On", StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + var version = Convert.ToString(child.GetValue("DisplayVersion")); + var date = Convert.ToString(child.GetValue("InstallDate")); + target.InstalledProducts.Add(new NameValueRecord( + displayName, + version + (string.IsNullOrWhiteSpace(date) ? string.Empty : " | InstallDate " + date))); + } + } + } + } + + target.InstalledProducts.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Liest Dateiversionen zentraler BizTalk-Binärdateien aus dem Installationspfad. + /// + /// BizTalk-Zielmodell. + /// Liste nicht fataler Dateizugriffsfehler. + private static void ReadComponentVersions(BizTalkInventory target, IList findings) + { + var paths = target.RegistryValues + .Where(item => item.Name.IndexOf("InstallPath", StringComparison.OrdinalIgnoreCase) >= 0 + || item.Name.IndexOf("InstallDir", StringComparison.OrdinalIgnoreCase) >= 0 + || item.Name.IndexOf("ProductPath", StringComparison.OrdinalIgnoreCase) >= 0) + .Select(item => Environment.ExpandEnvironmentVariables(item.Value ?? string.Empty)) + .Where(Directory.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (paths.Count == 0) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var fallback = Path.Combine(programFiles, "Microsoft BizTalk Server"); + if (Directory.Exists(fallback)) + { + paths.Add(fallback); + } + } + + var interestingNames = new HashSet( + new[] + { + "BTSNTSvc.exe", "BTSNTSvc64.exe", "BTSMMC.msc", "Microsoft.BizTalk.ExplorerOM.dll", + "Microsoft.BizTalk.Operations.dll", "SSOConfig.exe", "ENTSSO.exe", "BREDeployment.exe" + }, + StringComparer.OrdinalIgnoreCase); + + foreach (var root in paths) + { + try + { + foreach (var path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Where(path => interestingNames.Contains(Path.GetFileName(path)))) + { + var info = FileVersionInfo.GetVersionInfo(path); + target.Components.Add(new NameValueRecord( + path, + (info.FileVersion ?? "[keine Dateiversion]") + " | " + (info.ProductVersion ?? "[keine Produktversion]"))); + } + } + catch (Exception exception) + { + findings.Add(new Finding + { + Severity = "Warnung", + Area = "BizTalk-Komponenten", + Message = "BizTalk-Installationspfad konnte nicht vollständig gelesen werden: " + root, + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "Leseberechtigungen und Installationspfad prüfen." + }); + } + } + } + + /// + /// Prüft zentrale BizTalk-WMI-Klassen und dokumentiert deren Instanzzahlen. + /// + /// BizTalk-Zielmodell. + /// Liste klassenspezifischer WMI-Fehler. + private void ReadBizTalkWmi(BizTalkInventory target, IList findings) + { + var scope = new ManagementScope(@"\\.\root\MicrosoftBizTalkServer"); + try + { + scope.Connect(); + target.WmiNamespaceAvailable = scope.IsConnected; + } + catch (Exception exception) + { + target.WmiNamespaceAvailable = false; + findings.Add(new Finding + { + Severity = "Fehler", + Area = "BizTalk WMI", + Message = "Der BizTalk-WMI-Namespace ist nicht erreichbar.", + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "BizTalk-WMI-Provider, Namespace und Ausführungsberechtigungen prüfen." + }); + return; + } + + var classes = new[] + { + "MSBTS_GroupSetting", + "MSBTS_Host", + "MSBTS_HostInstance", + "MSBTS_ReceivePort", + "MSBTS_ReceiveLocation", + "MSBTS_SendPort", + "MSBTS_Orchestration", + "MSBTS_Server" + }; + + foreach (var className in classes) + { + try + { + var count = 0; + using (var searcher = new ManagementObjectSearcher( + scope, + new ObjectQuery("SELECT * FROM " + className), + new EnumerationOptions + { + ReturnImmediately = false, + Rewindable = false, + Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds) + })) + using (var results = searcher.Get()) + { + foreach (ManagementObject ignored in results) + { + count++; + } + } + + target.WmiClasses.Add(new NameValueRecord(className, count.ToString(CultureInfo.InvariantCulture) + " Instanz(en)")); + } + catch (Exception exception) + { + target.WmiClasses.Add(new NameValueRecord(className, "[Fehler: " + exception.GetType().Name + "]")); + findings.Add(new Finding + { + Severity = "Warnung", + Area = "BizTalk WMI", + Message = "WMI-Klasse konnte nicht gelesen werden: " + className, + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "BizTalk-Operatorrechte, SQL-Erreichbarkeit und WMI-Provider prüfen." + }); + } + } + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Collectors/CertificateCollector.cs b/src/BizTalkIisEnvironmentInventory/Collectors/CertificateCollector.cs new file mode 100644 index 0000000..dc4ae51 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Collectors/CertificateCollector.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Collectors +{ + /// + /// Dokumentiert lokale Computerzertifikate und private Schlüssel ausschließlich über Metadaten. + /// + internal sealed class CertificateCollector + { + private readonly CollectorOptions options; + + /// + /// Initialisiert den Zertifikat-Collector. + /// + /// Schalter für den Zertifikatsumfang. + public CertificateCollector(CollectorOptions options) + { + this.options = options; + } + + /// + /// Liest den LocalMachine-Personal-Store und markiert IIS-Bindungszertifikate. + /// + /// Zertifikats-Zielliste. + /// Bereits erfasste IIS-Bindings. + /// Liste für Ablauf- und Zugriffsauffälligkeiten. + public void Collect(IList target, IisInventory iis, IList findings) + { + var bindingThumbprints = new HashSet( + iis.Sites.SelectMany(site => site.Bindings) + .Select(binding => NormalizeThumbprint(binding.CertificateHash)) + .Where(value => !string.IsNullOrWhiteSpace(value)), + StringComparer.OrdinalIgnoreCase); + + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); + foreach (var certificate in store.Certificates.Cast()) + { + var thumbprint = NormalizeThumbprint(certificate.Thumbprint); + var usedByIis = bindingThumbprints.Contains(thumbprint); + if (!options.IncludeAllPersonalCertificates && !usedByIis) + { + continue; + } + + var record = CreateRecord(certificate, usedByIis, findings); + target.Add(record); + } + } + + foreach (var missing in bindingThumbprints.Where( + thumbprint => !target.Any(item => string.Equals( + NormalizeThumbprint(item.Thumbprint), + thumbprint, + StringComparison.OrdinalIgnoreCase)))) + { + findings.Add(new Finding + { + Severity = "Warnung", + Area = "Zertifikate", + Message = "Das von IIS referenzierte Zertifikat wurde nicht in LocalMachine\\My gefunden.", + TechnicalDetail = "Thumbprint: " + missing, + Recommendation = "Store-Name des Bindings, Zertifikatsbereitstellung und Berechtigungen prüfen." + }); + } + + var sorted = target.OrderByDescending(item => item.UsedByIisBinding) + .ThenBy(item => item.Subject, StringComparer.OrdinalIgnoreCase) + .ToList(); + target.Clear(); + foreach (var item in sorted) + { + target.Add(item); + } + } + + /// + /// Überführt ein X509-Zertifikat in ein sicheres Berichtsmodell. + /// + /// Lokales Zertifikat. + /// Gibt an, ob ein IIS-Binding den Thumbprint verwendet. + /// Liste für Auffälligkeiten. + /// Zertifikatsdatensatz ohne privates Schlüsselmaterial. + private static CertificateRecord CreateRecord( + X509Certificate2 certificate, + bool usedByIis, + IList findings) + { + var record = new CertificateRecord + { + StoreLocation = StoreLocation.LocalMachine.ToString(), + StoreName = StoreName.My.ToString(), + Subject = certificate.Subject, + Issuer = certificate.Issuer, + Thumbprint = NormalizeThumbprint(certificate.Thumbprint), + SerialNumber = certificate.SerialNumber, + NotBefore = certificate.NotBefore, + NotAfter = certificate.NotAfter, + SignatureAlgorithm = certificate.SignatureAlgorithm == null + ? string.Empty + : certificate.SignatureAlgorithm.FriendlyName, + PublicKeyAlgorithm = certificate.PublicKey == null + ? string.Empty + : certificate.PublicKey.Oid.FriendlyName, + HasPrivateKey = certificate.HasPrivateKey, + UsedByIisBinding = usedByIis, + PrivateKeyExportable = certificate.HasPrivateKey ? "[nicht ermittelbar]" : "Nein (kein privater Schlüssel)" + }; + + try + { + record.PublicKeySize = certificate.PublicKey.Key.KeySize; + } + catch + { + record.PublicKeySize = 0; + } + + foreach (var extension in certificate.Extensions.OfType()) + { + foreach (var usage in extension.EnhancedKeyUsages.Cast()) + { + record.EnhancedKeyUsages.Add((usage.FriendlyName ?? "[unbekannt]") + " (" + usage.Value + ")"); + } + } + + if (certificate.HasPrivateKey) + { + InspectPrivateKey(certificate, record, findings); + } + + if (certificate.NotAfter.ToUniversalTime() < DateTime.UtcNow) + { + AddExpiryFinding(findings, record, "Fehler", "Zertifikat ist abgelaufen."); + } + else if (certificate.NotAfter.ToUniversalTime() < DateTime.UtcNow.AddDays(60)) + { + AddExpiryFinding(findings, record, "Warnung", "Zertifikat läuft innerhalb von 60 Tagen ab."); + } + + return record; + } + + /// + /// Liest Provider, Container, Exportpolicy und Dateiberechtigungen eines privaten Schlüssels. + /// + /// Zertifikat mit privatem Schlüssel. + /// Zielmodell. + /// Liste für nicht fatale Zugriffsfehler. + private static void InspectPrivateKey( + X509Certificate2 certificate, + CertificateRecord record, + IList findings) + { + try + { +#pragma warning disable 618 + using (var privateKey = certificate.PrivateKey) +#pragma warning restore 618 + { + var csp = privateKey as RSACryptoServiceProvider; + if (csp != null) + { + var info = csp.CspKeyContainerInfo; + record.PrivateKeyProvider = info.ProviderName; + record.PrivateKeyContainer = info.UniqueKeyContainerName; + record.PrivateKeyExportable = info.Exportable ? "Ja" : "Nein"; + record.PrivateKeyFile = LocateKeyFile(info.UniqueKeyContainerName, true); + } + else + { + var rsaCng = privateKey as RSACng; + var ecdsaCng = privateKey as ECDsaCng; + var key = rsaCng != null ? rsaCng.Key : (ecdsaCng == null ? null : ecdsaCng.Key); + if (key != null) + { + record.PrivateKeyProvider = key.Provider.Provider; + record.PrivateKeyContainer = key.UniqueName; + record.PrivateKeyExportable = FormatExportPolicy(key.ExportPolicy); + record.PrivateKeyFile = LocateKeyFile(key.UniqueName, false); + } + else + { + record.PrivateKeyProvider = privateKey == null + ? "[Providerzugriff nicht möglich]" + : privateKey.GetType().FullName; + } + } + } + + if (!string.IsNullOrWhiteSpace(record.PrivateKeyFile) && File.Exists(record.PrivateKeyFile)) + { + CollectKeyAcl(record); + } + } + catch (Exception exception) + { + record.PrivateKeyProvider = "[nicht lesbar: " + exception.GetType().Name + "]"; + findings.Add(new Finding + { + Severity = "Warnung", + Area = "Zertifikate/Private Keys", + Message = "Private-Key-Metadaten konnten nicht vollständig gelesen werden: " + record.Thumbprint, + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "Tool erhöht ausführen und ACL des privaten Schlüsselcontainers prüfen." + }); + } + } + + /// + /// Liest die Dateisystem-ACL der Schlüsselcontainerdatei. + /// + /// Zertifikatsdatensatz mit Schlüsselpfad. + private static void CollectKeyAcl(CertificateRecord record) + { + var security = File.GetAccessControl(record.PrivateKeyFile, AccessControlSections.Access); + var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount)); + foreach (FileSystemAccessRule rule in rules) + { + record.KeyAccessRules.Add(new AccessRuleRecord + { + Target = record.PrivateKeyFile, + Identity = rule.IdentityReference.Value, + Rights = rule.FileSystemRights.ToString(), + AccessType = rule.AccessControlType.ToString(), + IsInherited = rule.IsInherited, + Inheritance = rule.InheritanceFlags + " / " + rule.PropagationFlags + }); + } + } + + /// + /// Sucht die Containerdatei eines CAPI- oder CNG-Schlüssels. + /// + /// Eindeutiger Containername. + /// Gibt CAPI statt CNG an. + /// Existierender Pfad oder leerer Text. + private static string LocateKeyFile(string uniqueName, bool capi) + { + if (string.IsNullOrWhiteSpace(uniqueName)) + { + return string.Empty; + } + + var common = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + var folder = capi + ? Path.Combine(common, "Microsoft", "Crypto", "RSA", "MachineKeys") + : Path.Combine(common, "Microsoft", "Crypto", "Keys"); + var path = Path.Combine(folder, uniqueName); + return File.Exists(path) ? path : string.Empty; + } + + /// + /// Formatiert eine CNG-Exportpolicy ohne den Schlüssel zu exportieren. + /// + /// CNG-Exportpolicy. + /// Lesbarer Policytext. + private static string FormatExportPolicy(CngExportPolicies policy) + { + if (policy == CngExportPolicies.None) + { + return "Nein"; + } + + var exportable = (policy & CngExportPolicies.AllowExport) != 0 + || (policy & CngExportPolicies.AllowPlaintextExport) != 0; + return (exportable ? "Ja" : "Nein") + " (" + policy + ")"; + } + + /// + /// Fügt einen Ablaufhinweis hinzu. + /// + /// Zielliste. + /// Betroffenes Zertifikat. + /// Schweregrad. + /// Aussage. + private static void AddExpiryFinding( + IList findings, + CertificateRecord record, + string severity, + string message) + { + findings.Add(new Finding + { + Severity = severity, + Area = "Zertifikate", + Message = message, + TechnicalDetail = record.Subject + " | " + record.Thumbprint + " | NotAfter " + + record.NotAfter.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Recommendation = "Erneuerung und IIS-Binding vor Ablauf terminieren." + }); + } + + /// + /// Normalisiert einen Thumbprint. + /// + /// Thumbprint. + /// Großgeschriebener Wert ohne Leerzeichen. + private static string NormalizeThumbprint(string value) + { + return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Collectors/IisCollector.cs b/src/BizTalkIisEnvironmentInventory/Collectors/IisCollector.cs new file mode 100644 index 0000000..831ab48 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Collectors/IisCollector.cs @@ -0,0 +1,705 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Infrastructure; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Collectors +{ + /// + /// Liest IIS-Konfiguration, Topologie, Webinhalte und NTFS-Berechtigungen ohne Microsoft.Web.Administration. + /// + internal sealed class IisCollector + { + private readonly CollectorOptions options; + + /// + /// Initialisiert den IIS-Collector. + /// + /// Grenzen für Dateimanifest und Hashing. + public IisCollector(CollectorOptions options) + { + this.options = options; + } + + /// + /// Erfasst die lokale applicationHost.config und die daraus referenzierten Inhalte. + /// + /// Zielmodell für IIS-Daten. + /// Gemeinsame Liste nicht fataler Auffälligkeiten. + /// Optionaler Pfad für Offline-Tests oder Sonderinstallationen. + public void Collect(IisInventory target, IList findings, string overridePath) + { + var path = ResolveConfigurationPath(overridePath); + if (!File.Exists(path)) + { + throw new FileNotFoundException("Die IIS-Konfiguration wurde nicht gefunden.", path); + } + + target.ConfigurationPath = path; + target.ConfigurationLastWriteUtc = File.GetLastWriteTimeUtc(path); + + XDocument document; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + document = XDocument.Load(stream, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + } + + if (document.Root == null) + { + throw new InvalidDataException("Die IIS-Konfiguration besitzt kein XML-Wurzelelement."); + } + + target.SanitizedConfigurationSha256 = HashSanitizedConfiguration(document.Root); + CollectEncryptionProviders(document.Root, target); + CollectSectionDeclarations(document.Root, target); + CollectApplicationPools(document.Root, target); + CollectSites(document.Root, target, findings); + ValidateExpectedApplications(target, findings); + } + + /// + /// Ermittelt den IIS-Konfigurationspfad. + /// + /// Optional explizit vorgegebener Pfad. + /// Vollständiger Pfad zur applicationHost.config. + internal static string ResolveConfigurationPath(string overridePath) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + return Path.GetFullPath(overridePath); + } + + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrWhiteSpace(windows)) + { + windows = Environment.ExpandEnvironmentVariables("%WINDIR%"); + } + + return Path.Combine(windows, "System32", "inetsrv", "config", "applicationHost.config"); + } + + /// + /// Erstellt einen SHA-256-Fingerprint der bereinigten Konfiguration. + /// + /// XML-Wurzelelement. + /// Hexadezimaler SHA-256-Hash. + private static string HashSanitizedConfiguration(XElement root) + { + var safe = SensitiveDataSanitizer.SanitizeXml(root).ToString(SaveOptions.DisableFormatting); + using (var algorithm = SHA256.Create()) + { + return ToHex(algorithm.ComputeHash(Encoding.UTF8.GetBytes(safe))); + } + } + + /// + /// Erfasst geschützte Konfigurationsprovider, jedoch niemals deren Schlüsselmaterial. + /// + /// XML-Wurzelelement. + /// Zielmodell. + private static void CollectEncryptionProviders(XElement root, IisInventory target) + { + var protectedData = root.Element("configProtectedData"); + var providers = protectedData == null ? null : protectedData.Element("providers"); + if (providers == null) + { + return; + } + + foreach (var add in providers.Elements("add")) + { + target.EncryptionProviders.Add(new EncryptionProviderRecord + { + Name = Attribute(add, "name"), + Type = Attribute(add, "type"), + KeyContainerName = Attribute(add, "keyContainerName"), + UseMachineContainer = Attribute(add, "useMachineContainer"), + Description = "Provider-Metadaten; Schlüssel und verschlüsselte Nutzdaten werden bewusst nicht exportiert." + }); + } + } + + /// + /// Erfasst globale IIS-Section-Deklarationen und deren grobe Belegung. + /// + /// XML-Wurzelelement. + /// Zielmodell. + private static void CollectSectionDeclarations(XElement root, IisInventory target) + { + var configSections = root.Element("configSections"); + if (configSections == null) + { + return; + } + + foreach (var section in configSections.Descendants("section")) + { + var path = BuildSectionPath(section); + var configuredElement = ResolveElementPath(root, path); + var isEncrypted = configuredElement != null + && configuredElement.DescendantsAndSelf().Any( + element => string.Equals(element.Name.LocalName, "EncryptedData", StringComparison.OrdinalIgnoreCase)); + + target.GlobalSections.Add(new ConfigurationSectionRecord + { + Path = path, + OverrideModeDefault = Attribute(section, "overrideModeDefault"), + AllowDefinition = Attribute(section, "allowDefinition"), + IsEncrypted = isEncrypted, + ElementCount = configuredElement == null ? 0 : configuredElement.Descendants().Count(), + SafeSummary = BuildSafeAttributeSummary(configuredElement) + }); + } + + target.GlobalSections.Sort((left, right) => + string.Compare(left.Path, right.Path, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Erfasst IIS-Anwendungspools und deren Identitäten. + /// + /// XML-Wurzelelement. + /// Zielmodell. + private static void CollectApplicationPools(XElement root, IisInventory target) + { + var host = root.Element("system.applicationHost"); + var pools = host == null ? null : host.Element("applicationPools"); + if (pools == null) + { + return; + } + + var defaults = pools.Element("applicationPoolDefaults"); + foreach (var add in pools.Elements("add")) + { + var processModel = add.Element("processModel"); + var defaultProcessModel = defaults == null ? null : defaults.Element("processModel"); + target.ApplicationPools.Add(new ApplicationPoolRecord + { + Name = Attribute(add, "name"), + ManagedRuntimeVersion = EffectiveAttribute(add, defaults, "managedRuntimeVersion"), + ManagedPipelineMode = EffectiveAttribute(add, defaults, "managedPipelineMode"), + AutoStart = EffectiveAttribute(add, defaults, "autoStart"), + StartMode = EffectiveAttribute(add, defaults, "startMode"), + Enable32BitAppOnWin64 = EffectiveAttribute(add, defaults, "enable32BitAppOnWin64"), + IdentityType = EffectiveAttribute(processModel, defaultProcessModel, "identityType"), + UserName = NormalizeIdentity( + EffectiveAttribute(processModel, defaultProcessModel, "identityType"), + EffectiveAttribute(processModel, defaultProcessModel, "userName"), + Attribute(add, "name")) + }); + } + + target.ApplicationPools.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Erfasst Sites, Anwendungen, virtuelle Verzeichnisse, Bindings und Inhalte. + /// + /// XML-Wurzelelement. + /// Zielmodell. + /// Liste für nicht fatale Zugriffsfehler. + private void CollectSites(XElement root, IisInventory target, IList findings) + { + var host = root.Element("system.applicationHost"); + var sites = host == null ? null : host.Element("sites"); + if (sites == null) + { + throw new InvalidDataException("Der IIS-Abschnitt system.applicationHost/sites fehlt."); + } + + foreach (var siteElement in sites.Elements("site")) + { + var site = new SiteRecord + { + Name = Attribute(siteElement, "name"), + Id = Attribute(siteElement, "id"), + ServerAutoStart = Attribute(siteElement, "serverAutoStart"), + LogDirectory = ExpandIisPath(Attribute(siteElement.Element("logFile"), "directory")) + }; + + var bindings = siteElement.Element("bindings"); + if (bindings != null) + { + foreach (var binding in bindings.Elements("binding")) + { + site.Bindings.Add(new BindingRecord + { + SiteName = site.Name, + Protocol = Attribute(binding, "protocol"), + BindingInformation = Attribute(binding, "bindingInformation"), + CertificateHash = NormalizeThumbprint(Attribute(binding, "certificateHash")), + CertificateStoreName = Attribute(binding, "certificateStoreName"), + SslFlags = Attribute(binding, "sslFlags") + }); + } + } + + foreach (var applicationElement in siteElement.Elements("application")) + { + var application = CreateApplication(site.Name, applicationElement); + site.Applications.Add(application); + InspectApplicationContent(application, findings); + } + + target.Sites.Add(site); + } + + target.Sites.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Meldet erwartete, aber nicht konfigurierte BEW-Webanwendungen. + /// + /// Erfasstes IIS-Inventar. + /// Liste für Vollständigkeitshinweise. + private void ValidateExpectedApplications(IisInventory target, IList findings) + { + if (options.ExpectedApplications == null || options.ExpectedApplications.Count == 0) + { + return; + } + + var discovered = new HashSet( + target.Sites.SelectMany(site => site.Applications) + .Select(application => (application.Path ?? string.Empty).Trim().Trim('/')) + .Where(path => path.Length > 0), + StringComparer.OrdinalIgnoreCase); + + foreach (var expected in options.ExpectedApplications.Where(item => !discovered.Contains(item))) + { + AddFinding( + findings, + "Warnung", + "IIS/Vollständigkeit", + "Erwartete Webanwendung wurde nicht gefunden: " + expected, + "ExpectedApplications in BizTalkIisEnvironmentInventory.exe.config", + "Prüfen, ob die Anwendung anders benannt, unter einer anderen Site konfiguriert oder in dieser Umgebung bewusst nicht vorhanden ist."); + } + } + + /// + /// Erstellt ein Anwendungsmodell aus einem IIS-XML-Element. + /// + /// Name der übergeordneten Site. + /// IIS-application-Element. + /// Initialisiertes Anwendungsmodell. + private static WebApplicationRecord CreateApplication(string siteName, XElement element) + { + var result = new WebApplicationRecord + { + SiteName = siteName, + Path = Attribute(element, "path"), + ApplicationPool = Attribute(element, "applicationPool"), + EnabledProtocols = Attribute(element, "enabledProtocols") + }; + + foreach (var virtualDirectory in element.Elements("virtualDirectory")) + { + var physicalPath = ExpandIisPath(Attribute(virtualDirectory, "physicalPath")); + result.VirtualDirectories.Add(new VirtualDirectoryRecord + { + Path = Attribute(virtualDirectory, "path"), + PhysicalPath = physicalPath, + UserName = string.IsNullOrWhiteSpace(Attribute(virtualDirectory, "userName")) + ? "[IIS-/Prozessidentität]" + : Attribute(virtualDirectory, "userName") + }); + + if (string.Equals(Attribute(virtualDirectory, "path"), "/", StringComparison.Ordinal)) + { + result.PhysicalPath = physicalPath; + } + } + + if (string.IsNullOrWhiteSpace(result.PhysicalPath) && result.VirtualDirectories.Count > 0) + { + result.PhysicalPath = result.VirtualDirectories[0].PhysicalPath; + } + + return result; + } + + /// + /// Inventarisiert Inhalt und ACL eines Web-Stammverzeichnisses mit Fehlerisolation. + /// + /// Zu untersuchende IIS-Anwendung. + /// Liste für nicht fatale Zugriffsfehler. + private void InspectApplicationContent(WebApplicationRecord application, IList findings) + { + if (string.IsNullOrWhiteSpace(application.PhysicalPath)) + { + AddFinding(findings, "Warnung", "IIS/Webinhalt", + application.SiteName + application.Path + " besitzt keinen auflösbaren physischen Pfad.", + "physicalPath fehlt oder verwendet nicht auflösbare Variablen.", + "IIS-Konfiguration und virtuelle Verzeichnisse prüfen."); + return; + } + + application.PhysicalPathExists = Directory.Exists(application.PhysicalPath); + if (!application.PhysicalPathExists) + { + AddFinding(findings, "Warnung", "IIS/Webinhalt", + "Physischer Pfad fehlt: " + application.PhysicalPath, + application.SiteName + application.Path, + "Deployment, Laufwerk/Mount und IIS-physicalPath prüfen."); + return; + } + + CollectDirectoryAcl(application, findings); + if (options.MaxFilesPerApplication <= 0) + { + return; + } + + var root = application.PhysicalPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var pending = new Queue(); + pending.Enqueue(new DirectoryWorkItem(root, 0)); + + while (pending.Count > 0) + { + var current = pending.Dequeue(); + try + { + foreach (var filePath in Directory.EnumerateFiles(current.Path)) + { + InspectFile(application, root, filePath, findings); + } + + if (current.Depth >= options.MaxContentDepth) + { + application.ManifestTruncated = true; + continue; + } + + foreach (var directoryPath in Directory.EnumerateDirectories(current.Path)) + { + var attributes = File.GetAttributes(directoryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + AddFinding(findings, "Hinweis", "IIS/Webinhalt", + "Reparse Point wurde nicht rekursiv verfolgt: " + directoryPath, + "Schutz vor Schleifen und Verlassen des Web-Stammverzeichnisses.", + "Verknüpftes Ziel bei Migrationsbedarf separat dokumentieren."); + continue; + } + + pending.Enqueue(new DirectoryWorkItem(directoryPath, current.Depth + 1)); + } + } + catch (Exception exception) + { + application.ManifestTruncated = true; + AddFinding(findings, "Warnung", "IIS/Webinhalt", + "Verzeichnis konnte nicht vollständig gelesen werden: " + current.Path, + exception.GetType().Name + ": " + exception.Message, + "Collector erhöht ausführen oder ACL gezielt prüfen."); + } + } + } + + /// + /// Erfasst Metadaten und optional SHA-256 eines einzelnen Webinhalts. + /// + /// Zielanwendung. + /// Normalisierter Web-Stammpfad. + /// Vollständiger Dateipfad. + /// Liste nicht fataler Auffälligkeiten. + private void InspectFile( + WebApplicationRecord application, + string root, + string filePath, + IList findings) + { + try + { + var info = new FileInfo(filePath); + application.TotalFiles++; + application.TotalBytes += info.Length; + if (!application.LatestWriteUtc.HasValue || info.LastWriteTimeUtc > application.LatestWriteUtc.Value) + { + application.LatestWriteUtc = info.LastWriteTimeUtc; + } + + if (application.ContentFiles.Count >= options.MaxFilesPerApplication) + { + application.ManifestTruncated = true; + return; + } + + application.ContentFiles.Add(new ContentFileRecord + { + RelativePath = filePath.Length > root.Length + ? filePath.Substring(root.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + : info.Name, + SizeBytes = info.Length, + LastWriteUtc = info.LastWriteTimeUtc, + Sha256 = options.IncludeFileHashes ? HashFile(filePath) : string.Empty + }); + } + catch (Exception exception) + { + application.ManifestTruncated = true; + AddFinding(findings, "Warnung", "IIS/Webinhalt", + "Datei konnte nicht inventarisiert werden: " + filePath, + exception.GetType().Name + ": " + exception.Message, + "Dateisperre und Leseberechtigung prüfen."); + } + } + + /// + /// Liest die expliziten und geerbten ACL-Regeln eines Web-Stammverzeichnisses. + /// + /// Zielanwendung. + /// Liste nicht fataler Auffälligkeiten. + private static void CollectDirectoryAcl(WebApplicationRecord application, IList findings) + { + try + { + var security = Directory.GetAccessControl( + application.PhysicalPath, + AccessControlSections.Access | AccessControlSections.Owner); + var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount)); + foreach (FileSystemAccessRule rule in rules) + { + application.AccessRules.Add(new AccessRuleRecord + { + Target = application.PhysicalPath, + Identity = rule.IdentityReference.Value, + Rights = rule.FileSystemRights.ToString(), + AccessType = rule.AccessControlType.ToString(), + IsInherited = rule.IsInherited, + Inheritance = rule.InheritanceFlags + " / " + rule.PropagationFlags + }); + } + } + catch (Exception exception) + { + AddFinding(findings, "Warnung", "NTFS", + "ACL konnte nicht gelesen werden: " + application.PhysicalPath, + exception.GetType().Name + ": " + exception.Message, + "Mit erhöhten Leserechten erneut ausführen."); + } + } + + /// + /// Berechnet SHA-256 einer Datei mit geteilter Lesefreigabe. + /// + /// Vollständiger Dateipfad. + /// Hexadezimaler SHA-256-Hash. + private static string HashFile(string path) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var algorithm = SHA256.Create()) + { + return ToHex(algorithm.ComputeHash(stream)); + } + } + + /// + /// Baut den vollständigen Pfad einer Section-Deklaration auf. + /// + /// Section-XML-Element. + /// Pfad mit Slash-Trennung. + private static string BuildSectionPath(XElement section) + { + var names = new Stack(); + names.Push(Attribute(section, "name")); + var parent = section.Parent; + while (parent != null && string.Equals(parent.Name.LocalName, "sectionGroup", StringComparison.Ordinal)) + { + names.Push(Attribute(parent, "name")); + parent = parent.Parent; + } + + return string.Join("/", names.ToArray()); + } + + /// + /// Löst einen Section-Pfad gegen die Konfigurationswurzel auf. + /// + /// Konfigurationswurzel. + /// Slash-getrennter Section-Pfad. + /// Gefundenes Element oder null. + private static XElement ResolveElementPath(XElement root, string path) + { + var current = root; + foreach (var part in path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) + { + current = current == null ? null : current.Element(part); + } + + return current; + } + + /// + /// Erstellt eine begrenzte, secret-bereinigte Attributübersicht. + /// + /// Konfigurationselement. + /// Kurze Attributübersicht. + private static string BuildSafeAttributeSummary(XElement element) + { + if (element == null) + { + return "[nicht global konfiguriert]"; + } + + return string.Join( + "; ", + element.Attributes() + .Take(20) + .Select(attribute => + attribute.Name.LocalName + "=" + + (SensitiveDataSanitizer.IsSensitiveName(attribute.Name.LocalName) + ? "[ENTFERNT]" + : Limit(attribute.Value, 160)))); + } + + /// + /// Liest einen Attributwert nullsicher. + /// + /// XML-Element oder null. + /// Attributname. + /// Attributwert oder leerer Text. + private static string Attribute(XElement element, string name) + { + var attribute = element == null ? null : element.Attribute(name); + return attribute == null ? string.Empty : attribute.Value; + } + + /// + /// Liest einen lokalen Attributwert oder den Wert des Defaults. + /// + /// Lokales XML-Element. + /// Default-XML-Element. + /// Attributname. + /// Effektiver Wert oder leerer Text. + private static string EffectiveAttribute(XElement element, XElement defaults, string name) + { + var local = Attribute(element, name); + return string.IsNullOrWhiteSpace(local) ? Attribute(defaults, name) : local; + } + + /// + /// Stellt eine verständliche Anwendungspool-Identität her. + /// + /// IIS-Identitätstyp. + /// Optionales Custom-Konto. + /// Name des Anwendungspools. + /// Effektiv zu erwartende Identität. + private static string NormalizeIdentity(string identityType, string userName, string poolName) + { + if (!string.IsNullOrWhiteSpace(userName)) + { + return userName; + } + + if (string.Equals(identityType, "ApplicationPoolIdentity", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(identityType)) + { + return @"IIS APPPOOL\" + poolName; + } + + return identityType; + } + + /// + /// Expandiert Umgebungsvariablen und normalisiert IIS-Pfade. + /// + /// IIS-Pfad. + /// Expandierter Pfad. + private static string ExpandIisPath(string path) + { + return string.IsNullOrWhiteSpace(path) + ? string.Empty + : Environment.ExpandEnvironmentVariables(path.Trim()); + } + + /// + /// Normalisiert einen Zertifikat-Fingerprint. + /// + /// Fingerprint aus IIS. + /// Großgeschriebener Fingerprint ohne Leerzeichen. + private static string NormalizeThumbprint(string value) + { + return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); + } + + /// + /// Begrenzt sehr lange Berichtswerte. + /// + /// Eingabewert. + /// Maximale Zeichenzahl. + /// Original oder gekürzter Wert. + private static string Limit(string value, int maximum) + { + return value != null && value.Length > maximum + ? value.Substring(0, maximum) + "…" + : value ?? string.Empty; + } + + /// + /// Wandelt Bytes in einen Hexadezimaltext um. + /// + /// Eingabebytes. + /// Großgeschriebener Hexadezimaltext. + private static string ToHex(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", string.Empty); + } + + /// + /// Fügt ein Finding standardisiert hinzu. + /// + /// Zielliste. + /// Schweregrad. + /// Fachlicher Bereich. + /// Benutzerlesbare Aussage. + /// Technisches Detail. + /// Empfohlene Folgemaßnahme. + private static void AddFinding( + IList findings, + string severity, + string area, + string message, + string detail, + string recommendation) + { + findings.Add(new Finding + { + Severity = severity, + Area = area, + Message = message, + TechnicalDetail = detail, + Recommendation = recommendation + }); + } + + private sealed class DirectoryWorkItem + { + /// + /// Initialisiert einen Eintrag für die iterative Verzeichnisbegehung. + /// + /// Zu lesender Verzeichnispfad. + /// Tiefe relativ zum Web-Stamm. + public DirectoryWorkItem(string path, int depth) + { + Path = path; + Depth = depth; + } + + public string Path { get; private set; } + public int Depth { get; private set; } + } + } +} diff --git a/src/BizTalkIisEnvironmentInventory/Collectors/SecurityCollector.cs b/src/BizTalkIisEnvironmentInventory/Collectors/SecurityCollector.cs new file mode 100644 index 0000000..190b19d --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Collectors/SecurityCollector.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management; +using System.Text; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Collectors +{ + /// + /// Erfasst relevante Windows-Dienste, Dienstkonten und lokale Sicherheitsrichtlinien. + /// + internal sealed class SecurityCollector + { + private readonly CollectorOptions options; + + /// + /// Initialisiert den Security-Collector. + /// + /// WMI-Timeout und technische Grenzen. + public SecurityCollector(CollectorOptions options) + { + this.options = options; + } + + /// + /// Erfasst IIS-/BizTalk-Dienstidentitäten und einen lesbaren secedit-Snapshot. + /// + /// Security-Zielmodell. + /// IIS-Daten für Anwendungspoolkonten. + /// Liste nicht fataler Auffälligkeiten. + public void Collect(SecurityInventory target, IisInventory iis, IList findings) + { + foreach (var pool in iis.ApplicationPools) + { + target.ServiceAccounts.Add(new ServiceAccountRecord + { + Source = "IIS Application Pool", + Name = pool.Name, + DisplayName = pool.Name, + Account = pool.UserName, + State = "[Laufzeitstatus nicht aus applicationHost.config verfügbar]", + StartMode = pool.StartMode, + Path = string.Empty + }); + } + + CollectWindowsServices(target); + CollectLocalSecurityPolicy(target, findings); + } + + /// + /// Liest relevante Windows-Dienste per WMI. + /// + /// Security-Zielmodell. + private void CollectWindowsServices(SecurityInventory target) + { + var scope = new ManagementScope(@"\\.\root\cimv2"); + scope.Connect(); + var query = "SELECT Name, DisplayName, StartName, State, StartMode, PathName FROM Win32_Service"; + using (var searcher = new ManagementObjectSearcher( + scope, + new ObjectQuery(query), + new EnumerationOptions + { + ReturnImmediately = false, + Rewindable = false, + Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds) + })) + using (var results = searcher.Get()) + { + foreach (ManagementObject service in results) + { + var name = Value(service, "Name"); + var displayName = Value(service, "DisplayName"); + if (!IsRelevantService(name, displayName)) + { + continue; + } + + target.ServiceAccounts.Add(new ServiceAccountRecord + { + Source = "Windows Service", + Name = name, + DisplayName = displayName, + Account = Value(service, "StartName"), + State = Value(service, "State"), + StartMode = Value(service, "StartMode"), + Path = Value(service, "PathName") + }); + } + } + + target.ServiceAccounts.Sort((left, right) => + string.Compare(left.Source + left.Name, right.Source + right.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Exportiert lokale User Rights und System Access über das Windows-Bordmittel secedit. + /// + /// Security-Zielmodell. + /// Liste für Berechtigungs- oder Toolfehler. + private static void CollectLocalSecurityPolicy(SecurityInventory target, IList findings) + { + var temporaryPath = Path.Combine( + Path.GetTempPath(), + "BizTalkIisInventory-" + Guid.NewGuid().ToString("N") + ".inf"); + try + { + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var executable = Path.Combine(windows, "System32", "secedit.exe"); + if (!File.Exists(executable)) + { + throw new FileNotFoundException("secedit.exe wurde nicht gefunden.", executable); + } + + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "/export /cfg \"" + temporaryPath + "\" /areas USER_RIGHTS SECURITYPOLICY /quiet", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetTempPath() + }; + + using (var process = Process.Start(startInfo)) + { + if (process == null) + { + throw new InvalidOperationException("secedit.exe konnte nicht gestartet werden."); + } + + if (!process.WaitForExit(60000)) + { + try + { + process.Kill(); + } + catch + { + // Der Report enthält bereits den Timeout; Kill-Fehler ist sekundär. + } + + throw new TimeoutException("secedit.exe wurde nach 60 Sekunden beendet."); + } + + var error = process.StandardError.ReadToEnd(); + if (process.ExitCode != 0 || !File.Exists(temporaryPath)) + { + throw new InvalidOperationException( + "secedit.exe ExitCode " + process.ExitCode + ": " + error.Trim()); + } + } + + ParseSecurityPolicy(temporaryPath, target); + } + catch (Exception exception) + { + findings.Add(new Finding + { + Severity = "Warnung", + Area = "Lokale Sicherheitsrichtlinie", + Message = "Die lokale Sicherheitsrichtlinie konnte nicht vollständig exportiert werden.", + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "Tool als lokaler Administrator starten und secedit-Verfügbarkeit prüfen." + }); + } + finally + { + try + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + catch + { + // Temporäre Datei enthält nur Richtlinienmetadaten; Cleanup-Fehler darf Report nicht verhindern. + } + } + } + + /// + /// Parst die relevanten Abschnitte eines secedit-Exports. + /// + /// Pfad zur temporären INF-Datei. + /// Security-Zielmodell. + internal static void ParseSecurityPolicy(string path, SecurityInventory target) + { + var section = string.Empty; + foreach (var rawLine in File.ReadAllLines(path, Encoding.Unicode)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith(";", StringComparison.Ordinal)) + { + continue; + } + + if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal)) + { + section = line.Substring(1, line.Length - 2); + continue; + } + + var separator = line.IndexOf('='); + if (separator < 0) + { + continue; + } + + var name = line.Substring(0, separator).Trim(); + var value = line.Substring(separator + 1).Trim(); + if (string.Equals(section, "Privilege Rights", StringComparison.OrdinalIgnoreCase)) + { + target.UserRights.Add(new UserRightRecord { Right = name, Accounts = value }); + } + else if (string.Equals(section, "System Access", StringComparison.OrdinalIgnoreCase) + || string.Equals(section, "Event Audit", StringComparison.OrdinalIgnoreCase)) + { + target.LocalPolicy.Add(new NameValueRecord(section + " / " + name, value)); + } + } + } + + /// + /// Entscheidet, ob ein Dienst IIS-, BizTalk- oder SSO-relevant ist. + /// + /// Technischer Dienstname. + /// Anzeigename. + /// true für relevante Dienste. + private static bool IsRelevantService(string name, string displayName) + { + var value = (name ?? string.Empty) + " " + (displayName ?? string.Empty); + var fragments = new[] + { + "BizTalk", "BTSSvc", "ENTSSO", "RuleEngine", "W3SVC", "WAS", + "IISADMIN", "AppHostSvc", "MSMQ", "World Wide Web" + }; + return fragments.Any(fragment => + value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// + /// Liest eine WMI-Property als Text. + /// + /// WMI-Objekt. + /// Propertyname. + /// Invariant formatierter Wert. + private static string Value(ManagementObject item, string property) + { + return Convert.ToString(item[property], CultureInfo.InvariantCulture); + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Collectors/SystemCollector.cs b/src/BizTalkIisEnvironmentInventory/Collectors/SystemCollector.cs new file mode 100644 index 0000000..57e3018 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Collectors/SystemCollector.cs @@ -0,0 +1,219 @@ +using System; +using System.Globalization; +using System.Management; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Collectors +{ + /// + /// Erfasst Betriebssystem, Prozesskontext und installierte IIS-nahe Windows-Rollen. + /// + internal sealed class SystemCollector + { + private readonly CollectorOptions options; + + /// + /// Initialisiert den System-Collector. + /// + /// Collector-Grenzen und Timeouts. + public SystemCollector(CollectorOptions options) + { + this.options = options; + } + + /// + /// Liest lokale Systeminformationen und relevante Server Features. + /// + /// Zielmodell für die Ergebnisse. + public void Collect(SystemInventory target) + { + target.Properties.Add(new NameValueRecord("Computername", Environment.MachineName)); + target.Properties.Add(new NameValueRecord("Domäne", Environment.UserDomainName)); + target.Properties.Add(new NameValueRecord("Ausführungsidentität", GetIdentity())); + target.Properties.Add(new NameValueRecord("64-Bit-Betriebssystem", Environment.Is64BitOperatingSystem.ToString())); + target.Properties.Add(new NameValueRecord("64-Bit-Prozess", Environment.Is64BitProcess.ToString())); + target.Properties.Add(new NameValueRecord(".NET-Laufzeit", RuntimeEnvironment.GetSystemVersion())); + target.Properties.Add(new NameValueRecord("Zeitzone", TimeZoneInfo.Local.DisplayName)); + target.Properties.Add(new NameValueRecord("Erfassungszeit lokal", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture))); + + ReadOperatingSystem(target); + ReadDotNetRelease(target); + ReadServerFeatures(target); + } + + /// + /// Ermittelt die aktuelle Windows-Identität. + /// + /// Kontoname oder ein erklärender Fallback. + private static string GetIdentity() + { + try + { + using (var identity = WindowsIdentity.GetCurrent()) + { + return identity == null ? "[nicht ermittelbar]" : identity.Name; + } + } + catch (Exception exception) + { + return "[nicht ermittelbar: " + exception.GetType().Name + "]"; + } + } + + /// + /// Liest Betriebssystemdetails per WMI. + /// + /// Zielmodell. + private void ReadOperatingSystem(SystemInventory target) + { + var scope = new ManagementScope(@"\\.\root\cimv2"); + scope.Connect(); + using (var searcher = CreateSearcher( + scope, + "SELECT Caption, Version, BuildNumber, OSArchitecture, InstallDate, LastBootUpTime FROM Win32_OperatingSystem")) + using (var results = searcher.Get()) + { + foreach (ManagementObject item in results) + { + AddWmiValue(target, "Betriebssystem", item, "Caption"); + AddWmiValue(target, "Version", item, "Version"); + AddWmiValue(target, "Build", item, "BuildNumber"); + AddWmiValue(target, "Architektur", item, "OSArchitecture"); + AddWmiDate(target, "Installationsdatum", item, "InstallDate"); + AddWmiDate(target, "Letzter Start", item, "LastBootUpTime"); + break; + } + } + } + + /// + /// Liest den .NET-Framework-Releasewert aus der Registry. + /// + /// Zielmodell. + private static void ReadDotNetRelease(SystemInventory target) + { + using (var key = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full", + false)) + { + var release = key == null ? null : key.GetValue("Release"); + target.Properties.Add(new NameValueRecord( + ".NET Framework Release", + release == null ? "[nicht gefunden]" : Convert.ToString(release, CultureInfo.InvariantCulture))); + } + } + + /// + /// Liest installierte Serverrollen über Win32_ServerFeature. + /// + /// Zielmodell. + private void ReadServerFeatures(SystemInventory target) + { + var scope = new ManagementScope(@"\\.\root\cimv2"); + scope.Connect(); + using (var searcher = CreateSearcher( + scope, + "SELECT ID, Name, ParentID FROM Win32_ServerFeature")) + using (var results = searcher.Get()) + { + foreach (ManagementObject item in results) + { + var name = Convert.ToString(item["Name"], CultureInfo.InvariantCulture); + if (!IsRelevantFeature(name)) + { + continue; + } + + var id = Convert.ToString(item["ID"], CultureInfo.InvariantCulture); + var parent = Convert.ToString(item["ParentID"], CultureInfo.InvariantCulture); + target.InstalledFeatures.Add(new NameValueRecord( + name, + string.Format(CultureInfo.InvariantCulture, "Installiert (ID {0}, Parent {1})", id, parent))); + } + } + + target.InstalledFeatures.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Erstellt einen WMI-Searcher mit begrenztem Timeout. + /// + /// Bereits verbundener WMI-Scope. + /// Schreibgeschützte WQL-Abfrage. + /// Konfigurierter Searcher. + private ManagementObjectSearcher CreateSearcher(ManagementScope scope, string query) + { + return new ManagementObjectSearcher( + scope, + new ObjectQuery(query), + new EnumerationOptions + { + ReturnImmediately = false, + Rewindable = false, + Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds) + }); + } + + /// + /// Entscheidet, ob ein Feature für IIS/BizTalk-Dokumentation relevant ist. + /// + /// Anzeigename des Server Features. + /// true bei IIS-, WAS-, HTTP-, MSMQ-, .NET- oder COM+-Bezug. + private static bool IsRelevantFeature(string name) + { + var value = name ?? string.Empty; + var fragments = new[] { "IIS", "Web", "HTTP", "WAS", "ASP.NET", ".NET", "MSMQ", "Message Queuing", "COM+" }; + foreach (var fragment in fragments) + { + if (value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } + + /// + /// Fügt eine WMI-Property als Text hinzu. + /// + /// Zielmodell. + /// Berichtsbezeichnung. + /// WMI-Objekt. + /// Propertyname. + private static void AddWmiValue(SystemInventory target, string label, ManagementObject item, string property) + { + target.Properties.Add(new NameValueRecord( + label, + Convert.ToString(item[property], CultureInfo.InvariantCulture))); + } + + /// + /// Fügt einen WMI-DMTF-Zeitwert formatiert hinzu. + /// + /// Zielmodell. + /// Berichtsbezeichnung. + /// WMI-Objekt. + /// Propertyname. + private static void AddWmiDate(SystemInventory target, string label, ManagementObject item, string property) + { + var raw = Convert.ToString(item[property], CultureInfo.InvariantCulture); + try + { + target.Properties.Add(new NameValueRecord( + label, + ManagementDateTimeConverter.ToDateTime(raw).ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture))); + } + catch + { + target.Properties.Add(new NameValueRecord(label, raw)); + } + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Configuration/CollectorOptions.cs b/src/BizTalkIisEnvironmentInventory/Configuration/CollectorOptions.cs new file mode 100644 index 0000000..18dc8da --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Configuration/CollectorOptions.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Linq; + +namespace BizTalkIisEnvironmentInventory.Configuration +{ + /// + /// Enthält die technischen Grenzen und Schalter der Bestandsaufnahme. + /// + internal sealed class CollectorOptions + { + public int MaxFilesPerApplication { get; private set; } + public int MaxContentDepth { get; private set; } + public bool IncludeFileHashes { get; private set; } + public bool IncludeAllPersonalCertificates { get; private set; } + public int WmiTimeoutSeconds { get; private set; } + public IList ExpectedApplications { get; private set; } + + /// + /// Liest die Einstellungen aus der Anwendungskonfiguration und verwendet bei ungültigen Werten sichere Defaults. + /// + /// Optionale Überschreibungen aus der Kommandozeile. + /// Validierte Collector-Einstellungen. + public static CollectorOptions Load(Infrastructure.CommandLineOptions commandLine) + { + var result = new CollectorOptions + { + MaxFilesPerApplication = ReadInt("MaxFilesPerApplication", 10000, 0, 100000), + MaxContentDepth = ReadInt("MaxContentDepth", 30, 1, 100), + IncludeFileHashes = ReadBool("IncludeFileHashes", false), + IncludeAllPersonalCertificates = ReadBool("IncludeAllPersonalCertificates", true), + WmiTimeoutSeconds = ReadInt("WmiTimeoutSeconds", 30, 5, 300), + ExpectedApplications = (ConfigurationManager.AppSettings["ExpectedApplications"] ?? string.Empty) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(item => item.Trim().Trim('/')) + .Where(item => item.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + }; + + if (commandLine.IncludeFileHashes) + { + result.IncludeFileHashes = true; + } + + if (commandLine.SkipContentManifest) + { + result.MaxFilesPerApplication = 0; + } + + return result; + } + + /// + /// Liest einen begrenzten Ganzzahlwert aus appSettings. + /// + /// Name der Einstellung. + /// Fallback bei fehlendem oder ungültigem Wert. + /// Kleinster zulässiger Wert. + /// Größter zulässiger Wert. + /// Validierter Wert. + private static int ReadInt(string key, int fallback, int minimum, int maximum) + { + int value; + if (!int.TryParse(ConfigurationManager.AppSettings[key], out value)) + { + return fallback; + } + + return Math.Max(minimum, Math.Min(maximum, value)); + } + + /// + /// Liest einen booleschen Wert aus appSettings. + /// + /// Name der Einstellung. + /// Fallback bei fehlendem oder ungültigem Wert. + /// Gelesener oder vorgegebener Wert. + private static bool ReadBool(string key, bool fallback) + { + bool value; + return bool.TryParse(ConfigurationManager.AppSettings[key], out value) ? value : fallback; + } + } +} diff --git a/src/BizTalkIisEnvironmentInventory/Infrastructure/CommandLineOptions.cs b/src/BizTalkIisEnvironmentInventory/Infrastructure/CommandLineOptions.cs new file mode 100644 index 0000000..ba2e816 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Infrastructure/CommandLineOptions.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace BizTalkIisEnvironmentInventory.Infrastructure +{ + /// + /// Beschreibt die validierten Kommandozeilenoptionen. + /// + internal sealed class CommandLineOptions + { + public string EnvironmentName { get; private set; } + public string OutputDirectory { get; private set; } + public string IisConfigPath { get; private set; } + public bool IncludeFileHashes { get; private set; } + public bool SkipContentManifest { get; private set; } + public bool ShowHelp { get; private set; } + public bool SelfTest { get; private set; } + + /// + /// Analysiert und validiert die Kommandozeile. + /// + /// Argumente des Prozesses. + /// Validierte Optionen. + /// Wird bei unbekannten oder unvollständigen Optionen ausgelöst. + public static CommandLineOptions Parse(string[] args) + { + var result = new CommandLineOptions + { + EnvironmentName = "UNBEKANNT", + OutputDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "reports") + }; + + for (var index = 0; index < args.Length; index++) + { + switch (args[index].ToLowerInvariant()) + { + case "--environment": + case "-e": + result.EnvironmentName = RequireValue(args, ref index); + break; + case "--output": + case "-o": + result.OutputDirectory = RequireValue(args, ref index); + break; + case "--iis-config": + result.IisConfigPath = RequireValue(args, ref index); + break; + case "--include-file-hashes": + result.IncludeFileHashes = true; + break; + case "--skip-content-manifest": + result.SkipContentManifest = true; + break; + case "--self-test": + result.SelfTest = true; + break; + case "--help": + case "-h": + case "/?": + result.ShowHelp = true; + break; + default: + throw new ArgumentException("Unbekannte Option: " + args[index]); + } + } + + result.EnvironmentName = SanitizeEnvironment(result.EnvironmentName); + result.OutputDirectory = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.OutputDirectory)); + + if (!string.IsNullOrWhiteSpace(result.IisConfigPath)) + { + result.IisConfigPath = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.IisConfigPath)); + } + + return result; + } + + /// + /// Normalisiert einen Umgebungsnamen für Dateiname und Bericht. + /// + /// Eingegebener Umgebungsname. + /// Sicherer Umgebungsname. + internal static string SanitizeEnvironment(string value) + { + var trimmed = string.IsNullOrWhiteSpace(value) ? "UNBEKANNT" : value.Trim(); + var sanitized = Regex.Replace(trimmed, @"[^A-Za-z0-9._-]+", "-").Trim('-', '.'); + return string.IsNullOrEmpty(sanitized) ? "UNBEKANNT" : sanitized.ToUpperInvariant(); + } + + /// + /// Liest den Wert hinter einer Option. + /// + /// Alle Prozessargumente. + /// Aktueller Argumentindex; wird auf den Wert weitergeschaltet. + /// Optionswert. + private static string RequireValue(string[] args, ref int index) + { + if (index + 1 >= args.Length || args[index + 1].StartsWith("-", StringComparison.Ordinal)) + { + throw new ArgumentException("Wert fehlt hinter " + args[index] + "."); + } + + index++; + return args[index]; + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Infrastructure/FileLogger.cs b/src/BizTalkIisEnvironmentInventory/Infrastructure/FileLogger.cs new file mode 100644 index 0000000..3a1fc69 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Infrastructure/FileLogger.cs @@ -0,0 +1,107 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace BizTalkIisEnvironmentInventory.Infrastructure +{ + /// + /// Schreibt sichtbare Konsolenmeldungen und ein dauerhaftes UTF-8-Protokoll. + /// + internal sealed class FileLogger : IDisposable + { + private readonly object sync = new object(); + private readonly StreamWriter writer; + + /// + /// Initialisiert das Protokoll. + /// + /// Vollständiger Pfad zur Protokolldatei. + public FileLogger(string logPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(logPath)); + writer = new StreamWriter(logPath, false, new UTF8Encoding(false)) { AutoFlush = true }; + LogPath = logPath; + } + + public string LogPath { get; private set; } + + /// + /// Schreibt eine Informationsmeldung. + /// + /// Benutzerlesbarer Meldungstext. + public void Info(string message) + { + Write("INFO", message, ConsoleColor.Gray); + } + + /// + /// Schreibt eine Warnung. + /// + /// Benutzerlesbarer Meldungstext. + public void Warning(string message) + { + Write("WARN", message, ConsoleColor.Yellow); + } + + /// + /// Schreibt einen Fehler einschließlich technischer Details. + /// + /// Benutzerlesbarer Meldungstext. + /// Optionale Ausnahme. + public void Error(string message, Exception exception = null) + { + var detail = exception == null ? message : message + " | " + exception; + Write("ERROR", detail, ConsoleColor.Red); + } + + /// + /// Schreibt einen erfolgreichen Arbeitsschritt. + /// + /// Benutzerlesbarer Meldungstext. + public void Success(string message) + { + Write("OK", message, ConsoleColor.Green); + } + + /// + /// Gibt den Dateihandle des Protokolls frei. + /// + public void Dispose() + { + writer.Dispose(); + } + + /// + /// Schreibt atomar auf Konsole und in Datei. + /// + /// Kurzbezeichnung der Meldungsstufe. + /// Meldung. + /// Konsolenfarbe. + private void Write(string level, string message, ConsoleColor color) + { + var line = string.Format( + CultureInfo.InvariantCulture, + "{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}", + DateTime.Now, + level, + message); + + lock (sync) + { + writer.WriteLine(line); + var previous = Console.ForegroundColor; + try + { + Console.ForegroundColor = color; + Console.WriteLine(line); + } + finally + { + Console.ForegroundColor = previous; + } + } + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Infrastructure/SafeCollector.cs b/src/BizTalkIisEnvironmentInventory/Infrastructure/SafeCollector.cs new file mode 100644 index 0000000..8515f64 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Infrastructure/SafeCollector.cs @@ -0,0 +1,72 @@ +using System; +using System.Diagnostics; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Infrastructure +{ + /// + /// Isoliert Collector-Fehler und überführt sie in Status und Report-Finding. + /// + internal static class SafeCollector + { + /// + /// Führt einen Collector aus, protokolliert Laufzeit und fängt nicht behandelte Fehler ab. + /// + /// Zieldokument für Status und Findings. + /// Gemeinsames Laufprotokoll. + /// Aktueller Schritt beginnend bei eins. + /// Gesamtzahl der Schritte. + /// Benutzerlesbarer Abschnittsname. + /// Auszuführender Collector. + internal static void Run( + InventoryDocument document, + FileLogger logger, + int step, + int totalSteps, + string name, + Action action) + { + var stopwatch = Stopwatch.StartNew(); + logger.Info(string.Format("[{0}/{1}] {2} wird erfasst ...", step, totalSteps, name)); + try + { + action(); + stopwatch.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = "Erfolgreich", + Message = "Abschnitt wurde vollständig ausgeführt.", + DurationMilliseconds = stopwatch.ElapsedMilliseconds + }); + logger.Success(string.Format( + "[{0}/{1}] {2} abgeschlossen ({3} ms).", + step, + totalSteps, + name, + stopwatch.ElapsedMilliseconds)); + } + catch (Exception exception) + { + stopwatch.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = "Teilweise", + Message = exception.Message, + DurationMilliseconds = stopwatch.ElapsedMilliseconds + }); + document.Findings.Add(new Finding + { + Severity = "Fehler", + Area = name, + Message = "Der Abschnitt konnte nicht vollständig erfasst werden.", + TechnicalDetail = exception.GetType().Name + ": " + exception.Message, + Recommendation = "Berechtigungen, lokale Datenquelle und Laufprotokoll prüfen; die übrigen Abschnitte sind weiterhin gültig." + }); + logger.Error(string.Format("[{0}/{1}] {2} fehlgeschlagen.", step, totalSteps, name), exception); + } + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs b/src/BizTalkIisEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs new file mode 100644 index 0000000..cfa4544 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace BizTalkIisEnvironmentInventory.Infrastructure +{ + /// + /// Entfernt Kennwörter, Tokens und verschlüsselte Nutzdaten aus Konfigurationsdarstellungen. + /// + internal static class SensitiveDataSanitizer + { + private static readonly string[] SensitiveAttributeFragments = + { + "password", + "pwd", + "secret", + "token", + "connectionstring", + "privatekey", + "validationkey", + "decryptionkey" + }; + + /// + /// Erstellt eine bereinigte Kopie eines XML-Elements. + /// + /// Zu bereinigendes Element. + /// Neue XML-Struktur ohne sensible Attributwerte oder verschlüsselte Blobs. + internal static XElement SanitizeXml(XElement source) + { + var copy = new XElement(source); + foreach (var element in copy.DescendantsAndSelf()) + { + foreach (var attribute in element.Attributes().ToList()) + { + if (IsSensitiveName(attribute.Name.LocalName)) + { + attribute.Value = "[ENTFERNT]"; + } + } + + if (IsEncryptedPayloadElement(element.Name.LocalName)) + { + element.RemoveNodes(); + element.Value = "[VERSCHLUESSELTE NUTZDATEN NICHT DOKUMENTIERT]"; + } + } + + return copy; + } + + /// + /// Prüft, ob ein Attributname typischerweise ein Secret bezeichnet. + /// + /// Attributname. + /// true, wenn der Wert entfernt werden muss. + internal static bool IsSensitiveName(string name) + { + var normalized = (name ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty); + return SensitiveAttributeFragments.Any( + item => normalized.IndexOf(item, StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// + /// Erkennt XML-Elemente, deren Inhalt ein geschützter Konfigurationsblob ist. + /// + /// Lokaler XML-Elementname. + /// true für bekannte verschlüsselte Payload-Elemente. + private static bool IsEncryptedPayloadElement(string name) + { + return string.Equals(name, "EncryptedData", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "CipherData", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "CipherValue", StringComparison.OrdinalIgnoreCase); + } + } +} + diff --git a/src/BizTalkIisEnvironmentInventory/Models/InventoryModels.cs b/src/BizTalkIisEnvironmentInventory/Models/InventoryModels.cs new file mode 100644 index 0000000..9bae99c --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Models/InventoryModels.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; + +namespace BizTalkIisEnvironmentInventory.Models +{ + /// + /// Vollständiges Ergebnis einer lokalen Bestandsaufnahme. + /// + internal sealed class InventoryDocument + { + /// + /// Initialisiert alle Abschnittslisten der Bestandsaufnahme. + /// + public InventoryDocument() + { + System = new SystemInventory(); + Iis = new IisInventory(); + Certificates = new List(); + Security = new SecurityInventory(); + BizTalk = new BizTalkInventory(); + Findings = new List(); + SectionStatuses = new List(); + } + + public string EnvironmentName { get; set; } + public string ComputerName { get; set; } + public DateTime StartedUtc { get; set; } + public DateTime CompletedUtc { get; set; } + public SystemInventory System { get; set; } + public IisInventory Iis { get; set; } + public List Certificates { get; set; } + public SecurityInventory Security { get; set; } + public BizTalkInventory BizTalk { get; set; } + public List Findings { get; private set; } + public List SectionStatuses { get; private set; } + } + + internal sealed class SystemInventory + { + /// + /// Initialisiert die Listen für Systemeigenschaften und Features. + /// + public SystemInventory() + { + Properties = new List(); + InstalledFeatures = new List(); + } + + public List Properties { get; private set; } + public List InstalledFeatures { get; private set; } + } + + internal sealed class IisInventory + { + /// + /// Initialisiert alle IIS-Ergebnislisten. + /// + public IisInventory() + { + ApplicationPools = new List(); + Sites = new List(); + GlobalSections = new List(); + EncryptionProviders = new List(); + } + + public string ConfigurationPath { get; set; } + public DateTime? ConfigurationLastWriteUtc { get; set; } + public string SanitizedConfigurationSha256 { get; set; } + public List ApplicationPools { get; private set; } + public List Sites { get; private set; } + public List GlobalSections { get; private set; } + public List EncryptionProviders { get; private set; } + } + + internal sealed class ApplicationPoolRecord + { + public string Name { get; set; } + public string ManagedRuntimeVersion { get; set; } + public string ManagedPipelineMode { get; set; } + public string AutoStart { get; set; } + public string StartMode { get; set; } + public string IdentityType { get; set; } + public string UserName { get; set; } + public string Enable32BitAppOnWin64 { get; set; } + public List AdditionalSettings { get; set; } = new List(); + } + + internal sealed class SiteRecord + { + /// + /// Initialisiert Binding- und Anwendungslisten einer Site. + /// + public SiteRecord() + { + Bindings = new List(); + Applications = new List(); + } + + public string Name { get; set; } + public string Id { get; set; } + public string ServerAutoStart { get; set; } + public string LogDirectory { get; set; } + public List Bindings { get; private set; } + public List Applications { get; private set; } + } + + internal sealed class BindingRecord + { + public string SiteName { get; set; } + public string Protocol { get; set; } + public string BindingInformation { get; set; } + public string CertificateHash { get; set; } + public string CertificateStoreName { get; set; } + public string SslFlags { get; set; } + } + + internal sealed class WebApplicationRecord + { + /// + /// Initialisiert Listen für virtuelle Verzeichnisse, Dateien und ACLs. + /// + public WebApplicationRecord() + { + VirtualDirectories = new List(); + ContentFiles = new List(); + AccessRules = new List(); + } + + public string SiteName { get; set; } + public string Path { get; set; } + public string ApplicationPool { get; set; } + public string EnabledProtocols { get; set; } + public string PhysicalPath { get; set; } + public bool PhysicalPathExists { get; set; } + public int TotalFiles { get; set; } + public long TotalBytes { get; set; } + public bool ManifestTruncated { get; set; } + public DateTime? LatestWriteUtc { get; set; } + public List VirtualDirectories { get; private set; } + public List ContentFiles { get; private set; } + public List AccessRules { get; private set; } + } + + internal sealed class VirtualDirectoryRecord + { + public string Path { get; set; } + public string PhysicalPath { get; set; } + public string UserName { get; set; } + } + + internal sealed class ContentFileRecord + { + public string RelativePath { get; set; } + public long SizeBytes { get; set; } + public DateTime LastWriteUtc { get; set; } + public string Sha256 { get; set; } + } + + internal sealed class AccessRuleRecord + { + public string Target { get; set; } + public string Identity { get; set; } + public string Rights { get; set; } + public string AccessType { get; set; } + public bool IsInherited { get; set; } + public string Inheritance { get; set; } + } + + internal sealed class ConfigurationSectionRecord + { + public string Path { get; set; } + public string OverrideModeDefault { get; set; } + public string AllowDefinition { get; set; } + public bool IsEncrypted { get; set; } + public int ElementCount { get; set; } + public string SafeSummary { get; set; } + } + + internal sealed class EncryptionProviderRecord + { + public string Name { get; set; } + public string Type { get; set; } + public string KeyContainerName { get; set; } + public string UseMachineContainer { get; set; } + public string Description { get; set; } + } + + internal sealed class CertificateRecord + { + /// + /// Initialisiert Key-Usage- und ACL-Listen eines Zertifikats. + /// + public CertificateRecord() + { + EnhancedKeyUsages = new List(); + KeyAccessRules = new List(); + } + + public string StoreLocation { get; set; } + public string StoreName { get; set; } + public string Subject { get; set; } + public string Issuer { get; set; } + public string Thumbprint { get; set; } + public string SerialNumber { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public string SignatureAlgorithm { get; set; } + public string PublicKeyAlgorithm { get; set; } + public int PublicKeySize { get; set; } + public bool HasPrivateKey { get; set; } + public string PrivateKeyExportable { get; set; } + public string PrivateKeyProvider { get; set; } + public string PrivateKeyContainer { get; set; } + public string PrivateKeyFile { get; set; } + public bool UsedByIisBinding { get; set; } + public List EnhancedKeyUsages { get; private set; } + public List KeyAccessRules { get; private set; } + } + + internal sealed class SecurityInventory + { + /// + /// Initialisiert die Listen der lokalen Sicherheitsaufnahme. + /// + public SecurityInventory() + { + ServiceAccounts = new List(); + UserRights = new List(); + LocalPolicy = new List(); + } + + public List ServiceAccounts { get; private set; } + public List UserRights { get; private set; } + public List LocalPolicy { get; private set; } + } + + internal sealed class ServiceAccountRecord + { + public string Source { get; set; } + public string Name { get; set; } + public string DisplayName { get; set; } + public string Account { get; set; } + public string State { get; set; } + public string StartMode { get; set; } + public string Path { get; set; } + } + + internal sealed class UserRightRecord + { + public string Right { get; set; } + public string Accounts { get; set; } + } + + internal sealed class BizTalkInventory + { + /// + /// Initialisiert alle Listen des BizTalk-Inventars. + /// + public BizTalkInventory() + { + RegistryValues = new List(); + InstalledProducts = new List(); + Components = new List(); + WmiClasses = new List(); + } + + public bool WmiNamespaceAvailable { get; set; } + public List RegistryValues { get; private set; } + public List InstalledProducts { get; private set; } + public List Components { get; private set; } + public List WmiClasses { get; private set; } + } + + internal sealed class NameValueRecord + { + /// + /// Initialisiert einen leeren Name/Wert-Datensatz. + /// + public NameValueRecord() + { + } + + /// + /// Initialisiert einen Name/Wert-Datensatz. + /// + /// Eigenschaftsname. + /// Eigenschaftswert. + public NameValueRecord(string name, string value) + { + Name = name; + Value = value; + } + + public string Name { get; set; } + public string Value { get; set; } + } + + internal sealed class Finding + { + public string Severity { get; set; } + public string Area { get; set; } + public string Message { get; set; } + public string TechnicalDetail { get; set; } + public string Recommendation { get; set; } + } + + internal sealed class SectionStatus + { + public string Name { get; set; } + public string Status { get; set; } + public string Message { get; set; } + public long DurationMilliseconds { get; set; } + } +} diff --git a/src/BizTalkIisEnvironmentInventory/Program.cs b/src/BizTalkIisEnvironmentInventory/Program.cs new file mode 100644 index 0000000..0a309b9 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Program.cs @@ -0,0 +1,216 @@ +using System; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Xml.Linq; +using BizTalkIisEnvironmentInventory.Collectors; +using BizTalkIisEnvironmentInventory.Configuration; +using BizTalkIisEnvironmentInventory.Infrastructure; +using BizTalkIisEnvironmentInventory.Models; +using BizTalkIisEnvironmentInventory.Reporting; + +namespace BizTalkIisEnvironmentInventory +{ + /// + /// Einstiegspunkt und Ablaufsteuerung der Bestandsaufnahme. + /// + internal static class Program + { + /// + /// Analysiert Optionen, führt alle read-only Collectoren aus und erzeugt das Word-Dokument. + /// + /// Kommandozeilenargumente. + /// 0 bei vollständigem Erfolg, 1 bei Teilfehler mit Report, 2 bei fatalem Fehler. + private static int Main(string[] args) + { + CommandLineOptions commandLine; + try + { + commandLine = CommandLineOptions.Parse(args); + } + catch (ArgumentException exception) + { + Console.Error.WriteLine("Fehler: " + exception.Message); + PrintHelp(); + return 2; + } + + if (commandLine.ShowHelp) + { + PrintHelp(); + return 0; + } + + if (commandLine.SelfTest) + { + return RunSelfTest(); + } + + try + { + Directory.CreateDirectory(commandLine.OutputDirectory); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var baseName = "IIS-Dokumentation-" + commandLine.EnvironmentName + "-" + + CommandLineOptions.SanitizeEnvironment(Environment.MachineName) + "-" + stamp; + var logPath = Path.Combine(commandLine.OutputDirectory, baseName + ".log"); + using (var logger = new FileLogger(logPath)) + { + return Run(commandLine, logger, baseName); + } + } + catch (Exception exception) + { + Console.Error.WriteLine("Fataler Fehler: " + exception); + return 2; + } + } + + /// + /// Führt die Collector-Pipeline aus und schreibt den Bericht. + /// + /// Validierte Laufoptionen. + /// Gemeinsames Konsolen-/Dateiprotokoll. + /// Eindeutiger Basisdateiname. + /// 0 bei vollständigem Erfolg, sonst 1 bei verwertbarem Teilreport. + private static int Run(CommandLineOptions commandLine, FileLogger logger, string baseName) + { + const int totalSteps = 6; + var options = CollectorOptions.Load(commandLine); + var document = new InventoryDocument + { + EnvironmentName = commandLine.EnvironmentName, + ComputerName = Environment.MachineName, + StartedUtc = DateTime.UtcNow + }; + + logger.Info("BizTalk IIS Environment Inventory 1.0 startet."); + logger.Info("Modus: read-only; keine Kennwörter, verschlüsselten Payloads oder privaten Schlüssel im Report."); + logger.Info("Umgebung: " + commandLine.EnvironmentName); + logger.Info("Ausgabe: " + commandLine.OutputDirectory); + logger.Info("Dateimanifest: " + (options.MaxFilesPerApplication > 0 + ? "max. " + options.MaxFilesPerApplication + " Einträge je Anwendung" + : "deaktiviert")); + logger.Info("Datei-Hashes: " + (options.IncludeFileHashes ? "aktiv" : "deaktiviert")); + + SafeCollector.Run(document, logger, 1, totalSteps, "Windows und Rollen", + () => new SystemCollector(options).Collect(document.System)); + SafeCollector.Run(document, logger, 2, totalSteps, "IIS und Webinhalte", + () => new IisCollector(options).Collect(document.Iis, document.Findings, commandLine.IisConfigPath)); + SafeCollector.Run(document, logger, 3, totalSteps, "Zertifikate und Schlüsselmetadaten", + () => new CertificateCollector(options).Collect(document.Certificates, document.Iis, document.Findings)); + SafeCollector.Run(document, logger, 4, totalSteps, "Dienstkonten und lokale Sicherheit", + () => new SecurityCollector(options).Collect(document.Security, document.Iis, document.Findings)); + SafeCollector.Run(document, logger, 5, totalSteps, "BizTalk-Komponenten", + () => new BizTalkCollector(options).Collect(document.BizTalk, document.Findings)); + + document.CompletedUtc = DateTime.UtcNow; + var reportPath = Path.Combine(commandLine.OutputDirectory, baseName + ".docx"); + SafeCollector.Run(document, logger, 6, totalSteps, "Word-Dokument", + () => new DocxReportWriter().Write(document, reportPath)); + + var partial = document.SectionStatuses.Any( + item => !string.Equals(item.Status, "Erfolgreich", StringComparison.OrdinalIgnoreCase)); + logger.Info("Auffälligkeiten: " + document.Findings.Count.ToString(CultureInfo.InvariantCulture)); + logger.Success("Word-Dokument: " + reportPath); + logger.Info("Laufprotokoll: " + logger.LogPath); + logger.Info(partial + ? "Erfassung mit Teilfehlern abgeschlossen (ExitCode 1)." + : "Erfassung vollständig abgeschlossen (ExitCode 0)."); + return partial ? 1 : 0; + } + + /// + /// Führt plattformneutrale interne Prüfungen ohne Zugriff auf IIS oder BizTalk aus. + /// + /// 0 bei Erfolg, sonst 2. + private static int RunSelfTest() + { + string temporaryDocument = null; + try + { + var environment = CommandLineOptions.SanitizeEnvironment(" acc / produkt "); + if (!string.Equals(environment, "ACC-PRODUKT", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Umgebungsnormalisierung fehlgeschlagen."); + } + + var xml = System.Xml.Linq.XElement.Parse( + "payload"); + var sanitized = SensitiveDataSanitizer.SanitizeXml(xml).ToString(); + if (sanitized.Contains("secret") || sanitized.Contains("payload") || sanitized.Contains("abc")) + { + throw new InvalidOperationException("Secret-Bereinigung fehlgeschlagen."); + } + + var document = new InventoryDocument + { + EnvironmentName = "", + ComputerName = "TEST", + StartedUtc = DateTime.UtcNow, + CompletedUtc = DateTime.UtcNow + }; + temporaryDocument = Path.Combine( + Path.GetTempPath(), + "BizTalkIisInventory-SelfTest-" + Guid.NewGuid().ToString("N") + ".docx"); + new DocxReportWriter().Write(document, temporaryDocument); + using (var archive = ZipFile.OpenRead(temporaryDocument)) + { + var documentEntry = archive.GetEntry("word/document.xml"); + var stylesEntry = archive.GetEntry("word/styles.xml"); + if (documentEntry == null || stylesEntry == null) + { + throw new InvalidOperationException("Erforderliche DOCX-Parts fehlen."); + } + + using (var stream = documentEntry.Open()) + { + XDocument.Load(stream); + } + } + + Console.WriteLine("Self-Test erfolgreich."); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("Self-Test fehlgeschlagen: " + exception); + return 2; + } + finally + { + if (!string.IsNullOrWhiteSpace(temporaryDocument) && File.Exists(temporaryDocument)) + { + try + { + File.Delete(temporaryDocument); + } + catch + { + // Eine fehlgeschlagene Temp-Bereinigung ändert das Self-Test-Ergebnis nicht. + } + } + } + } + + /// + /// Gibt die deutsche Kommandozeilenhilfe aus. + /// + private static void PrintHelp() + { + Console.WriteLine("BizTalk IIS Environment Inventory"); + Console.WriteLine(); + Console.WriteLine("Aufruf:"); + Console.WriteLine(" BizTalkIisEnvironmentInventory.exe --environment ACC --output C:\\IIS-Doku\\ACC"); + Console.WriteLine(); + Console.WriteLine("Optionen:"); + Console.WriteLine(" -e, --environment NAME Umgebungsname, z. B. ACC oder PROD"); + Console.WriteLine(" -o, --output PFAD Ausgabeordner für DOCX und Log"); + Console.WriteLine(" --iis-config DATEI Alternative applicationHost.config (Offline-Test)"); + Console.WriteLine(" --include-file-hashes SHA-256 für jede manifestierte Webdatei"); + Console.WriteLine(" --skip-content-manifest Nur Pfade/ACLs, keine Dateiliste"); + Console.WriteLine(" --self-test Prüft Parser und DOCX-Paketstruktur"); + Console.WriteLine(" -h, --help Diese Hilfe"); + } + } +} diff --git a/src/BizTalkIisEnvironmentInventory/Properties/AssemblyInfo.cs b/src/BizTalkIisEnvironmentInventory/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..7ba1934 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Properties/AssemblyInfo.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("BizTalk IIS Environment Inventory")] +[assembly: AssemblyDescription("Read-only IIS and BizTalk environment documentation collector")] +[assembly: AssemblyCompany("JR IT Services")] +[assembly: AssemblyProduct("BizTalk IIS Environment Inventory")] +[assembly: ComVisible(false)] +[assembly: Guid("66e7a524-f96b-46b9-a8b1-8f967c9fb77c")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("BizTalkIisEnvironmentInventory.Tests")] + diff --git a/src/BizTalkIisEnvironmentInventory/Reporting/DocxReportWriter.cs b/src/BizTalkIisEnvironmentInventory/Reporting/DocxReportWriter.cs new file mode 100644 index 0000000..083cb93 --- /dev/null +++ b/src/BizTalkIisEnvironmentInventory/Reporting/DocxReportWriter.cs @@ -0,0 +1,977 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml; +using BizTalkIisEnvironmentInventory.Models; + +namespace BizTalkIisEnvironmentInventory.Reporting +{ + /// + /// Erzeugt ein standardkonformes Microsoft-Word-Dokument im Office-Open-XML-Format ohne Office-Installation. + /// + internal sealed class DocxReportWriter + { + private const string WordNamespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private const string RelationshipNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private const string PackageRelationshipNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + private const string ContentTypeNamespace = "http://schemas.openxmlformats.org/package/2006/content-types"; + + /// + /// Schreibt den vollständigen DOCX-Bericht atomar in die Zieldatei. + /// + /// Abgeschlossene Bestandsaufnahme. + /// Vollständiger Zielpfad mit Erweiterung .docx. + public void Write(InventoryDocument document, string path) + { + if (document == null) + { + throw new ArgumentNullException("document"); + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, false, Encoding.UTF8)) + { + WriteContentTypes(archive); + WritePackageRelationships(archive); + WriteDocumentRelationships(archive); + WriteCoreProperties(archive, document); + WriteApplicationProperties(archive); + WriteStyles(archive); + WriteMainDocument(archive, document); + } + + if (File.Exists(path)) + { + File.Replace(temporaryPath, path, null); + } + else + { + File.Move(temporaryPath, path); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Schreibt die MIME-Zuordnungen des Office-Open-XML-Pakets. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + private static void WriteContentTypes(ZipArchive archive) + { + WriteXmlEntry(archive, "[Content_Types].xml", writer => + { + writer.WriteStartElement("Types", ContentTypeNamespace); + WriteContentTypeDefault(writer, "rels", "application/vnd.openxmlformats-package.relationships+xml"); + WriteContentTypeDefault(writer, "xml", "application/xml"); + WriteContentTypeOverride(writer, "/word/document.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"); + WriteContentTypeOverride(writer, "/word/styles.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"); + WriteContentTypeOverride(writer, "/docProps/core.xml", + "application/vnd.openxmlformats-package.core-properties+xml"); + WriteContentTypeOverride(writer, "/docProps/app.xml", + "application/vnd.openxmlformats-officedocument.extended-properties+xml"); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt eine Default-MIME-Zuordnung. + /// + /// XML-Writer. + /// Dateierweiterung. + /// MIME-Typ. + private static void WriteContentTypeDefault(XmlWriter writer, string extension, string contentType) + { + writer.WriteStartElement("Default", ContentTypeNamespace); + writer.WriteAttributeString("Extension", extension); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + /// + /// Schreibt eine part-spezifische MIME-Zuordnung. + /// + /// XML-Writer. + /// Absoluter Paketpart. + /// MIME-Typ. + private static void WriteContentTypeOverride(XmlWriter writer, string partName, string contentType) + { + writer.WriteStartElement("Override", ContentTypeNamespace); + writer.WriteAttributeString("PartName", partName); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + /// + /// Schreibt die Beziehungen von der Paketwurzel zu Dokument und Eigenschaften. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + private static void WritePackageRelationships(ZipArchive archive) + { + WriteXmlEntry(archive, "_rels/.rels", writer => + { + writer.WriteStartElement("Relationships", PackageRelationshipNamespace); + WriteRelationship(writer, "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + "word/document.xml"); + WriteRelationship(writer, "rId2", + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + "docProps/core.xml"); + WriteRelationship(writer, "rId3", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + "docProps/app.xml"); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt die Beziehungen des Hauptdokuments. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + private static void WriteDocumentRelationships(ZipArchive archive) + { + WriteXmlEntry(archive, "word/_rels/document.xml.rels", writer => + { + writer.WriteStartElement("Relationships", PackageRelationshipNamespace); + WriteRelationship(writer, "rIdStyles", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + "styles.xml"); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt eine OOXML-Beziehung. + /// + /// XML-Writer. + /// Beziehungs-ID. + /// Beziehungstyp. + /// Relatives Ziel. + private static void WriteRelationship(XmlWriter writer, string id, string type, string target) + { + writer.WriteStartElement("Relationship", PackageRelationshipNamespace); + writer.WriteAttributeString("Id", id); + writer.WriteAttributeString("Type", type); + writer.WriteAttributeString("Target", target); + writer.WriteEndElement(); + } + + /// + /// Schreibt Titel, Ersteller und Zeitstempel des Dokuments. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + /// Bestandsaufnahme. + private static void WriteCoreProperties(ZipArchive archive, InventoryDocument document) + { + WriteXmlEntry(archive, "docProps/core.xml", writer => + { + writer.WriteStartElement("cp", "coreProperties", + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"); + writer.WriteAttributeString("xmlns", "dc", null, "http://purl.org/dc/elements/1.1/"); + writer.WriteAttributeString("xmlns", "dcterms", null, "http://purl.org/dc/terms/"); + writer.WriteAttributeString("xmlns", "dcmitype", null, "http://purl.org/dc/dcmitype/"); + writer.WriteAttributeString("xmlns", "xsi", null, "http://www.w3.org/2001/XMLSchema-instance"); + writer.WriteElementString("dc", "title", "http://purl.org/dc/elements/1.1/", + "IIS- und BizTalk-Dokumentation " + document.EnvironmentName); + writer.WriteElementString("dc", "creator", "http://purl.org/dc/elements/1.1/", + "BizTalk IIS Environment Inventory"); + writer.WriteElementString("cp", "lastModifiedBy", + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "BizTalk IIS Environment Inventory"); + WriteDublinCoreDate(writer, "created", document.StartedUtc); + WriteDublinCoreDate(writer, "modified", document.CompletedUtc); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt einen typisierten Dublin-Core-Zeitwert. + /// + /// XML-Writer. + /// Elementname. + /// UTC-Zeitwert. + private static void WriteDublinCoreDate(XmlWriter writer, string name, DateTime value) + { + writer.WriteStartElement("dcterms", name, "http://purl.org/dc/terms/"); + writer.WriteAttributeString("xsi", "type", "http://www.w3.org/2001/XMLSchema-instance", "dcterms:W3CDTF"); + writer.WriteString(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + } + + /// + /// Schreibt die Office-Anwendungseigenschaften. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + private static void WriteApplicationProperties(ZipArchive archive) + { + WriteXmlEntry(archive, "docProps/app.xml", writer => + { + const string ns = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"; + writer.WriteStartElement("Properties", ns); + writer.WriteAttributeString("xmlns", "vt", null, + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); + writer.WriteElementString("Application", ns, "BizTalk IIS Environment Inventory"); + writer.WriteElementString("AppVersion", ns, "1.0"); + writer.WriteElementString("Company", ns, "JR IT Services"); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt die im Bericht verwendeten Word-Formatvorlagen. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + private static void WriteStyles(ZipArchive archive) + { + WriteXmlEntry(archive, "word/styles.xml", writer => + { + writer.WriteStartElement("w", "styles", WordNamespace); + WriteParagraphStyle(writer, "Normal", "Standard", 20, "1F2937", false, 0, 0); + WriteParagraphStyle(writer, "Title", "Titel", 42, "0B4F78", true, 220, 160); + WriteParagraphStyle(writer, "Subtitle", "Untertitel", 22, "526575", false, 0, 160); + WriteParagraphStyle(writer, "Heading1", "Überschrift 1", 32, "0B4F78", true, 360, 160); + WriteParagraphStyle(writer, "Heading2", "Überschrift 2", 26, "176B96", true, 280, 120); + WriteParagraphStyle(writer, "Heading3", "Überschrift 3", 22, "1F2937", true, 220, 80); + WriteTableStyle(writer); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt eine Absatzformatvorlage. + /// + /// XML-Writer. + /// Interne Style-ID. + /// Anzeigename. + /// Schriftgröße in halben Punkten. + /// RGB-Farbe. + /// Fettdruck. + /// Abstand davor in Twips. + /// Abstand danach in Twips. + private static void WriteParagraphStyle( + XmlWriter writer, + string styleId, + string name, + int fontSizeHalfPoints, + string color, + bool bold, + int spaceBefore, + int spaceAfter) + { + writer.WriteStartElement("w", "style", WordNamespace); + writer.WriteAttributeString("w", "type", WordNamespace, "paragraph"); + writer.WriteAttributeString("w", "styleId", WordNamespace, styleId); + writer.WriteStartElement("w", "name", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, name); + writer.WriteEndElement(); + writer.WriteStartElement("w", "pPr", WordNamespace); + writer.WriteStartElement("w", "spacing", WordNamespace); + writer.WriteAttributeString("w", "before", WordNamespace, spaceBefore.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("w", "after", WordNamespace, spaceAfter.ToString(CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteStartElement("w", "rPr", WordNamespace); + if (bold) + { + writer.WriteElementString("w", "b", WordNamespace, string.Empty); + } + + writer.WriteStartElement("w", "color", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, color); + writer.WriteEndElement(); + writer.WriteStartElement("w", "sz", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, + fontSizeHalfPoints.ToString(CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + /// + /// Definiert den Tabellenstil mit sichtbaren Gitternetzlinien. + /// + /// XML-Writer. + private static void WriteTableStyle(XmlWriter writer) + { + writer.WriteStartElement("w", "style", WordNamespace); + writer.WriteAttributeString("w", "type", WordNamespace, "table"); + writer.WriteAttributeString("w", "styleId", WordNamespace, "InventoryTable"); + writer.WriteStartElement("w", "name", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, "Inventartabelle"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "tblPr", WordNamespace); + writer.WriteStartElement("w", "tblBorders", WordNamespace); + foreach (var edge in new[] { "top", "left", "bottom", "right", "insideH", "insideV" }) + { + writer.WriteStartElement("w", edge, WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, "single"); + writer.WriteAttributeString("w", "sz", WordNamespace, "4"); + writer.WriteAttributeString("w", "color", WordNamespace, "CBD5E1"); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + /// + /// Schreibt den fachlichen Inhalt des Word-Dokuments. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + /// Bestandsaufnahme. + private static void WriteMainDocument(ZipArchive archive, InventoryDocument document) + { + WriteXmlEntry(archive, "word/document.xml", writer => + { + writer.WriteStartElement("w", "document", WordNamespace); + writer.WriteAttributeString("xmlns", "r", null, RelationshipNamespace); + writer.WriteStartElement("w", "body", WordNamespace); + + WriteParagraph(writer, "IIS- und BizTalk-Dokumentation", "Title"); + WriteParagraph(writer, document.EnvironmentName + " · " + document.ComputerName, "Subtitle"); + WriteParagraph(writer, + "Erstellt am " + document.CompletedUtc.ToLocalTime() + .ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture), + "Subtitle"); + WriteNotice(writer, + "Schutz sensibler Daten: Kennwörter, Tokens, Connection Strings, verschlüsselte Payloads " + + "und private Schlüssel werden nicht ausgegeben. Dokumentiert werden ausschließlich " + + "migrationsrelevante Metadaten, Fingerprints, Container und Berechtigungen."); + + WriteOverview(writer, document); + WriteFindings(writer, document.Findings); + WriteSystem(writer, document.System); + WriteIis(writer, document.Iis); + WriteCertificates(writer, document.Certificates); + WriteSecurity(writer, document.Security); + WriteBizTalk(writer, document.BizTalk); + + WriteParagraph(writer, + "Erzeugt durch BizTalk IIS Environment Inventory · read-only · keine Office-Installation erforderlich", + "Subtitle"); + WriteSectionProperties(writer); + writer.WriteEndElement(); + writer.WriteEndElement(); + }); + } + + /// + /// Schreibt Zusammenfassung und Collector-Status. + /// + /// XML-Writer. + /// Bestandsaufnahme. + private static void WriteOverview(XmlWriter writer, InventoryDocument document) + { + WriteHeading(writer, 1, "1. Übersicht"); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("Umgebung", document.EnvironmentName), + new NameValueRecord("Computer", document.ComputerName), + new NameValueRecord("IIS-Sites", document.Iis.Sites.Count.ToString(CultureInfo.InvariantCulture)), + new NameValueRecord("IIS-Anwendungen", + document.Iis.Sites.Sum(site => site.Applications.Count).ToString(CultureInfo.InvariantCulture)), + new NameValueRecord("Application Pools", + document.Iis.ApplicationPools.Count.ToString(CultureInfo.InvariantCulture)), + new NameValueRecord("Zertifikate", document.Certificates.Count.ToString(CultureInfo.InvariantCulture)), + new NameValueRecord("Auffälligkeiten", document.Findings.Count.ToString(CultureInfo.InvariantCulture)), + new NameValueRecord("Start UTC", FormatUtc(document.StartedUtc)), + new NameValueRecord("Ende UTC", FormatUtc(document.CompletedUtc)), + new NameValueRecord("Dauer", + (document.CompletedUtc - document.StartedUtc).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture)) + }); + WriteHeading(writer, 2, "Collector-Status"); + WriteTable(writer, + new[] { "Abschnitt", "Status", "Dauer", "Meldung" }, + document.SectionStatuses.Select(status => new[] + { + status.Name, + status.Status, + status.DurationMilliseconds.ToString(CultureInfo.InvariantCulture) + " ms", + status.Message + })); + } + + /// + /// Schreibt Auffälligkeiten und Maßnahmen. + /// + /// XML-Writer. + /// Auffälligkeiten. + private static void WriteFindings(XmlWriter writer, IList findings) + { + WriteHeading(writer, 1, "2. Auffälligkeiten und Hinweise"); + if (findings.Count == 0) + { + WriteParagraph(writer, "Keine technischen Auffälligkeiten während der Erfassung.", "Normal"); + return; + } + + WriteTable(writer, + new[] { "Stufe", "Bereich", "Aussage", "Technik", "Empfehlung" }, + findings.OrderBy(item => SeverityOrder(item.Severity)).ThenBy(item => item.Area).Select(item => new[] + { + item.Severity, item.Area, item.Message, item.TechnicalDetail, item.Recommendation + })); + } + + /// + /// Schreibt Windows- und Rolleninformationen. + /// + /// XML-Writer. + /// Systeminventar. + private static void WriteSystem(XmlWriter writer, SystemInventory system) + { + WriteHeading(writer, 1, "3. Windows und installierte Rollen"); + WriteHeading(writer, 2, "System"); + WriteNameValueTable(writer, system.Properties); + WriteHeading(writer, 2, "IIS-/BizTalk-relevante Windows Server Features"); + WriteNameValueTable(writer, system.InstalledFeatures); + } + + /// + /// Schreibt IIS-Konfiguration, Topologie, Webinhalte und ACLs. + /// + /// XML-Writer. + /// IIS-Inventar. + private static void WriteIis(XmlWriter writer, IisInventory iis) + { + WriteHeading(writer, 1, "4. IIS-Konfiguration"); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("applicationHost.config", iis.ConfigurationPath), + new NameValueRecord("Letzte Änderung UTC", + iis.ConfigurationLastWriteUtc.HasValue ? FormatUtc(iis.ConfigurationLastWriteUtc.Value) : string.Empty), + new NameValueRecord("SHA-256 der bereinigten Konfiguration", iis.SanitizedConfigurationSha256) + }); + + WriteHeading(writer, 2, "Application Pools"); + WriteTable(writer, + new[] { "Name", "Identitätstyp", "Effektives Konto", ".NET", "Pipeline", "StartMode", "32 Bit" }, + iis.ApplicationPools.Select(pool => new[] + { + pool.Name, pool.IdentityType, pool.UserName, pool.ManagedRuntimeVersion, + pool.ManagedPipelineMode, pool.StartMode, pool.Enable32BitAppOnWin64 + })); + + WriteHeading(writer, 2, "Sites, Anwendungen und Webinhalte"); + foreach (var site in iis.Sites) + { + WriteHeading(writer, 3, "Site: " + site.Name + " (ID " + site.Id + ")"); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("Automatischer Start", site.ServerAutoStart), + new NameValueRecord("Logverzeichnis", site.LogDirectory) + }); + WriteParagraph(writer, "Bindings", "Heading3"); + WriteTable(writer, + new[] { "Protokoll", "Binding", "Zertifikat", "Store", "SSL Flags" }, + site.Bindings.Select(binding => new[] + { + binding.Protocol, binding.BindingInformation, binding.CertificateHash, + binding.CertificateStoreName, binding.SslFlags + })); + + foreach (var application in site.Applications) + { + WriteApplication(writer, application); + } + } + + WriteHeading(writer, 2, "Globale Konfigurationsabschnitte"); + WriteTable(writer, + new[] { "Section", "Override", "Allow Definition", "Verschlüsselt", "Elemente", "Sichere Übersicht" }, + iis.GlobalSections.Select(section => new[] + { + section.Path, section.OverrideModeDefault, section.AllowDefinition, + section.IsEncrypted ? "Ja" : "Nein", + section.ElementCount.ToString(CultureInfo.InvariantCulture), section.SafeSummary + })); + + WriteHeading(writer, 2, "Konfigurationsverschlüsselung"); + WriteTable(writer, + new[] { "Provider", "Typ", "Key Container", "Machine Container", "Hinweis" }, + iis.EncryptionProviders.Select(provider => new[] + { + provider.Name, provider.Type, provider.KeyContainerName, + provider.UseMachineContainer, provider.Description + })); + } + + /// + /// Schreibt eine IIS-Anwendung einschließlich Dateimanifest. + /// + /// XML-Writer. + /// IIS-Anwendung. + private static void WriteApplication(XmlWriter writer, WebApplicationRecord application) + { + WriteParagraph(writer, "Anwendung: " + application.SiteName + application.Path, "Heading3"); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("Application Pool", application.ApplicationPool), + new NameValueRecord("Protokolle", application.EnabledProtocols), + new NameValueRecord("Physischer Pfad", application.PhysicalPath), + new NameValueRecord("Pfad vorhanden", application.PhysicalPathExists ? "Ja" : "Nein"), + new NameValueRecord("Dateien gesamt", application.TotalFiles.ToString("N0", + CultureInfo.GetCultureInfo("de-DE"))), + new NameValueRecord("Größe gesamt", FormatBytes(application.TotalBytes)), + new NameValueRecord("Letzte Dateiänderung UTC", + application.LatestWriteUtc.HasValue ? FormatUtc(application.LatestWriteUtc.Value) : string.Empty), + new NameValueRecord("Manifest begrenzt/unvollständig", application.ManifestTruncated ? "Ja" : "Nein") + }); + WriteParagraph(writer, "Virtuelle Verzeichnisse", "Heading3"); + WriteTable(writer, + new[] { "Pfad", "Physical Path", "Zugriffsidentität" }, + application.VirtualDirectories.Select(item => new[] { item.Path, item.PhysicalPath, item.UserName })); + WriteParagraph(writer, "NTFS-Berechtigungen des Web-Stamms", "Heading3"); + WriteAclTable(writer, application.AccessRules); + WriteParagraph(writer, + "Dateimanifest (" + application.ContentFiles.Count.ToString("N0", + CultureInfo.GetCultureInfo("de-DE")) + " Einträge)", + "Heading3"); + WriteTable(writer, + new[] { "Relativer Pfad", "Größe", "Letzte Änderung UTC", "SHA-256" }, + application.ContentFiles.Select(file => new[] + { + file.RelativePath, FormatBytes(file.SizeBytes), FormatUtc(file.LastWriteUtc), file.Sha256 + })); + } + + /// + /// Schreibt Zertifikate und private Key-ACLs. + /// + /// XML-Writer. + /// Zertifikatsinventar. + private static void WriteCertificates(XmlWriter writer, IList certificates) + { + WriteHeading(writer, 1, "5. Zertifikate und private Schlüssel"); + WriteNotice(writer, + "Es werden keine Schlüssel exportiert. „Exportierbar“ wird ausschließlich aus der Provider-Policy gelesen."); + if (certificates.Count == 0) + { + WriteParagraph(writer, "Keine Zertifikate erfasst.", "Normal"); + return; + } + + foreach (var certificate in certificates) + { + WriteHeading(writer, 3, + (certificate.UsedByIisBinding ? "[IIS] " : string.Empty) + certificate.Subject); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("IIS-Binding", certificate.UsedByIisBinding ? "Ja" : "Nein"), + new NameValueRecord("Store", certificate.StoreLocation + "\\" + certificate.StoreName), + new NameValueRecord("Subject", certificate.Subject), + new NameValueRecord("Issuer", certificate.Issuer), + new NameValueRecord("Thumbprint", certificate.Thumbprint), + new NameValueRecord("Serial", certificate.SerialNumber), + new NameValueRecord("Gültig von", + certificate.NotBefore.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)), + new NameValueRecord("Gültig bis", + certificate.NotAfter.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)), + new NameValueRecord("Public Key", + certificate.PublicKeyAlgorithm + " / " + certificate.PublicKeySize + " Bit"), + new NameValueRecord("Signature", certificate.SignatureAlgorithm), + new NameValueRecord("Enhanced Key Usage", string.Join("; ", certificate.EnhancedKeyUsages)), + new NameValueRecord("Privater Schlüssel vorhanden", certificate.HasPrivateKey ? "Ja" : "Nein"), + new NameValueRecord("Exportierbar", certificate.PrivateKeyExportable), + new NameValueRecord("Key Provider", certificate.PrivateKeyProvider), + new NameValueRecord("Key Container", certificate.PrivateKeyContainer), + new NameValueRecord("Key-Datei", certificate.PrivateKeyFile) + }); + WriteParagraph(writer, "ACL des privaten Schlüssels", "Heading3"); + WriteAclTable(writer, certificate.KeyAccessRules); + } + } + + /// + /// Schreibt Dienstidentitäten und lokale Sicherheitsrichtlinien. + /// + /// XML-Writer. + /// Security-Inventar. + private static void WriteSecurity(XmlWriter writer, SecurityInventory security) + { + WriteHeading(writer, 1, "6. NTFS, Dienstkonten und lokale Sicherheit"); + WriteHeading(writer, 2, "Dienstidentitäten"); + WriteTable(writer, + new[] { "Quelle", "Name", "Anzeigename", "Konto", "Status", "Startmodus", "Pfad" }, + security.ServiceAccounts.Select(item => new[] + { + item.Source, item.Name, item.DisplayName, item.Account, + item.State, item.StartMode, item.Path + })); + WriteHeading(writer, 2, "User Rights Assignment"); + WriteTable(writer, + new[] { "Benutzerrecht", "Konten/SIDs" }, + security.UserRights.Select(item => new[] { item.Right, item.Accounts })); + WriteHeading(writer, 2, "Lokale Security-/Audit-Policy"); + WriteNameValueTable(writer, security.LocalPolicy); + } + + /// + /// Schreibt BizTalk-Installations- und WMI-Daten. + /// + /// XML-Writer. + /// BizTalk-Inventar. + private static void WriteBizTalk(XmlWriter writer, BizTalkInventory bizTalk) + { + WriteHeading(writer, 1, "7. BizTalk-spezifische Komponenten"); + WriteNameValueTable(writer, new[] + { + new NameValueRecord("WMI root\\MicrosoftBizTalkServer", + bizTalk.WmiNamespaceAvailable ? "Erreichbar" : "Nicht erreichbar") + }); + WriteHeading(writer, 2, "Installierte Produkte"); + WriteNameValueTable(writer, bizTalk.InstalledProducts); + WriteHeading(writer, 2, "Registry-Metadaten"); + WriteNameValueTable(writer, bizTalk.RegistryValues); + WriteHeading(writer, 2, "Zentrale Binärversionen"); + WriteNameValueTable(writer, bizTalk.Components); + WriteHeading(writer, 2, "BizTalk-WMI-Inventar"); + WriteNameValueTable(writer, bizTalk.WmiClasses); + } + + /// + /// Schreibt eine ACL-Tabelle. + /// + /// XML-Writer. + /// ACL-Regeln. + private static void WriteAclTable(XmlWriter writer, IEnumerable rules) + { + WriteTable(writer, + new[] { "Identität", "Rechte", "Typ", "Geerbt", "Vererbung", "Ziel" }, + rules.Select(rule => new[] + { + rule.Identity, rule.Rights, rule.AccessType, + rule.IsInherited ? "Ja" : "Nein", rule.Inheritance, rule.Target + })); + } + + /// + /// Schreibt eine Name/Wert-Tabelle. + /// + /// XML-Writer. + /// Name/Wert-Datensätze. + private static void WriteNameValueTable(XmlWriter writer, IEnumerable items) + { + WriteTable(writer, + new[] { "Eigenschaft", "Wert" }, + items.Select(item => new[] { item.Name, item.Value })); + } + + /// + /// Schreibt eine Word-Tabelle und wiederholt die Kopfzeile auf Folgeseiten. + /// + /// XML-Writer. + /// Spaltenüberschriften. + /// Tabellenzeilen. + private static void WriteTable(XmlWriter writer, string[] headers, IEnumerable rows) + { + var materialized = rows == null ? new List() : rows.ToList(); + if (materialized.Count == 0) + { + WriteParagraph(writer, "Keine Daten erfasst.", "Normal"); + return; + } + + writer.WriteStartElement("w", "tbl", WordNamespace); + writer.WriteStartElement("w", "tblPr", WordNamespace); + writer.WriteStartElement("w", "tblStyle", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, "InventoryTable"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "tblW", WordNamespace); + writer.WriteAttributeString("w", "w", WordNamespace, "0"); + writer.WriteAttributeString("w", "type", WordNamespace, "auto"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "tblLayout", WordNamespace); + writer.WriteAttributeString("w", "type", WordNamespace, "autofit"); + writer.WriteEndElement(); + writer.WriteEndElement(); + + WriteTableRow(writer, headers, true); + foreach (var row in materialized) + { + var normalized = new string[headers.Length]; + for (var index = 0; index < headers.Length; index++) + { + normalized[index] = index < row.Length ? row[index] : string.Empty; + } + + WriteTableRow(writer, normalized, false); + } + + writer.WriteEndElement(); + WriteParagraph(writer, string.Empty, "Normal"); + } + + /// + /// Schreibt eine Tabellenzeile. + /// + /// XML-Writer. + /// Zellwerte. + /// Gibt eine wiederholbare Kopfzeile an. + private static void WriteTableRow(XmlWriter writer, IEnumerable values, bool header) + { + writer.WriteStartElement("w", "tr", WordNamespace); + if (header) + { + writer.WriteStartElement("w", "trPr", WordNamespace); + writer.WriteElementString("w", "tblHeader", WordNamespace, string.Empty); + writer.WriteEndElement(); + } + + foreach (var value in values) + { + writer.WriteStartElement("w", "tc", WordNamespace); + writer.WriteStartElement("w", "tcPr", WordNamespace); + if (header) + { + writer.WriteStartElement("w", "shd", WordNamespace); + writer.WriteAttributeString("w", "fill", WordNamespace, "DCEEF7"); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + WriteParagraphWithRun(writer, LimitCellText(value), header); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + } + + /// + /// Schreibt eine Überschrift mit Word-Navigationsebene. + /// + /// XML-Writer. + /// Ebene eins bis drei. + /// Überschriftstext. + private static void WriteHeading(XmlWriter writer, int level, string text) + { + WriteParagraph(writer, text, "Heading" + Math.Max(1, Math.Min(3, level))); + } + + /// + /// Schreibt einen hervorgehobenen Hinweis. + /// + /// XML-Writer. + /// Hinweistext. + private static void WriteNotice(XmlWriter writer, string text) + { + writer.WriteStartElement("w", "p", WordNamespace); + writer.WriteStartElement("w", "pPr", WordNamespace); + writer.WriteStartElement("w", "shd", WordNamespace); + writer.WriteAttributeString("w", "fill", WordNamespace, "FFF2CC"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "spacing", WordNamespace); + writer.WriteAttributeString("w", "before", WordNamespace, "120"); + writer.WriteAttributeString("w", "after", WordNamespace, "160"); + writer.WriteEndElement(); + writer.WriteEndElement(); + WriteRun(writer, text, true); + writer.WriteEndElement(); + } + + /// + /// Schreibt einen Absatz in einer benannten Formatvorlage. + /// + /// XML-Writer. + /// Absatztext. + /// Word-Style-ID. + private static void WriteParagraph(XmlWriter writer, string text, string style) + { + writer.WriteStartElement("w", "p", WordNamespace); + writer.WriteStartElement("w", "pPr", WordNamespace); + writer.WriteStartElement("w", "pStyle", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, style); + writer.WriteEndElement(); + writer.WriteEndElement(); + WriteRun(writer, text, false); + writer.WriteEndElement(); + } + + /// + /// Schreibt einen einfachen Tabellenzellenabsatz. + /// + /// XML-Writer. + /// Zelltext. + /// Fettdruck. + private static void WriteParagraphWithRun(XmlWriter writer, string text, bool bold) + { + writer.WriteStartElement("w", "p", WordNamespace); + WriteRun(writer, text, bold); + writer.WriteEndElement(); + } + + /// + /// Schreibt einen Textlauf und bewahrt Leerzeichen. + /// + /// XML-Writer. + /// Unvertrauenswürdiger Text; der XML-Writer kodiert ihn. + /// Fettdruck. + private static void WriteRun(XmlWriter writer, string text, bool bold) + { + writer.WriteStartElement("w", "r", WordNamespace); + if (bold) + { + writer.WriteStartElement("w", "rPr", WordNamespace); + writer.WriteElementString("w", "b", WordNamespace, string.Empty); + writer.WriteEndElement(); + } + + writer.WriteStartElement("w", "t", WordNamespace); + writer.WriteAttributeString("xml", "space", "http://www.w3.org/XML/1998/namespace", "preserve"); + writer.WriteString(NormalizeText(text)); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + /// + /// Schreibt Seitenformat und Ränder; Querformat verbessert breite Inventartabellen. + /// + /// XML-Writer. + private static void WriteSectionProperties(XmlWriter writer) + { + writer.WriteStartElement("w", "sectPr", WordNamespace); + writer.WriteStartElement("w", "pgSz", WordNamespace); + writer.WriteAttributeString("w", "w", WordNamespace, "16838"); + writer.WriteAttributeString("w", "h", WordNamespace, "11906"); + writer.WriteAttributeString("w", "orient", WordNamespace, "landscape"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "pgMar", WordNamespace); + writer.WriteAttributeString("w", "top", WordNamespace, "900"); + writer.WriteAttributeString("w", "right", WordNamespace, "720"); + writer.WriteAttributeString("w", "bottom", WordNamespace, "900"); + writer.WriteAttributeString("w", "left", WordNamespace, "720"); + writer.WriteAttributeString("w", "header", WordNamespace, "360"); + writer.WriteAttributeString("w", "footer", WordNamespace, "360"); + writer.WriteAttributeString("w", "gutter", WordNamespace, "0"); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + /// + /// Erstellt einen komprimierten XML-Part mit sicheren Writer-Einstellungen. + /// + /// Geöffnetes DOCX-ZIP-Archiv. + /// Partpfad innerhalb des Archivs. + /// Aktion zum Schreiben des XML-Inhalts. + private static void WriteXmlEntry(ZipArchive archive, string path, Action writeAction) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + using (var stream = entry.Open()) + using (var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = false, + CloseOutput = false, + CheckCharacters = true + })) + { + writer.WriteStartDocument(true); + writeAction(writer); + writer.WriteEndDocument(); + } + } + + /// + /// Entfernt Zeichen, die in XML 1.0 nicht zulässig sind. + /// + /// Eingabetext. + /// XML-kompatibler Text. + private static string NormalizeText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + if (XmlConvert.IsXmlChar(character)) + { + builder.Append(character); + } + else + { + builder.Append('\uFFFD'); + } + } + + return builder.ToString(); + } + + /// + /// Begrenzt Zelltext auf einen Word-kompatiblen und bedienbaren Umfang. + /// + /// Zelltext. + /// Unveränderter oder gekürzter Text. + private static string LimitCellText(string value) + { + const int maximum = 30000; + var normalized = NormalizeText(value); + return normalized.Length <= maximum + ? normalized + : normalized.Substring(0, maximum) + " … [GEKÜRZT]"; + } + + /// + /// Formatiert UTC-Zeit konsistent. + /// + /// Zeitwert. + /// UTC-Darstellung. + private static string FormatUtc(DateTime value) + { + return value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture); + } + + /// + /// Formatiert eine Bytezahl lesbar. + /// + /// Bytezahl. + /// Lesbare Größenangabe. + private static string FormatBytes(long bytes) + { + var suffixes = new[] { "B", "KiB", "MiB", "GiB", "TiB" }; + double value = bytes; + var index = 0; + while (value >= 1024 && index < suffixes.Length - 1) + { + value /= 1024; + index++; + } + + return value.ToString(index == 0 ? "N0" : "N2", + CultureInfo.GetCultureInfo("de-DE")) + " " + suffixes[index]; + } + + /// + /// Liefert eine Sortierzahl für Findings. + /// + /// Schweregrad. + /// Kleinere Zahl für höhere Priorität. + private static int SeverityOrder(string severity) + { + if (string.Equals(severity, "Fehler", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + return string.Equals(severity, "Warnung", StringComparison.OrdinalIgnoreCase) ? 1 : 2; + } + } +} diff --git a/tests/BizTalkIisEnvironmentInventory.Tests/BizTalkIisEnvironmentInventory.Tests.csproj b/tests/BizTalkIisEnvironmentInventory.Tests/BizTalkIisEnvironmentInventory.Tests.csproj new file mode 100644 index 0000000..05cb7d7 --- /dev/null +++ b/tests/BizTalkIisEnvironmentInventory.Tests/BizTalkIisEnvironmentInventory.Tests.csproj @@ -0,0 +1,28 @@ + + + Exe + net472 + + net472;net10.0 + BizTalkIisEnvironmentInventory.Tests + BizTalkIisEnvironmentInventory.Tests + 7.3 + true + true + AnyCPU + + + + + + + + + + + + + + + + diff --git a/tests/BizTalkIisEnvironmentInventory.Tests/Program.cs b/tests/BizTalkIisEnvironmentInventory.Tests/Program.cs new file mode 100644 index 0000000..ffaaf18 --- /dev/null +++ b/tests/BizTalkIisEnvironmentInventory.Tests/Program.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml.Linq; +#if NETFRAMEWORK +using BizTalkIisEnvironmentInventory.Collectors; +using BizTalkIisEnvironmentInventory.Configuration; +#endif +using BizTalkIisEnvironmentInventory.Infrastructure; +using BizTalkIisEnvironmentInventory.Models; +using BizTalkIisEnvironmentInventory.Reporting; + +namespace BizTalkIisEnvironmentInventory.Tests +{ + /// + /// Abhängigkeitsfreier Konsolen-Testläufer für zentrale Sicherheits- und Parserfunktionen. + /// + internal static class Program + { + private static int failures; + + /// + /// Führt alle Tests aus. + /// + /// Optional --write-sample PFAD für ein DOCX-Prüfdokument. + /// 0 bei Erfolg, sonst 1. + private static int Main(string[] args) + { + if (args.Length == 2 && string.Equals(args[0], "--write-sample", StringComparison.OrdinalIgnoreCase)) + { + WriteSampleDocument(args[1]); + Console.WriteLine("DOCX-Prüfdokument geschrieben: " + Path.GetFullPath(args[1])); + return 0; + } + + Run("Kommandozeile normalisiert Umgebung", TestCommandLine); + Run("XML-Sanitizer entfernt Secrets", TestSanitizer); +#if NETFRAMEWORK + Run("IIS-Parser liest Topologie", TestIisParser); + Run("Security-Policy-Parser liest User Rights", TestSecurityPolicyParser); +#endif + Run("DOCX-Paket ist valide und XML-sicher", TestDocxGeneration); + + Console.WriteLine(failures == 0 + ? "Alle Tests erfolgreich." + : failures + " Test(s) fehlgeschlagen."); + return failures == 0 ? 0 : 1; + } + + /// + /// Erzeugt ein dauerhaftes minimales DOCX zur Validierung mit externen Office-Programmen. + /// + /// Zielpfad des Prüfdokuments. + private static void WriteSampleDocument(string path) + { + var document = new InventoryDocument + { + EnvironmentName = "ACC-TEST", + ComputerName = "BIZTALK-TEST", + StartedUtc = DateTime.UtcNow.AddSeconds(-2), + CompletedUtc = DateTime.UtcNow + }; + document.System.Properties.Add(new NameValueRecord("Betriebssystem", "Windows Server 2019 Datacenter")); + document.SectionStatuses.Add(new SectionStatus + { + Name = "DOCX-Test", + Status = "Erfolgreich", + Message = "Prüfdokument", + DurationMilliseconds = 12 + }); + new DocxReportWriter().Write(document, Path.GetFullPath(path)); + } + + /// + /// Führt einen einzelnen Test isoliert aus. + /// + /// Testname. + /// Testaktion. + private static void Run(string name, Action test) + { + try + { + test(); + Console.WriteLine("[OK] " + name); + } + catch (Exception exception) + { + failures++; + Console.Error.WriteLine("[FEHLER] " + name + ": " + exception.Message); + } + } + + /// + /// Prüft Optionsparser und sichere Dateinamensbildung. + /// + private static void TestCommandLine() + { + var options = CommandLineOptions.Parse(new[] { "--environment", " acc / 01 " }); + Equal("ACC-01", options.EnvironmentName); + } + + /// + /// Prüft Attribut- und Ciphertext-Bereinigung. + /// + private static void TestSanitizer() + { + var source = XElement.Parse( + "blob"); + var result = SensitiveDataSanitizer.SanitizeXml(source).ToString(); + False(result.Contains("klartext"), "Kennwort blieb erhalten."); + False(result.Contains("abc"), "Token blieb erhalten."); + False(result.Contains("blob"), "Ciphertext blieb erhalten."); + } + +#if NETFRAMEWORK + /// + /// Prüft IIS-Topologie und Secret-freien Konfigurationshash anhand einer Testdatei. + /// + private static void TestIisParser() + { + var directory = CreateTemporaryDirectory(); + try + { + var webRoot = Path.Combine(directory, "web"); + Directory.CreateDirectory(webRoot); + File.WriteAllText(Path.Combine(webRoot, "default.htm"), "test", Encoding.UTF8); + var configPath = Path.Combine(directory, "applicationHost.config"); + var xml = @" + +
+ + + + + + + +"; + File.WriteAllText(configPath, xml, Encoding.UTF8); + + var commandLine = CommandLineOptions.Parse(new[] + { + "--environment", "TEST", "--iis-config", configPath + }); + var options = CollectorOptions.Load(commandLine); + var target = new IisInventory(); + var findings = new List(); + new IisCollector(options).Collect(target, findings, configPath); + + Equal(1, target.ApplicationPools.Count); + Equal(@"DOMAIN\svc", target.ApplicationPools[0].UserName); + Equal(1, target.Sites.Count); + Equal("AABB", target.Sites[0].Bindings[0].CertificateHash); + Equal(1, target.Sites[0].Applications[0].TotalFiles); + True(!string.IsNullOrWhiteSpace(target.SanitizedConfigurationSha256), "Konfigurationshash fehlt."); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// Prüft das Parsen eines minimalen secedit-Exports. + /// + private static void TestSecurityPolicyParser() + { + var directory = CreateTemporaryDirectory(); + try + { + var path = Path.Combine(directory, "security.inf"); + File.WriteAllText( + path, + "[Unicode]\r\nUnicode=yes\r\n[Privilege Rights]\r\nSeServiceLogonRight = *S-1-5-20,DOMAIN\\svc\r\n[System Access]\r\nMinimumPasswordAge = 1\r\n", + Encoding.Unicode); + var target = new SecurityInventory(); + SecurityCollector.ParseSecurityPolicy(path, target); + Equal(1, target.UserRights.Count); + Equal("SeServiceLogonRight", target.UserRights[0].Right); + Equal(1, target.LocalPolicy.Count); + } + finally + { + Directory.Delete(directory, true); + } + } +#endif + + /// + /// Prüft erforderliche Office-Open-XML-Parts, XML-Validität und sichere Textkodierung. + /// + private static void TestDocxGeneration() + { + var path = Path.Combine( + Path.GetTempPath(), + "BizTalkIisInventory-DocxTest-" + Guid.NewGuid().ToString("N") + ".docx"); + var document = new InventoryDocument + { + EnvironmentName = "", + ComputerName = "TEST&HOST", + StartedUtc = DateTime.UtcNow, + CompletedUtc = DateTime.UtcNow + }; + document.System.Properties.Add(new NameValueRecord("Test", "nicht fett")); + try + { + new DocxReportWriter().Write(document, path); + using (var archive = ZipFile.OpenRead(path)) + { + var required = new[] + { + "[Content_Types].xml", "_rels/.rels", "word/document.xml", + "word/styles.xml", "word/_rels/document.xml.rels", + "docProps/core.xml", "docProps/app.xml" + }; + foreach (var entryName in required) + { + True(archive.GetEntry(entryName) != null, "DOCX-Part fehlt: " + entryName); + } + + foreach (var entry in archive.Entries.Where(item => + item.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + || item.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))) + { + using (var stream = entry.Open()) + { + XDocument.Load(stream); + } + } + + AssertOpenXmlNamespaces(archive); + + var documentEntry = archive.GetEntry("word/document.xml"); + using (var stream = documentEntry.Open()) + using (var reader = new StreamReader(stream, Encoding.UTF8)) + { + var xml = reader.ReadToEnd(); + False(xml.Contains(""), + "Umgebungsname wurde als Markup statt Text geschrieben."); + False(xml.Contains("nicht fett"), + "Tabellenwert wurde als Markup statt Text geschrieben."); + True(xml.Contains("Schutz sensibler Daten"), + "Sicherheitskennzeichnung fehlt."); + } + } + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + /// + /// Prüft, dass OPC-Elemente im vorgeschriebenen Namespace statt im leeren Namespace liegen. + /// + /// Geöffnetes DOCX-Paket. + private static void AssertOpenXmlNamespaces(ZipArchive archive) + { + var contentTypes = LoadEntryXml(archive, "[Content_Types].xml"); + var contentTypeNamespace = (XNamespace) + "http://schemas.openxmlformats.org/package/2006/content-types"; + True(contentTypes.Root.Elements(contentTypeNamespace + "Override").Any(), + "Content-Type-Overrides liegen nicht im OPC-Namespace."); + + var relationships = LoadEntryXml(archive, "_rels/.rels"); + var relationshipNamespace = (XNamespace) + "http://schemas.openxmlformats.org/package/2006/relationships"; + True(relationships.Root.Elements(relationshipNamespace + "Relationship").Count() == 3, + "Paketbeziehungen liegen nicht vollständig im OPC-Namespace."); + } + + /// + /// Lädt einen XML-Part aus dem DOCX-Paket. + /// + /// Geöffnetes DOCX-Paket. + /// Pfad des XML-Parts. + /// Geparstes XML-Dokument. + private static XDocument LoadEntryXml(ZipArchive archive, string entryName) + { + using (var stream = archive.GetEntry(entryName).Open()) + { + return XDocument.Load(stream); + } + } + + /// + /// Erstellt ein eindeutiges temporäres Testverzeichnis. + /// + /// Vollständiger Pfad. + private static string CreateTemporaryDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "BizTalkIisInventoryTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + /// + /// XML-kodiert einen Attributwert. + /// + /// Unkodierter Wert. + /// XML-kodierter Wert. + private static string EscapeXml(string value) + { + return new XAttribute("x", value).ToString() + .Substring(3) + .TrimEnd('\"'); + } + + /// + /// Vergleicht zwei Werte. + /// + /// Werttyp. + /// Erwarteter Wert. + /// Tatsächlicher Wert. + private static void Equal(T expected, T actual) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException("Erwartet: " + expected + "; tatsächlich: " + actual); + } + } + + /// + /// Erwartet einen wahren Ausdruck. + /// + /// Zu prüfender Ausdruck. + /// Fehlermeldung. + private static void True(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + + /// + /// Erwartet einen falschen Ausdruck. + /// + /// Zu prüfender Ausdruck. + /// Fehlermeldung. + private static void False(bool condition, string message) + { + True(!condition, message); + } + } +}