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."); logger.Detail("BTSTask-Pfad: " + btsTaskPath); logger.Detail("BTSTask-Timeout: " + processTimeout.TotalSeconds.ToString(CultureInfo.InvariantCulture) + " Sekunden."); var output = new StringBuilder(); var outputSync = new object(); var timer = Stopwatch.StartNew(); 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)) { var safeLine = SensitiveDataSanitizer.SanitizeText(args.Data); lock (outputSync) { output.AppendLine(safeLine); } logger.Detail("BTSTask: " + safeLine); } }; process.ErrorDataReceived += (sender, args) => { if (!string.IsNullOrWhiteSpace(args.Data)) { var safeLine = SensitiveDataSanitizer.SanitizeText(args.Data); lock (outputSync) { output.AppendLine(safeLine); } logger.Detail("BTSTask: " + safeLine); } }; 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(); timer.Stop(); if (process.ExitCode != 0) { string errorOutput; lock (outputSync) { errorOutput = output.ToString(); } throw new InvalidOperationException( "BTSTask ExportBindings meldete Exitcode " + process.ExitCode + ": " + LastLines(errorOutput, 8)); } logger.Info("BTSTask ExportBindings erfolgreich abgeschlossen (" + timer.ElapsedMilliseconds + " ms)."); } if (!File.Exists(destination) || new FileInfo(destination).Length == 0) { throw new InvalidDataException("BTSTask erzeugte keine Binding-Datei."); } logger.Detail("Temporärer Binding-Export: " + new FileInfo(destination).Length .ToString(CultureInfo.InvariantCulture) + " Bytes."); } 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) { } } } }