Initial commit: BizTalk IIS inventory with DOCX reporting
Build und Test / build (push) Has been cancelled
Build und Test / build (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<appSettings>
|
||||
<!-- Obergrenze fuer das Dateimanifest je IIS-Anwendung. -->
|
||||
<add key="MaxFilesPerApplication" value="10000" />
|
||||
<!-- Maximale Rekursionstiefe unterhalb eines Web-Stammverzeichnisses. -->
|
||||
<add key="MaxContentDepth" value="30" />
|
||||
<!-- SHA-256 fuer Webdateien ist genauer, kann aber auf grossen Verzeichnissen teuer sein. -->
|
||||
<add key="IncludeFileHashes" value="false" />
|
||||
<!-- Alle Zertifikate aus LocalMachine\My dokumentieren; Bindungszertifikate immer. -->
|
||||
<add key="IncludeAllPersonalCertificates" value="true" />
|
||||
<!-- Timeout fuer jede WMI-Abfrage. -->
|
||||
<add key="WmiTimeoutSeconds" value="30" />
|
||||
<!-- Erwarteter BEW-Scope; leer lassen, um die Vollstaendigkeitspruefung zu deaktivieren. -->
|
||||
<add key="ExpectedApplications" value="heat_archive,heat_bankdata,heat_bbill,heat_caccount,heat_invoice,heat_meterchange,heat_meterlist,heat_meterreading" />
|
||||
</appSettings>
|
||||
<startup useLegacyV2RuntimeActivationPolicy="true">
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<RootNamespace>BizTalkIisEnvironmentInventory</RootNamespace>
|
||||
<AssemblyName>BizTalkIisEnvironmentInventory</AssemblyName>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
<Deterministic>true</Deterministic>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Configuration" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.Management" />
|
||||
<Reference Include="System.Security" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst BizTalk-Installation, Registrymetadaten, Binärversionen und WMI-Komponentenzahlen.
|
||||
/// </summary>
|
||||
internal sealed class BizTalkCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den BizTalk-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">WMI-Timeout.</param>
|
||||
public BizTalkCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst lokale BizTalk-Komponenten ohne ExplorerOM-Abhängigkeit.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
public void Collect(BizTalkInventory target, IList<Finding> findings)
|
||||
{
|
||||
ReadBizTalkRegistry(target);
|
||||
ReadInstalledProducts(target);
|
||||
ReadComponentVersions(target, findings);
|
||||
ReadBizTalkWmi(target, findings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest nicht sensible Werte aus dem BizTalk-Hauptschlüssel in beiden Registry Views.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest BizTalk-bezogene Einträge aus den Windows-Uninstall-Schlüsseln.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Dateiversionen zentraler BizTalk-Binärdateien aus dem Installationspfad.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste nicht fataler Dateizugriffsfehler.</param>
|
||||
private static void ReadComponentVersions(BizTalkInventory target, IList<Finding> 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<string>(
|
||||
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."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft zentrale BizTalk-WMI-Klassen und dokumentiert deren Instanzzahlen.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste klassenspezifischer WMI-Fehler.</param>
|
||||
private void ReadBizTalkWmi(BizTalkInventory target, IList<Finding> 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."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Dokumentiert lokale Computerzertifikate und private Schlüssel ausschließlich über Metadaten.
|
||||
/// </summary>
|
||||
internal sealed class CertificateCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Zertifikat-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Schalter für den Zertifikatsumfang.</param>
|
||||
public CertificateCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den LocalMachine-Personal-Store und markiert IIS-Bindungszertifikate.
|
||||
/// </summary>
|
||||
/// <param name="target">Zertifikats-Zielliste.</param>
|
||||
/// <param name="iis">Bereits erfasste IIS-Bindings.</param>
|
||||
/// <param name="findings">Liste für Ablauf- und Zugriffsauffälligkeiten.</param>
|
||||
public void Collect(IList<CertificateRecord> target, IisInventory iis, IList<Finding> findings)
|
||||
{
|
||||
var bindingThumbprints = new HashSet<string>(
|
||||
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<X509Certificate2>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Überführt ein X509-Zertifikat in ein sicheres Berichtsmodell.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Lokales Zertifikat.</param>
|
||||
/// <param name="usedByIis">Gibt an, ob ein IIS-Binding den Thumbprint verwendet.</param>
|
||||
/// <param name="findings">Liste für Auffälligkeiten.</param>
|
||||
/// <returns>Zertifikatsdatensatz ohne privates Schlüsselmaterial.</returns>
|
||||
private static CertificateRecord CreateRecord(
|
||||
X509Certificate2 certificate,
|
||||
bool usedByIis,
|
||||
IList<Finding> 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<X509EnhancedKeyUsageExtension>())
|
||||
{
|
||||
foreach (var usage in extension.EnhancedKeyUsages.Cast<Oid>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Provider, Container, Exportpolicy und Dateiberechtigungen eines privaten Schlüssels.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Zertifikat mit privatem Schlüssel.</param>
|
||||
/// <param name="record">Zielmodell.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private static void InspectPrivateKey(
|
||||
X509Certificate2 certificate,
|
||||
CertificateRecord record,
|
||||
IList<Finding> 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."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Dateisystem-ACL der Schlüsselcontainerdatei.
|
||||
/// </summary>
|
||||
/// <param name="record">Zertifikatsdatensatz mit Schlüsselpfad.</param>
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht die Containerdatei eines CAPI- oder CNG-Schlüssels.
|
||||
/// </summary>
|
||||
/// <param name="uniqueName">Eindeutiger Containername.</param>
|
||||
/// <param name="capi">Gibt CAPI statt CNG an.</param>
|
||||
/// <returns>Existierender Pfad oder leerer Text.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert eine CNG-Exportpolicy ohne den Schlüssel zu exportieren.
|
||||
/// </summary>
|
||||
/// <param name="policy">CNG-Exportpolicy.</param>
|
||||
/// <returns>Lesbarer Policytext.</returns>
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen Ablaufhinweis hinzu.
|
||||
/// </summary>
|
||||
/// <param name="findings">Zielliste.</param>
|
||||
/// <param name="record">Betroffenes Zertifikat.</param>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <param name="message">Aussage.</param>
|
||||
private static void AddExpiryFinding(
|
||||
IList<Finding> 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."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="value">Thumbprint.</param>
|
||||
/// <returns>Großgeschriebener Wert ohne Leerzeichen.</returns>
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest IIS-Konfiguration, Topologie, Webinhalte und NTFS-Berechtigungen ohne Microsoft.Web.Administration.
|
||||
/// </summary>
|
||||
internal sealed class IisCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den IIS-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Grenzen für Dateimanifest und Hashing.</param>
|
||||
public IisCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst die lokale applicationHost.config und die daraus referenzierten Inhalte.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell für IIS-Daten.</param>
|
||||
/// <param name="findings">Gemeinsame Liste nicht fataler Auffälligkeiten.</param>
|
||||
/// <param name="overridePath">Optionaler Pfad für Offline-Tests oder Sonderinstallationen.</param>
|
||||
public void Collect(IisInventory target, IList<Finding> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt den IIS-Konfigurationspfad.
|
||||
/// </summary>
|
||||
/// <param name="overridePath">Optional explizit vorgegebener Pfad.</param>
|
||||
/// <returns>Vollständiger Pfad zur applicationHost.config.</returns>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen SHA-256-Fingerprint der bereinigten Konfiguration.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <returns>Hexadezimaler SHA-256-Hash.</returns>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst geschützte Konfigurationsprovider, jedoch niemals deren Schlüsselmaterial.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst globale IIS-Section-Deklarationen und deren grobe Belegung.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst IIS-Anwendungspools und deren Identitäten.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst Sites, Anwendungen, virtuelle Verzeichnisse, Bindings und Inhalte.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private void CollectSites(XElement root, IisInventory target, IList<Finding> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet erwartete, aber nicht konfigurierte BEW-Webanwendungen.
|
||||
/// </summary>
|
||||
/// <param name="target">Erfasstes IIS-Inventar.</param>
|
||||
/// <param name="findings">Liste für Vollständigkeitshinweise.</param>
|
||||
private void ValidateExpectedApplications(IisInventory target, IList<Finding> findings)
|
||||
{
|
||||
if (options.ExpectedApplications == null || options.ExpectedApplications.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var discovered = new HashSet<string>(
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt ein Anwendungsmodell aus einem IIS-XML-Element.
|
||||
/// </summary>
|
||||
/// <param name="siteName">Name der übergeordneten Site.</param>
|
||||
/// <param name="element">IIS-application-Element.</param>
|
||||
/// <returns>Initialisiertes Anwendungsmodell.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inventarisiert Inhalt und ACL eines Web-Stammverzeichnisses mit Fehlerisolation.
|
||||
/// </summary>
|
||||
/// <param name="application">Zu untersuchende IIS-Anwendung.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private void InspectApplicationContent(WebApplicationRecord application, IList<Finding> 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<DirectoryWorkItem>();
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst Metadaten und optional SHA-256 eines einzelnen Webinhalts.
|
||||
/// </summary>
|
||||
/// <param name="application">Zielanwendung.</param>
|
||||
/// <param name="root">Normalisierter Web-Stammpfad.</param>
|
||||
/// <param name="filePath">Vollständiger Dateipfad.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
private void InspectFile(
|
||||
WebApplicationRecord application,
|
||||
string root,
|
||||
string filePath,
|
||||
IList<Finding> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die expliziten und geerbten ACL-Regeln eines Web-Stammverzeichnisses.
|
||||
/// </summary>
|
||||
/// <param name="application">Zielanwendung.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
private static void CollectDirectoryAcl(WebApplicationRecord application, IList<Finding> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet SHA-256 einer Datei mit geteilter Lesefreigabe.
|
||||
/// </summary>
|
||||
/// <param name="path">Vollständiger Dateipfad.</param>
|
||||
/// <returns>Hexadezimaler SHA-256-Hash.</returns>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut den vollständigen Pfad einer Section-Deklaration auf.
|
||||
/// </summary>
|
||||
/// <param name="section">Section-XML-Element.</param>
|
||||
/// <returns>Pfad mit Slash-Trennung.</returns>
|
||||
private static string BuildSectionPath(XElement section)
|
||||
{
|
||||
var names = new Stack<string>();
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Section-Pfad gegen die Konfigurationswurzel auf.
|
||||
/// </summary>
|
||||
/// <param name="root">Konfigurationswurzel.</param>
|
||||
/// <param name="path">Slash-getrennter Section-Pfad.</param>
|
||||
/// <returns>Gefundenes Element oder <c>null</c>.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine begrenzte, secret-bereinigte Attributübersicht.
|
||||
/// </summary>
|
||||
/// <param name="element">Konfigurationselement.</param>
|
||||
/// <returns>Kurze Attributübersicht.</returns>
|
||||
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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen Attributwert nullsicher.
|
||||
/// </summary>
|
||||
/// <param name="element">XML-Element oder <c>null</c>.</param>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns>Attributwert oder leerer Text.</returns>
|
||||
private static string Attribute(XElement element, string name)
|
||||
{
|
||||
var attribute = element == null ? null : element.Attribute(name);
|
||||
return attribute == null ? string.Empty : attribute.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen lokalen Attributwert oder den Wert des Defaults.
|
||||
/// </summary>
|
||||
/// <param name="element">Lokales XML-Element.</param>
|
||||
/// <param name="defaults">Default-XML-Element.</param>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns>Effektiver Wert oder leerer Text.</returns>
|
||||
private static string EffectiveAttribute(XElement element, XElement defaults, string name)
|
||||
{
|
||||
var local = Attribute(element, name);
|
||||
return string.IsNullOrWhiteSpace(local) ? Attribute(defaults, name) : local;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt eine verständliche Anwendungspool-Identität her.
|
||||
/// </summary>
|
||||
/// <param name="identityType">IIS-Identitätstyp.</param>
|
||||
/// <param name="userName">Optionales Custom-Konto.</param>
|
||||
/// <param name="poolName">Name des Anwendungspools.</param>
|
||||
/// <returns>Effektiv zu erwartende Identität.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expandiert Umgebungsvariablen und normalisiert IIS-Pfade.
|
||||
/// </summary>
|
||||
/// <param name="path">IIS-Pfad.</param>
|
||||
/// <returns>Expandierter Pfad.</returns>
|
||||
private static string ExpandIisPath(string path)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(path)
|
||||
? string.Empty
|
||||
: Environment.ExpandEnvironmentVariables(path.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Zertifikat-Fingerprint.
|
||||
/// </summary>
|
||||
/// <param name="value">Fingerprint aus IIS.</param>
|
||||
/// <returns>Großgeschriebener Fingerprint ohne Leerzeichen.</returns>
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begrenzt sehr lange Berichtswerte.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingabewert.</param>
|
||||
/// <param name="maximum">Maximale Zeichenzahl.</param>
|
||||
/// <returns>Original oder gekürzter Wert.</returns>
|
||||
private static string Limit(string value, int maximum)
|
||||
{
|
||||
return value != null && value.Length > maximum
|
||||
? value.Substring(0, maximum) + "…"
|
||||
: value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt Bytes in einen Hexadezimaltext um.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Eingabebytes.</param>
|
||||
/// <returns>Großgeschriebener Hexadezimaltext.</returns>
|
||||
private static string ToHex(byte[] bytes)
|
||||
{
|
||||
return BitConverter.ToString(bytes).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt ein Finding standardisiert hinzu.
|
||||
/// </summary>
|
||||
/// <param name="findings">Zielliste.</param>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <param name="area">Fachlicher Bereich.</param>
|
||||
/// <param name="message">Benutzerlesbare Aussage.</param>
|
||||
/// <param name="detail">Technisches Detail.</param>
|
||||
/// <param name="recommendation">Empfohlene Folgemaßnahme.</param>
|
||||
private static void AddFinding(
|
||||
IList<Finding> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert einen Eintrag für die iterative Verzeichnisbegehung.
|
||||
/// </summary>
|
||||
/// <param name="path">Zu lesender Verzeichnispfad.</param>
|
||||
/// <param name="depth">Tiefe relativ zum Web-Stamm.</param>
|
||||
public DirectoryWorkItem(string path, int depth)
|
||||
{
|
||||
Path = path;
|
||||
Depth = depth;
|
||||
}
|
||||
|
||||
public string Path { get; private set; }
|
||||
public int Depth { get; private set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst relevante Windows-Dienste, Dienstkonten und lokale Sicherheitsrichtlinien.
|
||||
/// </summary>
|
||||
internal sealed class SecurityCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Security-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">WMI-Timeout und technische Grenzen.</param>
|
||||
public SecurityCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst IIS-/BizTalk-Dienstidentitäten und einen lesbaren secedit-Snapshot.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
/// <param name="iis">IIS-Daten für Anwendungspoolkonten.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
public void Collect(SecurityInventory target, IisInventory iis, IList<Finding> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest relevante Windows-Dienste per WMI.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exportiert lokale User Rights und System Access über das Windows-Bordmittel secedit.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
/// <param name="findings">Liste für Berechtigungs- oder Toolfehler.</param>
|
||||
private static void CollectLocalSecurityPolicy(SecurityInventory target, IList<Finding> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parst die relevanten Abschnitte eines secedit-Exports.
|
||||
/// </summary>
|
||||
/// <param name="path">Pfad zur temporären INF-Datei.</param>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein Dienst IIS-, BizTalk- oder SSO-relevant ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Technischer Dienstname.</param>
|
||||
/// <param name="displayName">Anzeigename.</param>
|
||||
/// <returns><c>true</c> für relevante Dienste.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine WMI-Property als Text.
|
||||
/// </summary>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
/// <returns>Invariant formatierter Wert.</returns>
|
||||
private static string Value(ManagementObject item, string property)
|
||||
{
|
||||
return Convert.ToString(item[property], CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst Betriebssystem, Prozesskontext und installierte IIS-nahe Windows-Rollen.
|
||||
/// </summary>
|
||||
internal sealed class SystemCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den System-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Collector-Grenzen und Timeouts.</param>
|
||||
public SystemCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest lokale Systeminformationen und relevante Server Features.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell für die Ergebnisse.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt die aktuelle Windows-Identität.
|
||||
/// </summary>
|
||||
/// <returns>Kontoname oder ein erklärender Fallback.</returns>
|
||||
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 + "]";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Betriebssystemdetails per WMI.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den .NET-Framework-Releasewert aus der Registry.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest installierte Serverrollen über Win32_ServerFeature.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen WMI-Searcher mit begrenztem Timeout.
|
||||
/// </summary>
|
||||
/// <param name="scope">Bereits verbundener WMI-Scope.</param>
|
||||
/// <param name="query">Schreibgeschützte WQL-Abfrage.</param>
|
||||
/// <returns>Konfigurierter Searcher.</returns>
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein Feature für IIS/BizTalk-Dokumentation relevant ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Anzeigename des Server Features.</param>
|
||||
/// <returns><c>true</c> bei IIS-, WAS-, HTTP-, MSMQ-, .NET- oder COM+-Bezug.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt eine WMI-Property als Text hinzu.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="label">Berichtsbezeichnung.</param>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
private static void AddWmiValue(SystemInventory target, string label, ManagementObject item, string property)
|
||||
{
|
||||
target.Properties.Add(new NameValueRecord(
|
||||
label,
|
||||
Convert.ToString(item[property], CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen WMI-DMTF-Zeitwert formatiert hinzu.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="label">Berichtsbezeichnung.</param>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Configuration;
|
||||
using System.Linq;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enthält die technischen Grenzen und Schalter der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
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<string> ExpectedApplications { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Einstellungen aus der Anwendungskonfiguration und verwendet bei ungültigen Werten sichere Defaults.
|
||||
/// </summary>
|
||||
/// <param name="commandLine">Optionale Überschreibungen aus der Kommandozeile.</param>
|
||||
/// <returns>Validierte Collector-Einstellungen.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen begrenzten Ganzzahlwert aus appSettings.
|
||||
/// </summary>
|
||||
/// <param name="key">Name der Einstellung.</param>
|
||||
/// <param name="fallback">Fallback bei fehlendem oder ungültigem Wert.</param>
|
||||
/// <param name="minimum">Kleinster zulässiger Wert.</param>
|
||||
/// <param name="maximum">Größter zulässiger Wert.</param>
|
||||
/// <returns>Validierter Wert.</returns>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen booleschen Wert aus appSettings.
|
||||
/// </summary>
|
||||
/// <param name="key">Name der Einstellung.</param>
|
||||
/// <param name="fallback">Fallback bei fehlendem oder ungültigem Wert.</param>
|
||||
/// <returns>Gelesener oder vorgegebener Wert.</returns>
|
||||
private static bool ReadBool(string key, bool fallback)
|
||||
{
|
||||
bool value;
|
||||
return bool.TryParse(ConfigurationManager.AppSettings[key], out value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Beschreibt die validierten Kommandozeilenoptionen.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysiert und validiert die Kommandozeile.
|
||||
/// </summary>
|
||||
/// <param name="args">Argumente des Prozesses.</param>
|
||||
/// <returns>Validierte Optionen.</returns>
|
||||
/// <exception cref="ArgumentException">Wird bei unbekannten oder unvollständigen Optionen ausgelöst.</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Umgebungsnamen für Dateiname und Bericht.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingegebener Umgebungsname.</param>
|
||||
/// <returns>Sicherer Umgebungsname.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den Wert hinter einer Option.
|
||||
/// </summary>
|
||||
/// <param name="args">Alle Prozessargumente.</param>
|
||||
/// <param name="index">Aktueller Argumentindex; wird auf den Wert weitergeschaltet.</param>
|
||||
/// <returns>Optionswert.</returns>
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt sichtbare Konsolenmeldungen und ein dauerhaftes UTF-8-Protokoll.
|
||||
/// </summary>
|
||||
internal sealed class FileLogger : IDisposable
|
||||
{
|
||||
private readonly object sync = new object();
|
||||
private readonly StreamWriter writer;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert das Protokoll.
|
||||
/// </summary>
|
||||
/// <param name="logPath">Vollständiger Pfad zur Protokolldatei.</param>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Informationsmeldung.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Info(string message)
|
||||
{
|
||||
Write("INFO", message, ConsoleColor.Gray);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Warnung.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Warning(string message)
|
||||
{
|
||||
Write("WARN", message, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Fehler einschließlich technischer Details.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
/// <param name="exception">Optionale Ausnahme.</param>
|
||||
public void Error(string message, Exception exception = null)
|
||||
{
|
||||
var detail = exception == null ? message : message + " | " + exception;
|
||||
Write("ERROR", detail, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen erfolgreichen Arbeitsschritt.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Success(string message)
|
||||
{
|
||||
Write("OK", message, ConsoleColor.Green);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt den Dateihandle des Protokolls frei.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
writer.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt atomar auf Konsole und in Datei.
|
||||
/// </summary>
|
||||
/// <param name="level">Kurzbezeichnung der Meldungsstufe.</param>
|
||||
/// <param name="message">Meldung.</param>
|
||||
/// <param name="color">Konsolenfarbe.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Isoliert Collector-Fehler und überführt sie in Status und Report-Finding.
|
||||
/// </summary>
|
||||
internal static class SafeCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Führt einen Collector aus, protokolliert Laufzeit und fängt nicht behandelte Fehler ab.
|
||||
/// </summary>
|
||||
/// <param name="document">Zieldokument für Status und Findings.</param>
|
||||
/// <param name="logger">Gemeinsames Laufprotokoll.</param>
|
||||
/// <param name="step">Aktueller Schritt beginnend bei eins.</param>
|
||||
/// <param name="totalSteps">Gesamtzahl der Schritte.</param>
|
||||
/// <param name="name">Benutzerlesbarer Abschnittsname.</param>
|
||||
/// <param name="action">Auszuführender Collector.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Entfernt Kennwörter, Tokens und verschlüsselte Nutzdaten aus Konfigurationsdarstellungen.
|
||||
/// </summary>
|
||||
internal static class SensitiveDataSanitizer
|
||||
{
|
||||
private static readonly string[] SensitiveAttributeFragments =
|
||||
{
|
||||
"password",
|
||||
"pwd",
|
||||
"secret",
|
||||
"token",
|
||||
"connectionstring",
|
||||
"privatekey",
|
||||
"validationkey",
|
||||
"decryptionkey"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine bereinigte Kopie eines XML-Elements.
|
||||
/// </summary>
|
||||
/// <param name="source">Zu bereinigendes Element.</param>
|
||||
/// <returns>Neue XML-Struktur ohne sensible Attributwerte oder verschlüsselte Blobs.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Attributname typischerweise ein Secret bezeichnet.
|
||||
/// </summary>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns><c>true</c>, wenn der Wert entfernt werden muss.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erkennt XML-Elemente, deren Inhalt ein geschützter Konfigurationsblob ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Lokaler XML-Elementname.</param>
|
||||
/// <returns><c>true</c> für bekannte verschlüsselte Payload-Elemente.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Vollständiges Ergebnis einer lokalen Bestandsaufnahme.
|
||||
/// </summary>
|
||||
internal sealed class InventoryDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle Abschnittslisten der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
public InventoryDocument()
|
||||
{
|
||||
System = new SystemInventory();
|
||||
Iis = new IisInventory();
|
||||
Certificates = new List<CertificateRecord>();
|
||||
Security = new SecurityInventory();
|
||||
BizTalk = new BizTalkInventory();
|
||||
Findings = new List<Finding>();
|
||||
SectionStatuses = new List<SectionStatus>();
|
||||
}
|
||||
|
||||
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<CertificateRecord> Certificates { get; set; }
|
||||
public SecurityInventory Security { get; set; }
|
||||
public BizTalkInventory BizTalk { get; set; }
|
||||
public List<Finding> Findings { get; private set; }
|
||||
public List<SectionStatus> SectionStatuses { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class SystemInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert die Listen für Systemeigenschaften und Features.
|
||||
/// </summary>
|
||||
public SystemInventory()
|
||||
{
|
||||
Properties = new List<NameValueRecord>();
|
||||
InstalledFeatures = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public List<NameValueRecord> Properties { get; private set; }
|
||||
public List<NameValueRecord> InstalledFeatures { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class IisInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle IIS-Ergebnislisten.
|
||||
/// </summary>
|
||||
public IisInventory()
|
||||
{
|
||||
ApplicationPools = new List<ApplicationPoolRecord>();
|
||||
Sites = new List<SiteRecord>();
|
||||
GlobalSections = new List<ConfigurationSectionRecord>();
|
||||
EncryptionProviders = new List<EncryptionProviderRecord>();
|
||||
}
|
||||
|
||||
public string ConfigurationPath { get; set; }
|
||||
public DateTime? ConfigurationLastWriteUtc { get; set; }
|
||||
public string SanitizedConfigurationSha256 { get; set; }
|
||||
public List<ApplicationPoolRecord> ApplicationPools { get; private set; }
|
||||
public List<SiteRecord> Sites { get; private set; }
|
||||
public List<ConfigurationSectionRecord> GlobalSections { get; private set; }
|
||||
public List<EncryptionProviderRecord> 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<NameValueRecord> AdditionalSettings { get; set; } = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
internal sealed class SiteRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Binding- und Anwendungslisten einer Site.
|
||||
/// </summary>
|
||||
public SiteRecord()
|
||||
{
|
||||
Bindings = new List<BindingRecord>();
|
||||
Applications = new List<WebApplicationRecord>();
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string ServerAutoStart { get; set; }
|
||||
public string LogDirectory { get; set; }
|
||||
public List<BindingRecord> Bindings { get; private set; }
|
||||
public List<WebApplicationRecord> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Listen für virtuelle Verzeichnisse, Dateien und ACLs.
|
||||
/// </summary>
|
||||
public WebApplicationRecord()
|
||||
{
|
||||
VirtualDirectories = new List<VirtualDirectoryRecord>();
|
||||
ContentFiles = new List<ContentFileRecord>();
|
||||
AccessRules = new List<AccessRuleRecord>();
|
||||
}
|
||||
|
||||
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<VirtualDirectoryRecord> VirtualDirectories { get; private set; }
|
||||
public List<ContentFileRecord> ContentFiles { get; private set; }
|
||||
public List<AccessRuleRecord> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Key-Usage- und ACL-Listen eines Zertifikats.
|
||||
/// </summary>
|
||||
public CertificateRecord()
|
||||
{
|
||||
EnhancedKeyUsages = new List<string>();
|
||||
KeyAccessRules = new List<AccessRuleRecord>();
|
||||
}
|
||||
|
||||
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<string> EnhancedKeyUsages { get; private set; }
|
||||
public List<AccessRuleRecord> KeyAccessRules { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class SecurityInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert die Listen der lokalen Sicherheitsaufnahme.
|
||||
/// </summary>
|
||||
public SecurityInventory()
|
||||
{
|
||||
ServiceAccounts = new List<ServiceAccountRecord>();
|
||||
UserRights = new List<UserRightRecord>();
|
||||
LocalPolicy = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public List<ServiceAccountRecord> ServiceAccounts { get; private set; }
|
||||
public List<UserRightRecord> UserRights { get; private set; }
|
||||
public List<NameValueRecord> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle Listen des BizTalk-Inventars.
|
||||
/// </summary>
|
||||
public BizTalkInventory()
|
||||
{
|
||||
RegistryValues = new List<NameValueRecord>();
|
||||
InstalledProducts = new List<NameValueRecord>();
|
||||
Components = new List<NameValueRecord>();
|
||||
WmiClasses = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public bool WmiNamespaceAvailable { get; set; }
|
||||
public List<NameValueRecord> RegistryValues { get; private set; }
|
||||
public List<NameValueRecord> InstalledProducts { get; private set; }
|
||||
public List<NameValueRecord> Components { get; private set; }
|
||||
public List<NameValueRecord> WmiClasses { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class NameValueRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert einen leeren Name/Wert-Datensatz.
|
||||
/// </summary>
|
||||
public NameValueRecord()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert einen Name/Wert-Datensatz.
|
||||
/// </summary>
|
||||
/// <param name="name">Eigenschaftsname.</param>
|
||||
/// <param name="value">Eigenschaftswert.</param>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt und Ablaufsteuerung der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Analysiert Optionen, führt alle read-only Collectoren aus und erzeugt das Word-Dokument.
|
||||
/// </summary>
|
||||
/// <param name="args">Kommandozeilenargumente.</param>
|
||||
/// <returns>0 bei vollständigem Erfolg, 1 bei Teilfehler mit Report, 2 bei fatalem Fehler.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt die Collector-Pipeline aus und schreibt den Bericht.
|
||||
/// </summary>
|
||||
/// <param name="commandLine">Validierte Laufoptionen.</param>
|
||||
/// <param name="logger">Gemeinsames Konsolen-/Dateiprotokoll.</param>
|
||||
/// <param name="baseName">Eindeutiger Basisdateiname.</param>
|
||||
/// <returns>0 bei vollständigem Erfolg, sonst 1 bei verwertbarem Teilreport.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt plattformneutrale interne Prüfungen ohne Zugriff auf IIS oder BizTalk aus.
|
||||
/// </summary>
|
||||
/// <returns>0 bei Erfolg, sonst 2.</returns>
|
||||
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(
|
||||
"<root password=\"secret\"><child token=\"abc\"/><EncryptedData>payload</EncryptedData></root>");
|
||||
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 = "<ACC>",
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die deutsche Kommandozeilenhilfe aus.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Erzeugt ein standardkonformes Microsoft-Word-Dokument im Office-Open-XML-Format ohne Office-Installation.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt den vollständigen DOCX-Bericht atomar in die Zieldatei.
|
||||
/// </summary>
|
||||
/// <param name="document">Abgeschlossene Bestandsaufnahme.</param>
|
||||
/// <param name="path">Vollständiger Zielpfad mit Erweiterung <c>.docx</c>.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die MIME-Zuordnungen des Office-Open-XML-Pakets.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Default-MIME-Zuordnung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="extension">Dateierweiterung.</param>
|
||||
/// <param name="contentType">MIME-Typ.</param>
|
||||
private static void WriteContentTypeDefault(XmlWriter writer, string extension, string contentType)
|
||||
{
|
||||
writer.WriteStartElement("Default", ContentTypeNamespace);
|
||||
writer.WriteAttributeString("Extension", extension);
|
||||
writer.WriteAttributeString("ContentType", contentType);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine part-spezifische MIME-Zuordnung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="partName">Absoluter Paketpart.</param>
|
||||
/// <param name="contentType">MIME-Typ.</param>
|
||||
private static void WriteContentTypeOverride(XmlWriter writer, string partName, string contentType)
|
||||
{
|
||||
writer.WriteStartElement("Override", ContentTypeNamespace);
|
||||
writer.WriteAttributeString("PartName", partName);
|
||||
writer.WriteAttributeString("ContentType", contentType);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Beziehungen von der Paketwurzel zu Dokument und Eigenschaften.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Beziehungen des Hauptdokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine OOXML-Beziehung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="id">Beziehungs-ID.</param>
|
||||
/// <param name="type">Beziehungstyp.</param>
|
||||
/// <param name="target">Relatives Ziel.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Titel, Ersteller und Zeitstempel des Dokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen typisierten Dublin-Core-Zeitwert.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="name">Elementname.</param>
|
||||
/// <param name="value">UTC-Zeitwert.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Office-Anwendungseigenschaften.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die im Bericht verwendeten Word-Formatvorlagen.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Absatzformatvorlage.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="styleId">Interne Style-ID.</param>
|
||||
/// <param name="name">Anzeigename.</param>
|
||||
/// <param name="fontSizeHalfPoints">Schriftgröße in halben Punkten.</param>
|
||||
/// <param name="color">RGB-Farbe.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
/// <param name="spaceBefore">Abstand davor in Twips.</param>
|
||||
/// <param name="spaceAfter">Abstand danach in Twips.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definiert den Tabellenstil mit sichtbaren Gitternetzlinien.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt den fachlichen Inhalt des Word-Dokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Zusammenfassung und Collector-Status.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Auffälligkeiten und Maßnahmen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="findings">Auffälligkeiten.</param>
|
||||
private static void WriteFindings(XmlWriter writer, IList<Finding> 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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Windows- und Rolleninformationen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="system">Systeminventar.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt IIS-Konfiguration, Topologie, Webinhalte und ACLs.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="iis">IIS-Inventar.</param>
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine IIS-Anwendung einschließlich Dateimanifest.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="application">IIS-Anwendung.</param>
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Zertifikate und private Key-ACLs.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="certificates">Zertifikatsinventar.</param>
|
||||
private static void WriteCertificates(XmlWriter writer, IList<CertificateRecord> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Dienstidentitäten und lokale Sicherheitsrichtlinien.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="security">Security-Inventar.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt BizTalk-Installations- und WMI-Daten.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="bizTalk">BizTalk-Inventar.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine ACL-Tabelle.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="rules">ACL-Regeln.</param>
|
||||
private static void WriteAclTable(XmlWriter writer, IEnumerable<AccessRuleRecord> 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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Name/Wert-Tabelle.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="items">Name/Wert-Datensätze.</param>
|
||||
private static void WriteNameValueTable(XmlWriter writer, IEnumerable<NameValueRecord> items)
|
||||
{
|
||||
WriteTable(writer,
|
||||
new[] { "Eigenschaft", "Wert" },
|
||||
items.Select(item => new[] { item.Name, item.Value }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Word-Tabelle und wiederholt die Kopfzeile auf Folgeseiten.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="headers">Spaltenüberschriften.</param>
|
||||
/// <param name="rows">Tabellenzeilen.</param>
|
||||
private static void WriteTable(XmlWriter writer, string[] headers, IEnumerable<string[]> rows)
|
||||
{
|
||||
var materialized = rows == null ? new List<string[]>() : 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Tabellenzeile.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="values">Zellwerte.</param>
|
||||
/// <param name="header">Gibt eine wiederholbare Kopfzeile an.</param>
|
||||
private static void WriteTableRow(XmlWriter writer, IEnumerable<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Überschrift mit Word-Navigationsebene.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="level">Ebene eins bis drei.</param>
|
||||
/// <param name="text">Überschriftstext.</param>
|
||||
private static void WriteHeading(XmlWriter writer, int level, string text)
|
||||
{
|
||||
WriteParagraph(writer, text, "Heading" + Math.Max(1, Math.Min(3, level)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen hervorgehobenen Hinweis.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Hinweistext.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Absatz in einer benannten Formatvorlage.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Absatztext.</param>
|
||||
/// <param name="style">Word-Style-ID.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen einfachen Tabellenzellenabsatz.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Zelltext.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
private static void WriteParagraphWithRun(XmlWriter writer, string text, bool bold)
|
||||
{
|
||||
writer.WriteStartElement("w", "p", WordNamespace);
|
||||
WriteRun(writer, text, bold);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Textlauf und bewahrt Leerzeichen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Unvertrauenswürdiger Text; der XML-Writer kodiert ihn.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Seitenformat und Ränder; Querformat verbessert breite Inventartabellen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen komprimierten XML-Part mit sicheren Writer-Einstellungen.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="path">Partpfad innerhalb des Archivs.</param>
|
||||
/// <param name="writeAction">Aktion zum Schreiben des XML-Inhalts.</param>
|
||||
private static void WriteXmlEntry(ZipArchive archive, string path, Action<XmlWriter> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt Zeichen, die in XML 1.0 nicht zulässig sind.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingabetext.</param>
|
||||
/// <returns>XML-kompatibler Text.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begrenzt Zelltext auf einen Word-kompatiblen und bedienbaren Umfang.
|
||||
/// </summary>
|
||||
/// <param name="value">Zelltext.</param>
|
||||
/// <returns>Unveränderter oder gekürzter Text.</returns>
|
||||
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]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert UTC-Zeit konsistent.
|
||||
/// </summary>
|
||||
/// <param name="value">Zeitwert.</param>
|
||||
/// <returns>UTC-Darstellung.</returns>
|
||||
private static string FormatUtc(DateTime value)
|
||||
{
|
||||
return value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert eine Bytezahl lesbar.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Bytezahl.</param>
|
||||
/// <returns>Lesbare Größenangabe.</returns>
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert eine Sortierzahl für Findings.
|
||||
/// </summary>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <returns>Kleinere Zahl für höhere Priorität.</returns>
|
||||
private static int SeverityOrder(string severity)
|
||||
{
|
||||
if (string.Equals(severity, "Fehler", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return string.Equals(severity, "Warnung", StringComparison.OrdinalIgnoreCase) ? 1 : 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net472</TargetFrameworks>
|
||||
<!-- Auf Linux zusätzlich plattformneutrale Sicherheits-/DOCX-Tests tatsächlich ausführen. -->
|
||||
<TargetFrameworks Condition="'$(OS)' != 'Windows_NT'">net472;net10.0</TargetFrameworks>
|
||||
<RootNamespace>BizTalkIisEnvironmentInventory.Tests</RootNamespace>
|
||||
<AssemblyName>BizTalkIisEnvironmentInventory.Tests</AssemblyName>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<Deterministic>true</Deterministic>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
|
||||
<ProjectReference Include="..\..\src\BizTalkIisEnvironmentInventory\BizTalkIisEnvironmentInventory.csproj" />
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="All" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<Compile Include="..\..\src\BizTalkIisEnvironmentInventory\Infrastructure\CommandLineOptions.cs" Link="Shared\CommandLineOptions.cs" />
|
||||
<Compile Include="..\..\src\BizTalkIisEnvironmentInventory\Infrastructure\SensitiveDataSanitizer.cs" Link="Shared\SensitiveDataSanitizer.cs" />
|
||||
<Compile Include="..\..\src\BizTalkIisEnvironmentInventory\Models\InventoryModels.cs" Link="Shared\InventoryModels.cs" />
|
||||
<Compile Include="..\..\src\BizTalkIisEnvironmentInventory\Reporting\DocxReportWriter.cs" Link="Shared\DocxReportWriter.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Abhängigkeitsfreier Konsolen-Testläufer für zentrale Sicherheits- und Parserfunktionen.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static int failures;
|
||||
|
||||
/// <summary>
|
||||
/// Führt alle Tests aus.
|
||||
/// </summary>
|
||||
/// <param name="args">Optional <c>--write-sample PFAD</c> für ein DOCX-Prüfdokument.</param>
|
||||
/// <returns>0 bei Erfolg, sonst 1.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt ein dauerhaftes minimales DOCX zur Validierung mit externen Office-Programmen.
|
||||
/// </summary>
|
||||
/// <param name="path">Zielpfad des Prüfdokuments.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen einzelnen Test isoliert aus.
|
||||
/// </summary>
|
||||
/// <param name="name">Testname.</param>
|
||||
/// <param name="test">Testaktion.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft Optionsparser und sichere Dateinamensbildung.
|
||||
/// </summary>
|
||||
private static void TestCommandLine()
|
||||
{
|
||||
var options = CommandLineOptions.Parse(new[] { "--environment", " acc / 01 " });
|
||||
Equal("ACC-01", options.EnvironmentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft Attribut- und Ciphertext-Bereinigung.
|
||||
/// </summary>
|
||||
private static void TestSanitizer()
|
||||
{
|
||||
var source = XElement.Parse(
|
||||
"<configuration password=\"klartext\"><add token=\"abc\"/><EncryptedData><CipherValue>blob</CipherValue></EncryptedData></configuration>");
|
||||
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
|
||||
/// <summary>
|
||||
/// Prüft IIS-Topologie und Secret-freien Konfigurationshash anhand einer Testdatei.
|
||||
/// </summary>
|
||||
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 = @"<configuration>
|
||||
<configSections>
|
||||
<sectionGroup name=""system.webServer""><section name=""security"" overrideModeDefault=""Allow"" /></sectionGroup>
|
||||
</configSections>
|
||||
<configProtectedData><providers><add name=""IISWASOnlyRsaProvider"" type=""Provider"" keyContainerName=""iisWasKey"" useMachineContainer=""true"" /></providers></configProtectedData>
|
||||
<system.applicationHost>
|
||||
<applicationPools><add name=""BizTalkPool"" managedRuntimeVersion=""v4.0""><processModel identityType=""SpecificUser"" userName=""DOMAIN\svc"" password=""secret"" /></add></applicationPools>
|
||||
<sites><site name=""BizTalk"" id=""1""><application path=""/"" applicationPool=""BizTalkPool""><virtualDirectory path=""/"" physicalPath=""" + EscapeXml(webRoot) + @""" /></application><bindings><binding protocol=""https"" bindingInformation=""*:443:test"" certificateHash=""AA BB"" certificateStoreName=""MY"" /></bindings></site></sites>
|
||||
</system.applicationHost>
|
||||
<system.webServer><security /></system.webServer>
|
||||
</configuration>";
|
||||
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<Finding>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft das Parsen eines minimalen secedit-Exports.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Prüft erforderliche Office-Open-XML-Parts, XML-Validität und sichere Textkodierung.
|
||||
/// </summary>
|
||||
private static void TestDocxGeneration()
|
||||
{
|
||||
var path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"BizTalkIisInventory-DocxTest-" + Guid.NewGuid().ToString("N") + ".docx");
|
||||
var document = new InventoryDocument
|
||||
{
|
||||
EnvironmentName = "<script>alert(1)</script>",
|
||||
ComputerName = "TEST&HOST",
|
||||
StartedUtc = DateTime.UtcNow,
|
||||
CompletedUtc = DateTime.UtcNow
|
||||
};
|
||||
document.System.Properties.Add(new NameValueRecord("Test", "<b>nicht fett</b>"));
|
||||
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("<script>alert(1)</script>"),
|
||||
"Umgebungsname wurde als Markup statt Text geschrieben.");
|
||||
False(xml.Contains("<b>nicht fett</b>"),
|
||||
"Tabellenwert wurde als Markup statt Text geschrieben.");
|
||||
True(xml.Contains("Schutz sensibler Daten"),
|
||||
"Sicherheitskennzeichnung fehlt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, dass OPC-Elemente im vorgeschriebenen Namespace statt im leeren Namespace liegen.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-Paket.</param>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt einen XML-Part aus dem DOCX-Paket.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-Paket.</param>
|
||||
/// <param name="entryName">Pfad des XML-Parts.</param>
|
||||
/// <returns>Geparstes XML-Dokument.</returns>
|
||||
private static XDocument LoadEntryXml(ZipArchive archive, string entryName)
|
||||
{
|
||||
using (var stream = archive.GetEntry(entryName).Open())
|
||||
{
|
||||
return XDocument.Load(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt ein eindeutiges temporäres Testverzeichnis.
|
||||
/// </summary>
|
||||
/// <returns>Vollständiger Pfad.</returns>
|
||||
private static string CreateTemporaryDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "BizTalkIisInventoryTests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// XML-kodiert einen Attributwert.
|
||||
/// </summary>
|
||||
/// <param name="value">Unkodierter Wert.</param>
|
||||
/// <returns>XML-kodierter Wert.</returns>
|
||||
private static string EscapeXml(string value)
|
||||
{
|
||||
return new XAttribute("x", value).ToString()
|
||||
.Substring(3)
|
||||
.TrimEnd('\"');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht zwei Werte.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Werttyp.</typeparam>
|
||||
/// <param name="expected">Erwarteter Wert.</param>
|
||||
/// <param name="actual">Tatsächlicher Wert.</param>
|
||||
private static void Equal<T>(T expected, T actual)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException("Erwartet: " + expected + "; tatsächlich: " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erwartet einen wahren Ausdruck.
|
||||
/// </summary>
|
||||
/// <param name="condition">Zu prüfender Ausdruck.</param>
|
||||
/// <param name="message">Fehlermeldung.</param>
|
||||
private static void True(bool condition, string message)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erwartet einen falschen Ausdruck.
|
||||
/// </summary>
|
||||
/// <param name="condition">Zu prüfender Ausdruck.</param>
|
||||
/// <param name="message">Fehlermeldung.</param>
|
||||
private static void False(bool condition, string message)
|
||||
{
|
||||
True(!condition, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user