From 439427cc17f76247c83eff299b177ada929b57d4 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Mon, 27 Jul 2026 14:21:50 +0200 Subject: [PATCH] Initial commit: BizTalk SAP environment inventory --- .editorconfig | 13 + .gitea/workflows/build.yml | 27 + .gitignore | 9 + BizTalkSapEnvironmentInventory.sln | 25 + Dokumentation.md | 407 ++++++++ Readme.md | 231 +++++ deployment/run-inventory.cmd | 52 + scripts/build-release.cmd | 44 + scripts/package-release.cmd | 25 + src/BizTalkSapEnvironmentInventory/App.config | 14 + .../BizTalkSapEnvironmentInventory.csproj | 69 ++ .../Collectors/BindingExportCollector.cs | 796 +++++++++++++++ .../Collectors/BizTalkWmiCollector.cs | 433 ++++++++ .../Collectors/SapRuntimeCollector.cs | 535 ++++++++++ .../Collectors/SystemCollector.cs | 313 ++++++ .../Configuration/CommandLineOptions.cs | 152 +++ .../Infrastructure/ConsoleFileLogger.cs | 60 ++ .../Infrastructure/SafeCollector.cs | 68 ++ .../Infrastructure/SelfTestRunner.cs | 342 +++++++ .../Infrastructure/SensitiveDataSanitizer.cs | 125 +++ .../Models/InventoryModels.cs | 202 ++++ src/BizTalkSapEnvironmentInventory/Program.cs | 303 ++++++ .../Properties/AssemblyInfo.cs | 14 + .../Reporting/DocxReportWriter.cs | 934 ++++++++++++++++++ ...izTalkSapEnvironmentInventory.Tests.csproj | 57 ++ .../Program.cs | 31 + 26 files changed, 5281 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 BizTalkSapEnvironmentInventory.sln create mode 100644 Dokumentation.md create mode 100644 Readme.md create mode 100644 deployment/run-inventory.cmd create mode 100644 scripts/build-release.cmd create mode 100644 scripts/package-release.cmd create mode 100644 src/BizTalkSapEnvironmentInventory/App.config create mode 100644 src/BizTalkSapEnvironmentInventory/BizTalkSapEnvironmentInventory.csproj create mode 100644 src/BizTalkSapEnvironmentInventory/Collectors/BindingExportCollector.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Collectors/BizTalkWmiCollector.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Collectors/SapRuntimeCollector.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Collectors/SystemCollector.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Configuration/CommandLineOptions.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Infrastructure/ConsoleFileLogger.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Infrastructure/SafeCollector.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Infrastructure/SelfTestRunner.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Models/InventoryModels.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Program.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Properties/AssemblyInfo.cs create mode 100644 src/BizTalkSapEnvironmentInventory/Reporting/DocxReportWriter.cs create mode 100644 tests/BizTalkSapEnvironmentInventory.Tests/BizTalkSapEnvironmentInventory.Tests.csproj create mode 100644 tests/BizTalkSapEnvironmentInventory.Tests/Program.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f2608c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.md] +end_of_line = lf +trim_trailing_whitespace = false + diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..cf1c8f6 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build und Test + +on: + push: + pull_request: + +jobs: + build: + runs-on: windows + steps: + - name: Repository auschecken + uses: actions/checkout@v4 + + - name: Release bauen und Tests ausführen + shell: cmd + run: scripts\build-release.cmd + + - name: Deployment-Ordner erzeugen + shell: cmd + run: scripts\package-release.cmd + + - name: Deployment-Artefakt bereitstellen + uses: actions/upload-artifact@v4 + with: + name: BizTalkSapEnvironmentInventory-deploy + path: artifacts\BizTalkSapEnvironmentInventory-deploy + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5138323 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +artifacts/ +.vs/ +*.user +*.suo +*.log +*.docx + diff --git a/BizTalkSapEnvironmentInventory.sln b/BizTalkSapEnvironmentInventory.sln new file mode 100644 index 0000000..eeaa351 --- /dev/null +++ b/BizTalkSapEnvironmentInventory.sln @@ -0,0 +1,25 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31729.503 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkSapEnvironmentInventory", "src\BizTalkSapEnvironmentInventory\BizTalkSapEnvironmentInventory.csproj", "{43962556-3DD2-4846-96E2-76B4749F17F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkSapEnvironmentInventory.Tests", "tests\BizTalkSapEnvironmentInventory.Tests\BizTalkSapEnvironmentInventory.Tests.csproj", "{9F9E3933-BB12-4F05-984D-8B86C2AB963D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {43962556-3DD2-4846-96E2-76B4749F17F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43962556-3DD2-4846-96E2-76B4749F17F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43962556-3DD2-4846-96E2-76B4749F17F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43962556-3DD2-4846-96E2-76B4749F17F2}.Release|Any CPU.Build.0 = Release|Any CPU + {9F9E3933-BB12-4F05-984D-8B86C2AB963D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F9E3933-BB12-4F05-984D-8B86C2AB963D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F9E3933-BB12-4F05-984D-8B86C2AB963D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F9E3933-BB12-4F05-984D-8B86C2AB963D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal + diff --git a/Dokumentation.md b/Dokumentation.md new file mode 100644 index 0000000..9ace5f7 --- /dev/null +++ b/Dokumentation.md @@ -0,0 +1,407 @@ +# Technische Dokumentation: BEW BizTalk SAP Environment Inventory + +## 1. Zielbild und Topologie + +Das Tool erzeugt je Umgebung einen belastbaren Ist-Bericht der SAP-Adapterkonfiguration und der vollständigen BizTalk-Anwendungsliste. + +```text +ACC + +-- 1 BizTalk Server 2020 <-- Tool lokal ausführen + +-- 1 SQL Server <-- BizTalk-Datenbanken + +PROD + +-- 1 BizTalk Server 2020 <-- Tool lokal ausführen + +-- 1 SQL Server <-- BizTalk-Datenbanken +``` + +Es gibt in ACC und PROD jeweils keinen zweiten BizTalk-Knoten, der verglichen werden müsste. Die Berichte sind getrennt zu erzeugen und anschließend gegenüberzustellen. + +Aufgabenbezug: + +- SAP-Adapter-Verbindungsparameter dokumentieren +- vollständige BizTalk-Anwendungsliste statt einer unvollständigen bisherigen App-Liste erzeugen +- tatsächliche Zuordnung der SAP-Verbindungen zu BizTalk-Anwendungen herstellen +- ERP-/ISU-Receive- und Send-Hosts dokumentieren +- SNC-Konfiguration, Bibliotheken und Zertifikats-/PSE-Metadaten erfassen +- IDoc-Basistypen/Erweiterungen sowie WE20/WE21 als Nachweise adressieren +- DR-Abhängigkeit zur SAP-seitigen Umschaltung nach Frankfurt sichtbar machen + +## 2. Architektur + +```text +Administrative cmd.exe + | + +-- run-inventory.cmd ACC|PROD + | + +-- BizTalkSapEnvironmentInventory.exe + | + +-- SystemCollector + | +-- Windows WMI + | +-- BizTalk Registry + | +-- Management SQL/DB + | + +-- BizTalkWmiCollector + | +-- root\MicrosoftBizTalkServer + | +-- Anwendungen und Artefakte + | +-- Adapter, Hosts und Handler + | +-- SAP-Endpunkt-Fallback + | + +-- BindingExportCollector + | +-- BTSTask ExportBindings /GroupLevel + | +-- WCF-SAP und WCF-Custom/sapBinding + | +-- SAP-URI und CustomProps + | +-- Secret-Redaktion + | + +-- SapRuntimeCollector + | +-- Adapter Pack/NCo-Installationen + | +-- Runtime-Dateiversion und Architektur + | +-- SNC/PSE/Zertifikatsmetadaten + | + +-- DocxReportWriter + +-- Office Open XML + +-- atomare Ausgabe +``` + +Das Programm referenziert keine `Microsoft.BizTalk.*`-Assembly. Dadurch besteht das Deployment nur aus EXE, Konfiguration, Startskript und Dokumentation. Die BizTalk-Integration erfolgt über die offiziell vorhandenen Verwaltungsoberflächen WMI und `BTSTask.exe`. + +## 3. Datenquellen + +### 3.1 System und Registry + +Windows-Daten stammen aus `root\cimv2`. BizTalk-Installations- und Managementinformationen werden in beiden Registry-Ansichten unter folgenden Schlüsseln gesucht: + +```text +HKLM\SOFTWARE\Microsoft\BizTalk Server\3.0 +HKLM\SOFTWARE\Microsoft\BizTalk Server\3.0\Administration +``` + +Ermittelt werden unter anderem: + +- Produktname, Version und Edition +- Installationspfad +- BizTalk Management SQL Server +- BizTalk Management Database +- Betriebssystem, Build und Prozessarchitektur + +Kommandozeilenparameter überschreiben automatisch ermittelte Management-DB-Werte. + +### 3.2 BizTalk-WMI + +Primärer Namespace: + +```text +root\MicrosoftBizTalkServer +``` + +Pflichtquelle: + +- `MSBTS_Application` +- `MSBTS_GroupSetting` wird zusätzlich zur sicheren Erkennung von Management SQL Server und Datenbank verwendet + +Optionale beziehungsweise versionsabhängige Artefaktklassen: + +- `MSBTS_Orchestration` +- `MSBTS_SendPort` +- `MSBTS_SendPortGroup` +- `MSBTS_ReceivePort` +- `MSBTS_ReceiveLocation` +- `MSBTS_Assembly` +- `MSBTS_Schema` +- `MSBTS_Map` +- `MSBTS_Pipeline` +- `MSBTS_AdapterSetting` +- `MSBTS_ReceiveHandler` +- `MSBTS_SendHandler2` bzw. `MSBTS_SendHandler` +- `MSBTS_HostSetting` +- `MSBTS_HostInstance` + +Eine fehlende optionale Klasse erzeugt einen Hinweis, verhindert aber nicht die Anwendungsliste oder den Word-Bericht. + +Zum Schutz vor ungewöhnlich großen Artefaktmengen begrenzt `MaxArtifactDetailsPerType` +die aufgenommenen Detaildatensätze pro WMI-Klasse auf standardmäßig 5000. Die +Anwendungsliste selbst wird nicht begrenzt. + +### 3.3 BTSTask-Gruppenbinding + +Das Tool verwendet: + +```cmd +BTSTask.exe ExportBindings ^ + /GroupLevel ^ + /Destination:"" ^ + /Server:"" ^ + /Database:"" +``` + +`/GroupLevel` ist entscheidend: Ohne diesen Parameter würde `BTSTask` nur die Default-Anwendung exportieren und die geforderte vollständige Port-/App-Zuordnung wäre nicht gewährleistet. + +Die temporäre XML-Datei wird: + +1. in einem eindeutigen Temp-Pfad erzeugt, +2. mit deaktivierter DTD-/External-Entity-Auflösung gelesen, +3. ausschließlich in sichere Berichtsmodelle überführt, +4. im `finally`-Block gelöscht. + +Microsoft dokumentiert, dass Kennwörter beim Binding-Export entfernt werden. Zusätzlich redigiert das Tool sensitive Element-, Attribut- und URI-Werte. + +### 3.4 Offline-Binding + +Für Diagnose- oder Berechtigungsfälle kann ein vorhandener Export verarbeitet werden: + +```cmd +BizTalkSapEnvironmentInventory.exe ^ + --environment ACC ^ + --binding-file C:\Temp\ACC-GroupBindings.xml +``` + +Die Datei wird nicht verändert. + +## 4. SAP-Endpunkterkennung + +Ein Endpunkt wird als SAP-relevant erkannt, wenn mindestens eines zutrifft: + +- Adaptername enthält `SAP` +- Adresse beginnt mit `sap://` +- Binding Type enthält `sapBinding` +- Custom Properties enthalten `Microsoft.Adapters.SAP` + +Dadurch werden sowohl dedizierte `WCF-SAP`-Ports als auch `WCF-Custom`-Ports mit SAP Custom Binding erkannt. + +## 5. SAP-Verbindungsparameter + +### 5.1 SAP-URI + +Beispiel: + +```text +sap://Client=100;lang=DE@A/sap.example.local/00 + ?GwHost=gateway.example.local + &GwServ=sapgw00 + &ListenerProgramId=BIZTALK_ACC +``` + +Ausgewertete Parameter: + +| Gruppe | Parameter | +| --- | --- | +| Login | `Client`, `Language` | +| Application Server | Host, System Number | +| Message Server | Host, R/3 System Name, Gruppe | +| Destination | Destination Name | +| Gateway | `GwHost`, `GwServ` | +| RFC Listener | `ListenerDest`, `ListenerGwHost`, `ListenerGwServ`, `ListenerProgramId` | +| Routing | `SAPROUTER` | +| Security | `UseSnc` | + +### 5.2 Binding Properties + +`TransportTypeData` und `ReceiveLocationTransportTypeData` enthalten bei WCF-Adaptern verschachtelte `CustomProps` und teilweise nochmals XML-kodierte Bindingkonfiguration. Der Parser verarbeitet diese Ebenen begrenzt rekursiv und erfasst unter anderem: + +- `BindingType` +- `UseSnc` +- `SncLibrary` +- `SncPartnerName` +- `SncMyName` +- `SncQop` +- `AffiliateApplicationName` +- `UserName` +- `Action` und `ActionMapping` + +Große Werte werden im Bericht begrenzt. Sensitive Werte werden vor Aufnahme in das Modell redigiert. + +### 5.3 Anwendungszuordnung + +Die Zuordnung erfolgt in dieser Reihenfolge: + +1. `ApplicationName` im Gruppenbinding +2. gleichnamiger Send Port oder Receive Port aus WMI +3. keine Schätzung; stattdessen Finding + +Damit wird sichtbar, welche der vollständigen BizTalk-Anwendungen tatsächlich SAP-Ports verwendet. + +## 6. SAP NCo und Prozessarchitektur + +Gesucht werden: + +- installierter Microsoft BizTalk Adapter Pack/WCF LOB Adapter SDK +- SAP .NET Connector +- `sapnco.dll` +- `sapnco_utils.dll` +- SAP Cryptographic Library +- `sapgenpse.exe` + +Für gefundene PE-Dateien werden Dateiversion, Produktversion, Änderungszeit und PE-Architektur dokumentiert. Dies unterstützt die Prüfung, ob der jeweilige 32-/64-Bit-BizTalk-Host zur installierten SAP-Laufzeit passt. + +Die Erfassung durchsucht nur klar begrenzte Installations- und Konfigurationspfade bis zu einer Maximalebene. Es findet keine unbeschränkte Volltextsuche über alle Laufwerke statt. + +## 7. SNC und Credential-Sicherheit + +Der Security-Modus wird aus den Bindingparametern klassifiziert: + +1. `UseSnc=true` → SAP SNC +2. SSO Affiliate Application vorhanden → Enterprise SSO +3. Benutzername vorhanden → SAP-Benutzer +4. sonst nicht eindeutig ableitbar + +Dokumentiert werden dürfen: + +- SAP-Benutzername oder SSO-Referenz +- SNC-Partner-/My-Name +- SNC-Bibliothek und QOP +- PSE-/Zertifikatspfad +- Fingerprint und Gültigkeit +- Vorhandensein eines privaten Schlüssels bei X.509-Zertifikaten + +Nicht dokumentiert werden: + +- Kennwörter +- PSE-Inhalte +- private Schlüssel +- Tokens +- entschlüsselbare Connection Strings + +Rotation und Owner sind keine technischen Bindingeigenschaften. Sie bleiben als manueller Security-Nachweis offen. + +## 8. IDoc, WE20 und WE21 + +### 8.1 Automatisch belegbare Indikatoren + +Das Tool sucht nach SAP-/IDoc-Indikatoren in: + +- Schema- und Assemblynamen +- Target Namespaces und Root Names +- WCF Actions +- SAP-Adapteroperationen + +Dies kann IDoc-Basistypen wie beispielsweise `ORDERS05` sichtbar machen, ist aber kein Ersatz für eine SAP-seitige Bestandsaufnahme. + +### 8.2 SAP-seitige Pflichtnachweise + +Von SAP Basis beziehungsweise dem Fachteam beizustellen: + +| Bereich | Benötigte Daten | +| --- | --- | +| WE20 | Partner, Nachrichtentyp, Basistyp, Erweiterung, Empfängerport, Verarbeitungsoptionen | +| WE21 | Portname, RFC-Destination, IDoc-Version, WE20-Zuordnung | +| RFC Destination | Typ, Zielsystem, Gateway, Program ID, Verbindungstest | +| IDoc | vollständige Liste der Basistypen und kundeneigenen Erweiterungen | + +Das Tool markiert diese Punkte in jedem DOCX als offen, selbst wenn einzelne Namen aus dem BizTalk-Binding abgeleitet werden konnten. + +## 9. Disaster Recovery + +Der lokale BizTalk-Bericht zeigt die aktuell konfigurierten RFC-/Gateway-/Listenerwerte. Er kann nicht entscheiden, ob und wie die RFC-Destination im SAP-System auf Frankfurt umgeschaltet wird. + +Das DR-Runbook muss mindestens enthalten: + +1. Owner und Freigabe für die Umschaltung +2. betroffene RFC-Destinationen +3. Zielwerte in Frankfurt +4. SNC-PSE/Bibliothek und Identität am DR-Ziel +5. Reihenfolge BizTalk/SAP +6. Verbindungstest +7. Rückschaltung +8. Dokumentation des letzten Tests + +## 10. Resilienz + +- Collector-Isolation mit eigenem Status und Zeitmessung +- WMI-Timeout +- Prozess-Timeout für `BTSTask` +- WMI-Fallback für SAP-Endpunkte +- Offline-Bindingmodus +- optionale WMI-Klassen stoppen den Lauf nicht +- begrenzte Dateisystemsuche +- Fehlerisolation bei Registry, Zertifikaten und Dateimetadaten +- XML-Parser ohne DTD/External Entities +- atomare DOCX-Ausgabe +- eindeutige Dateinamen mit Umgebung, Server und Zeitstempel +- Exitcode `1` bei unvollständigem Pflichtabschnitt + +## 11. DOCX-Erzeugung ohne Office + +Der Report Writer erzeugt ein standardkonformes Office-Open-XML-Paket mit: + +```text +[Content_Types].xml +_rels/.rels +docProps/core.xml +docProps/app.xml +word/document.xml +word/styles.xml +word/_rels/document.xml.rels +``` + +Alle Texte werden über `XmlWriter` geschrieben. Dadurch werden Host-, Anwendungs- und Portnamen als Text behandelt und können kein XML-Markup einschleusen. + +## 12. Lauf auf ACC und PROD + +Auf jedem der beiden BizTalk Server: + +1. Deployment in einen lokalen Ordner kopieren. +2. Administrative `cmd.exe` öffnen. +3. `BizTalkSapEnvironmentInventory.exe --self-test` ausführen. +4. Erfassung starten. +5. Exitcode prüfen. +6. DOCX und Log gemeinsam kontrollieren. +7. Anwendungsliste gegen BizTalk Administration abgleichen. +8. SAP-Endpunkte und Hostzuordnung prüfen. +9. WE20-/WE21-/Credential-/DR-Findings an SAP Basis geben. + +ACC: + +```cmd +run-inventory.cmd ACC C:\BizTalk-Doku\ACC +``` + +PROD: + +```cmd +run-inventory.cmd PROD C:\BizTalk-Doku\PROD +``` + +## 13. Build und Tests + +Buildbaseline: + +- klassisches MSBuild-Projekt +- .NET Framework 4.7.2 +- C# 7.3 +- Visual Studio 2019/MSBuild 16.x +- keine SDK-style-Projekte +- keine NuGet-Pakete + +```cmd +scripts\build-release.cmd +scripts\package-release.cmd +``` + +Tests prüfen: + +- Secret-Redaktion in XML und SAP-URI +- Parsing eines WCF-SAP-Gruppenbindings +- SAP-Client, Host und `UseSnc` +- Anwendungszuordnung +- DOCX-Paketstruktur +- XML-Gültigkeit aller DOCX-Parts +- Ausschluss von Testkennwörtern + +## 14. Bekannte Grenzen + +- kein SAP-Login +- kein aktiver RFC-Ping +- keine direkte Abfrage von WE20, WE21 oder SM59 +- keine Auflösung von Credential-Owner oder Rotation +- keine Garantie, dass Schemanamen alle verwendeten IDoc-Erweiterungen enthalten +- dynamisch zur Laufzeit konstruierte SAP-Adressen können im statischen Binding fehlen +- WMI-Klassen unterscheiden sich teilweise je BizTalk-Version/CU +- lokaler Ist-Stand; ACC und PROD müssen getrennt ausgeführt werden + +## 15. Referenzen + +- Microsoft: BizTalk Server 2020 Hardware and Software Requirements +- Microsoft: `BTSTask ExportBindings` +- Microsoft: BizTalk WMI Technical Reference +- Microsoft: WCF-SAP Port Configuration +- Microsoft: SAP System Connection URI +- Microsoft: Security between the SAP system and the adapter diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..32bb8dd --- /dev/null +++ b/Readme.md @@ -0,0 +1,231 @@ +# BEW BizTalk SAP Environment Inventory + +`BEW BizTalk SAP Environment Inventory` erzeugt lokal auf einem BizTalk Server 2020 eine Microsoft-Word-Dokumentation (`.docx`) der SAP-Adapterverbindungen und eine vollständige Liste aller BizTalk-Anwendungen. + +Das Tool wird einmal auf dem BizTalk Server in `ACC` und einmal auf dem BizTalk Server in `PROD` ausgeführt. Beide Umgebungen bestehen jeweils aus: + +- einem BizTalk Server 2020 +- einem separaten SQL Server für die BizTalk-Datenbanken + +Microsoft Office, Word, PowerShell, ein .NET SDK, Internetzugriff und zusätzliche BizTalk-Programmier-DLLs werden auf dem Zielserver nicht benötigt. + +## Erfasster Umfang + +### Vollständige BizTalk-Anwendungsliste + +- alle Anwendungen aus `MSBTS_Application` +- Anwendungsstatus und Beschreibung +- Anzahl und Zuordnung von Orchestrierungen +- Send Ports und Send Port Groups +- Receive Ports und Receive Locations +- Assemblies, Schemas, Maps und Pipelines, soweit die jeweilige WMI-Klasse verfügbar ist +- SAP-/IDoc-Indikatoren aus bereitgestellten Schemas, Actions und Artefaktnamen + +Die Liste ist nicht auf eine bisher bekannte 15-App-Liste begrenzt. Damit werden auch weitere Anwendungen wie beispielsweise `MasterData_SAP_WebGIS`, `MasterDataHeat`, `WoWHeatInvoice` oder `MeterChangeInfo/Order` sichtbar, sofern sie tatsächlich in der jeweiligen BizTalk-Gruppe installiert sind. + +### SAP-Adapterverbindungen + +Aus dem BizTalk-Gruppenbinding und WMI werden unter anderem dokumentiert: + +- BizTalk-Anwendung und Portzuordnung +- Sende- oder Empfangsrichtung +- WCF-SAP bzw. WCF-Custom mit `sapBinding` +- Port-, Receive-Location- und Handlername +- RFC-/Listener-Destination +- SAP-System-ID beziehungsweise R/3-Systemname +- Application Server oder Message Server +- Systemnummer +- Gateway Host und Gateway Service +- SAP Client und Sprache +- Listener Program ID +- SAP Router +- Actions beziehungsweise erkannte RFC-/IDoc-Operationen +- SSO-/Benutzerreferenz, jedoch niemals das Kennwort + +### SAP NCo, Hosts und SNC + +- konfigurierte SAP-Adapterdefinitionen +- Receive-/Send-Handler und BizTalk Hosts +- bekannte ERP-/ISU-Hostfamilien werden nicht fest verdrahtet, sondern aus der realen Umgebung gelesen +- SAP .NET Connector und Adapter-Pack-Installationen +- `sapnco.dll`, `sapnco_utils.dll` und Dateiversion/Architektur +- `UseSnc`, `SncLibrary`, `SncPartnerName`, `SncMyName` und `SncQop` +- `SNC_LIB`, `SECUDIR` und `SAPNWRFC_HOME` +- PSE-/Zertifikatspfad, Gültigkeit und Fingerprint, soweit lokal lesbar + +## Was nicht automatisch aus BizTalk ausgelesen werden kann + +Folgende Informationen liegen im SAP-System oder in Betriebsprozessen und werden im Bericht als offene Nachweise ausgewiesen: + +- Partnerprofile aus Transaktion `WE20` +- Portdefinitionen aus Transaktion `WE21` +- vollständige SAP-seitige RFC-Destination einschließlich Verbindungstest +- verbindliche Liste der IDoc-Basistypen und Erweiterungen +- tatsächliche SAP-Kennwörter +- Owner und Rotationsprozess für SAP-Credentials bzw. SNC-PSE/Zertifikate +- DR-Verfahren für die SAP-seitige Umschaltung der RFC-Destination nach Frankfurt + +Das Tool erfindet hierfür keine Werte. Es dokumentiert nur lokal belegbare Indikatoren und nennt die erforderliche Ergänzung sowie die zuständige Rolle. + +## Sicherheitsprinzip + +- ausschließlich read-only WMI- und Registry-Abfragen +- `BTSTask ExportBindings /GroupLevel` wird nur zum Lesen verwendet +- temporärer Binding-Export wird nach dem Parsen gelöscht +- BizTalk entfernt Kennwörter bereits beim Binding-Export +- zusätzliche Redigierung von Passwort-, Secret-, Token-, Private-Key- und Connection-String-Feldern +- keine privaten Schlüssel oder PSE-Inhalte im DOCX +- kein SAP-Login und kein aktiver RFC-Verbindungstest + +DOCX und Log enthalten trotzdem interne Host-, Anwendungs-, Zertifikats- und Pfadinformationen und müssen geschützt abgelegt werden. + +## Voraussetzungen auf ACC und PROD + +- lokale Ausführung auf dem jeweiligen BizTalk Server +- administrative `cmd.exe` +- Konto ist Mitglied von `BizTalk Server Administrators` oder besitzt vergleichbare Leserechte +- .NET Framework 4.7.2 oder höher +- Zugriff auf die konfigurierte BizTalk Management Database +- `BTSTask.exe` aus der lokalen BizTalk-Installation + +## Schnellstart + +1. Deployment-Ordner auf den BizTalk Server kopieren. +2. Administrative `cmd.exe` öffnen. +3. In den Deployment-Ordner wechseln. +4. Self-Test starten. +5. Inventarisierung für die richtige Umgebung ausführen. + +Self-Test: + +```cmd +BizTalkSapEnvironmentInventory.exe --self-test +``` + +ACC: + +```cmd +run-inventory.cmd ACC C:\BizTalk-Doku\ACC +``` + +PROD: + +```cmd +run-inventory.cmd PROD C:\BizTalk-Doku\PROD +``` + +Direkter Aufruf: + +```cmd +BizTalkSapEnvironmentInventory.exe ^ + --environment ACC ^ + --output C:\BizTalk-Doku\ACC +``` + +## Konsolenausgabe + +Das Tool zeigt jeden Abschnitt und die erzeugten Dateien direkt auf der Konsole an: + +```text +[INFO] Starte Abschnitt: BizTalk-Anwendungen und WMI-Artefakte +[INFO] 34 BizTalk-Anwendung(en) über WMI gefunden. +[INFO] Exportiere BizTalk-Gruppenbindings read-only mit BTSTask. +[INFO] 12 SAP-Endpunkt(e) aus dem Binding-Export ausgewertet. +[INFO] Erzeuge Microsoft-Word-Dokument: C:\BizTalk-Doku\ACC\... +[INFO] Word-Dokument erfolgreich erzeugt: C:\BizTalk-Doku\ACC\... +``` + +Fehler eines Collectors stoppen die übrigen Abschnitte nicht. Sie erscheinen im Log, im Erfassungsstatus und als Finding im Word-Bericht. + +## Ausgabedateien + +```text +BizTalk-SAP-Dokumentation-ACC-BIZTALKSERVER-20260727-150000.docx +BizTalk-SAP-Dokumentation-ACC-BIZTALKSERVER-20260727-150000.log +``` + +Das DOCX wird direkt als Office Open XML erzeugt. Auf dem BizTalk Server wird keine Office-Anwendung benötigt. + +## Kommandozeilenoptionen + +| Option | Bedeutung | +| --- | --- | +| `--environment NAME` | Umgebung, regulär `ACC` oder `PROD`. | +| `--output PFAD` | Zielordner für DOCX und Log. | +| `--management-server NAME` | SQL-Server der BizTalk Management Database; normalerweise aus Registry. | +| `--management-database NAME` | Name der Management Database; Standard/Fallback `BizTalkMgmtDb`. | +| `--btstask DATEI` | Expliziter Pfad zu `BTSTask.exe`. | +| `--binding-file DATEI` | Vorhandenen Binding-Export offline auswerten, ohne `BTSTask` aufzurufen. | +| `--self-test` | Prüft Secret-Redaktion, SAP-Parser und DOCX-Struktur. | +| `--help` | Hilfe anzeigen. | + +## Exitcodes + +| Code | Bedeutung | +| ---: | --- | +| `0` | Pflichtabschnitte vollständig und DOCX erzeugt. | +| `1` | DOCX erzeugt, aber mindestens ein Pflichtabschnitt war unvollständig. | +| `2` | Fataler Aufruf-, Ausgabe- oder Berichtserzeugungsfehler. | + +## Build für BizTalk Server 2020 + +Die Solution verwendet bewusst das klassische Visual-Studio-2019-Projektformat: + +| Komponente | Vorgabe | +| --- | --- | +| Ziel | .NET Framework 4.7.2 | +| Sprache | C# 7.3 | +| Projektformat | klassisches MSBuild, kein `Microsoft.NET.Sdk` | +| Buildumgebung | Visual Studio 2019/Build Tools, MSBuild 16.x oder neuer | +| Pakete | keine NuGet-Abhängigkeiten | + +Build und Tests: + +```cmd +scripts\build-release.cmd +scripts\package-release.cmd +``` + +Deployment: + +```text +artifacts\BizTalkSapEnvironmentInventory-deploy\ +``` + +Die Gitea-Workflowdatei `.gitea/workflows/build.yml` baut, testet und veröffentlicht den Deployment-Ordner auf einem Windows-Runner. + +## Troubleshooting + +### Keine Anwendungen + +- lokal auf dem BizTalk Server ausführen +- Konto und Mitgliedschaft in den BizTalk-Administrator-/Operator-Gruppen prüfen +- WMI-Namespace `root\MicrosoftBizTalkServer` prüfen + +### BTSTask findet die Management Database nicht + +Management SQL Server und Datenbank explizit angeben: + +```cmd +BizTalkSapEnvironmentInventory.exe ^ + --environment ACC ^ + --management-server SQL-ACC ^ + --management-database BizTalkMgmtDb +``` + +Alternativ einen autorisierten Binding-Export bereitstellen: + +```cmd +BizTalkSapEnvironmentInventory.exe ^ + --environment ACC ^ + --binding-file C:\Temp\ACC-GroupBindings.xml +``` + +### Keine SAP-Endpunkte + +- prüfen, ob der Adapter als `WCF-SAP` oder als `WCF-Custom` mit `sapBinding` verwendet wird +- vollständigen Gruppenbindingexport statt nur einer Anwendung verwenden +- DOCX-Findings und Log prüfen + +Weitere Architektur- und Sicherheitsdetails stehen in [Dokumentation.md](Dokumentation.md). + diff --git a/deployment/run-inventory.cmd b/deployment/run-inventory.cmd new file mode 100644 index 0000000..0ee8357 --- /dev/null +++ b/deployment/run-inventory.cmd @@ -0,0 +1,52 @@ +@echo off +setlocal EnableExtensions + +set "APPDIR=%~dp0" +set "EXE=%APPDIR%BizTalkSapEnvironmentInventory.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%\BizTalk-SAP-Dokumentation\%ENVIRONMENT%" +) else ( + set "OUTPUT=%~2" +) + +echo ============================================================ +echo BEW BizTalk SAP Environment Inventory +echo Umgebung: %ENVIRONMENT% +echo Server: %COMPUTERNAME% +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 UMGEBUNG [AUSGABEORDNER] +echo. +echo Beispiele: +echo run-inventory.cmd ACC C:\BizTalk-Doku\ACC +echo run-inventory.cmd PROD C:\BizTalk-Doku\PROD +exit /b 2 + diff --git a/scripts/build-release.cmd b/scripts/build-release.cmd new file mode 100644 index 0000000..65244b6 --- /dev/null +++ b/scripts/build-release.cmd @@ -0,0 +1,44 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "SOLUTION=%ROOT%\BizTalkSapEnvironmentInventory.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 2019 Build Tools und das .NET Framework 4.7.2 Developer Pack installieren. + exit /b 2 +) + +echo Verwende MSBuild: %MSBUILD% +"%MSBUILD%" -version +echo Baue klassisches .NET-Framework-4.7.2-Projekt ohne .NET-SDK- oder NuGet-Abhaengigkeit ... +"%MSBUILD%" "%SOLUTION%" /m /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre Tests aus ... +"%ROOT%\tests\BizTalkSapEnvironmentInventory.Tests\bin\Release\BizTalkSapEnvironmentInventory.Tests.exe" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre Self-Test der Anwendung aus ... +"%ROOT%\src\BizTalkSapEnvironmentInventory\bin\Release\BizTalkSapEnvironmentInventory.exe" --self-test +exit /b %ERRORLEVEL% + diff --git a/scripts/package-release.cmd b/scripts/package-release.cmd new file mode 100644 index 0000000..658cb09 --- /dev/null +++ b/scripts/package-release.cmd @@ -0,0 +1,25 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "BIN=%ROOT%\src\BizTalkSapEnvironmentInventory\bin\Release" +set "ARTIFACTS=%ROOT%\artifacts" +set "DEPLOY=%ARTIFACTS%\BizTalkSapEnvironmentInventory-deploy" + +call "%ROOT%\scripts\build-release.cmd" +if errorlevel 1 exit /b %ERRORLEVEL% + +if exist "%DEPLOY%" rmdir /s /q "%DEPLOY%" +mkdir "%DEPLOY%" + +copy "%BIN%\BizTalkSapEnvironmentInventory.exe" "%DEPLOY%\" >nul +copy "%BIN%\BizTalkSapEnvironmentInventory.exe.config" "%DEPLOY%\" >nul +if exist "%BIN%\BizTalkSapEnvironmentInventory.pdb" copy "%BIN%\BizTalkSapEnvironmentInventory.pdb" "%DEPLOY%\" >nul +copy "%ROOT%\deployment\run-inventory.cmd" "%DEPLOY%\" >nul +copy "%ROOT%\Readme.md" "%DEPLOY%\" >nul +copy "%ROOT%\Dokumentation.md" "%DEPLOY%\" >nul + +echo Deployment-Ordner erstellt: +echo %DEPLOY% +exit /b 0 + diff --git a/src/BizTalkSapEnvironmentInventory/App.config b/src/BizTalkSapEnvironmentInventory/App.config new file mode 100644 index 0000000..b76acd1 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/App.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/BizTalkSapEnvironmentInventory/BizTalkSapEnvironmentInventory.csproj b/src/BizTalkSapEnvironmentInventory/BizTalkSapEnvironmentInventory.csproj new file mode 100644 index 0000000..dbb6316 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/BizTalkSapEnvironmentInventory.csproj @@ -0,0 +1,69 @@ + + + + Debug + AnyCPU + {43962556-3DD2-4846-96E2-76B4749F17F2} + Exe + BizTalkSapEnvironmentInventory + BizTalkSapEnvironmentInventory + v4.7.2 + + 512 + 7.3 + true + true + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + false + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + AnyCPU + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BizTalkSapEnvironmentInventory/Collectors/BindingExportCollector.cs b/src/BizTalkSapEnvironmentInventory/Collectors/BindingExportCollector.cs new file mode 100644 index 0000000..0d1c4fb --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Collectors/BindingExportCollector.cs @@ -0,0 +1,796 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using BizTalkSapEnvironmentInventory.Configuration; +using BizTalkSapEnvironmentInventory.Infrastructure; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Collectors +{ + /// + /// Exportiert BizTalk-Gruppenbindings read-only und extrahiert sichere SAP-Endpunktparameter. + /// + internal sealed class BindingExportCollector + { + private static readonly Regex ApplicationModuleName = new Regex( + @"^\[Application:(.+)\]$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly CommandLineOptions options; + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + private readonly TimeSpan processTimeout; + + public BindingExportCollector( + CommandLineOptions options, + InventoryDocument document, + ConsoleFileLogger logger, + int timeoutSeconds) + { + this.options = options; + this.document = document; + this.logger = logger; + processTimeout = TimeSpan.FromSeconds(Math.Max(30, timeoutSeconds)); + } + + /// + /// Verarbeitet entweder eine vorgegebene Binding-Datei oder einen temporären BTSTask-Export. + /// + public void Collect() + { + if (!string.IsNullOrWhiteSpace(options.BindingFilePath)) + { + logger.Info("Werte vorhandenen Binding-Export offline aus: " + options.BindingFilePath); + ParseBindingDocument(LoadXml(options.BindingFilePath), "Angegebene Binding-Datei"); + return; + } + + var btsTask = ResolveBtsTaskPath(); + if (string.IsNullOrWhiteSpace(btsTask)) + { + throw new FileNotFoundException( + "BTSTask.exe wurde nicht gefunden. --btstask oder --binding-file verwenden."); + } + + var temporaryPath = Path.Combine( + Path.GetTempPath(), + "BizTalkSapInventory-" + Guid.NewGuid().ToString("N") + ".xml"); + try + { + ExportBindings(btsTask, temporaryPath); + ParseBindingDocument(LoadXml(temporaryPath), "BTSTask GroupLevel Export"); + } + finally + { + TryDelete(temporaryPath); + } + } + + /// + /// Parst ein Binding-Dokument; intern sichtbar, damit der Self-Test reale XML-Strukturen prüft. + /// + internal void ParseBindingDocument(XDocument bindingDocument, string source) + { + if (bindingDocument == null || bindingDocument.Root == null) + { + throw new InvalidDataException("Binding-XML ist leer."); + } + + AddApplicationsFromBinding(bindingDocument); + + var parsed = 0; + foreach (var sendPort in bindingDocument.Descendants() + .Where(item => IsName(item, "SendPort") && HasAncestor(item, "SendPortCollection"))) + { + var endpoint = ParseSendPort(sendPort, source); + if (endpoint != null) + { + MergeEndpoint(endpoint); + parsed++; + } + } + + foreach (var receiveLocation in bindingDocument.Descendants() + .Where(item => IsName(item, "ReceiveLocation") + && HasAncestor(item, "ReceiveLocations"))) + { + var endpoint = ParseReceiveLocation(receiveLocation, source); + if (endpoint != null) + { + MergeEndpoint(endpoint); + parsed++; + } + } + + logger.Info(parsed + " SAP-Endpunkt(e) aus dem Binding-Export ausgewertet."); + } + + private SapEndpointRecord ParseSendPort(XElement sendPort, string source) + { + var primaryTransport = Child(sendPort, "PrimaryTransport"); + var transportType = primaryTransport == null ? null : Child(primaryTransport, "TransportType"); + var transportData = primaryTransport == null ? null : Child(primaryTransport, "TransportTypeData"); + var properties = ParsePropertyBag(transportData); + var adapterName = Attribute(transportType, "Name"); + var address = ChildValue(primaryTransport, "Address"); + + if (!LooksLikeSap(adapterName, address, properties)) + { + return null; + } + + var endpoint = new SapEndpointRecord + { + ApplicationName = FirstNonEmpty( + Attribute(sendPort, "ApplicationName"), + ChildValue(sendPort, "ApplicationName")), + Direction = "Senden", + Name = Attribute(sendPort, "Name"), + ParentPortName = Attribute(sendPort, "Name"), + AdapterName = adapterName, + HostName = FirstNonEmpty( + Attribute(Child(primaryTransport, "SendHandler"), "Name"), + ChildValue(primaryTransport, "SendHandler")), + Status = FirstNonEmpty(Attribute(sendPort, "Status"), "Konfiguriert"), + Address = SensitiveDataSanitizer.SanitizeText(address), + Source = source + }; + AddProperties(endpoint, properties); + ParseSapAddress(endpoint, address); + FinalizeEndpoint(endpoint); + return endpoint; + } + + private SapEndpointRecord ParseReceiveLocation(XElement receiveLocation, string source) + { + var transportType = Child(receiveLocation, "ReceiveLocationTransportType"); + var transportData = Child(receiveLocation, "ReceiveLocationTransportTypeData"); + var properties = ParsePropertyBag(transportData); + var adapterName = Attribute(transportType, "Name"); + var address = FirstNonEmpty( + ChildValue(receiveLocation, "Address"), + Attribute(receiveLocation, "Address")); + + if (!LooksLikeSap(adapterName, address, properties)) + { + return null; + } + + var receivePort = receiveLocation.Ancestors() + .FirstOrDefault(item => IsName(item, "ReceivePort")); + var endpoint = new SapEndpointRecord + { + ApplicationName = FirstNonEmpty( + Attribute(receiveLocation, "ApplicationName"), + ChildValue(receiveLocation, "ApplicationName"), + Attribute(receivePort, "ApplicationName"), + ChildValue(receivePort, "ApplicationName")), + Direction = "Empfangen", + Name = Attribute(receiveLocation, "Name"), + ParentPortName = Attribute(receivePort, "Name"), + AdapterName = adapterName, + HostName = FirstNonEmpty( + Attribute(Child(receiveLocation, "ReceiveHandler"), "Name"), + ChildValue(receiveLocation, "ReceiveHandler")), + Status = FirstNonEmpty( + Attribute(receiveLocation, "Enable"), + Attribute(receiveLocation, "Status"), + "Konfiguriert"), + Address = SensitiveDataSanitizer.SanitizeText(address), + Source = source + }; + AddProperties(endpoint, properties); + ParseSapAddress(endpoint, address); + FinalizeEndpoint(endpoint); + return endpoint; + } + + private static List ParsePropertyBag(XElement dataElement) + { + var result = new List(); + if (dataElement == null) + { + return result; + } + + var raw = dataElement.HasElements + ? string.Concat(dataElement.Nodes().Select(item => item.ToString(SaveOptions.DisableFormatting))) + : dataElement.Value; + ParseXmlProperties(raw, "Binding", result, 0); + return result; + } + + private static void ParseXmlProperties( + string raw, + string source, + List target, + int depth) + { + if (string.IsNullOrWhiteSpace(raw) || depth > 4) + { + return; + } + + raw = WebUtility.HtmlDecode(raw.Trim()); + if (!raw.StartsWith("<", StringComparison.Ordinal)) + { + return; + } + + XElement root; + try + { + root = XElement.Parse(raw, LoadOptions.None); + } + catch (XmlException) + { + return; + } + + foreach (var element in root.DescendantsAndSelf()) + { + foreach (var attribute in element.Attributes()) + { + if (string.Equals(attribute.Name.LocalName, "vt", StringComparison.OrdinalIgnoreCase) + || attribute.IsNamespaceDeclaration) + { + continue; + } + + AddUnique( + target, + element.Name.LocalName + "." + attribute.Name.LocalName, + SensitiveDataSanitizer.RedactValue(attribute.Name.LocalName, attribute.Value), + source); + AddUnique( + target, + NormalizePropertyName(attribute.Name.LocalName), + SensitiveDataSanitizer.RedactValue(attribute.Name.LocalName, attribute.Value), + source); + } + + if (element.HasElements) + { + continue; + } + + var name = element.Name.LocalName; + if (!SensitiveDataSanitizer.IsSensitiveName(name) + && element.Value.TrimStart().StartsWith("<", StringComparison.Ordinal)) + { + ParseXmlProperties(element.Value, name, target, depth + 1); + continue; + } + + var value = SensitiveDataSanitizer.RedactValue(name, element.Value); + AddUnique(target, name, value, source); + } + } + + private static void ParseSapAddress(SapEndpointRecord endpoint, string rawAddress) + { + if (string.IsNullOrWhiteSpace(rawAddress) + || !rawAddress.StartsWith("sap://", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var address = SensitiveDataSanitizer.SanitizeText(rawAddress); + var withoutScheme = address.Substring("sap://".Length); + var atIndex = withoutScheme.IndexOf('@'); + var userInfo = atIndex >= 0 ? withoutScheme.Substring(0, atIndex) : string.Empty; + var hostAndQuery = atIndex >= 0 ? withoutScheme.Substring(atIndex + 1) : withoutScheme; + + foreach (var item in userInfo.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var pair = item.Split(new[] { '=' }, 2); + if (pair.Length == 2) + { + AddUnique( + endpoint.Properties, + NormalizePropertyName(pair[0]), + Decode(pair[1]), + "SAP-URI"); + } + } + + var queryIndex = hostAndQuery.IndexOf('?'); + var hostPart = queryIndex >= 0 + ? hostAndQuery.Substring(0, queryIndex) + : hostAndQuery; + var query = queryIndex >= 0 + ? hostAndQuery.Substring(queryIndex + 1) + : string.Empty; + + var hostSegments = hostPart.Split('/'); + if (hostSegments.Length > 0) + { + AddUnique(endpoint.Properties, "ConnectionType", Decode(hostSegments[0]), "SAP-URI"); + if (string.Equals(hostSegments[0], "A", StringComparison.OrdinalIgnoreCase)) + { + if (hostSegments.Length > 1) + { + AddUnique(endpoint.Properties, "ApplicationServerHost", Decode(hostSegments[1]), "SAP-URI"); + } + if (hostSegments.Length > 2) + { + AddUnique(endpoint.Properties, "SystemNumber", Decode(hostSegments[2]), "SAP-URI"); + } + } + else if (string.Equals(hostSegments[0], "B", StringComparison.OrdinalIgnoreCase)) + { + if (hostSegments.Length > 1) + { + AddUnique(endpoint.Properties, "MessageServerHost", Decode(hostSegments[1]), "SAP-URI"); + } + if (hostSegments.Length > 2) + { + AddUnique(endpoint.Properties, "R3SystemName", Decode(hostSegments[2]), "SAP-URI"); + } + } + else if (string.Equals(hostSegments[0], "D", StringComparison.OrdinalIgnoreCase) + && hostSegments.Length > 1) + { + AddUnique(endpoint.Properties, "DestinationName", Decode(hostSegments[1]), "SAP-URI"); + } + } + + foreach (var item in query.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + var pair = item.Split(new[] { '=' }, 2); + if (pair.Length == 2) + { + AddUnique( + endpoint.Properties, + NormalizePropertyName(Decode(pair[0])), + SensitiveDataSanitizer.RedactValue(pair[0], Decode(pair[1])), + "SAP-URI"); + } + } + } + + private void FinalizeEndpoint(SapEndpointRecord endpoint) + { + ResolveApplication(endpoint); + + var useSnc = endpoint.GetProperty("UseSnc", "UseSNC"); + var sncEnabled = IsTrue(useSnc); + var affiliateApplication = endpoint.GetProperty( + "AffiliateApplicationName", + "SsoAffiliateApplication", + "SSOApplication"); + var userName = endpoint.GetProperty("UserName", "Username"); + + if (sncEnabled) + { + endpoint.SecurityMode = "SAP SNC"; + } + else if (!string.IsNullOrWhiteSpace(affiliateApplication)) + { + endpoint.SecurityMode = "Enterprise SSO"; + } + else if (!string.IsNullOrWhiteSpace(userName)) + { + endpoint.SecurityMode = "SAP-Benutzer"; + } + else + { + endpoint.SecurityMode = "Nicht eindeutig aus Binding ableitbar"; + } + + if (!string.IsNullOrWhiteSpace(affiliateApplication)) + { + endpoint.CredentialReference = "SSO Affiliate Application: " + affiliateApplication; + } + else if (!string.IsNullOrWhiteSpace(userName)) + { + endpoint.CredentialReference = "SAP-Benutzer: " + userName + + "; Kennwort wird nicht exportiert/dokumentiert."; + } + else + { + endpoint.CredentialReference = + "Keine Kennwortinformation im Binding-Export; Credentials separat kontrollieren."; + } + + foreach (var property in endpoint.Properties.Where(item => + item.Name.IndexOf("Action", StringComparison.OrdinalIgnoreCase) >= 0 + || item.Value.IndexOf("Microsoft.LobServices.Sap", StringComparison.OrdinalIgnoreCase) >= 0)) + { + var operation = SensitiveDataSanitizer.SanitizeText(property.Value); + if (!string.IsNullOrWhiteSpace(operation) + && !endpoint.Operations.Contains(operation, StringComparer.OrdinalIgnoreCase)) + { + endpoint.Operations.Add(operation); + } + } + } + + private void ResolveApplication(SapEndpointRecord endpoint) + { + if (!string.IsNullOrWhiteSpace(endpoint.ApplicationName)) + { + EnsureApplication(endpoint.ApplicationName); + return; + } + + foreach (var application in document.Applications) + { + if (application.Artifacts.Any(item => + string.Equals(item.Name, endpoint.Name, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(endpoint.ParentPortName) + && string.Equals( + item.Name, + endpoint.ParentPortName, + StringComparison.OrdinalIgnoreCase)))) + { + endpoint.ApplicationName = application.Name; + return; + } + } + } + + private void MergeEndpoint(SapEndpointRecord parsed) + { + var existing = document.SapEndpoints.FirstOrDefault(item => + string.Equals(item.Direction, parsed.Direction, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Name, parsed.Name, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { + document.SapEndpoints.Add(parsed); + return; + } + + existing.ApplicationName = FirstNonEmpty(parsed.ApplicationName, existing.ApplicationName); + existing.ParentPortName = FirstNonEmpty(parsed.ParentPortName, existing.ParentPortName); + existing.AdapterName = FirstNonEmpty(parsed.AdapterName, existing.AdapterName); + existing.HostName = FirstNonEmpty(parsed.HostName, existing.HostName); + existing.Status = FirstNonEmpty(parsed.Status, existing.Status); + existing.Address = FirstNonEmpty(parsed.Address, existing.Address); + existing.SecurityMode = FirstNonEmpty(parsed.SecurityMode, existing.SecurityMode); + existing.CredentialReference = FirstNonEmpty( + parsed.CredentialReference, + existing.CredentialReference); + existing.Source = FirstNonEmpty(parsed.Source, existing.Source); + foreach (var property in parsed.Properties) + { + AddUnique(existing.Properties, property.Name, property.Value, property.Source); + } + foreach (var operation in parsed.Operations) + { + if (!existing.Operations.Contains(operation, StringComparer.OrdinalIgnoreCase)) + { + existing.Operations.Add(operation); + } + } + } + + private void AddApplicationsFromBinding(XDocument bindingDocument) + { + foreach (var module in bindingDocument.Descendants().Where(item => IsName(item, "ModuleRef"))) + { + var match = ApplicationModuleName.Match(Attribute(module, "Name")); + if (match.Success) + { + EnsureApplication(match.Groups[1].Value); + } + } + + foreach (var applicationName in bindingDocument.Descendants() + .Where(item => IsName(item, "ApplicationName")) + .Select(item => item.Value) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + EnsureApplication(applicationName); + } + } + + private void EnsureApplication(string name) + { + if (string.IsNullOrWhiteSpace(name) + || document.Applications.Any(item => + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + document.Applications.Add(new ApplicationRecord + { + Name = name.Trim(), + Status = "Aus Binding-Export erkannt" + }); + } + + private void ExportBindings(string btsTaskPath, string destination) + { + var arguments = new StringBuilder(); + arguments.Append("ExportBindings /GroupLevel /Destination:\"") + .Append(EscapeCommandArgument(destination)) + .Append("\""); + if (!string.IsNullOrWhiteSpace(document.System.ManagementServer)) + { + arguments.Append(" /Server:\"") + .Append(EscapeCommandArgument(document.System.ManagementServer)) + .Append("\""); + } + if (!string.IsNullOrWhiteSpace(document.System.ManagementDatabase)) + { + arguments.Append(" /Database:\"") + .Append(EscapeCommandArgument(document.System.ManagementDatabase)) + .Append("\""); + } + + logger.Info("Exportiere BizTalk-Gruppenbindings read-only mit BTSTask."); + var output = new StringBuilder(); + var startInfo = new ProcessStartInfo + { + FileName = btsTaskPath, + Arguments = arguments.ToString(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(btsTaskPath) + }; + + using (var process = new Process { StartInfo = startInfo }) + { + process.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + { + output.AppendLine(args.Data); + } + }; + process.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + { + output.AppendLine(args.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit((int)processTimeout.TotalMilliseconds)) + { + try + { + process.Kill(); + } + catch (InvalidOperationException) + { + } + throw new TimeoutException( + "BTSTask ExportBindings überschritt " + processTimeout.TotalSeconds + " Sekunden."); + } + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + "BTSTask ExportBindings meldete Exitcode " + + process.ExitCode + + ": " + + LastLines(output.ToString(), 8)); + } + } + + if (!File.Exists(destination) || new FileInfo(destination).Length == 0) + { + throw new InvalidDataException("BTSTask erzeugte keine Binding-Datei."); + } + } + + private string ResolveBtsTaskPath() + { + var candidates = new List(); + if (!string.IsNullOrWhiteSpace(options.BtsTaskPath)) + { + candidates.Add(options.BtsTaskPath); + } + if (!string.IsNullOrWhiteSpace(document.System.BizTalkInstallPath)) + { + candidates.Add(Path.Combine(document.System.BizTalkInstallPath, "BTSTask.exe")); + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + candidates.Add(Path.Combine(programFiles, "Microsoft BizTalk Server 2020", "BTSTask.exe")); + candidates.Add(Path.Combine(programFilesX86, "Microsoft BizTalk Server 2020", "BTSTask.exe")); + + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + candidates.AddRange(path.Split(Path.PathSeparator) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => Path.Combine(item.Trim(), "BTSTask.exe"))); + + return candidates.FirstOrDefault(File.Exists); + } + + private static XDocument LoadXml(string path) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreComments = true + }; + using (var stream = File.OpenRead(path)) + using (var reader = XmlReader.Create(stream, settings)) + { + return XDocument.Load(reader, LoadOptions.None); + } + } + + private static bool LooksLikeSap( + string adapterName, + string address, + IEnumerable properties) + { + if ((adapterName ?? string.Empty).IndexOf("SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || (address ?? string.Empty).StartsWith("sap://", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return properties.Any(item => + item.Name.IndexOf("SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || item.Value.IndexOf("sapBinding", StringComparison.OrdinalIgnoreCase) >= 0 + || item.Value.IndexOf("Microsoft.Adapters.SAP", StringComparison.OrdinalIgnoreCase) >= 0); + } + + private static void AddProperties( + SapEndpointRecord endpoint, + IEnumerable properties) + { + foreach (var property in properties) + { + AddUnique(endpoint.Properties, property.Name, property.Value, property.Source); + } + } + + private static void AddUnique( + List target, + string name, + string value, + string source) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) + { + return; + } + + var safeValue = SensitiveDataSanitizer.RedactValue(name, value); + if (target.Any(item => + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Value, safeValue, StringComparison.Ordinal))) + { + return; + } + target.Add(new NameValueRecord(name, safeValue, source)); + } + + private static string NormalizePropertyName(string name) + { + var normalized = (name ?? string.Empty).Trim(); + var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Client", "Client" }, + { "lang", "Language" }, + { "Language", "Language" }, + { "GwHost", "GatewayHost" }, + { "GwServ", "GatewayService" }, + { "ListenerDest", "ListenerDestination" }, + { "ListenerGwHost", "ListenerGatewayHost" }, + { "ListenerGwServ", "ListenerGatewayService" }, + { "ListenerProgramId", "ListenerProgramId" }, + { "UseSnc", "UseSnc" }, + { "SAPROUTER", "SapRouter" } + }; + string mapped; + return mappings.TryGetValue(normalized, out mapped) ? mapped : normalized; + } + + private static XElement Child(XElement parent, string localName) + { + return parent == null + ? null + : parent.Elements().FirstOrDefault(item => IsName(item, localName)); + } + + private static string ChildValue(XElement parent, string localName) + { + var child = Child(parent, localName); + return child == null ? string.Empty : child.Value; + } + + private static string Attribute(XElement element, string localName) + { + if (element == null) + { + return string.Empty; + } + var attribute = element.Attributes().FirstOrDefault(item => + string.Equals(item.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + return attribute == null ? string.Empty : attribute.Value; + } + + private static bool IsName(XElement element, string localName) + { + return string.Equals( + element.Name.LocalName, + localName, + StringComparison.OrdinalIgnoreCase); + } + + private static bool HasAncestor(XElement element, string localName) + { + return element.Ancestors().Any(item => IsName(item, localName)); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + } + + private static bool IsTrue(string value) + { + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "ja", StringComparison.OrdinalIgnoreCase); + } + + private static string Decode(string value) + { + try + { + return Uri.UnescapeDataString((value ?? string.Empty).Replace("+", " ")); + } + catch (UriFormatException) + { + return value ?? string.Empty; + } + } + + private static string EscapeCommandArgument(string value) + { + return (value ?? string.Empty).Replace("\"", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty); + } + + private static string LastLines(string text, int count) + { + var lines = (text ?? string.Empty) + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + return string.Join(" | ", lines.Skip(Math.Max(0, lines.Length - count))); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Collectors/BizTalkWmiCollector.cs b/src/BizTalkSapEnvironmentInventory/Collectors/BizTalkWmiCollector.cs new file mode 100644 index 0000000..bf85bc4 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Collectors/BizTalkWmiCollector.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management; +using BizTalkSapEnvironmentInventory.Infrastructure; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Collectors +{ + /// + /// Liest Anwendungen, Artefakte, Adapter und Hosts aus dem BizTalk-WMI-Provider. + /// + internal sealed class BizTalkWmiCollector + { + private const string NamespacePath = @"root\MicrosoftBizTalkServer"; + + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + private readonly ManagementScope scope; + private readonly TimeSpan timeout; + private readonly int maxArtifactDetailsPerType; + + public BizTalkWmiCollector( + InventoryDocument document, + ConsoleFileLogger logger, + int timeoutSeconds, + int maxArtifactDetailsPerType) + { + this.document = document; + this.logger = logger; + timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds)); + this.maxArtifactDetailsPerType = Math.Max(100, maxArtifactDetailsPerType); + scope = new ManagementScope(@"\\" + Environment.MachineName + @"\" + NamespacePath); + scope.Options.Timeout = timeout; + } + + /// + /// Baut die vollständige Anwendungsliste auf und ergänzt verfügbare Artefaktbeziehungen. + /// + public void Collect() + { + scope.Connect(); + CollectApplications(); + + var artifactClasses = new[] + { + new ClassDescriptor("MSBTS_Orchestration", "Orchestration"), + new ClassDescriptor("MSBTS_SendPort", "SendPort"), + new ClassDescriptor("MSBTS_SendPortGroup", "SendPortGroup"), + new ClassDescriptor("MSBTS_ReceivePort", "ReceivePort"), + new ClassDescriptor("MSBTS_ReceiveLocation", "ReceiveLocation"), + new ClassDescriptor("MSBTS_Assembly", "Assembly"), + new ClassDescriptor("MSBTS_Schema", "Schema"), + new ClassDescriptor("MSBTS_Map", "Map"), + new ClassDescriptor("MSBTS_Pipeline", "Pipeline") + }; + + foreach (var descriptor in artifactClasses) + { + TryCollectArtifacts(descriptor); + } + + TryCollectComponents("MSBTS_AdapterSetting", "Adapter", document.Adapters, true); + TryCollectComponents("MSBTS_ReceiveHandler", "ReceiveHandler", document.Hosts, false); + if (!TryCollectComponents("MSBTS_SendHandler2", "SendHandler", document.Hosts, false)) + { + TryCollectComponents("MSBTS_SendHandler", "SendHandler", document.Hosts, false); + } + TryCollectComponents("MSBTS_HostSetting", "Host", document.Hosts, false); + TryCollectComponents("MSBTS_HostInstance", "HostInstance", document.Hosts, false); + + TryCollectWmiSapEndpoints("MSBTS_SendPort", "Senden"); + TryCollectWmiSapEndpoints("MSBTS_ReceiveLocation", "Empfangen"); + + document.Applications.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + } + + private void CollectApplications() + { + var rows = Query("SELECT * FROM MSBTS_Application"); + foreach (var row in rows) + { + var name = First(row, "Name", "ApplicationName"); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + GetOrCreateApplication(name).Description = First(row, "Description"); + GetOrCreateApplication(name).Status = FormatStatus(First(row, "Status")); + } + + if (document.Applications.Count == 0) + { + throw new InvalidOperationException( + "WMI lieferte keine BizTalk-Anwendungen. Berechtigungen und BizTalk-Gruppe prüfen."); + } + + logger.Info(document.Applications.Count + " BizTalk-Anwendung(en) über WMI gefunden."); + } + + private void TryCollectArtifacts(ClassDescriptor descriptor) + { + try + { + var count = 0; + foreach (var row in Query("SELECT * FROM " + descriptor.ClassName)) + { + if (count >= maxArtifactDetailsPerType) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = descriptor.DisplayName, + Message = "Artefaktdetails wurden bei " + + maxArtifactDetailsPerType + + " Einträgen begrenzt.", + RecommendedAction = "MaxArtifactDetailsPerType bei Bedarf kontrolliert erhöhen.", + Owner = "BizTalk-Betrieb" + }); + break; + } + + var applicationName = First(row, "ApplicationName", "Application"); + var name = First( + row, + "Name", + "AssemblyName", + "FullName", + "ReceivePortName", + "OrchestrationName"); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var artifact = new ArtifactRecord + { + Type = descriptor.DisplayName, + Name = name, + ApplicationName = applicationName, + Status = FormatStatus(First(row, "Status", "ServiceStatus")) + }; + AddSelectedProperties(row, artifact.Properties); + + if (!string.IsNullOrWhiteSpace(applicationName)) + { + GetOrCreateApplication(applicationName).Artifacts.Add(artifact); + } + else + { + var defaultApplication = document.Applications.FirstOrDefault(item => + string.Equals(item.Name, "BizTalk Application 1", StringComparison.OrdinalIgnoreCase)); + if (defaultApplication != null) + { + defaultApplication.Artifacts.Add(artifact); + } + } + + count++; + } + + logger.Info(descriptor.DisplayName + ": " + count + " Artefakt(e)."); + } + catch (ManagementException exception) + { + AddOptionalWmiFinding(descriptor.ClassName, exception); + } + } + + private bool TryCollectComponents( + string className, + string category, + List target, + bool onlySap) + { + try + { + foreach (var row in Query("SELECT * FROM " + className)) + { + if (onlySap && !LooksLikeSap(row)) + { + continue; + } + + var component = new ComponentRecord + { + Category = category, + Name = First(row, "Name", "AdapterName", "HostName", "RunningServer"), + Status = FormatStatus(First(row, "Status", "ServiceState", "HostType")) + }; + AddSelectedProperties(row, component.Properties); + if (!string.IsNullOrWhiteSpace(component.Name)) + { + target.Add(component); + } + } + return true; + } + catch (ManagementException exception) + { + AddOptionalWmiFinding(className, exception); + return false; + } + } + + private void TryCollectWmiSapEndpoints(string className, string direction) + { + try + { + foreach (var row in Query("SELECT * FROM " + className)) + { + if (!LooksLikeSap(row)) + { + continue; + } + + var endpointName = First(row, "Name"); + if (document.SapEndpoints.Any(item => + string.Equals(item.Direction, direction, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Name, endpointName, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var endpoint = new SapEndpointRecord + { + ApplicationName = First(row, "ApplicationName"), + Direction = direction, + Name = endpointName, + ParentPortName = First(row, "ReceivePortName"), + AdapterName = First( + row, + "PTTransportType", + "AdapterName", + "TransportType"), + HostName = First(row, "HostName", "SendHandler", "ReceiveHandler"), + Status = FormatStatus(First(row, "Status", "IsDisabled")), + Address = SensitiveDataSanitizer.SanitizeText(First( + row, + "PTAddress", + "InboundTransportURL", + "Address")), + Source = "WMI-Fallback" + }; + AddSelectedProperties(row, endpoint.Properties); + document.SapEndpoints.Add(endpoint); + } + } + catch (ManagementException exception) + { + AddOptionalWmiFinding(className + " SAP-Fallback", exception); + } + } + + private List Query(string query) + { + var options = new EnumerationOptions + { + ReturnImmediately = false, + Rewindable = false, + Timeout = timeout + }; + using (var searcher = new ManagementObjectSearcher( + scope, + new ObjectQuery(query), + options)) + { + return searcher.Get().Cast().ToList(); + } + } + + private ApplicationRecord GetOrCreateApplication(string name) + { + var application = document.Applications.FirstOrDefault(item => + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + if (application != null) + { + return application; + } + + application = new ApplicationRecord + { + Name = name, + Status = "Unbekannt" + }; + document.Applications.Add(application); + return application; + } + + private static void AddSelectedProperties( + ManagementBaseObject row, + List target) + { + var preferred = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ApplicationName", "Name", "Description", "Status", "HostName", + "ReceivePortName", "AdapterName", "PTTransportType", "STTransportType", + "PTAddress", "STAddress", "InboundTransportURL", "IsDisabled", + "IsTwoWay", "SendPipeline", "ReceivePipeline", "AssemblyName", + "FullName", "TargetNameSpace", "RootName", "MgmtDbNameOverride", + "MgmtDbServerOverride", "IsDefault", "HostType", "RunningServer", + "NTGroupName", "IsHost32BitOnly", "AuthTrusted" + }; + + foreach (PropertyData property in row.Properties) + { + if (!preferred.Contains(property.Name) || property.Value == null) + { + continue; + } + + var value = ConvertValue(property.Value); + target.Add(new NameValueRecord( + property.Name, + SensitiveDataSanitizer.RedactValue(property.Name, value), + "WMI")); + } + } + + private static bool LooksLikeSap(ManagementBaseObject row) + { + foreach (PropertyData property in row.Properties) + { + if (property.Value == null) + { + continue; + } + + var value = ConvertValue(property.Value); + if (value.IndexOf("WCF-SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || value.IndexOf("Microsoft.Adapters.SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || value.StartsWith("sap://", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "SAP", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string First(ManagementBaseObject row, params string[] names) + { + foreach (var name in names) + { + try + { + var value = row[name]; + if (value != null) + { + var text = ConvertValue(value); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + } + } + catch (ManagementException) + { + // Property is not part of this BizTalk WMI class/version. + } + } + + return string.Empty; + } + + private static string ConvertValue(object value) + { + var array = value as Array; + if (array != null && !(value is byte[])) + { + return string.Join( + ", ", + array.Cast().Select(item => + Convert.ToString(item, CultureInfo.InvariantCulture))); + } + + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private static string FormatStatus(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Unbekannt"; + } + + switch (value.Trim()) + { + case "1": + return "Gestoppt (1)"; + case "2": + return "Gestartet (2)"; + case "3": + return "Teilweise gestartet (3)"; + case "True": + return "Ja"; + case "False": + return "Nein"; + default: + return value; + } + } + + private void AddOptionalWmiFinding(string className, ManagementException exception) + { + document.Findings.Add(new Finding + { + Severity = "Hinweis", + Area = "WMI " + className, + Message = "Optionale WMI-Klasse konnte nicht gelesen werden: " + exception.Message, + RecommendedAction = "Nur relevant, wenn die zugehörige Artefaktspalte vollständig benötigt wird.", + Owner = "BizTalk-Betrieb" + }); + logger.Warning("Optionale WMI-Abfrage " + className + " fehlgeschlagen: " + exception.Message); + } + + private sealed class ClassDescriptor + { + public ClassDescriptor(string className, string displayName) + { + ClassName = className; + DisplayName = displayName; + } + + public string ClassName { get; private set; } + public string DisplayName { get; private set; } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Collectors/SapRuntimeCollector.cs b/src/BizTalkSapEnvironmentInventory/Collectors/SapRuntimeCollector.cs new file mode 100644 index 0000000..6fb45e9 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Collectors/SapRuntimeCollector.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Win32; +using BizTalkSapEnvironmentInventory.Infrastructure; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Collectors +{ + /// + /// Dokumentiert SAP-NCo-/SNC-Installationen, Architektur und sichere Security-Metadaten. + /// + internal sealed class SapRuntimeCollector + { + private static readonly string[] RuntimeFileNames = + { + "sapnco.dll", + "sapnco_utils.dll", + "sapcrypto.dll", + "sapgenpse.exe", + "libsapcrypto.dll", + "sapgsskrb5.dll" + }; + + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + private readonly bool includeCertificates; + + public SapRuntimeCollector( + InventoryDocument document, + ConsoleFileLogger logger, + bool includeCertificates) + { + this.document = document; + this.logger = logger; + this.includeCertificates = includeCertificates; + } + + /// + /// Führt begrenzte Registry-, Pfad-, Datei- und Zertifikatserfassung aus. + /// + public void Collect() + { + CollectInstalledProducts(); + var configuredPaths = CollectConfiguredSecurityPaths(); + CollectEnvironment(document.RuntimeComponents); + CollectRuntimeFiles(configuredPaths); + CollectSecurityFiles(configuredPaths); + if (includeCertificates) + { + CollectCertificates(); + } + + if (!document.RuntimeComponents.Any(item => + string.Equals( + item.Category, + "SAP Runtime-Datei", + StringComparison.OrdinalIgnoreCase) + || item.Category.StartsWith( + "Installierte SAP", + StringComparison.OrdinalIgnoreCase))) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "SAP Runtime", + Message = "Keine SAP-NCo-/SNC-Laufzeitdatei oder installierte SAP-Komponente wurde automatisch gefunden.", + RecommendedAction = "Installationspfad, GAC und Architektur der WCF-SAP-/NCo-Komponenten manuell bestätigen.", + Owner = "BizTalk-Betrieb" + }); + } + } + + private void CollectInstalledProducts() + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + { + CollectUninstallKey( + baseKey, + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + view); + } + } + } + + private void CollectUninstallKey(RegistryKey baseKey, string path, RegistryView view) + { + using (var uninstall = baseKey.OpenSubKey(path, false)) + { + if (uninstall == null) + { + return; + } + + foreach (var subKeyName in uninstall.GetSubKeyNames()) + { + using (var product = uninstall.OpenSubKey(subKeyName, false)) + { + if (product == null) + { + continue; + } + + var name = Convert.ToString( + product.GetValue("DisplayName"), + CultureInfo.InvariantCulture); + if (!IsRelevantProduct(name)) + { + continue; + } + + var component = new ComponentRecord + { + Category = "Installierte SAP/BizTalk-Adapter-Komponente", + Name = name, + Status = "Installiert" + }; + AddIfPresent(component, "Version", product.GetValue("DisplayVersion"), "Registry " + view); + AddIfPresent(component, "Hersteller", product.GetValue("Publisher"), "Registry " + view); + AddIfPresent(component, "Installationspfad", product.GetValue("InstallLocation"), "Registry " + view); + AddIfPresent(component, "Architektur", view.ToString(), "Registry"); + document.RuntimeComponents.Add(component); + } + } + } + } + + private HashSet CollectConfiguredSecurityPaths() + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in document.SapEndpoints) + { + foreach (var property in endpoint.Properties) + { + if (property.Name.IndexOf("SncLibrary", StringComparison.OrdinalIgnoreCase) >= 0 + || property.Name.IndexOf("LibraryPath", StringComparison.OrdinalIgnoreCase) >= 0 + || property.Name.IndexOf("SecuDir", StringComparison.OrdinalIgnoreCase) >= 0 + || property.Name.IndexOf("Pse", StringComparison.OrdinalIgnoreCase) >= 0) + { + AddPath(result, property.Value); + } + } + } + + AddPath(result, Environment.GetEnvironmentVariable("SNC_LIB")); + AddPath(result, Environment.GetEnvironmentVariable("SECUDIR")); + AddPath(result, Environment.GetEnvironmentVariable("SAPNWRFC_HOME")); + return result; + } + + private static void CollectEnvironment(List target) + { + var component = new ComponentRecord + { + Category = "SAP/SNC-Umgebungsvariablen", + Name = "Prozessumgebung", + Status = "Erfasst" + }; + foreach (var variable in new[] { "SNC_LIB", "SECUDIR", "SAPNWRFC_HOME", "SAP_CODEPAGE" }) + { + var value = Environment.GetEnvironmentVariable(variable); + component.Properties.Add(new NameValueRecord( + variable, + string.IsNullOrWhiteSpace(value) ? "Nicht gesetzt" : value, + "Prozessumgebung")); + } + target.Add(component); + } + + private void CollectRuntimeFiles(HashSet configuredPaths) + { + var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in configuredPaths) + { + if (File.Exists(path) && IsRuntimeFile(path)) + { + candidates.Add(path); + } + } + + var roots = new HashSet(StringComparer.OrdinalIgnoreCase) + { + AppDomain.CurrentDomain.BaseDirectory, + document.System.BizTalkInstallPath, + Environment.GetEnvironmentVariable("SAPNWRFC_HOME"), + Environment.GetEnvironmentVariable("SECUDIR"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "SAP"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "SAP"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Microsoft BizTalk Adapter Pack"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Microsoft BizTalk Adapter Pack") + }; + + foreach (var root in roots.Where(Directory.Exists)) + { + FindRuntimeFiles(root, candidates); + } + + foreach (var path in candidates.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)) + { + var component = new ComponentRecord + { + Category = "SAP Runtime-Datei", + Name = Path.GetFileName(path), + Status = "Vorhanden" + }; + component.Properties.Add(new NameValueRecord("Pfad", path)); + try + { + var version = FileVersionInfo.GetVersionInfo(path); + component.Properties.Add(new NameValueRecord( + "Dateiversion", + EmptyAsUnknown(version.FileVersion))); + component.Properties.Add(new NameValueRecord( + "Produktversion", + EmptyAsUnknown(version.ProductVersion))); + component.Properties.Add(new NameValueRecord( + "Architektur", + ReadPeArchitecture(path))); + var file = new FileInfo(path); + component.Properties.Add(new NameValueRecord( + "Geändert (UTC)", + file.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture))); + } + catch (Exception exception) when ( + exception is IOException + || exception is UnauthorizedAccessException + || exception is ArgumentException) + { + component.Status = "Metadaten teilweise"; + component.Properties.Add(new NameValueRecord("Fehler", exception.Message)); + } + document.RuntimeComponents.Add(component); + } + } + + private void CollectSecurityFiles(HashSet configuredPaths) + { + foreach (var path in configuredPaths.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)) + { + if (Directory.Exists(path)) + { + foreach (var file in SafeEnumerateFiles(path) + .Where(IsSecurityFile) + .Take(100)) + { + AddSecurityFile(file, "SNC/PSE-Datei"); + } + } + else if (File.Exists(path) && IsSecurityFile(path)) + { + AddSecurityFile(path, "SNC/PSE-Datei"); + } + else if (LooksLikePath(path)) + { + document.SecurityMaterials.Add(new SecurityMaterialRecord + { + Kind = "Konfigurierter SNC-/Security-Pfad", + Name = Path.GetFileName(path), + Location = path, + Exists = "Nein oder nicht lesbar", + Details = "Pfad stammt aus Binding oder Umgebungsvariable." + }); + } + } + } + + private void AddSecurityFile(string path, string kind) + { + var record = new SecurityMaterialRecord + { + Kind = kind, + Name = Path.GetFileName(path), + Location = path, + Exists = "Ja" + }; + try + { + var file = new FileInfo(path); + record.Details = string.Format( + CultureInfo.InvariantCulture, + "Größe {0} Bytes; geändert (UTC) {1:yyyy-MM-dd HH:mm:ss}", + file.Length, + file.LastWriteTimeUtc); + record.Fingerprint = ComputeSha256(path); + } + catch (Exception exception) when ( + exception is IOException + || exception is UnauthorizedAccessException + || exception is CryptographicException) + { + record.Details = "Metadaten nur teilweise lesbar: " + exception.Message; + } + document.SecurityMaterials.Add(record); + } + + private void CollectCertificates() + { + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); + foreach (var certificate in store.Certificates.Cast() + .Where(IsSapRelatedCertificate)) + { + document.SecurityMaterials.Add(new SecurityMaterialRecord + { + Kind = "LocalMachine\\My Zertifikat (SAP/SNC-Indikator)", + Name = certificate.Subject, + Location = "LocalMachine\\My", + Exists = "Ja", + Fingerprint = certificate.Thumbprint, + ValidFrom = certificate.NotBefore.ToString( + "yyyy-MM-dd HH:mm:ss", + CultureInfo.InvariantCulture), + ValidTo = certificate.NotAfter.ToString( + "yyyy-MM-dd HH:mm:ss", + CultureInfo.InvariantCulture), + Details = "Issuer: " + certificate.Issuer + + "; privater Schlüssel vorhanden: " + + (certificate.HasPrivateKey ? "Ja" : "Nein") + }); + } + } + } + + private static void FindRuntimeFiles(string root, HashSet target) + { + var pending = new Queue(); + pending.Enqueue(new PathDepth(root, 0)); + var visited = 0; + while (pending.Count > 0 && visited < 2000) + { + var current = pending.Dequeue(); + visited++; + foreach (var file in SafeEnumerateFiles(current.Path)) + { + if (IsRuntimeFile(file)) + { + target.Add(file); + } + } + if (current.Depth >= 5) + { + continue; + } + foreach (var directory in SafeEnumerateDirectories(current.Path)) + { + pending.Enqueue(new PathDepth(directory, current.Depth + 1)); + } + } + } + + private static IEnumerable SafeEnumerateFiles(string path) + { + try + { + return Directory.EnumerateFiles(path).ToArray(); + } + catch (IOException) + { + return new string[0]; + } + catch (UnauthorizedAccessException) + { + return new string[0]; + } + } + + private static IEnumerable SafeEnumerateDirectories(string path) + { + try + { + return Directory.EnumerateDirectories(path).ToArray(); + } + catch (IOException) + { + return new string[0]; + } + catch (UnauthorizedAccessException) + { + return new string[0]; + } + } + + private static void AddPath(HashSet target, string raw) + { + if (string.IsNullOrWhiteSpace(raw) + || raw.IndexOf("***REDACTED***", StringComparison.OrdinalIgnoreCase) >= 0 + || raw.StartsWith("Konfiguriert", StringComparison.OrdinalIgnoreCase)) + { + return; + } + var expanded = Environment.ExpandEnvironmentVariables(raw.Trim().Trim('"')); + if (LooksLikePath(expanded)) + { + target.Add(expanded); + } + } + + private static bool LooksLikePath(string value) + { + return !string.IsNullOrWhiteSpace(value) + && (Path.IsPathRooted(value) + || value.IndexOf('\\') >= 0 + || value.IndexOf('/') >= 0); + } + + private static bool IsRelevantProduct(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + return name.IndexOf("SAP .NET Connector", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("BizTalk Adapter Pack", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("WCF LOB Adapter", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("SAP Secure", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("SAP Cryptographic", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool IsRuntimeFile(string path) + { + var name = Path.GetFileName(path); + return RuntimeFileNames.Any(item => + string.Equals(item, name, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsSecurityFile(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".pse", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".crt", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".cer", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".pem", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSapRelatedCertificate(X509Certificate2 certificate) + { + var text = string.Join( + " ", + certificate.Subject, + certificate.Issuer, + certificate.FriendlyName); + return text.IndexOf("SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("SNC", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string ComputeSha256(string path) + { + using (var algorithm = SHA256.Create()) + using (var stream = File.OpenRead(path)) + { + return string.Concat(algorithm.ComputeHash(stream) + .Select(value => value.ToString("X2", CultureInfo.InvariantCulture))); + } + } + + private static string ReadPeArchitecture(string path) + { + using (var stream = File.OpenRead(path)) + using (var reader = new BinaryReader(stream)) + { + if (reader.ReadUInt16() != 0x5A4D) + { + return "Kein PE-Format"; + } + stream.Position = 0x3C; + var peOffset = reader.ReadInt32(); + stream.Position = peOffset; + if (reader.ReadUInt32() != 0x00004550) + { + return "Unbekanntes PE-Format"; + } + var machine = reader.ReadUInt16(); + switch (machine) + { + case 0x014c: + return "x86/AnyCPU PE"; + case 0x8664: + return "x64"; + case 0x0200: + return "IA64"; + case 0xAA64: + return "ARM64"; + default: + return "PE Machine 0x" + machine.ToString("X4", CultureInfo.InvariantCulture); + } + } + } + + private static void AddIfPresent( + ComponentRecord component, + string name, + object value, + string source) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(text)) + { + component.Properties.Add(new NameValueRecord(name, text, source)); + } + } + + private static string EmptyAsUnknown(string value) + { + return string.IsNullOrWhiteSpace(value) ? "Unbekannt" : value; + } + + private sealed class PathDepth + { + public PathDepth(string path, int depth) + { + Path = path; + Depth = depth; + } + + public string Path { get; private set; } + public int Depth { get; private set; } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Collectors/SystemCollector.cs b/src/BizTalkSapEnvironmentInventory/Collectors/SystemCollector.cs new file mode 100644 index 0000000..a9872b2 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Collectors/SystemCollector.cs @@ -0,0 +1,313 @@ +using System; +using System.Globalization; +using System.Management; +using System.Reflection; +using Microsoft.Win32; +using BizTalkSapEnvironmentInventory.Configuration; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Collectors +{ + /// + /// Erfasst lokale Windows-, BizTalk-Installations- und Management-DB-Metadaten. + /// + internal sealed class SystemCollector + { + private const string BizTalkKey = @"SOFTWARE\Microsoft\BizTalk Server\3.0"; + private const string AdministrationKey = BizTalkKey + @"\Administration"; + + private readonly CommandLineOptions options; + + public SystemCollector(CommandLineOptions options) + { + this.options = options; + } + + /// + /// Liest ausschließlich lokale WMI- und Registry-Daten. + /// + public void Collect(InventoryDocument document) + { + document.ComputerName = Environment.MachineName; + document.ToolVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + document.System.Properties.Add(new NameValueRecord( + "Ausführender Benutzer", + Environment.UserDomainName + "\\" + Environment.UserName)); + document.System.Properties.Add(new NameValueRecord( + "64-Bit-Betriebssystem", + Environment.Is64BitOperatingSystem ? "Ja" : "Nein")); + document.System.Properties.Add(new NameValueRecord( + "64-Bit-Prozess", + Environment.Is64BitProcess ? "Ja" : "Nein")); + document.System.Properties.Add(new NameValueRecord( + ".NET Runtime", + Environment.Version.ToString())); + + CollectOperatingSystem(document); + CollectBizTalkRegistry(document); + CollectBizTalkGroupSetting(document); + + if (!string.IsNullOrWhiteSpace(options.ManagementServer)) + { + document.System.ManagementServer = options.ManagementServer; + } + + if (!string.IsNullOrWhiteSpace(options.ManagementDatabase)) + { + document.System.ManagementDatabase = options.ManagementDatabase; + } + else if (string.IsNullOrWhiteSpace(document.System.ManagementDatabase)) + { + document.System.ManagementDatabase = "BizTalkMgmtDb"; + } + + document.System.Properties.Add(new NameValueRecord( + "BizTalk Management SQL Server", + EmptyAsUnknown(document.System.ManagementServer))); + document.System.Properties.Add(new NameValueRecord( + "BizTalk Management Database", + EmptyAsUnknown(document.System.ManagementDatabase))); + + if (!string.Equals(document.EnvironmentName, "ACC", StringComparison.OrdinalIgnoreCase) + && !string.Equals(document.EnvironmentName, "PROD", StringComparison.OrdinalIgnoreCase)) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "Aufruf", + Message = "Die Umgebung ist weder ACC noch PROD: " + document.EnvironmentName, + RecommendedAction = "Umgebungsparameter und Zielserver vor der Ablage des Berichts bestätigen.", + Owner = "BizTalk-Betrieb" + }); + } + } + + private static void CollectOperatingSystem(InventoryDocument document) + { + using (var searcher = new ManagementObjectSearcher( + "root\\cimv2", + "SELECT Caption,Version,BuildNumber,OSArchitecture,LastBootUpTime FROM Win32_OperatingSystem")) + { + foreach (ManagementObject item in searcher.Get()) + { + document.System.Properties.Add(new NameValueRecord("Betriebssystem", Value(item, "Caption"))); + document.System.Properties.Add(new NameValueRecord("Windows-Version", Value(item, "Version"))); + document.System.Properties.Add(new NameValueRecord("Windows-Build", Value(item, "BuildNumber"))); + document.System.Properties.Add(new NameValueRecord("Architektur", Value(item, "OSArchitecture"))); + document.System.Properties.Add(new NameValueRecord( + "Letzter Systemstart", + ConvertWmiDate(Value(item, "LastBootUpTime")))); + break; + } + } + } + + private static void CollectBizTalkRegistry(InventoryDocument document) + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var product = baseKey.OpenSubKey(BizTalkKey, false)) + { + if (product != null) + { + AddRegistryValue(document, product, "ProductVersion", "BizTalk Produktversion", view); + AddRegistryValue(document, product, "ProductName", "BizTalk Produktname", view); + AddRegistryValue(document, product, "Edition", "BizTalk Edition", view); + var installPath = FirstRegistryValue( + product, + "InstallPath", + "BizTalkServerInstallPath", + "Path"); + if (!string.IsNullOrWhiteSpace(installPath) + && string.IsNullOrWhiteSpace(document.System.BizTalkInstallPath)) + { + document.System.BizTalkInstallPath = installPath; + document.System.Properties.Add(new NameValueRecord( + "BizTalk Installationspfad", + installPath, + "Registry " + view)); + } + } + } + + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var administration = baseKey.OpenSubKey(AdministrationKey, false)) + { + if (administration == null) + { + continue; + } + + if (string.IsNullOrWhiteSpace(document.System.ManagementServer)) + { + document.System.ManagementServer = FirstRegistryValue( + administration, + "MgmtDBServer", + "ManagementDBServer"); + } + + if (string.IsNullOrWhiteSpace(document.System.ManagementDatabase)) + { + document.System.ManagementDatabase = FirstRegistryValue( + administration, + "MgmtDBName", + "ManagementDBName"); + } + } + } + + if (string.IsNullOrWhiteSpace(document.System.BizTalkInstallPath)) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "BizTalk-Installation", + Message = "BizTalk-Installationspfad wurde nicht in der Registry gefunden.", + RecommendedAction = "Prüfen, ob das Tool lokal auf einem BizTalk Server 2020 ausgeführt wird.", + Owner = "BizTalk-Betrieb" + }); + } + } + + private static void CollectBizTalkGroupSetting(InventoryDocument document) + { + try + { + using (var searcher = new ManagementObjectSearcher( + @"root\MicrosoftBizTalkServer", + "SELECT Name,MgmtDbServerName,MgmtDbName,BizTalkAdministratorGroup," + + "BizTalkOperatorGroup,BizTalkReadOnlyUserGroup,SSOServerName " + + "FROM MSBTS_GroupSetting")) + { + foreach (ManagementObject item in searcher.Get()) + { + var managementServer = Value(item, "MgmtDbServerName"); + var managementDatabase = Value(item, "MgmtDbName"); + if (!string.IsNullOrWhiteSpace(managementServer)) + { + document.System.ManagementServer = managementServer; + } + if (!string.IsNullOrWhiteSpace(managementDatabase)) + { + document.System.ManagementDatabase = managementDatabase; + } + + AddWmiValue(document, item, "Name", "BizTalk Gruppenname"); + AddWmiValue( + document, + item, + "BizTalkAdministratorGroup", + "BizTalk Administratorengruppe"); + AddWmiValue( + document, + item, + "BizTalkOperatorGroup", + "BizTalk Operatorengruppe"); + AddWmiValue( + document, + item, + "BizTalkReadOnlyUserGroup", + "BizTalk ReadOnly-Gruppe"); + AddWmiValue(document, item, "SSOServerName", "Enterprise SSO Server"); + break; + } + } + } + catch (ManagementException exception) + { + document.Findings.Add(new Finding + { + Severity = "Hinweis", + Area = "BizTalk GroupSetting", + Message = "MSBTS_GroupSetting konnte für die Management-DB-Erkennung nicht gelesen werden: " + + exception.Message, + RecommendedAction = "Registry-Ergebnis prüfen oder --management-server explizit angeben.", + Owner = "BizTalk-Betrieb" + }); + } + } + + private static void AddWmiValue( + InventoryDocument document, + ManagementBaseObject item, + string property, + string displayName) + { + var value = Value(item, property); + if (!string.IsNullOrWhiteSpace(value)) + { + document.System.Properties.Add( + new NameValueRecord(displayName, value, "MSBTS_GroupSetting")); + } + } + + private static void AddRegistryValue( + InventoryDocument document, + RegistryKey key, + string valueName, + string displayName, + RegistryView view) + { + var value = Convert.ToString(key.GetValue(valueName), CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(value) + && !document.System.Properties.Exists(item => + string.Equals(item.Name, displayName, StringComparison.OrdinalIgnoreCase))) + { + document.System.Properties.Add(new NameValueRecord( + displayName, + value, + "Registry " + view)); + } + } + + private static string FirstRegistryValue(RegistryKey key, params string[] names) + { + foreach (var name in names) + { + var value = Convert.ToString(key.GetValue(name), CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + } + + return string.Empty; + } + + private static string Value(ManagementBaseObject item, string property) + { + try + { + return Convert.ToString(item[property], CultureInfo.InvariantCulture) ?? string.Empty; + } + catch (ManagementException) + { + return string.Empty; + } + } + + private static string ConvertWmiDate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Unbekannt"; + } + + try + { + return ManagementDateTimeConverter.ToDateTime(value) + .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return value; + } + } + + private static string EmptyAsUnknown(string value) + { + return string.IsNullOrWhiteSpace(value) ? "Nicht ermittelt" : value; + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Configuration/CommandLineOptions.cs b/src/BizTalkSapEnvironmentInventory/Configuration/CommandLineOptions.cs new file mode 100644 index 0000000..2bf61c0 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Configuration/CommandLineOptions.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace BizTalkSapEnvironmentInventory.Configuration +{ + /// + /// Validierte, unveränderliche Sicht auf die Kommandozeilenparameter. + /// + internal sealed class CommandLineOptions + { + private static readonly Regex SafeEnvironment = + new Regex("[^A-Z0-9_-]+", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public string EnvironmentName { get; private set; } + public string OutputDirectory { get; private set; } + public string ManagementServer { get; private set; } + public string ManagementDatabase { get; private set; } + public string BtsTaskPath { get; private set; } + public string BindingFilePath { get; private set; } + public bool SelfTest { get; private set; } + public bool ShowHelp { get; private set; } + + /// + /// Parst die Kommandozeile, normalisiert die Umgebung und löst lokale Pfade auf. + /// + public static CommandLineOptions Parse(string[] args) + { + var result = new CommandLineOptions(); + + for (var index = 0; index < args.Length; index++) + { + var argument = args[index]; + switch (argument.ToLowerInvariant()) + { + case "--environment": + result.EnvironmentName = RequireValue(args, ref index, argument); + break; + case "--output": + result.OutputDirectory = RequireValue(args, ref index, argument); + break; + case "--management-server": + result.ManagementServer = RequireValue(args, ref index, argument); + break; + case "--management-database": + result.ManagementDatabase = RequireValue(args, ref index, argument); + break; + case "--btstask": + result.BtsTaskPath = RequireValue(args, ref index, argument); + break; + case "--binding-file": + result.BindingFilePath = RequireValue(args, ref index, argument); + break; + case "--self-test": + result.SelfTest = true; + break; + case "--help": + case "-h": + case "/?": + result.ShowHelp = true; + break; + default: + throw new ArgumentException("Unbekannte Option: " + argument); + } + } + + if (result.SelfTest || result.ShowHelp) + { + return result; + } + + if (string.IsNullOrWhiteSpace(result.EnvironmentName)) + { + throw new ArgumentException("--environment fehlt."); + } + + result.EnvironmentName = SafeEnvironment.Replace( + result.EnvironmentName.Trim().ToUpperInvariant(), + "-").Trim('-'); + if (string.IsNullOrWhiteSpace(result.EnvironmentName)) + { + throw new ArgumentException("--environment enthält keinen gültigen Namen."); + } + + if (string.IsNullOrWhiteSpace(result.OutputDirectory)) + { + result.OutputDirectory = Path.Combine( + Environment.CurrentDirectory, + "BizTalk-SAP-Dokumentation", + result.EnvironmentName); + } + + result.OutputDirectory = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.OutputDirectory)); + + if (!string.IsNullOrWhiteSpace(result.BindingFilePath)) + { + result.BindingFilePath = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.BindingFilePath)); + if (!File.Exists(result.BindingFilePath)) + { + throw new FileNotFoundException( + "Angegebene Binding-Datei wurde nicht gefunden.", + result.BindingFilePath); + } + } + + if (!string.IsNullOrWhiteSpace(result.BtsTaskPath)) + { + result.BtsTaskPath = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.BtsTaskPath)); + } + + return result; + } + + /// + /// Liefert die konsolenfreundliche Aufrufhilfe. + /// + public static string Usage() + { + return string.Join(Environment.NewLine, new[] + { + "BEW BizTalk SAP Environment Inventory", + string.Empty, + "Aufruf:", + " BizTalkSapEnvironmentInventory.exe --environment ACC [--output PFAD]", + " BizTalkSapEnvironmentInventory.exe --environment PROD [--output PFAD]", + string.Empty, + "Optionen:", + " --management-server NAME SQL-Server der BizTalkMgmtDb; normalerweise automatisch.", + " --management-database NAME Management-Datenbank; Default BizTalkMgmtDb.", + " --btstask DATEI Expliziter Pfad zu BTSTask.exe.", + " --binding-file DATEI Vorhandenen Binding-Export offline auswerten.", + " --self-test Sicherheits-, Parser- und DOCX-Selbsttest.", + " --help Diese Hilfe." + }); + } + + private static string RequireValue(string[] args, ref int index, string option) + { + index++; + if (index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal)) + { + throw new ArgumentException("Wert für " + option + " fehlt."); + } + + return args[index]; + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Infrastructure/ConsoleFileLogger.cs b/src/BizTalkSapEnvironmentInventory/Infrastructure/ConsoleFileLogger.cs new file mode 100644 index 0000000..d1e8410 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Infrastructure/ConsoleFileLogger.cs @@ -0,0 +1,60 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace BizTalkSapEnvironmentInventory.Infrastructure +{ + /// + /// Schreibt identische, zeitgestempelte Fortschrittsmeldungen auf Konsole und in die Logdatei. + /// + internal sealed class ConsoleFileLogger : IDisposable + { + private readonly object sync = new object(); + private readonly StreamWriter writer; + + public ConsoleFileLogger(string path) + { + writer = new StreamWriter(path, false, new UTF8Encoding(false)) + { + AutoFlush = true + }; + } + + public void Info(string message) + { + Write("INFO", message); + } + + public void Warning(string message) + { + Write("WARNUNG", message); + } + + public void Error(string message) + { + Write("FEHLER", message); + } + + public void Dispose() + { + writer.Dispose(); + } + + private void Write(string level, string message) + { + var line = string.Format( + CultureInfo.InvariantCulture, + "{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}", + DateTime.Now, + level, + message ?? string.Empty); + + lock (sync) + { + Console.WriteLine(line); + writer.WriteLine(line); + } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Infrastructure/SafeCollector.cs b/src/BizTalkSapEnvironmentInventory/Infrastructure/SafeCollector.cs new file mode 100644 index 0000000..a72b10c --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Infrastructure/SafeCollector.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Infrastructure +{ + /// + /// Isoliert Collector-Fehler und überführt Laufzeit, Status und Fehler in den Bericht. + /// + internal sealed class SafeCollector + { + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + + public SafeCollector(InventoryDocument document, ConsoleFileLogger logger) + { + this.document = document; + this.logger = logger; + } + + /// + /// Führt einen Abschnitt aus, ohne nachfolgende Abschnitte bei einem Fehler zu unterdrücken. + /// + public void Execute(string name, bool required, Action action) + { + var stopwatch = Stopwatch.StartNew(); + logger.Info("Starte Abschnitt: " + name); + try + { + action(); + stopwatch.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = "Erfolgreich", + Message = "Abschnitt vollständig ausgeführt.", + Required = required, + DurationMilliseconds = stopwatch.ElapsedMilliseconds + }); + logger.Info(string.Format( + "Abschnitt abgeschlossen: {0} ({1} ms)", + name, + stopwatch.ElapsedMilliseconds)); + } + catch (Exception exception) + { + stopwatch.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = required ? "Fehler" : "Teilweise", + Message = exception.GetType().Name + ": " + exception.Message, + Required = required, + DurationMilliseconds = stopwatch.ElapsedMilliseconds + }); + document.Findings.Add(new Finding + { + Severity = required ? "Fehler" : "Warnung", + Area = name, + Message = "Datenerfassung fehlgeschlagen: " + exception.Message, + RecommendedAction = "Logdatei, Berechtigungen und lokale BizTalk-Konfiguration prüfen.", + Owner = "BizTalk-Betrieb" + }); + logger.Error(name + ": " + exception.Message); + } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Infrastructure/SelfTestRunner.cs b/src/BizTalkSapEnvironmentInventory/Infrastructure/SelfTestRunner.cs new file mode 100644 index 0000000..5652ab9 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Infrastructure/SelfTestRunner.cs @@ -0,0 +1,342 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using BizTalkSapEnvironmentInventory.Collectors; +using BizTalkSapEnvironmentInventory.Configuration; +using BizTalkSapEnvironmentInventory.Models; +using BizTalkSapEnvironmentInventory.Reporting; + +namespace BizTalkSapEnvironmentInventory.Infrastructure +{ + /// + /// Abhängigkeitsfreier Testläufer für Secret-Schutz, SAP-Bindingparser und DOCX-Paket. + /// + internal static class SelfTestRunner + { + /// + /// Führt alle sicherheits- und ausgaberelevanten Tests aus. + /// + public static void Run() + { + TestSanitizer(); + TestBindingParser(); + TestDocxPackage(); + } + + /// + /// Schreibt einen dauerhaften Musterbericht für eine externe Word-/LibreOffice-Prüfung. + /// + public static void WriteSample(string path) + { + var document = new InventoryDocument + { + EnvironmentName = "ACC-MUSTER", + ComputerName = "BIZTALK-ACC-01", + ToolVersion = "1.0.0.0", + StartedUtc = DateTime.UtcNow.AddSeconds(-3), + CompletedUtc = DateTime.UtcNow + }; + document.System.Properties.Add( + new NameValueRecord("BizTalk Produktversion", "3.13.x (Muster)")); + document.System.Properties.Add( + new NameValueRecord("BizTalk Management SQL Server", "SQL-ACC-01")); + document.Applications.Add(new ApplicationRecord + { + Name = "MasterData_SAP_WebGIS", + Status = "Gestartet" + }); + var endpoint = new SapEndpointRecord + { + ApplicationName = "MasterData_SAP_WebGIS", + Direction = "Senden", + Name = "Send_SAP_WebGIS", + ParentPortName = "Send_SAP_WebGIS", + AdapterName = "WCF-SAP", + HostName = "Snd_SAP_ERP_C1_64_B", + Status = "Gestartet", + Address = "sap://Client=100;lang=DE@A/sap-acc.example.local/00", + SecurityMode = "SAP SNC", + CredentialReference = "Kennwort wird nicht dokumentiert.", + Source = "Muster" + }; + endpoint.Properties.Add(new NameValueRecord("Client", "100", "SAP-URI")); + endpoint.Properties.Add(new NameValueRecord("GatewayHost", "sap-gw-acc", "SAP-URI")); + endpoint.Properties.Add(new NameValueRecord("UseSnc", "true", "Binding")); + endpoint.Properties.Add(new NameValueRecord("SncLibrary", @"C:\SAP\sapcrypto.dll", "Binding")); + endpoint.Operations.Add("http://Microsoft.LobServices.Sap/2007/03/Idoc/3/ORDERS05"); + document.SapEndpoints.Add(endpoint); + document.SectionStatuses.Add(new SectionStatus + { + Name = "Mustererfassung", + Status = "Erfolgreich", + Message = "Musterbericht", + Required = true, + DurationMilliseconds = 42 + }); + document.Findings.Add(new Finding + { + Severity = "Offen", + Area = "SAP WE20", + Message = "Partnerprofil muss aus SAP ergänzt werden.", + RecommendedAction = "WE20-Export beifügen.", + Owner = "SAP Basis" + }); + new DocxReportWriter().Write(document, Path.GetFullPath(path)); + } + + private static void TestSanitizer() + { + var source = XElement.Parse( + "" + + "blob"); + var result = SensitiveDataSanitizer.SanitizeXml(source).ToString(); + Assert(!result.Contains("clear"), "XML-Sanitizer ließ Kennwort stehen."); + Assert(!result.Contains("abc"), "XML-Sanitizer ließ Token stehen."); + Assert(!result.Contains("blob"), "XML-Sanitizer ließ Ciphertext stehen."); + + var uri = SensitiveDataSanitizer.SanitizeText( + "sap://Client=100;Password=secret@A/sap.example/00?token=abc"); + Assert(!uri.Contains("secret"), "URI-Sanitizer ließ Kennwort stehen."); + Assert(!uri.Contains("abc"), "URI-Sanitizer ließ Token stehen."); + + var embeddedXml = SensitiveDataSanitizer.SanitizeText( + ""); + Assert(!embeddedXml.Contains("xmlsecret"), "Text-Sanitizer ließ XML-Kennwort stehen."); + Assert(!embeddedXml.Contains("xmltoken"), "Text-Sanitizer ließ XML-Token stehen."); + } + + private static void TestBindingParser() + { + var directory = CreateTemporaryDirectory(); + var logPath = Path.Combine(directory, "test.log"); + try + { + var document = new InventoryDocument + { + EnvironmentName = "TEST", + ComputerName = "BIZTALK-TEST", + StartedUtc = DateTime.UtcNow + }; + var options = CommandLineOptions.Parse(new[] + { + "--environment", "TEST", + "--output", directory + }); + using (var logger = new ConsoleFileLogger(logPath)) + { + var collector = new BindingExportCollector(options, document, logger, 30); + collector.ParseBindingDocument(CreateSampleBinding(), "Self-Test"); + } + + Assert(document.SapEndpoints.Count == 2, "Nicht alle SAP-Endpunkte wurden erkannt."); + var endpoint = document.SapEndpoints.Single(item => item.Direction == "Senden"); + Assert(endpoint.GetProperty("Client") == "100", "SAP Client wurde nicht aus URI gelesen."); + Assert( + endpoint.GetProperty("ApplicationServerHost") == "sap.example.local", + "SAP Application Server wurde nicht aus URI gelesen."); + Assert(endpoint.GetProperty("UseSnc") == "true", "UseSnc wurde nicht gelesen."); + Assert( + endpoint.Properties.All(item => !item.Value.Contains("supersecret")), + "Binding-Parser ließ ein Kennwort stehen."); + Assert(endpoint.SecurityMode == "SAP SNC", "SNC-Modus wurde nicht erkannt."); + Assert(endpoint.ApplicationName == "SAP_Order", "Anwendungszuordnung fehlt."); + + var receive = document.SapEndpoints.Single(item => item.Direction == "Empfangen"); + Assert(receive.AdapterName == "WCF-Custom", "WCF-Custom SAP-Endpunkt fehlt."); + Assert( + receive.GetProperty("ListenerProgramId") == "BIZTALK_TEST", + "Listener Program ID wurde nicht aus URI gelesen."); + Assert( + receive.GetProperty("DestinationName") == "RFC_TEST", + "Destination Name wurde nicht aus URI gelesen."); + } + finally + { + DeleteDirectory(directory); + } + } + + private static void TestDocxPackage() + { + var directory = CreateTemporaryDirectory(); + var path = Path.Combine(directory, "self-test.docx"); + try + { + var document = new InventoryDocument + { + EnvironmentName = "", + ComputerName = "BIZTALK&01", + ToolVersion = "1.0.0.0", + StartedUtc = DateTime.UtcNow.AddSeconds(-1), + CompletedUtc = DateTime.UtcNow + }; + document.Applications.Add(new ApplicationRecord + { + Name = "SAP_Order", + Status = "Gestartet" + }); + document.SapEndpoints.Add(new SapEndpointRecord + { + ApplicationName = "SAP_Order", + Direction = "Senden", + Name = "SAP", + AdapterName = "WCF-SAP", + Address = "sap://Client=100@A/sap.example.local/00", + SecurityMode = "SAP SNC", + CredentialReference = "Kein Kennwort", + Source = "Self-Test" + }); + document.SapEndpoints[0].Properties.Add( + new NameValueRecord("Password", "supersecret", "Self-Test")); + + new DocxReportWriter().Write(document, path); + using (var archive = ZipFile.OpenRead(path)) + { + foreach (var required in new[] + { + "[Content_Types].xml", + "_rels/.rels", + "word/document.xml", + "word/styles.xml", + "word/_rels/document.xml.rels", + "docProps/core.xml", + "docProps/app.xml" + }) + { + Assert(archive.GetEntry(required) != null, "DOCX-Part fehlt: " + required); + } + + 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); + } + } + + AssertPackageNamespaces(archive); + + using (var stream = archive.GetEntry("word/document.xml").Open()) + using (var reader = new StreamReader(stream, Encoding.UTF8)) + { + var xml = reader.ReadToEnd(); + Assert(!xml.Contains("supersecret"), "DOCX enthält Testkennwort."); + Assert(!xml.Contains(""), "Text wurde nicht XML-sicher geschrieben."); + } + } + } + finally + { + DeleteDirectory(directory); + } + } + + private static void AssertPackageNamespaces(ZipArchive archive) + { + var contentTypes = LoadEntryXml(archive, "[Content_Types].xml"); + var contentTypeNamespace = (XNamespace) + "http://schemas.openxmlformats.org/package/2006/content-types"; + Assert( + 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"; + Assert( + relationships.Root.Elements(relationshipNamespace + "Relationship").Count() == 3, + "Paketbeziehungen liegen nicht vollständig im OPC-Namespace."); + } + + private static XDocument LoadEntryXml(ZipArchive archive, string entryName) + { + using (var stream = archive.GetEntry(entryName).Open()) + { + return XDocument.Load(stream); + } + } + + private static XDocument CreateSampleBinding() + { + return XDocument.Parse( + @" + + + + + + SAP_Order + +
sap://Client=100;lang=DE@A/sap.example.local/00?GwHost=gateway.example.local&GwServ=sapgw00
+ + + <CustomProps> + <BindingType vt=""8"">sapBinding</BindingType> + <BindingConfiguration vt=""8"">&lt;binding name=""sapBinding"" useSnc=""true"" sncLibrary=""C:\SAP\sapcrypto.dll"" sncPartnerName=""p:SAP/ERP"" /&gt;</BindingConfiguration> + <Password vt=""8"">supersecret</Password> + <Action vt=""8"">http://Microsoft.LobServices.Sap/2007/03/Idoc/3/ORDERS05</Action> + </CustomProps> +
+
+
+ + + SAP_Order + + +
sap://Client=100;lang=DE@D/RFC_TEST?ListenerGwHost=gateway.example.local&ListenerGwServ=sapgw00&ListenerProgramId=BIZTALK_TEST
+ + + + + sapBinding + <binding name=""sapBinding"" useSnc=""true"" sncLibrary=""C:\SAP\sapcrypto.dll"" sncPartnerName=""p:SAP/ERP"" /> + + +
+
+
+
+
"); + } + + private static string CreateTemporaryDirectory() + { + var path = Path.Combine( + Path.GetTempPath(), + "BizTalkSapInventoryTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static void Assert(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs b/src/BizTalkSapEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs new file mode 100644 index 0000000..dafd6b0 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Infrastructure/SensitiveDataSanitizer.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace BizTalkSapEnvironmentInventory.Infrastructure +{ + /// + /// Zentraler Schutz gegen die Aufnahme von Kennwörtern, Tokens und Schlüsseldaten in Log oder DOCX. + /// + internal static class SensitiveDataSanitizer + { + private static readonly string[] SensitiveFragments = + { + "password", "passwd", "passphrase", "pwd", "secret", "token", + "privatekey", "clientsecret", "accesskey", "apikey", "connectionstring" + }; + + private static readonly Regex UriSecretPattern = new Regex( + @"(?i)(password|passwd|passphrase|pwd|secret|token|clientsecret)\s*=\s*(?:""[^""]*""|'[^']*'|[^;&\s""']+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex UserInfoPattern = new Regex( + @"(?i)([a-z][a-z0-9+.-]*://)([^/@:\s]+):([^/@\s]+)@", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Erkennt sensitive Feldnamen unabhängig von Großschreibung und Trennzeichen. + /// + public static bool IsSensitiveName(string name) + { + var normalized = (name ?? string.Empty).Replace("_", string.Empty) + .Replace("-", string.Empty); + return SensitiveFragments.Any(fragment => + normalized.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// + /// Redigiert einen Wert anhand seines semantischen Feldnamens. + /// + public static string RedactValue(string name, string value) + { + if (IsSensitiveName(name)) + { + return string.IsNullOrWhiteSpace(value) + ? "Nicht gesetzt" + : "Konfiguriert (Wert nicht dokumentiert)"; + } + + return SanitizeText(value); + } + + /// + /// Entfernt Secrets aus URI-/Key-Value-Texten und eingebetteten Benutzerinformationen. + /// + public static string SanitizeText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var sanitized = UriSecretPattern.Replace( + value, + match => match.Groups[1].Value + "=***REDACTED***"); + sanitized = UserInfoPattern.Replace( + sanitized, + match => match.Groups[1].Value + match.Groups[2].Value + ":***REDACTED***@"); + return sanitized; + } + + /// + /// Erstellt eine secret-bereinigte Kopie eines XML-Teilbaums. + /// + public static XElement SanitizeXml(XElement source) + { + if (source == null) + { + return null; + } + + if (IsSensitiveName(source.Name.LocalName) + || IsCipherElement(source.Name.LocalName)) + { + return new XElement(source.Name, "***REDACTED***"); + } + + var result = new XElement(source.Name); + foreach (var attribute in source.Attributes()) + { + result.Add(new XAttribute( + attribute.Name, + IsSensitiveName(attribute.Name.LocalName) + ? "***REDACTED***" + : SanitizeText(attribute.Value))); + } + + foreach (var node in source.Nodes()) + { + var element = node as XElement; + if (element != null) + { + result.Add(SanitizeXml(element)); + continue; + } + + var text = node as XText; + if (text != null) + { + result.Add(new XText(SanitizeText(text.Value))); + } + } + + return result; + } + + private static bool IsCipherElement(string name) + { + return string.Equals(name, "EncryptedData", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "CipherData", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "CipherValue", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Models/InventoryModels.cs b/src/BizTalkSapEnvironmentInventory/Models/InventoryModels.cs new file mode 100644 index 0000000..4c7e5de --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Models/InventoryModels.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BizTalkSapEnvironmentInventory.Models +{ + /// + /// Zentrales, ausschließlich secret-bereinigtes Berichtsmodell aller Collector-Ergebnisse. + /// + internal sealed class InventoryDocument + { + public InventoryDocument() + { + System = new SystemInventory(); + Applications = new List(); + SapEndpoints = new List(); + Adapters = new List(); + Hosts = new List(); + RuntimeComponents = new List(); + SecurityMaterials = new List(); + Findings = new List(); + SectionStatuses = new List(); + } + + public string EnvironmentName { get; set; } + public string ComputerName { get; set; } + public string ToolVersion { get; set; } + public DateTime StartedUtc { get; set; } + public DateTime CompletedUtc { get; set; } + public SystemInventory System { get; private set; } + public List Applications { get; private set; } + public List SapEndpoints { get; private set; } + public List Adapters { get; private set; } + public List Hosts { get; private set; } + public List RuntimeComponents { get; private set; } + public List SecurityMaterials { get; private set; } + public List Findings { get; private set; } + public List SectionStatuses { get; private set; } + + public bool HasRequiredSectionFailure + { + get + { + return SectionStatuses.Any(item => + item.Required + && !string.Equals(item.Status, "Erfolgreich", StringComparison.OrdinalIgnoreCase)) + || Findings.Any(item => + string.Equals(item.Severity, "Fehler", StringComparison.OrdinalIgnoreCase)); + } + } + } + + internal sealed class SystemInventory + { + public SystemInventory() + { + Properties = new List(); + } + + public string BizTalkInstallPath { get; set; } + public string ManagementServer { get; set; } + public string ManagementDatabase { get; set; } + public List Properties { get; private set; } + } + + internal sealed class ApplicationRecord + { + public ApplicationRecord() + { + Artifacts = new List(); + } + + public string Name { get; set; } + public string Description { get; set; } + public string Status { get; set; } + public List Artifacts { get; private set; } + + public int Count(string artifactType) + { + return Artifacts.Count(item => + string.Equals(item.Type, artifactType, StringComparison.OrdinalIgnoreCase)); + } + } + + internal sealed class ArtifactRecord + { + public ArtifactRecord() + { + Properties = new List(); + } + + public string Type { get; set; } + public string Name { get; set; } + public string ApplicationName { get; set; } + public string Status { get; set; } + public List Properties { get; private set; } + } + + internal sealed class SapEndpointRecord + { + public SapEndpointRecord() + { + Properties = new List(); + Operations = new List(); + } + + public string ApplicationName { get; set; } + public string Direction { get; set; } + public string Name { get; set; } + public string ParentPortName { get; set; } + public string AdapterName { get; set; } + public string HostName { get; set; } + public string Status { get; set; } + public string Address { get; set; } + public string SecurityMode { get; set; } + public string CredentialReference { get; set; } + public string Source { get; set; } + public List Properties { get; private set; } + public List Operations { get; private set; } + + public string GetProperty(params string[] names) + { + foreach (var name in names) + { + var match = Properties.FirstOrDefault(item => + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + return match.Value; + } + } + + return string.Empty; + } + } + + internal sealed class ComponentRecord + { + public ComponentRecord() + { + Properties = new List(); + } + + public string Category { get; set; } + public string Name { get; set; } + public string Status { get; set; } + public List Properties { get; private set; } + } + + internal sealed class SecurityMaterialRecord + { + public string Kind { get; set; } + public string Name { get; set; } + public string Location { get; set; } + public string Exists { get; set; } + public string Fingerprint { get; set; } + public string ValidFrom { get; set; } + public string ValidTo { get; set; } + public string Details { get; set; } + } + + internal sealed class NameValueRecord + { + public NameValueRecord() + { + } + + public NameValueRecord(string name, string value) + : this(name, value, string.Empty) + { + } + + public NameValueRecord(string name, string value, string source) + { + Name = name ?? string.Empty; + Value = value ?? string.Empty; + Source = source ?? string.Empty; + } + + public string Name { get; set; } + public string Value { get; set; } + public string Source { get; set; } + } + + internal sealed class Finding + { + public string Severity { get; set; } + public string Area { get; set; } + public string Message { get; set; } + public string RecommendedAction { get; set; } + public string Owner { get; set; } + } + + internal sealed class SectionStatus + { + public string Name { get; set; } + public string Status { get; set; } + public string Message { get; set; } + public bool Required { get; set; } + public long DurationMilliseconds { get; set; } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Program.cs b/src/BizTalkSapEnvironmentInventory/Program.cs new file mode 100644 index 0000000..64ddd8a --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Program.cs @@ -0,0 +1,303 @@ +using System; +using System.Configuration; +using System.Globalization; +using System.IO; +using System.Linq; +using BizTalkSapEnvironmentInventory.Collectors; +using BizTalkSapEnvironmentInventory.Configuration; +using BizTalkSapEnvironmentInventory.Infrastructure; +using BizTalkSapEnvironmentInventory.Models; +using BizTalkSapEnvironmentInventory.Reporting; + +namespace BizTalkSapEnvironmentInventory +{ + /// + /// Orchestriert die read-only Erfassung, Fortschrittsausgabe und atomare DOCX-Erzeugung. + /// + internal static class Program + { + /// + /// Programmeinstieg mit den dokumentierten Exitcodes 0, 1 und 2. + /// + private static int Main(string[] args) + { + CommandLineOptions options; + try + { + options = CommandLineOptions.Parse(args); + } + catch (Exception exception) + { + Console.Error.WriteLine("FEHLER: " + exception.Message); + Console.Error.WriteLine(); + Console.Error.WriteLine(CommandLineOptions.Usage()); + return 2; + } + + if (options.ShowHelp) + { + Console.WriteLine(CommandLineOptions.Usage()); + return 0; + } + + if (options.SelfTest) + { + return RunSelfTest(); + } + + try + { + Directory.CreateDirectory(options.OutputDirectory); + } + catch (Exception exception) when ( + exception is IOException + || exception is UnauthorizedAccessException + || exception is ArgumentException) + { + Console.Error.WriteLine("FEHLER: Ausgabeordner kann nicht erstellt werden: " + exception.Message); + return 2; + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var safeMachine = SanitizeFileName(Environment.MachineName); + var baseName = "BizTalk-SAP-Dokumentation-" + + options.EnvironmentName + + "-" + + safeMachine + + "-" + + timestamp; + var logPath = Path.Combine(options.OutputDirectory, baseName + ".log"); + var reportPath = Path.Combine(options.OutputDirectory, baseName + ".docx"); + + using (var logger = new ConsoleFileLogger(logPath)) + { + var document = new InventoryDocument + { + EnvironmentName = options.EnvironmentName, + ComputerName = Environment.MachineName, + StartedUtc = DateTime.UtcNow + }; + + logger.Info("BEW BizTalk SAP Environment Inventory startet."); + logger.Info("Umgebung: " + options.EnvironmentName); + logger.Info("Server: " + Environment.MachineName); + logger.Info("Ausgabeordner: " + options.OutputDirectory); + logger.Info("Modus: read-only"); + + var safeCollector = new SafeCollector(document, logger); + safeCollector.Execute( + "System und BizTalk-Installation", + true, + () => new SystemCollector(options).Collect(document)); + safeCollector.Execute( + "BizTalk-Anwendungen und WMI-Artefakte", + true, + () => new BizTalkWmiCollector( + document, + logger, + ReadIntSetting("WmiTimeoutSeconds", 30), + ReadIntSetting("MaxArtifactDetailsPerType", 5000)).Collect()); + safeCollector.Execute( + "SAP-Bindingparameter", + true, + () => new BindingExportCollector( + options, + document, + logger, + ReadIntSetting("ProcessTimeoutSeconds", 120)).Collect()); + safeCollector.Execute( + "SAP NCo, SNC und Zertifikatsmetadaten", + false, + () => new SapRuntimeCollector( + document, + logger, + ReadBoolSetting("IncludeSapRelatedLocalMachineCertificates", true)).Collect()); + + EvaluateCompleteness(document, ReadBoolSetting("ExpectedUseSnc", true)); + document.CompletedUtc = DateTime.UtcNow; + + try + { + logger.Info("Erzeuge Microsoft-Word-Dokument: " + reportPath); + new DocxReportWriter().Write(document, reportPath); + logger.Info("Word-Dokument erfolgreich erzeugt: " + reportPath); + logger.Info("Logdatei: " + logPath); + } + catch (Exception exception) + { + logger.Error("Word-Dokument konnte nicht erzeugt werden: " + exception); + return 2; + } + + var exitCode = document.HasRequiredSectionFailure ? 1 : 0; + logger.Info("Erfassung beendet. Exitcode: " + exitCode); + return exitCode; + } + } + + private static int RunSelfTest() + { + try + { + Console.WriteLine("Starte Sicherheits-, Binding- und DOCX-Self-Test ..."); + SelfTestRunner.Run(); + Console.WriteLine("Self-Test erfolgreich."); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("Self-Test fehlgeschlagen: " + exception); + return 1; + } + } + + private static void EvaluateCompleteness(InventoryDocument document, bool expectedUseSnc) + { + if (document.Applications.Count == 0) + { + document.Findings.Add(new Finding + { + Severity = "Fehler", + Area = "BizTalk-Anwendungen", + Message = "Keine BizTalk-Anwendungen wurden ermittelt.", + RecommendedAction = "Mit einem Mitglied der BizTalk Server Administrators oder Operators lokal erneut ausführen.", + Owner = "BizTalk-Betrieb" + }); + } + + if (document.SapEndpoints.Count == 0) + { + document.Findings.Add(new Finding + { + Severity = "Fehler", + Area = "SAP-Endpunkte", + Message = "Keine WCF-SAP-/SAP-Endpunkte wurden erkannt.", + RecommendedAction = "BTSTask-Gruppenexport, WCF-Custom sapBinding und WCF-SAP-Adapterdefinition prüfen.", + Owner = "BizTalk-Betrieb" + }); + } + + foreach (var endpoint in document.SapEndpoints) + { + if (string.IsNullOrWhiteSpace(endpoint.ApplicationName)) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "Anwendungszuordnung", + Message = "SAP-Endpunkt " + endpoint.Name + " konnte keiner BizTalk-Anwendung zugeordnet werden.", + RecommendedAction = "Port in der BizTalk Administration Console prüfen und Zuordnung ergänzen.", + Owner = "BizTalk-Betrieb" + }); + } + + var useSnc = endpoint.GetProperty("UseSnc", "UseSNC"); + if (expectedUseSnc && !IsTrue(useSnc)) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "SNC", + Message = "Für SAP-Endpunkt " + endpoint.Name + + " ist UseSnc nicht eindeutig true (Wert: " + + (string.IsNullOrWhiteSpace(useSnc) ? "nicht ermittelt" : useSnc) + + ").", + RecommendedAction = "WCF-SAP-Binding und tatsächlichen SNC-Verbindungsaufbau prüfen.", + Owner = "BizTalk-Betrieb/SAP Basis" + }); + } + + if (IsTrue(useSnc) + && string.IsNullOrWhiteSpace(endpoint.GetProperty( + "SncLibrary", + "sncLibrary", + "SncLibraryPath"))) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "SNC", + Message = "SNC ist für " + endpoint.Name + + " aktiv, aber die SNC-Bibliothek wurde nicht eindeutig aus dem Binding gelesen.", + RecommendedAction = "SncLibrary, Prozessarchitektur und Bibliotheksablage auf dem BizTalk Server bestätigen.", + Owner = "BizTalk-Betrieb/Security" + }); + } + } + + AddManualEvidenceFindings(document); + } + + private static void AddManualEvidenceFindings(InventoryDocument document) + { + document.Findings.Add(new Finding + { + Severity = "Offen", + Area = "SAP WE20", + Message = "Partnerprofile (WE20) liegen im SAP-System und sind lokal nicht vollständig auslesbar.", + RecommendedAction = "Partner, Nachrichtentyp, Basistyp/Erweiterung und Empfängerport aus SAP exportieren.", + Owner = "SAP Basis/Fachteam" + }); + document.Findings.Add(new Finding + { + Severity = "Offen", + Area = "SAP WE21", + Message = "Portdefinitionen (WE21) liegen im SAP-System und sind lokal nicht vollständig auslesbar.", + RecommendedAction = "Portname, RFC-Destination, IDoc-Version und WE20-Zuordnung aus SAP ergänzen.", + Owner = "SAP Basis" + }); + document.Findings.Add(new Finding + { + Severity = "Offen", + Area = "Credential-/SNC-Rotation", + Message = "Ablage, Verantwortlicher und Rotationsprozess für SAP-Credentials bzw. SNC-PSE/Zertifikat sind keine technische Bindingeigenschaft.", + RecommendedAction = "Security-Runbook mit Ablaufdatum, Rotation, Berechtigungen und Recovery referenzieren.", + Owner = "SAP Basis/Security" + }); + document.Findings.Add(new Finding + { + Severity = "Offen", + Area = "Disaster Recovery", + Message = "Ob die RFC-Destination SAP-seitig für Frankfurt umgeschaltet wird, ist vom BizTalk Server nicht feststellbar.", + RecommendedAction = "Umschalt- und Rückschaltverfahren mit SAP Basis klären und testen.", + Owner = "SAP Basis/DR-Verantwortliche" + }); + } + + private static int ReadIntSetting(string name, int defaultValue) + { + int parsed; + return int.TryParse( + ConfigurationManager.AppSettings[name], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out parsed) + ? parsed + : defaultValue; + } + + private static bool ReadBoolSetting(string name, bool defaultValue) + { + bool parsed; + return bool.TryParse(ConfigurationManager.AppSettings[name], out parsed) + ? parsed + : defaultValue; + } + + private static bool IsTrue(string value) + { + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "ja", StringComparison.OrdinalIgnoreCase); + } + + private static string SanitizeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string((value ?? "UNKNOWN") + .Select(character => invalid.Contains(character) ? '-' : character) + .ToArray()); + } + } +} diff --git a/src/BizTalkSapEnvironmentInventory/Properties/AssemblyInfo.cs b/src/BizTalkSapEnvironmentInventory/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..70585f4 --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Properties/AssemblyInfo.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("BEW BizTalk SAP Environment Inventory")] +[assembly: AssemblyDescription("Read-only BizTalk 2020 SAP adapter and application inventory")] +[assembly: AssemblyCompany("JR IT Services")] +[assembly: AssemblyProduct("BEW BizTalk SAP Environment Inventory")] +[assembly: ComVisible(false)] +[assembly: Guid("ae86a6ce-17c1-4c10-925e-e25f25f1b56e")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("BizTalkSapEnvironmentInventory.Tests")] + diff --git a/src/BizTalkSapEnvironmentInventory/Reporting/DocxReportWriter.cs b/src/BizTalkSapEnvironmentInventory/Reporting/DocxReportWriter.cs new file mode 100644 index 0000000..5d888bf --- /dev/null +++ b/src/BizTalkSapEnvironmentInventory/Reporting/DocxReportWriter.cs @@ -0,0 +1,934 @@ +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 BizTalkSapEnvironmentInventory.Infrastructure; +using BizTalkSapEnvironmentInventory.Models; + +namespace BizTalkSapEnvironmentInventory.Reporting +{ + /// + /// Erzeugt ein Word-kompatibles Office-Open-XML-Paket ohne Office-Interop. + /// + 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"; + private const string ExtendedPropertiesNamespace = + "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"; + + /// + /// Schreibt den Bericht zunächst temporär und verschiebt ihn anschließend atomar an das Ziel. + /// + public void Write(InventoryDocument document, string outputPath) + { + var directory = Path.GetDirectoryName(outputPath); + if (string.IsNullOrWhiteSpace(directory)) + { + throw new ArgumentException("Ausgabepfad besitzt kein Verzeichnis.", "outputPath"); + } + Directory.CreateDirectory(directory); + + var temporaryPath = Path.Combine( + directory, + "." + Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + using (var archive = ZipFile.Open(temporaryPath, ZipArchiveMode.Create)) + { + WriteContentTypes(archive); + WritePackageRelationships(archive); + WriteDocumentRelationships(archive); + WriteStyles(archive); + WriteCoreProperties(archive, document); + WriteApplicationProperties(archive); + WriteMainDocument(archive, document); + } + + if (File.Exists(outputPath)) + { + File.Replace(temporaryPath, outputPath, null); + } + else + { + File.Move(temporaryPath, outputPath); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static void WriteMainDocument(ZipArchive archive, InventoryDocument document) + { + using (var writer = CreateXmlWriter(archive, "word/document.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("w", "document", WordNamespace); + writer.WriteAttributeString("xmlns", "r", null, RelationshipNamespace); + writer.WriteStartElement("w", "body", WordNamespace); + + WriteTitle(writer, "BizTalk SAP Environment Inventory"); + WriteSubtitle( + writer, + "Umgebung " + Safe(document.EnvironmentName) + + " – " + Safe(document.ComputerName)); + WriteParagraph( + writer, + "Erstellt am " + + document.CompletedUtc.ToLocalTime().ToString( + "yyyy-MM-dd HH:mm:ss", + CultureInfo.InvariantCulture) + + " mit Toolversion " + + Safe(document.ToolVersion) + + ".", + "Normal", + false, + "666666"); + WriteParagraph( + writer, + "VERTRAULICHE BETRIEBSDOKUMENTATION – enthält interne System-, Endpunkt-, " + + "Host-, Zertifikats- und Dateipfadinformationen. Keine Kennwörter oder privaten Schlüssel.", + "IntenseQuote", + true, + "9C0006"); + + WriteHeading(writer, "1. Ziel und Erfassungsumfang", 1); + WriteParagraph( + writer, + "Der Bericht dokumentiert die lokal sichtbare BizTalk-Server-2020-Konfiguration " + + "für SAP-Adapterverbindungen und stellt die vollständige Liste der BizTalk-Anwendungen " + + "bereit. ACC und PROD bestehen jeweils aus genau einem BizTalk Server und einem " + + "separaten SQL Server. Das Tool wird lokal auf dem jeweiligen BizTalk Server ausgeführt.", + "Normal", + false, + null); + WriteBullet(writer, "read-only WMI-Abfragen im Namespace root\\MicrosoftBizTalkServer"); + WriteBullet(writer, "temporärer BTSTask-Gruppenbindingexport zur Auswertung physischer Ports"); + WriteBullet(writer, "Ermittlung von WCF-SAP-, SAP-NCo- und SNC-Metadaten"); + WriteBullet(writer, "Word-kompatibles DOCX ohne Microsoft Office auf dem Server"); + WriteBullet(writer, "keine Remoteänderungen, kein SAP-Login und kein Export geheimer Werte"); + + WriteHeading(writer, "2. Management Summary", 1); + WriteTable( + writer, + new[] { "Kennzahl", "Wert" }, + new[] + { + Row("Umgebung", document.EnvironmentName), + Row("Untersuchter BizTalk Server", document.ComputerName), + Row("BizTalk-Anwendungen", document.Applications.Count.ToString(CultureInfo.InvariantCulture)), + Row("SAP-Endpunkte", document.SapEndpoints.Count.ToString(CultureInfo.InvariantCulture)), + Row("SAP-Adapterdefinitionen", document.Adapters.Count.ToString(CultureInfo.InvariantCulture)), + Row("Host-/Handler-Datensätze", document.Hosts.Count.ToString(CultureInfo.InvariantCulture)), + Row("Runtime-Komponenten", document.RuntimeComponents.Count.ToString(CultureInfo.InvariantCulture)), + Row("Security-Materialien", document.SecurityMaterials.Count.ToString(CultureInfo.InvariantCulture)), + Row("Findings", document.Findings.Count.ToString(CultureInfo.InvariantCulture)) + }); + + WriteHeading(writer, "3. Erfassungsstatus", 1); + WriteTable( + writer, + new[] { "Abschnitt", "Pflicht", "Status", "Dauer (ms)", "Meldung" }, + document.SectionStatuses.Select(item => new[] + { + item.Name, + item.Required ? "Ja" : "Nein", + item.Status, + item.DurationMilliseconds.ToString(CultureInfo.InvariantCulture), + item.Message + })); + + WriteHeading(writer, "4. System und BizTalk-Gruppe", 1); + WriteTable( + writer, + new[] { "Eigenschaft", "Wert", "Quelle" }, + document.System.Properties.Select(item => new[] + { + item.Name, + item.Value, + item.Source + })); + + WriteHeading(writer, "5. Vollständige BizTalk-Anwendungsliste", 1); + WriteParagraph( + writer, + "Die Liste stammt primär aus MSBTS_Application. Artefaktzahlen werden aus den " + + "lokal verfügbaren BizTalk-WMI-Klassen gebildet; optionale WMI-Klassen können " + + "versionsabhängig fehlen und werden dann im Erfassungsstatus bzw. als Finding ausgewiesen.", + "Normal", + false, + null); + WriteTable( + writer, + new[] + { + "Anwendung", "Status", "Orchestrierungen", "Send Ports", "Receive Ports", + "Receive Locations", "Assemblies", "Schemas", "Maps", "Pipelines" + }, + document.Applications.Select(item => new[] + { + item.Name, + item.Status, + Count(item, "Orchestration"), + Count(item, "SendPort"), + Count(item, "ReceivePort"), + Count(item, "ReceiveLocation"), + Count(item, "Assembly"), + Count(item, "Schema"), + Count(item, "Map"), + Count(item, "Pipeline") + })); + + WriteHeading(writer, "6. SAP-Endpunkte und Verbindungsparameter", 1); + if (document.SapEndpoints.Count == 0) + { + WriteParagraph( + writer, + "Es wurden keine SAP-Endpunkte erkannt. Das ist für die bekannte Umgebung unerwartet; " + + "Binding-Export, Adaptername WCF-SAP/WCF-Custom und Berechtigungen prüfen.", + "IntenseQuote", + true, + "9C0006"); + } + else + { + WriteTable( + writer, + new[] + { + "Anwendung", "Richtung", "Endpunkt", "Adapter", + "Host/Handler", "Security", "Quelle" + }, + document.SapEndpoints + .OrderBy(item => item.ApplicationName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Direction, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select(item => new[] + { + Empty(item.ApplicationName), + item.Direction, + item.Name, + item.AdapterName, + Empty(item.HostName), + item.SecurityMode, + item.Source + })); + + foreach (var endpoint in document.SapEndpoints + .OrderBy(item => item.ApplicationName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)) + { + WriteHeading( + writer, + endpoint.Direction + ": " + Empty(endpoint.Name), + 2); + WriteTable( + writer, + new[] { "Eigenschaft", "Wert" }, + new[] + { + Row("BizTalk-Anwendung", Empty(endpoint.ApplicationName)), + Row("Übergeordneter Port", Empty(endpoint.ParentPortName)), + Row("Adapter", Empty(endpoint.AdapterName)), + Row("Host/Handler", Empty(endpoint.HostName)), + Row("Status", Empty(endpoint.Status)), + Row("Adresse", Empty(endpoint.Address)), + Row("Security-Modus", Empty(endpoint.SecurityMode)), + Row("Credential-Nachweis", Empty(endpoint.CredentialReference)) + }); + + var orderedProperties = endpoint.Properties + .OrderBy(item => PropertyOrder(item.Name)) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select(item => new[] + { + item.Name, + Truncate( + SensitiveDataSanitizer.RedactValue(item.Name, item.Value), + 4000), + item.Source + }); + WriteTable( + writer, + new[] { "Verbindungs-/Bindingparameter", "Wert", "Quelle" }, + orderedProperties); + + if (endpoint.Operations.Count > 0) + { + WriteParagraph(writer, "Aktionen/Operationen:", "Normal", true, null); + foreach (var operation in endpoint.Operations) + { + WriteBullet(writer, Truncate(operation, 2000)); + } + } + } + } + + WriteHeading(writer, "7. SAP-Adapter, Hosts und Handler", 1); + WriteComponentSection(writer, "Adapterdefinitionen", document.Adapters); + WriteComponentSection(writer, "Hosts und Handler", document.Hosts); + + WriteHeading(writer, "8. SAP NCo und SNC Runtime", 1); + WriteComponentSection(writer, "Installationen, Dateien und Umgebungsvariablen", document.RuntimeComponents); + + WriteHeading(writer, "9. SNC-/Zertifikatsmaterial", 1); + WriteParagraph( + writer, + "Dokumentiert werden ausschließlich Pfad, Vorhandensein, Gültigkeit und Fingerprint. " + + "Private Schlüssel, PSE-Inhalte und Kennwörter werden nicht exportiert.", + "Normal", + false, + null); + WriteTable( + writer, + new[] + { + "Art", "Name", "Ablage", "Vorhanden", "Fingerprint", + "Gültig ab", "Gültig bis", "Details" + }, + document.SecurityMaterials.Select(item => new[] + { + item.Kind, + item.Name, + item.Location, + item.Exists, + item.Fingerprint, + item.ValidFrom, + item.ValidTo, + item.Details + })); + + WriteHeading(writer, "10. IDoc-/RFC-Indikatoren", 1); + var sapArtifacts = FindSapArtifacts(document).ToList(); + WriteTable( + writer, + new[] { "Anwendung", "Artefakttyp", "Name", "Status/Indikator" }, + sapArtifacts.Select(item => new[] + { + item.ApplicationName, + item.Type, + item.Name, + item.Status + })); + if (sapArtifacts.Count == 0) + { + WriteParagraph( + writer, + "Keine eindeutig als SAP/IDoc erkennbaren Schema- oder Artefaktnamen gefunden. " + + "Das beweist nicht, dass keine IDoc-Verarbeitung existiert; SAP-Basis- und " + + "Quellprojekt-Nachweis bleibt erforderlich.", + "IntenseQuote", + false, + "9C6500"); + } + + WriteHeading(writer, "11. Erforderliche SAP-seitige Nachweise", 1); + WriteParagraph( + writer, + "Die folgenden Objekte liegen im SAP-System oder in Betriebsprozessen und können " + + "ohne SAP-Zugang nicht belastbar vom BizTalk Server ausgelesen werden. Sie sind " + + "bewusst als offene Nachweise enthalten.", + "Normal", + false, + null); + WriteTable( + writer, + new[] { "Nachweis", "Automatisch aus BizTalk", "Benötigte Ergänzung", "Zuständigkeit" }, + new[] + { + new[] + { + "RFC-Destinationen (SAP, z. B. SM59)", + "Name/ListenerDest und Gatewayparameter, soweit im Binding vorhanden", + "SAP-seitige Definition, Zielhost, Verbindungstest, Verantwortlicher", + "SAP Basis" + }, + new[] + { + "Partnerprofile WE20", + "Nicht direkt auslesbar", + "Partner, Nachrichtentyp, Basistyp, Erweiterung, Empfängerport und Verarbeitungsoptionen", + "SAP Basis/Fachteam" + }, + new[] + { + "Portdefinitionen WE21", + "Nicht direkt auslesbar", + "Portname, RFC-Destination, IDoc-Version und Zuordnung zu WE20", + "SAP Basis" + }, + new[] + { + "IDoc-Basistypen und Erweiterungen", + "Nur Indikatoren aus deployed Schemas und Actions", + "Vollständige SAP-Liste inklusive kundeneigener Erweiterungen bestätigen", + "SAP Basis/Entwicklung" + }, + new[] + { + "SAP-Benutzer/Kennwort oder SNC-Identität", + "Modus, Benutzername/SSO-Referenz und SNC-Parameter; kein Kennwort", + "Credential Owner, Ablage, Rotation, Ablauf und Wiederherstellung dokumentieren", + "SAP Basis/Security" + }, + new[] + { + "DR-Umschaltung nach Frankfurt", + "Aktuelle RFC-/Gatewayparameter im Binding", + "Klären, ob SAP-seitige RFC-Destination umgeschaltet wird; Runbook und Rückschaltung", + "SAP Basis/DR-Verantwortliche" + } + }); + + WriteHeading(writer, "12. Findings und offene Punkte", 1); + WriteTable( + writer, + new[] { "Schweregrad", "Bereich", "Feststellung", "Empfehlung", "Owner" }, + document.Findings + .OrderBy(item => SeverityOrder(item.Severity)) + .ThenBy(item => item.Area, StringComparer.OrdinalIgnoreCase) + .Select(item => new[] + { + item.Severity, + item.Area, + item.Message, + item.RecommendedAction, + item.Owner + })); + + WriteHeading(writer, "13. Methodik und Sicherheitsgrenzen", 1); + WriteBullet(writer, "BTSTask-Export ist read-only; temporäre Binding-Datei wird anschließend gelöscht."); + WriteBullet(writer, "BizTalk entfernt Kennwörter beim Binding-Export; das Tool redigiert zusätzlich sensitive Namen und URI-Werte."); + WriteBullet(writer, "DOCX enthält keine privaten Schlüssel, PSE-Inhalte, Kennwörter, Tokens oder entschlüsselbare Secrets."); + WriteBullet(writer, "WMI- und Dateisystemfehler werden abschnittsweise isoliert und als Findings dokumentiert."); + WriteBullet(writer, "Ein Bericht pro Umgebung: lokal auf dem einzelnen BizTalk Server in ACC und PROD ausführen."); + + WriteSectionProperties(writer); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteComponentSection( + XmlWriter writer, + string heading, + IEnumerable components) + { + WriteHeading(writer, heading, 2); + var list = components + .OrderBy(item => item.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (list.Count == 0) + { + WriteParagraph(writer, "Keine Datensätze ermittelt.", "Normal", false, "666666"); + return; + } + WriteTable( + writer, + new[] { "Kategorie", "Name", "Status", "Eigenschaften" }, + list.Select(item => new[] + { + item.Category, + item.Name, + item.Status, + string.Join( + "; ", + item.Properties.Select(property => + property.Name + "=" + + SensitiveDataSanitizer.RedactValue(property.Name, property.Value))) + })); + } + + private static IEnumerable FindSapArtifacts(InventoryDocument document) + { + foreach (var application in document.Applications) + { + foreach (var artifact in application.Artifacts) + { + var searchText = artifact.Name + " " + + string.Join(" ", artifact.Properties.Select(item => item.Value)); + if (searchText.IndexOf("SAP", StringComparison.OrdinalIgnoreCase) >= 0 + || searchText.IndexOf("IDOC", StringComparison.OrdinalIgnoreCase) >= 0 + || searchText.IndexOf("Microsoft.LobServices.Sap", StringComparison.OrdinalIgnoreCase) >= 0) + { + yield return artifact; + } + } + } + } + + private static void WriteContentTypes(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "[Content_Types].xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Types", ContentTypeNamespace); + WriteContentDefault(writer, "rels", "application/vnd.openxmlformats-package.relationships+xml"); + WriteContentDefault(writer, "xml", "application/xml"); + WriteContentOverride( + writer, + "/word/document.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"); + WriteContentOverride( + writer, + "/word/styles.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"); + WriteContentOverride( + writer, + "/docProps/core.xml", + "application/vnd.openxmlformats-package.core-properties+xml"); + WriteContentOverride( + writer, + "/docProps/app.xml", + "application/vnd.openxmlformats-officedocument.extended-properties+xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteContentDefault(XmlWriter writer, string extension, string contentType) + { + writer.WriteStartElement("Default", ContentTypeNamespace); + writer.WriteAttributeString("Extension", extension); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + private static void WriteContentOverride(XmlWriter writer, string partName, string contentType) + { + writer.WriteStartElement("Override", ContentTypeNamespace); + writer.WriteAttributeString("PartName", partName); + writer.WriteAttributeString("ContentType", contentType); + writer.WriteEndElement(); + } + + private static void WritePackageRelationships(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "_rels/.rels")) + { + writer.WriteStartDocument(); + 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(); + writer.WriteEndDocument(); + } + } + + private static void WriteDocumentRelationships(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "word/_rels/document.xml.rels")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Relationships", PackageRelationshipNamespace); + WriteRelationship( + writer, + "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + "styles.xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + 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(); + } + + private static void WriteStyles(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "word/styles.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("w", "styles", WordNamespace); + WriteStyle(writer, "Normal", "Normal", 20, false, "000000", null); + WriteStyle(writer, "Title", "Titel", 38, true, "1F4E78", null); + WriteStyle(writer, "Subtitle", "Untertitel", 24, false, "5B6573", null); + WriteStyle(writer, "Heading1", "Überschrift 1", 30, true, "1F4E78", "1"); + WriteStyle(writer, "Heading2", "Überschrift 2", 24, true, "2F75B5", "2"); + WriteStyle(writer, "IntenseQuote", "Intensives Zitat", 20, false, "666666", null); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteStyle( + XmlWriter writer, + string id, + string name, + int size, + bool bold, + string color, + string outlineLevel) + { + writer.WriteStartElement("w", "style", WordNamespace); + writer.WriteAttributeString("w", "type", WordNamespace, "paragraph"); + writer.WriteAttributeString("w", "styleId", WordNamespace, id); + writer.WriteStartElement("w", "name", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, name); + writer.WriteEndElement(); + if (!string.IsNullOrWhiteSpace(outlineLevel)) + { + writer.WriteStartElement("w", "pPr", WordNamespace); + writer.WriteStartElement("w", "outlineLvl", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, outlineLevel); + 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, size.ToString(CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteCoreProperties(ZipArchive archive, InventoryDocument document) + { + using (var writer = CreateXmlWriter(archive, "docProps/core.xml")) + { + writer.WriteStartDocument(); + 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", + "xsi", + null, + "http://www.w3.org/2001/XMLSchema-instance"); + writer.WriteElementString( + "dc", + "title", + "http://purl.org/dc/elements/1.1/", + "BizTalk SAP Environment Inventory " + document.EnvironmentName); + writer.WriteElementString( + "dc", + "creator", + "http://purl.org/dc/elements/1.1/", + "BizTalkSapEnvironmentInventory"); + writer.WriteElementString( + "dc", + "subject", + "http://purl.org/dc/elements/1.1/", + "BizTalk 2020 SAP Adapter und Anwendungsliste"); + writer.WriteStartElement( + "dcterms", + "created", + "http://purl.org/dc/terms/"); + writer.WriteAttributeString( + "xsi", + "type", + "http://www.w3.org/2001/XMLSchema-instance", + "dcterms:W3CDTF"); + writer.WriteString(document.CompletedUtc.ToString("s", CultureInfo.InvariantCulture) + "Z"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteApplicationProperties(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "docProps/app.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Properties", ExtendedPropertiesNamespace); + writer.WriteElementString( + "Application", + ExtendedPropertiesNamespace, + "BizTalkSapEnvironmentInventory"); + writer.WriteElementString("AppVersion", ExtendedPropertiesNamespace, "1.0"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static XmlWriter CreateXmlWriter(ZipArchive archive, string path) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + return XmlWriter.Create( + entry.Open(), + new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = false, + CloseOutput = true, + CheckCharacters = true + }); + } + + private static void WriteTitle(XmlWriter writer, string text) + { + WriteParagraph(writer, text, "Title", true, "1F4E78"); + } + + private static void WriteSubtitle(XmlWriter writer, string text) + { + WriteParagraph(writer, text, "Subtitle", false, "5B6573"); + } + + private static void WriteHeading(XmlWriter writer, string text, int level) + { + WriteParagraph( + writer, + text, + level <= 1 ? "Heading1" : "Heading2", + true, + level <= 1 ? "1F4E78" : "2F75B5"); + } + + private static void WriteBullet(XmlWriter writer, string text) + { + WriteParagraph(writer, "• " + text, "Normal", false, null); + } + + private static void WriteParagraph( + XmlWriter writer, + string text, + string style, + bool bold, + string color) + { + writer.WriteStartElement("w", "p", WordNamespace); + writer.WriteStartElement("w", "pPr", WordNamespace); + writer.WriteStartElement("w", "pStyle", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, style); + writer.WriteEndElement(); + writer.WriteStartElement("w", "spacing", WordNamespace); + writer.WriteAttributeString("w", "after", WordNamespace, "100"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteStartElement("w", "r", WordNamespace); + if (bold || !string.IsNullOrWhiteSpace(color)) + { + writer.WriteStartElement("w", "rPr", WordNamespace); + if (bold) + { + writer.WriteElementString("w", "b", WordNamespace, string.Empty); + } + if (!string.IsNullOrWhiteSpace(color)) + { + writer.WriteStartElement("w", "color", WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, color); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + writer.WriteStartElement("w", "t", WordNamespace); + writer.WriteAttributeString("xml", "space", null, "preserve"); + writer.WriteString(Safe(text)); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteTable( + XmlWriter writer, + string[] headers, + IEnumerable rows) + { + var safeRows = (rows ?? Enumerable.Empty()).ToList(); + writer.WriteStartElement("w", "tbl", WordNamespace); + WriteTableProperties(writer); + WriteTableRow(writer, headers, true); + foreach (var row in safeRows) + { + var normalized = new string[headers.Length]; + for (var index = 0; index < normalized.Length; index++) + { + normalized[index] = row != null && index < row.Length ? row[index] : string.Empty; + } + WriteTableRow(writer, normalized, false); + } + writer.WriteEndElement(); + WriteParagraph(writer, string.Empty, "Normal", false, null); + } + + private static void WriteTableProperties(XmlWriter writer) + { + writer.WriteStartElement("w", "tblPr", WordNamespace); + writer.WriteStartElement("w", "tblW", WordNamespace); + writer.WriteAttributeString("w", "w", WordNamespace, "0"); + writer.WriteAttributeString("w", "type", WordNamespace, "auto"); + writer.WriteEndElement(); + writer.WriteStartElement("w", "tblBorders", WordNamespace); + foreach (var side in new[] { "top", "left", "bottom", "right", "insideH", "insideV" }) + { + writer.WriteStartElement("w", side, WordNamespace); + writer.WriteAttributeString("w", "val", WordNamespace, "single"); + writer.WriteAttributeString("w", "sz", WordNamespace, "4"); + writer.WriteAttributeString("w", "color", WordNamespace, "B4C6E7"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteTableRow(XmlWriter writer, string[] cells, 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 cell in cells) + { + writer.WriteStartElement("w", "tc", WordNamespace); + writer.WriteStartElement("w", "tcPr", WordNamespace); + if (header) + { + writer.WriteStartElement("w", "shd", WordNamespace); + writer.WriteAttributeString("w", "fill", WordNamespace, "D9EAF7"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + WriteParagraph( + writer, + Truncate(cell, 6000), + "Normal", + header, + header ? "1F1F1F" : null); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + 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, "900"); + writer.WriteAttributeString("w", "bottom", WordNamespace, "900"); + writer.WriteAttributeString("w", "left", WordNamespace, "900"); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static string[] Row(string name, string value) + { + return new[] { name, value }; + } + + private static string Count(ApplicationRecord application, string type) + { + return application.Count(type).ToString(CultureInfo.InvariantCulture); + } + + private static int PropertyOrder(string name) + { + var preferred = new[] + { + "DestinationName", "ListenerDestination", "R3SystemName", + "ApplicationServerHost", "MessageServerHost", "SystemNumber", + "GatewayHost", "GatewayService", "ListenerGatewayHost", + "ListenerGatewayService", "ListenerProgramId", "Client", + "Language", "UseSnc", "SncLibrary", "SncPartnerName", + "SncMyName", "SncQop", "UserName", "AffiliateApplicationName" + }; + for (var index = 0; index < preferred.Length; index++) + { + if (string.Equals(name, preferred[index], StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + return preferred.Length + 1; + } + + private static int SeverityOrder(string severity) + { + if (string.Equals(severity, "Fehler", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + if (string.Equals(severity, "Warnung", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + return 2; + } + + private static string Empty(string value) + { + return string.IsNullOrWhiteSpace(value) ? "Nicht ermittelt" : value; + } + + private static string Truncate(string value, int maximum) + { + var safe = Safe(value); + return safe.Length <= maximum + ? safe + : safe.Substring(0, maximum) + " … [gekürzt]"; + } + + private static string Safe(string value) + { + return value ?? string.Empty; + } + } +} diff --git a/tests/BizTalkSapEnvironmentInventory.Tests/BizTalkSapEnvironmentInventory.Tests.csproj b/tests/BizTalkSapEnvironmentInventory.Tests/BizTalkSapEnvironmentInventory.Tests.csproj new file mode 100644 index 0000000..ab13d4a --- /dev/null +++ b/tests/BizTalkSapEnvironmentInventory.Tests/BizTalkSapEnvironmentInventory.Tests.csproj @@ -0,0 +1,57 @@ + + + + Debug + AnyCPU + {9F9E3933-BB12-4F05-984D-8B86C2AB963D} + Exe + BizTalkSapEnvironmentInventory.Tests + BizTalkSapEnvironmentInventory.Tests + v4.7.2 + + 512 + 7.3 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE;NETFRAMEWORK + prompt + 4 + AnyCPU + false + + + pdbonly + true + bin\Release\ + TRACE;NETFRAMEWORK + prompt + 4 + AnyCPU + false + + + + + + + + + + + + + + + {43962556-3DD2-4846-96E2-76B4749F17F2} + BizTalkSapEnvironmentInventory + True + + + + + diff --git a/tests/BizTalkSapEnvironmentInventory.Tests/Program.cs b/tests/BizTalkSapEnvironmentInventory.Tests/Program.cs new file mode 100644 index 0000000..f328b27 --- /dev/null +++ b/tests/BizTalkSapEnvironmentInventory.Tests/Program.cs @@ -0,0 +1,31 @@ +using System; +using BizTalkSapEnvironmentInventory.Infrastructure; + +namespace BizTalkSapEnvironmentInventory.Tests +{ + internal static class Program + { + private static int Main(string[] args) + { + try + { + if (args.Length == 2 + && string.Equals(args[0], "--write-sample", StringComparison.OrdinalIgnoreCase)) + { + SelfTestRunner.WriteSample(args[1]); + Console.WriteLine("Musterbericht geschrieben: " + args[1]); + return 0; + } + + SelfTestRunner.Run(); + Console.WriteLine("Alle Tests erfolgreich."); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("Tests fehlgeschlagen: " + exception); + return 1; + } + } + } +}