Files
admin d326341488
Build und Test / build (push) Has been cancelled
Initial commit: BizTalk IIS inventory with DOCX reporting
2026-07-24 15:32:18 +02:00

353 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Xml.Linq;
#if NETFRAMEWORK
using BizTalkIisEnvironmentInventory.Collectors;
using BizTalkIisEnvironmentInventory.Configuration;
#endif
using BizTalkIisEnvironmentInventory.Infrastructure;
using BizTalkIisEnvironmentInventory.Models;
using BizTalkIisEnvironmentInventory.Reporting;
namespace BizTalkIisEnvironmentInventory.Tests
{
/// <summary>
/// Abhängigkeitsfreier Konsolen-Testläufer für zentrale Sicherheits- und Parserfunktionen.
/// </summary>
internal static class Program
{
private static int failures;
/// <summary>
/// Führt alle Tests aus.
/// </summary>
/// <param name="args">Optional <c>--write-sample PFAD</c> für ein DOCX-Prüfdokument.</param>
/// <returns>0 bei Erfolg, sonst 1.</returns>
private static int Main(string[] args)
{
if (args.Length == 2 && string.Equals(args[0], "--write-sample", StringComparison.OrdinalIgnoreCase))
{
WriteSampleDocument(args[1]);
Console.WriteLine("DOCX-Prüfdokument geschrieben: " + Path.GetFullPath(args[1]));
return 0;
}
Run("Kommandozeile normalisiert Umgebung", TestCommandLine);
Run("XML-Sanitizer entfernt Secrets", TestSanitizer);
#if NETFRAMEWORK
Run("IIS-Parser liest Topologie", TestIisParser);
Run("Security-Policy-Parser liest User Rights", TestSecurityPolicyParser);
#endif
Run("DOCX-Paket ist valide und XML-sicher", TestDocxGeneration);
Console.WriteLine(failures == 0
? "Alle Tests erfolgreich."
: failures + " Test(s) fehlgeschlagen.");
return failures == 0 ? 0 : 1;
}
/// <summary>
/// Erzeugt ein dauerhaftes minimales DOCX zur Validierung mit externen Office-Programmen.
/// </summary>
/// <param name="path">Zielpfad des Prüfdokuments.</param>
private static void WriteSampleDocument(string path)
{
var document = new InventoryDocument
{
EnvironmentName = "ACC-TEST",
ComputerName = "BIZTALK-TEST",
StartedUtc = DateTime.UtcNow.AddSeconds(-2),
CompletedUtc = DateTime.UtcNow
};
document.System.Properties.Add(new NameValueRecord("Betriebssystem", "Windows Server 2019 Datacenter"));
document.SectionStatuses.Add(new SectionStatus
{
Name = "DOCX-Test",
Status = "Erfolgreich",
Message = "Prüfdokument",
DurationMilliseconds = 12
});
new DocxReportWriter().Write(document, Path.GetFullPath(path));
}
/// <summary>
/// Führt einen einzelnen Test isoliert aus.
/// </summary>
/// <param name="name">Testname.</param>
/// <param name="test">Testaktion.</param>
private static void Run(string name, Action test)
{
try
{
test();
Console.WriteLine("[OK] " + name);
}
catch (Exception exception)
{
failures++;
Console.Error.WriteLine("[FEHLER] " + name + ": " + exception.Message);
}
}
/// <summary>
/// Prüft Optionsparser und sichere Dateinamensbildung.
/// </summary>
private static void TestCommandLine()
{
var options = CommandLineOptions.Parse(new[] { "--environment", " acc / 01 " });
Equal("ACC-01", options.EnvironmentName);
}
/// <summary>
/// Prüft Attribut- und Ciphertext-Bereinigung.
/// </summary>
private static void TestSanitizer()
{
var source = XElement.Parse(
"<configuration password=\"klartext\"><add token=\"abc\"/><EncryptedData><CipherValue>blob</CipherValue></EncryptedData></configuration>");
var result = SensitiveDataSanitizer.SanitizeXml(source).ToString();
False(result.Contains("klartext"), "Kennwort blieb erhalten.");
False(result.Contains("abc"), "Token blieb erhalten.");
False(result.Contains("blob"), "Ciphertext blieb erhalten.");
}
#if NETFRAMEWORK
/// <summary>
/// Prüft IIS-Topologie und Secret-freien Konfigurationshash anhand einer Testdatei.
/// </summary>
private static void TestIisParser()
{
var directory = CreateTemporaryDirectory();
try
{
var webRoot = Path.Combine(directory, "web");
Directory.CreateDirectory(webRoot);
File.WriteAllText(Path.Combine(webRoot, "default.htm"), "test", Encoding.UTF8);
var configPath = Path.Combine(directory, "applicationHost.config");
var xml = @"<configuration>
<configSections>
<sectionGroup name=""system.webServer""><section name=""security"" overrideModeDefault=""Allow"" /></sectionGroup>
</configSections>
<configProtectedData><providers><add name=""IISWASOnlyRsaProvider"" type=""Provider"" keyContainerName=""iisWasKey"" useMachineContainer=""true"" /></providers></configProtectedData>
<system.applicationHost>
<applicationPools><add name=""BizTalkPool"" managedRuntimeVersion=""v4.0""><processModel identityType=""SpecificUser"" userName=""DOMAIN\svc"" password=""secret"" /></add></applicationPools>
<sites><site name=""BizTalk"" id=""1""><application path=""/"" applicationPool=""BizTalkPool""><virtualDirectory path=""/"" physicalPath=""" + EscapeXml(webRoot) + @""" /></application><bindings><binding protocol=""https"" bindingInformation=""*:443:test"" certificateHash=""AA BB"" certificateStoreName=""MY"" /></bindings></site></sites>
</system.applicationHost>
<system.webServer><security /></system.webServer>
</configuration>";
File.WriteAllText(configPath, xml, Encoding.UTF8);
var commandLine = CommandLineOptions.Parse(new[]
{
"--environment", "TEST", "--iis-config", configPath
});
var options = CollectorOptions.Load(commandLine);
var target = new IisInventory();
var findings = new List<Finding>();
new IisCollector(options).Collect(target, findings, configPath);
Equal(1, target.ApplicationPools.Count);
Equal(@"DOMAIN\svc", target.ApplicationPools[0].UserName);
Equal(1, target.Sites.Count);
Equal("AABB", target.Sites[0].Bindings[0].CertificateHash);
Equal(1, target.Sites[0].Applications[0].TotalFiles);
True(!string.IsNullOrWhiteSpace(target.SanitizedConfigurationSha256), "Konfigurationshash fehlt.");
}
finally
{
Directory.Delete(directory, true);
}
}
/// <summary>
/// Prüft das Parsen eines minimalen secedit-Exports.
/// </summary>
private static void TestSecurityPolicyParser()
{
var directory = CreateTemporaryDirectory();
try
{
var path = Path.Combine(directory, "security.inf");
File.WriteAllText(
path,
"[Unicode]\r\nUnicode=yes\r\n[Privilege Rights]\r\nSeServiceLogonRight = *S-1-5-20,DOMAIN\\svc\r\n[System Access]\r\nMinimumPasswordAge = 1\r\n",
Encoding.Unicode);
var target = new SecurityInventory();
SecurityCollector.ParseSecurityPolicy(path, target);
Equal(1, target.UserRights.Count);
Equal("SeServiceLogonRight", target.UserRights[0].Right);
Equal(1, target.LocalPolicy.Count);
}
finally
{
Directory.Delete(directory, true);
}
}
#endif
/// <summary>
/// Prüft erforderliche Office-Open-XML-Parts, XML-Validität und sichere Textkodierung.
/// </summary>
private static void TestDocxGeneration()
{
var path = Path.Combine(
Path.GetTempPath(),
"BizTalkIisInventory-DocxTest-" + Guid.NewGuid().ToString("N") + ".docx");
var document = new InventoryDocument
{
EnvironmentName = "<script>alert(1)</script>",
ComputerName = "TEST&HOST",
StartedUtc = DateTime.UtcNow,
CompletedUtc = DateTime.UtcNow
};
document.System.Properties.Add(new NameValueRecord("Test", "<b>nicht fett</b>"));
try
{
new DocxReportWriter().Write(document, path);
using (var archive = ZipFile.OpenRead(path))
{
var required = new[]
{
"[Content_Types].xml", "_rels/.rels", "word/document.xml",
"word/styles.xml", "word/_rels/document.xml.rels",
"docProps/core.xml", "docProps/app.xml"
};
foreach (var entryName in required)
{
True(archive.GetEntry(entryName) != null, "DOCX-Part fehlt: " + entryName);
}
foreach (var entry in archive.Entries.Where(item =>
item.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)
|| item.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase)))
{
using (var stream = entry.Open())
{
XDocument.Load(stream);
}
}
AssertOpenXmlNamespaces(archive);
var documentEntry = archive.GetEntry("word/document.xml");
using (var stream = documentEntry.Open())
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
var xml = reader.ReadToEnd();
False(xml.Contains("<script>alert(1)</script>"),
"Umgebungsname wurde als Markup statt Text geschrieben.");
False(xml.Contains("<b>nicht fett</b>"),
"Tabellenwert wurde als Markup statt Text geschrieben.");
True(xml.Contains("Schutz sensibler Daten"),
"Sicherheitskennzeichnung fehlt.");
}
}
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
/// <summary>
/// Prüft, dass OPC-Elemente im vorgeschriebenen Namespace statt im leeren Namespace liegen.
/// </summary>
/// <param name="archive">Geöffnetes DOCX-Paket.</param>
private static void AssertOpenXmlNamespaces(ZipArchive archive)
{
var contentTypes = LoadEntryXml(archive, "[Content_Types].xml");
var contentTypeNamespace = (XNamespace)
"http://schemas.openxmlformats.org/package/2006/content-types";
True(contentTypes.Root.Elements(contentTypeNamespace + "Override").Any(),
"Content-Type-Overrides liegen nicht im OPC-Namespace.");
var relationships = LoadEntryXml(archive, "_rels/.rels");
var relationshipNamespace = (XNamespace)
"http://schemas.openxmlformats.org/package/2006/relationships";
True(relationships.Root.Elements(relationshipNamespace + "Relationship").Count() == 3,
"Paketbeziehungen liegen nicht vollständig im OPC-Namespace.");
}
/// <summary>
/// Lädt einen XML-Part aus dem DOCX-Paket.
/// </summary>
/// <param name="archive">Geöffnetes DOCX-Paket.</param>
/// <param name="entryName">Pfad des XML-Parts.</param>
/// <returns>Geparstes XML-Dokument.</returns>
private static XDocument LoadEntryXml(ZipArchive archive, string entryName)
{
using (var stream = archive.GetEntry(entryName).Open())
{
return XDocument.Load(stream);
}
}
/// <summary>
/// Erstellt ein eindeutiges temporäres Testverzeichnis.
/// </summary>
/// <returns>Vollständiger Pfad.</returns>
private static string CreateTemporaryDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "BizTalkIisInventoryTests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
/// <summary>
/// XML-kodiert einen Attributwert.
/// </summary>
/// <param name="value">Unkodierter Wert.</param>
/// <returns>XML-kodierter Wert.</returns>
private static string EscapeXml(string value)
{
return new XAttribute("x", value).ToString()
.Substring(3)
.TrimEnd('\"');
}
/// <summary>
/// Vergleicht zwei Werte.
/// </summary>
/// <typeparam name="T">Werttyp.</typeparam>
/// <param name="expected">Erwarteter Wert.</param>
/// <param name="actual">Tatsächlicher Wert.</param>
private static void Equal<T>(T expected, T actual)
{
if (!EqualityComparer<T>.Default.Equals(expected, actual))
{
throw new InvalidOperationException("Erwartet: " + expected + "; tatsächlich: " + actual);
}
}
/// <summary>
/// Erwartet einen wahren Ausdruck.
/// </summary>
/// <param name="condition">Zu prüfender Ausdruck.</param>
/// <param name="message">Fehlermeldung.</param>
private static void True(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
/// <summary>
/// Erwartet einen falschen Ausdruck.
/// </summary>
/// <param name="condition">Zu prüfender Ausdruck.</param>
/// <param name="message">Fehlermeldung.</param>
private static void False(bool condition, string message)
{
True(!condition, message);
}
}
}