Initial commit: BizTalk application catalog

This commit is contained in:
2026-07-27 14:46:02 +02:00
commit 2b5b97c869
25 changed files with 3026 additions and 0 deletions
@@ -0,0 +1,41 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
namespace BizTalkApplicationCatalog.Infrastructure
{
/// <summary>
/// Spiegelt alle Fortschrittsmeldungen zeitgleich auf die Konsole und in eine UTF-8-Logdatei.
/// </summary>
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);
}
}
}
}
@@ -0,0 +1,61 @@
using System;
using System.Diagnostics;
using BizTalkApplicationCatalog.Models;
namespace BizTalkApplicationCatalog.Infrastructure
{
/// <summary>
/// Isoliert Erfassungsfehler, protokolliert sie und lässt unabhängige Abschnitte weiterlaufen.
/// </summary>
internal sealed class SafeCollector
{
private readonly InventoryDocument document;
private readonly ConsoleFileLogger logger;
public SafeCollector(InventoryDocument document, ConsoleFileLogger logger)
{
this.document = document;
this.logger = logger;
}
public void Execute(string name, bool required, Action action)
{
var timer = Stopwatch.StartNew();
logger.Info("Starte Abschnitt: " + name);
try
{
action();
timer.Stop();
document.SectionStatuses.Add(new SectionStatus
{
Name = name,
Status = "Erfolgreich",
Message = "Abschnitt vollständig ausgeführt.",
Required = required,
DurationMilliseconds = timer.ElapsedMilliseconds
});
logger.Info("Abschnitt abgeschlossen: " + name + " (" + timer.ElapsedMilliseconds + " ms)");
}
catch (Exception exception)
{
timer.Stop();
document.SectionStatuses.Add(new SectionStatus
{
Name = name,
Status = required ? "Fehler" : "Teilweise",
Message = exception.GetType().Name + ": " + exception.Message,
Required = required,
DurationMilliseconds = timer.ElapsedMilliseconds
});
document.Findings.Add(new Finding
{
Severity = required ? "Fehler" : "Warnung",
Area = name,
Message = "Datenerfassung fehlgeschlagen: " + exception.Message,
RecommendedAction = "Logdatei, WMI-Provider und Leseberechtigungen prüfen."
});
logger.Error(name + ": " + exception.Message);
}
}
}
}
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using BizTalkApplicationCatalog.Models;
using BizTalkApplicationCatalog.Reporting;
namespace BizTalkApplicationCatalog.Infrastructure
{
/// <summary>
/// Prüft die zentrale Berichtslogik ohne Zugriff auf Windows- oder BizTalk-WMI.
/// </summary>
internal static class SelfTestRunner
{
public static void Run(string outputPath = null)
{
Assert(SensitiveDataSanitizer.Sanitize(
"https://host/path?password=secret&client=100")
== "https://host/path?password=[REDACTED]&client=100",
"Secret-Redaktion ist fehlerhaft.");
var keepOutput = !string.IsNullOrWhiteSpace(outputPath);
var directory = keepOutput
? Path.GetDirectoryName(Path.GetFullPath(outputPath))
: Path.Combine(
Path.GetTempPath(),
"BizTalkApplicationCatalogTests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = keepOutput ? Path.GetFullPath(outputPath) : Path.Combine(directory, "test.xlsx");
try
{
var document = SampleDocument();
new XlsxReportWriter().Write(document, path);
Assert(File.Exists(path), "XLSX-Datei wurde nicht erzeugt.");
using (var archive = ZipFile.OpenRead(path))
{
var required = new[]
{
"[Content_Types].xml",
"_rels/.rels",
"docProps/core.xml",
"docProps/app.xml",
"xl/workbook.xml",
"xl/_rels/workbook.xml.rels",
"xl/styles.xml"
};
foreach (var name in required)
{
Assert(archive.GetEntry(name) != null, "XLSX-Part fehlt: " + name);
}
for (var index = 1; index <= 10; index++)
{
Assert(
archive.GetEntry("xl/worksheets/sheet" + index + ".xml") != null,
"Arbeitsblatt fehlt: " + index);
}
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);
}
}
var workbook = LoadXml(archive, "xl/workbook.xml");
XNamespace spreadsheet =
"http://schemas.openxmlformats.org/spreadsheetml/2006/main";
var names = workbook.Descendants(spreadsheet + "sheet")
.Select(item => (string)item.Attribute("name")).ToList();
Assert(names.Contains("Anwendungen"), "Anwendungsblatt fehlt.");
Assert(names.Contains("Abdeckung"), "Abdeckungsblatt fehlt.");
var contentTypes = LoadXml(archive, "[Content_Types].xml");
XNamespace contentTypeNamespace =
"http://schemas.openxmlformats.org/package/2006/content-types";
Assert(
contentTypes.Root.Elements(contentTypeNamespace + "Override").Count() == 14,
"Content-Type-Overrides sind unvollständig oder im falschen Namespace.");
var relationships = LoadXml(archive, "_rels/.rels");
XNamespace relationshipNamespace =
"http://schemas.openxmlformats.org/package/2006/relationships";
Assert(
relationships.Root.Elements(relationshipNamespace + "Relationship").Count() == 3,
"Paketbeziehungen sind unvollständig oder im falschen Namespace.");
var combinedText = new StringBuilder();
foreach (var entry in archive.Entries.Where(item =>
item.FullName.StartsWith("xl/worksheets/", StringComparison.Ordinal)))
{
using (var reader = new StreamReader(entry.Open(), Encoding.UTF8))
{
combinedText.Append(reader.ReadToEnd());
}
}
Assert(!combinedText.ToString().Contains("supersecret"), "XLSX enthält Testkennwort.");
Assert(combinedText.ToString().Contains("[REDACTED]"), "Redaktionsmarker fehlt.");
}
}
finally
{
try
{
if (!keepOutput && Directory.Exists(directory)) Directory.Delete(directory, true);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}
private static InventoryDocument SampleDocument()
{
var document = new InventoryDocument
{
EnvironmentName = "ACC",
ComputerName = "BIZTALK-ACC",
ToolVersion = "1.0.0.0",
ManagementServer = "SQL-ACC",
ManagementDatabase = "BizTalkMgmtDb",
StartedUtc = DateTime.UtcNow.AddSeconds(-1),
CompletedUtc = DateTime.UtcNow
};
document.SystemProperties.Add(new NameValueRecord(
"Betriebssystem", "Windows Server 2019", "Self-Test"));
document.Applications.Add(new ApplicationRecord
{
Name = "OrderProcessing",
Description = "Aufträge & Sonderzeichen <Test>",
Status = "Gestartet (2)",
IsDefault = "Nein",
Source = "Self-Test"
});
var port = new ArtifactRecord
{
Type = "Send Port",
ApplicationName = "OrderProcessing",
Name = "Send_Orders",
Status = "Gestartet (2)",
HostName = "SendHost",
AdapterName = "WCF-Custom",
Address = SensitiveDataSanitizer.Sanitize(
"https://service/orders?password=supersecret")
};
port.Properties.Add(new NameValueRecord("IsTwoWay", "True", "Self-Test"));
document.Artifacts.Add(port);
document.Coverage.Add(new CoverageRecord
{
DataSource = "MSBTS_Application",
Status = "Vollständig",
RowCount = 1,
Required = "Ja",
Message = "Self-Test"
});
document.SectionStatuses.Add(new SectionStatus
{
Name = "Self-Test",
Status = "Erfolgreich",
Message = "OK",
Required = true,
DurationMilliseconds = 1
});
return document;
}
private static XDocument LoadXml(ZipArchive archive, string name)
{
using (var stream = archive.GetEntry(name).Open())
{
return XDocument.Load(stream);
}
}
private static void Assert(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}
}
@@ -0,0 +1,39 @@
using System;
using System.Text.RegularExpressions;
namespace BizTalkApplicationCatalog.Infrastructure
{
/// <summary>
/// Verhindert, dass versehentlich Kennwörter oder Token aus WMI-Textwerten in den Bericht gelangen.
/// </summary>
internal static class SensitiveDataSanitizer
{
private static readonly Regex NamedSecret = new Regex(
@"(?i)(password|passwd|pwd|secret|token|clientsecret|accesskey)\s*([=:])\s*([^;&\s""']+)",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return NamedSecret.Replace(value, match =>
match.Groups[1].Value + match.Groups[2].Value + "[REDACTED]");
}
public static string RedactProperty(string name, string value)
{
if (!string.IsNullOrWhiteSpace(name)
&& (name.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("secret", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("token", StringComparison.OrdinalIgnoreCase) >= 0))
{
return "[REDACTED]";
}
return Sanitize(value);
}
}
}