Initial commit: BizTalk IIS inventory with DOCX reporting
Build und Test / build (push) Has been cancelled
Build und Test / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<appSettings>
|
||||
<!-- Obergrenze fuer das Dateimanifest je IIS-Anwendung. -->
|
||||
<add key="MaxFilesPerApplication" value="10000" />
|
||||
<!-- Maximale Rekursionstiefe unterhalb eines Web-Stammverzeichnisses. -->
|
||||
<add key="MaxContentDepth" value="30" />
|
||||
<!-- SHA-256 fuer Webdateien ist genauer, kann aber auf grossen Verzeichnissen teuer sein. -->
|
||||
<add key="IncludeFileHashes" value="false" />
|
||||
<!-- Alle Zertifikate aus LocalMachine\My dokumentieren; Bindungszertifikate immer. -->
|
||||
<add key="IncludeAllPersonalCertificates" value="true" />
|
||||
<!-- Timeout fuer jede WMI-Abfrage. -->
|
||||
<add key="WmiTimeoutSeconds" value="30" />
|
||||
<!-- Erwarteter BEW-Scope; leer lassen, um die Vollstaendigkeitspruefung zu deaktivieren. -->
|
||||
<add key="ExpectedApplications" value="heat_archive,heat_bankdata,heat_bbill,heat_caccount,heat_invoice,heat_meterchange,heat_meterlist,heat_meterreading" />
|
||||
</appSettings>
|
||||
<startup useLegacyV2RuntimeActivationPolicy="true">
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<RootNamespace>BizTalkIisEnvironmentInventory</RootNamespace>
|
||||
<AssemblyName>BizTalkIisEnvironmentInventory</AssemblyName>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
<Deterministic>true</Deterministic>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Configuration" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.Management" />
|
||||
<Reference Include="System.Security" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using Microsoft.Win32;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Infrastructure;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst BizTalk-Installation, Registrymetadaten, Binärversionen und WMI-Komponentenzahlen.
|
||||
/// </summary>
|
||||
internal sealed class BizTalkCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den BizTalk-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">WMI-Timeout.</param>
|
||||
public BizTalkCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst lokale BizTalk-Komponenten ohne ExplorerOM-Abhängigkeit.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
public void Collect(BizTalkInventory target, IList<Finding> findings)
|
||||
{
|
||||
ReadBizTalkRegistry(target);
|
||||
ReadInstalledProducts(target);
|
||||
ReadComponentVersions(target, findings);
|
||||
ReadBizTalkWmi(target, findings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest nicht sensible Werte aus dem BizTalk-Hauptschlüssel in beiden Registry Views.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
private static void ReadBizTalkRegistry(BizTalkInventory target)
|
||||
{
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view))
|
||||
using (var key = baseKey.OpenSubKey(@"SOFTWARE\Microsoft\BizTalk Server\3.0", false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var name in key.GetValueNames().OrderBy(item => item, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (SensitiveDataSanitizer.IsSensitiveName(name))
|
||||
{
|
||||
target.RegistryValues.Add(new NameValueRecord(view + " / " + name, "[ENTFERNT]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = key.GetValue(name);
|
||||
target.RegistryValues.Add(new NameValueRecord(
|
||||
view + " / " + name,
|
||||
Convert.ToString(value, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest BizTalk-bezogene Einträge aus den Windows-Uninstall-Schlüsseln.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
private static void ReadInstalledProducts(BizTalkInventory target)
|
||||
{
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view))
|
||||
using (var uninstall = baseKey.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
false))
|
||||
{
|
||||
if (uninstall == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var childName in uninstall.GetSubKeyNames())
|
||||
{
|
||||
using (var child = uninstall.OpenSubKey(childName, false))
|
||||
{
|
||||
var displayName = Convert.ToString(child == null ? null : child.GetValue("DisplayName"));
|
||||
if (displayName.IndexOf("BizTalk", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& displayName.IndexOf("Enterprise Single Sign-On", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = Convert.ToString(child.GetValue("DisplayVersion"));
|
||||
var date = Convert.ToString(child.GetValue("InstallDate"));
|
||||
target.InstalledProducts.Add(new NameValueRecord(
|
||||
displayName,
|
||||
version + (string.IsNullOrWhiteSpace(date) ? string.Empty : " | InstallDate " + date)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target.InstalledProducts.Sort((left, right) =>
|
||||
string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Dateiversionen zentraler BizTalk-Binärdateien aus dem Installationspfad.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste nicht fataler Dateizugriffsfehler.</param>
|
||||
private static void ReadComponentVersions(BizTalkInventory target, IList<Finding> findings)
|
||||
{
|
||||
var paths = target.RegistryValues
|
||||
.Where(item => item.Name.IndexOf("InstallPath", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| item.Name.IndexOf("InstallDir", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| item.Name.IndexOf("ProductPath", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.Select(item => Environment.ExpandEnvironmentVariables(item.Value ?? string.Empty))
|
||||
.Where(Directory.Exists)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var fallback = Path.Combine(programFiles, "Microsoft BizTalk Server");
|
||||
if (Directory.Exists(fallback))
|
||||
{
|
||||
paths.Add(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
var interestingNames = new HashSet<string>(
|
||||
new[]
|
||||
{
|
||||
"BTSNTSvc.exe", "BTSNTSvc64.exe", "BTSMMC.msc", "Microsoft.BizTalk.ExplorerOM.dll",
|
||||
"Microsoft.BizTalk.Operations.dll", "SSOConfig.exe", "ENTSSO.exe", "BREDeployment.exe"
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var root in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
||||
.Where(path => interestingNames.Contains(Path.GetFileName(path))))
|
||||
{
|
||||
var info = FileVersionInfo.GetVersionInfo(path);
|
||||
target.Components.Add(new NameValueRecord(
|
||||
path,
|
||||
(info.FileVersion ?? "[keine Dateiversion]") + " | " + (info.ProductVersion ?? "[keine Produktversion]")));
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Warnung",
|
||||
Area = "BizTalk-Komponenten",
|
||||
Message = "BizTalk-Installationspfad konnte nicht vollständig gelesen werden: " + root,
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "Leseberechtigungen und Installationspfad prüfen."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft zentrale BizTalk-WMI-Klassen und dokumentiert deren Instanzzahlen.
|
||||
/// </summary>
|
||||
/// <param name="target">BizTalk-Zielmodell.</param>
|
||||
/// <param name="findings">Liste klassenspezifischer WMI-Fehler.</param>
|
||||
private void ReadBizTalkWmi(BizTalkInventory target, IList<Finding> findings)
|
||||
{
|
||||
var scope = new ManagementScope(@"\\.\root\MicrosoftBizTalkServer");
|
||||
try
|
||||
{
|
||||
scope.Connect();
|
||||
target.WmiNamespaceAvailable = scope.IsConnected;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
target.WmiNamespaceAvailable = false;
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Fehler",
|
||||
Area = "BizTalk WMI",
|
||||
Message = "Der BizTalk-WMI-Namespace ist nicht erreichbar.",
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "BizTalk-WMI-Provider, Namespace und Ausführungsberechtigungen prüfen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var classes = new[]
|
||||
{
|
||||
"MSBTS_GroupSetting",
|
||||
"MSBTS_Host",
|
||||
"MSBTS_HostInstance",
|
||||
"MSBTS_ReceivePort",
|
||||
"MSBTS_ReceiveLocation",
|
||||
"MSBTS_SendPort",
|
||||
"MSBTS_Orchestration",
|
||||
"MSBTS_Server"
|
||||
};
|
||||
|
||||
foreach (var className in classes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = 0;
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
scope,
|
||||
new ObjectQuery("SELECT * FROM " + className),
|
||||
new EnumerationOptions
|
||||
{
|
||||
ReturnImmediately = false,
|
||||
Rewindable = false,
|
||||
Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds)
|
||||
}))
|
||||
using (var results = searcher.Get())
|
||||
{
|
||||
foreach (ManagementObject ignored in results)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
target.WmiClasses.Add(new NameValueRecord(className, count.ToString(CultureInfo.InvariantCulture) + " Instanz(en)"));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
target.WmiClasses.Add(new NameValueRecord(className, "[Fehler: " + exception.GetType().Name + "]"));
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Warnung",
|
||||
Area = "BizTalk WMI",
|
||||
Message = "WMI-Klasse konnte nicht gelesen werden: " + className,
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "BizTalk-Operatorrechte, SQL-Erreichbarkeit und WMI-Provider prüfen."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Dokumentiert lokale Computerzertifikate und private Schlüssel ausschließlich über Metadaten.
|
||||
/// </summary>
|
||||
internal sealed class CertificateCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Zertifikat-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Schalter für den Zertifikatsumfang.</param>
|
||||
public CertificateCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den LocalMachine-Personal-Store und markiert IIS-Bindungszertifikate.
|
||||
/// </summary>
|
||||
/// <param name="target">Zertifikats-Zielliste.</param>
|
||||
/// <param name="iis">Bereits erfasste IIS-Bindings.</param>
|
||||
/// <param name="findings">Liste für Ablauf- und Zugriffsauffälligkeiten.</param>
|
||||
public void Collect(IList<CertificateRecord> target, IisInventory iis, IList<Finding> findings)
|
||||
{
|
||||
var bindingThumbprints = new HashSet<string>(
|
||||
iis.Sites.SelectMany(site => site.Bindings)
|
||||
.Select(binding => NormalizeThumbprint(binding.CertificateHash))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
|
||||
{
|
||||
store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
|
||||
foreach (var certificate in store.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
var thumbprint = NormalizeThumbprint(certificate.Thumbprint);
|
||||
var usedByIis = bindingThumbprints.Contains(thumbprint);
|
||||
if (!options.IncludeAllPersonalCertificates && !usedByIis)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var record = CreateRecord(certificate, usedByIis, findings);
|
||||
target.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var missing in bindingThumbprints.Where(
|
||||
thumbprint => !target.Any(item => string.Equals(
|
||||
NormalizeThumbprint(item.Thumbprint),
|
||||
thumbprint,
|
||||
StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Warnung",
|
||||
Area = "Zertifikate",
|
||||
Message = "Das von IIS referenzierte Zertifikat wurde nicht in LocalMachine\\My gefunden.",
|
||||
TechnicalDetail = "Thumbprint: " + missing,
|
||||
Recommendation = "Store-Name des Bindings, Zertifikatsbereitstellung und Berechtigungen prüfen."
|
||||
});
|
||||
}
|
||||
|
||||
var sorted = target.OrderByDescending(item => item.UsedByIisBinding)
|
||||
.ThenBy(item => item.Subject, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
target.Clear();
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
target.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Überführt ein X509-Zertifikat in ein sicheres Berichtsmodell.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Lokales Zertifikat.</param>
|
||||
/// <param name="usedByIis">Gibt an, ob ein IIS-Binding den Thumbprint verwendet.</param>
|
||||
/// <param name="findings">Liste für Auffälligkeiten.</param>
|
||||
/// <returns>Zertifikatsdatensatz ohne privates Schlüsselmaterial.</returns>
|
||||
private static CertificateRecord CreateRecord(
|
||||
X509Certificate2 certificate,
|
||||
bool usedByIis,
|
||||
IList<Finding> findings)
|
||||
{
|
||||
var record = new CertificateRecord
|
||||
{
|
||||
StoreLocation = StoreLocation.LocalMachine.ToString(),
|
||||
StoreName = StoreName.My.ToString(),
|
||||
Subject = certificate.Subject,
|
||||
Issuer = certificate.Issuer,
|
||||
Thumbprint = NormalizeThumbprint(certificate.Thumbprint),
|
||||
SerialNumber = certificate.SerialNumber,
|
||||
NotBefore = certificate.NotBefore,
|
||||
NotAfter = certificate.NotAfter,
|
||||
SignatureAlgorithm = certificate.SignatureAlgorithm == null
|
||||
? string.Empty
|
||||
: certificate.SignatureAlgorithm.FriendlyName,
|
||||
PublicKeyAlgorithm = certificate.PublicKey == null
|
||||
? string.Empty
|
||||
: certificate.PublicKey.Oid.FriendlyName,
|
||||
HasPrivateKey = certificate.HasPrivateKey,
|
||||
UsedByIisBinding = usedByIis,
|
||||
PrivateKeyExportable = certificate.HasPrivateKey ? "[nicht ermittelbar]" : "Nein (kein privater Schlüssel)"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
record.PublicKeySize = certificate.PublicKey.Key.KeySize;
|
||||
}
|
||||
catch
|
||||
{
|
||||
record.PublicKeySize = 0;
|
||||
}
|
||||
|
||||
foreach (var extension in certificate.Extensions.OfType<X509EnhancedKeyUsageExtension>())
|
||||
{
|
||||
foreach (var usage in extension.EnhancedKeyUsages.Cast<Oid>())
|
||||
{
|
||||
record.EnhancedKeyUsages.Add((usage.FriendlyName ?? "[unbekannt]") + " (" + usage.Value + ")");
|
||||
}
|
||||
}
|
||||
|
||||
if (certificate.HasPrivateKey)
|
||||
{
|
||||
InspectPrivateKey(certificate, record, findings);
|
||||
}
|
||||
|
||||
if (certificate.NotAfter.ToUniversalTime() < DateTime.UtcNow)
|
||||
{
|
||||
AddExpiryFinding(findings, record, "Fehler", "Zertifikat ist abgelaufen.");
|
||||
}
|
||||
else if (certificate.NotAfter.ToUniversalTime() < DateTime.UtcNow.AddDays(60))
|
||||
{
|
||||
AddExpiryFinding(findings, record, "Warnung", "Zertifikat läuft innerhalb von 60 Tagen ab.");
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Provider, Container, Exportpolicy und Dateiberechtigungen eines privaten Schlüssels.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Zertifikat mit privatem Schlüssel.</param>
|
||||
/// <param name="record">Zielmodell.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private static void InspectPrivateKey(
|
||||
X509Certificate2 certificate,
|
||||
CertificateRecord record,
|
||||
IList<Finding> findings)
|
||||
{
|
||||
try
|
||||
{
|
||||
#pragma warning disable 618
|
||||
using (var privateKey = certificate.PrivateKey)
|
||||
#pragma warning restore 618
|
||||
{
|
||||
var csp = privateKey as RSACryptoServiceProvider;
|
||||
if (csp != null)
|
||||
{
|
||||
var info = csp.CspKeyContainerInfo;
|
||||
record.PrivateKeyProvider = info.ProviderName;
|
||||
record.PrivateKeyContainer = info.UniqueKeyContainerName;
|
||||
record.PrivateKeyExportable = info.Exportable ? "Ja" : "Nein";
|
||||
record.PrivateKeyFile = LocateKeyFile(info.UniqueKeyContainerName, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rsaCng = privateKey as RSACng;
|
||||
var ecdsaCng = privateKey as ECDsaCng;
|
||||
var key = rsaCng != null ? rsaCng.Key : (ecdsaCng == null ? null : ecdsaCng.Key);
|
||||
if (key != null)
|
||||
{
|
||||
record.PrivateKeyProvider = key.Provider.Provider;
|
||||
record.PrivateKeyContainer = key.UniqueName;
|
||||
record.PrivateKeyExportable = FormatExportPolicy(key.ExportPolicy);
|
||||
record.PrivateKeyFile = LocateKeyFile(key.UniqueName, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
record.PrivateKeyProvider = privateKey == null
|
||||
? "[Providerzugriff nicht möglich]"
|
||||
: privateKey.GetType().FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.PrivateKeyFile) && File.Exists(record.PrivateKeyFile))
|
||||
{
|
||||
CollectKeyAcl(record);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
record.PrivateKeyProvider = "[nicht lesbar: " + exception.GetType().Name + "]";
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Warnung",
|
||||
Area = "Zertifikate/Private Keys",
|
||||
Message = "Private-Key-Metadaten konnten nicht vollständig gelesen werden: " + record.Thumbprint,
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "Tool erhöht ausführen und ACL des privaten Schlüsselcontainers prüfen."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Dateisystem-ACL der Schlüsselcontainerdatei.
|
||||
/// </summary>
|
||||
/// <param name="record">Zertifikatsdatensatz mit Schlüsselpfad.</param>
|
||||
private static void CollectKeyAcl(CertificateRecord record)
|
||||
{
|
||||
var security = File.GetAccessControl(record.PrivateKeyFile, AccessControlSections.Access);
|
||||
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
foreach (FileSystemAccessRule rule in rules)
|
||||
{
|
||||
record.KeyAccessRules.Add(new AccessRuleRecord
|
||||
{
|
||||
Target = record.PrivateKeyFile,
|
||||
Identity = rule.IdentityReference.Value,
|
||||
Rights = rule.FileSystemRights.ToString(),
|
||||
AccessType = rule.AccessControlType.ToString(),
|
||||
IsInherited = rule.IsInherited,
|
||||
Inheritance = rule.InheritanceFlags + " / " + rule.PropagationFlags
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht die Containerdatei eines CAPI- oder CNG-Schlüssels.
|
||||
/// </summary>
|
||||
/// <param name="uniqueName">Eindeutiger Containername.</param>
|
||||
/// <param name="capi">Gibt CAPI statt CNG an.</param>
|
||||
/// <returns>Existierender Pfad oder leerer Text.</returns>
|
||||
private static string LocateKeyFile(string uniqueName, bool capi)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uniqueName))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var common = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
var folder = capi
|
||||
? Path.Combine(common, "Microsoft", "Crypto", "RSA", "MachineKeys")
|
||||
: Path.Combine(common, "Microsoft", "Crypto", "Keys");
|
||||
var path = Path.Combine(folder, uniqueName);
|
||||
return File.Exists(path) ? path : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert eine CNG-Exportpolicy ohne den Schlüssel zu exportieren.
|
||||
/// </summary>
|
||||
/// <param name="policy">CNG-Exportpolicy.</param>
|
||||
/// <returns>Lesbarer Policytext.</returns>
|
||||
private static string FormatExportPolicy(CngExportPolicies policy)
|
||||
{
|
||||
if (policy == CngExportPolicies.None)
|
||||
{
|
||||
return "Nein";
|
||||
}
|
||||
|
||||
var exportable = (policy & CngExportPolicies.AllowExport) != 0
|
||||
|| (policy & CngExportPolicies.AllowPlaintextExport) != 0;
|
||||
return (exportable ? "Ja" : "Nein") + " (" + policy + ")";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen Ablaufhinweis hinzu.
|
||||
/// </summary>
|
||||
/// <param name="findings">Zielliste.</param>
|
||||
/// <param name="record">Betroffenes Zertifikat.</param>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <param name="message">Aussage.</param>
|
||||
private static void AddExpiryFinding(
|
||||
IList<Finding> findings,
|
||||
CertificateRecord record,
|
||||
string severity,
|
||||
string message)
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = severity,
|
||||
Area = "Zertifikate",
|
||||
Message = message,
|
||||
TechnicalDetail = record.Subject + " | " + record.Thumbprint + " | NotAfter "
|
||||
+ record.NotAfter.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Recommendation = "Erneuerung und IIS-Binding vor Ablauf terminieren."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="value">Thumbprint.</param>
|
||||
/// <returns>Großgeschriebener Wert ohne Leerzeichen.</returns>
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Infrastructure;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest IIS-Konfiguration, Topologie, Webinhalte und NTFS-Berechtigungen ohne Microsoft.Web.Administration.
|
||||
/// </summary>
|
||||
internal sealed class IisCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den IIS-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Grenzen für Dateimanifest und Hashing.</param>
|
||||
public IisCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst die lokale applicationHost.config und die daraus referenzierten Inhalte.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell für IIS-Daten.</param>
|
||||
/// <param name="findings">Gemeinsame Liste nicht fataler Auffälligkeiten.</param>
|
||||
/// <param name="overridePath">Optionaler Pfad für Offline-Tests oder Sonderinstallationen.</param>
|
||||
public void Collect(IisInventory target, IList<Finding> findings, string overridePath)
|
||||
{
|
||||
var path = ResolveConfigurationPath(overridePath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("Die IIS-Konfiguration wurde nicht gefunden.", path);
|
||||
}
|
||||
|
||||
target.ConfigurationPath = path;
|
||||
target.ConfigurationLastWriteUtc = File.GetLastWriteTimeUtc(path);
|
||||
|
||||
XDocument document;
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
document = XDocument.Load(stream, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
|
||||
}
|
||||
|
||||
if (document.Root == null)
|
||||
{
|
||||
throw new InvalidDataException("Die IIS-Konfiguration besitzt kein XML-Wurzelelement.");
|
||||
}
|
||||
|
||||
target.SanitizedConfigurationSha256 = HashSanitizedConfiguration(document.Root);
|
||||
CollectEncryptionProviders(document.Root, target);
|
||||
CollectSectionDeclarations(document.Root, target);
|
||||
CollectApplicationPools(document.Root, target);
|
||||
CollectSites(document.Root, target, findings);
|
||||
ValidateExpectedApplications(target, findings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt den IIS-Konfigurationspfad.
|
||||
/// </summary>
|
||||
/// <param name="overridePath">Optional explizit vorgegebener Pfad.</param>
|
||||
/// <returns>Vollständiger Pfad zur applicationHost.config.</returns>
|
||||
internal static string ResolveConfigurationPath(string overridePath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(overridePath))
|
||||
{
|
||||
return Path.GetFullPath(overridePath);
|
||||
}
|
||||
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
if (string.IsNullOrWhiteSpace(windows))
|
||||
{
|
||||
windows = Environment.ExpandEnvironmentVariables("%WINDIR%");
|
||||
}
|
||||
|
||||
return Path.Combine(windows, "System32", "inetsrv", "config", "applicationHost.config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen SHA-256-Fingerprint der bereinigten Konfiguration.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <returns>Hexadezimaler SHA-256-Hash.</returns>
|
||||
private static string HashSanitizedConfiguration(XElement root)
|
||||
{
|
||||
var safe = SensitiveDataSanitizer.SanitizeXml(root).ToString(SaveOptions.DisableFormatting);
|
||||
using (var algorithm = SHA256.Create())
|
||||
{
|
||||
return ToHex(algorithm.ComputeHash(Encoding.UTF8.GetBytes(safe)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst geschützte Konfigurationsprovider, jedoch niemals deren Schlüsselmaterial.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private static void CollectEncryptionProviders(XElement root, IisInventory target)
|
||||
{
|
||||
var protectedData = root.Element("configProtectedData");
|
||||
var providers = protectedData == null ? null : protectedData.Element("providers");
|
||||
if (providers == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var add in providers.Elements("add"))
|
||||
{
|
||||
target.EncryptionProviders.Add(new EncryptionProviderRecord
|
||||
{
|
||||
Name = Attribute(add, "name"),
|
||||
Type = Attribute(add, "type"),
|
||||
KeyContainerName = Attribute(add, "keyContainerName"),
|
||||
UseMachineContainer = Attribute(add, "useMachineContainer"),
|
||||
Description = "Provider-Metadaten; Schlüssel und verschlüsselte Nutzdaten werden bewusst nicht exportiert."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst globale IIS-Section-Deklarationen und deren grobe Belegung.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private static void CollectSectionDeclarations(XElement root, IisInventory target)
|
||||
{
|
||||
var configSections = root.Element("configSections");
|
||||
if (configSections == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var section in configSections.Descendants("section"))
|
||||
{
|
||||
var path = BuildSectionPath(section);
|
||||
var configuredElement = ResolveElementPath(root, path);
|
||||
var isEncrypted = configuredElement != null
|
||||
&& configuredElement.DescendantsAndSelf().Any(
|
||||
element => string.Equals(element.Name.LocalName, "EncryptedData", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
target.GlobalSections.Add(new ConfigurationSectionRecord
|
||||
{
|
||||
Path = path,
|
||||
OverrideModeDefault = Attribute(section, "overrideModeDefault"),
|
||||
AllowDefinition = Attribute(section, "allowDefinition"),
|
||||
IsEncrypted = isEncrypted,
|
||||
ElementCount = configuredElement == null ? 0 : configuredElement.Descendants().Count(),
|
||||
SafeSummary = BuildSafeAttributeSummary(configuredElement)
|
||||
});
|
||||
}
|
||||
|
||||
target.GlobalSections.Sort((left, right) =>
|
||||
string.Compare(left.Path, right.Path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst IIS-Anwendungspools und deren Identitäten.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private static void CollectApplicationPools(XElement root, IisInventory target)
|
||||
{
|
||||
var host = root.Element("system.applicationHost");
|
||||
var pools = host == null ? null : host.Element("applicationPools");
|
||||
if (pools == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaults = pools.Element("applicationPoolDefaults");
|
||||
foreach (var add in pools.Elements("add"))
|
||||
{
|
||||
var processModel = add.Element("processModel");
|
||||
var defaultProcessModel = defaults == null ? null : defaults.Element("processModel");
|
||||
target.ApplicationPools.Add(new ApplicationPoolRecord
|
||||
{
|
||||
Name = Attribute(add, "name"),
|
||||
ManagedRuntimeVersion = EffectiveAttribute(add, defaults, "managedRuntimeVersion"),
|
||||
ManagedPipelineMode = EffectiveAttribute(add, defaults, "managedPipelineMode"),
|
||||
AutoStart = EffectiveAttribute(add, defaults, "autoStart"),
|
||||
StartMode = EffectiveAttribute(add, defaults, "startMode"),
|
||||
Enable32BitAppOnWin64 = EffectiveAttribute(add, defaults, "enable32BitAppOnWin64"),
|
||||
IdentityType = EffectiveAttribute(processModel, defaultProcessModel, "identityType"),
|
||||
UserName = NormalizeIdentity(
|
||||
EffectiveAttribute(processModel, defaultProcessModel, "identityType"),
|
||||
EffectiveAttribute(processModel, defaultProcessModel, "userName"),
|
||||
Attribute(add, "name"))
|
||||
});
|
||||
}
|
||||
|
||||
target.ApplicationPools.Sort((left, right) =>
|
||||
string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst Sites, Anwendungen, virtuelle Verzeichnisse, Bindings und Inhalte.
|
||||
/// </summary>
|
||||
/// <param name="root">XML-Wurzelelement.</param>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private void CollectSites(XElement root, IisInventory target, IList<Finding> findings)
|
||||
{
|
||||
var host = root.Element("system.applicationHost");
|
||||
var sites = host == null ? null : host.Element("sites");
|
||||
if (sites == null)
|
||||
{
|
||||
throw new InvalidDataException("Der IIS-Abschnitt system.applicationHost/sites fehlt.");
|
||||
}
|
||||
|
||||
foreach (var siteElement in sites.Elements("site"))
|
||||
{
|
||||
var site = new SiteRecord
|
||||
{
|
||||
Name = Attribute(siteElement, "name"),
|
||||
Id = Attribute(siteElement, "id"),
|
||||
ServerAutoStart = Attribute(siteElement, "serverAutoStart"),
|
||||
LogDirectory = ExpandIisPath(Attribute(siteElement.Element("logFile"), "directory"))
|
||||
};
|
||||
|
||||
var bindings = siteElement.Element("bindings");
|
||||
if (bindings != null)
|
||||
{
|
||||
foreach (var binding in bindings.Elements("binding"))
|
||||
{
|
||||
site.Bindings.Add(new BindingRecord
|
||||
{
|
||||
SiteName = site.Name,
|
||||
Protocol = Attribute(binding, "protocol"),
|
||||
BindingInformation = Attribute(binding, "bindingInformation"),
|
||||
CertificateHash = NormalizeThumbprint(Attribute(binding, "certificateHash")),
|
||||
CertificateStoreName = Attribute(binding, "certificateStoreName"),
|
||||
SslFlags = Attribute(binding, "sslFlags")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var applicationElement in siteElement.Elements("application"))
|
||||
{
|
||||
var application = CreateApplication(site.Name, applicationElement);
|
||||
site.Applications.Add(application);
|
||||
InspectApplicationContent(application, findings);
|
||||
}
|
||||
|
||||
target.Sites.Add(site);
|
||||
}
|
||||
|
||||
target.Sites.Sort((left, right) =>
|
||||
string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet erwartete, aber nicht konfigurierte BEW-Webanwendungen.
|
||||
/// </summary>
|
||||
/// <param name="target">Erfasstes IIS-Inventar.</param>
|
||||
/// <param name="findings">Liste für Vollständigkeitshinweise.</param>
|
||||
private void ValidateExpectedApplications(IisInventory target, IList<Finding> findings)
|
||||
{
|
||||
if (options.ExpectedApplications == null || options.ExpectedApplications.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var discovered = new HashSet<string>(
|
||||
target.Sites.SelectMany(site => site.Applications)
|
||||
.Select(application => (application.Path ?? string.Empty).Trim().Trim('/'))
|
||||
.Where(path => path.Length > 0),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var expected in options.ExpectedApplications.Where(item => !discovered.Contains(item)))
|
||||
{
|
||||
AddFinding(
|
||||
findings,
|
||||
"Warnung",
|
||||
"IIS/Vollständigkeit",
|
||||
"Erwartete Webanwendung wurde nicht gefunden: " + expected,
|
||||
"ExpectedApplications in BizTalkIisEnvironmentInventory.exe.config",
|
||||
"Prüfen, ob die Anwendung anders benannt, unter einer anderen Site konfiguriert oder in dieser Umgebung bewusst nicht vorhanden ist.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt ein Anwendungsmodell aus einem IIS-XML-Element.
|
||||
/// </summary>
|
||||
/// <param name="siteName">Name der übergeordneten Site.</param>
|
||||
/// <param name="element">IIS-application-Element.</param>
|
||||
/// <returns>Initialisiertes Anwendungsmodell.</returns>
|
||||
private static WebApplicationRecord CreateApplication(string siteName, XElement element)
|
||||
{
|
||||
var result = new WebApplicationRecord
|
||||
{
|
||||
SiteName = siteName,
|
||||
Path = Attribute(element, "path"),
|
||||
ApplicationPool = Attribute(element, "applicationPool"),
|
||||
EnabledProtocols = Attribute(element, "enabledProtocols")
|
||||
};
|
||||
|
||||
foreach (var virtualDirectory in element.Elements("virtualDirectory"))
|
||||
{
|
||||
var physicalPath = ExpandIisPath(Attribute(virtualDirectory, "physicalPath"));
|
||||
result.VirtualDirectories.Add(new VirtualDirectoryRecord
|
||||
{
|
||||
Path = Attribute(virtualDirectory, "path"),
|
||||
PhysicalPath = physicalPath,
|
||||
UserName = string.IsNullOrWhiteSpace(Attribute(virtualDirectory, "userName"))
|
||||
? "[IIS-/Prozessidentität]"
|
||||
: Attribute(virtualDirectory, "userName")
|
||||
});
|
||||
|
||||
if (string.Equals(Attribute(virtualDirectory, "path"), "/", StringComparison.Ordinal))
|
||||
{
|
||||
result.PhysicalPath = physicalPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.PhysicalPath) && result.VirtualDirectories.Count > 0)
|
||||
{
|
||||
result.PhysicalPath = result.VirtualDirectories[0].PhysicalPath;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inventarisiert Inhalt und ACL eines Web-Stammverzeichnisses mit Fehlerisolation.
|
||||
/// </summary>
|
||||
/// <param name="application">Zu untersuchende IIS-Anwendung.</param>
|
||||
/// <param name="findings">Liste für nicht fatale Zugriffsfehler.</param>
|
||||
private void InspectApplicationContent(WebApplicationRecord application, IList<Finding> findings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(application.PhysicalPath))
|
||||
{
|
||||
AddFinding(findings, "Warnung", "IIS/Webinhalt",
|
||||
application.SiteName + application.Path + " besitzt keinen auflösbaren physischen Pfad.",
|
||||
"physicalPath fehlt oder verwendet nicht auflösbare Variablen.",
|
||||
"IIS-Konfiguration und virtuelle Verzeichnisse prüfen.");
|
||||
return;
|
||||
}
|
||||
|
||||
application.PhysicalPathExists = Directory.Exists(application.PhysicalPath);
|
||||
if (!application.PhysicalPathExists)
|
||||
{
|
||||
AddFinding(findings, "Warnung", "IIS/Webinhalt",
|
||||
"Physischer Pfad fehlt: " + application.PhysicalPath,
|
||||
application.SiteName + application.Path,
|
||||
"Deployment, Laufwerk/Mount und IIS-physicalPath prüfen.");
|
||||
return;
|
||||
}
|
||||
|
||||
CollectDirectoryAcl(application, findings);
|
||||
if (options.MaxFilesPerApplication <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = application.PhysicalPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var pending = new Queue<DirectoryWorkItem>();
|
||||
pending.Enqueue(new DirectoryWorkItem(root, 0));
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var current = pending.Dequeue();
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(current.Path))
|
||||
{
|
||||
InspectFile(application, root, filePath, findings);
|
||||
}
|
||||
|
||||
if (current.Depth >= options.MaxContentDepth)
|
||||
{
|
||||
application.ManifestTruncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var directoryPath in Directory.EnumerateDirectories(current.Path))
|
||||
{
|
||||
var attributes = File.GetAttributes(directoryPath);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
AddFinding(findings, "Hinweis", "IIS/Webinhalt",
|
||||
"Reparse Point wurde nicht rekursiv verfolgt: " + directoryPath,
|
||||
"Schutz vor Schleifen und Verlassen des Web-Stammverzeichnisses.",
|
||||
"Verknüpftes Ziel bei Migrationsbedarf separat dokumentieren.");
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.Enqueue(new DirectoryWorkItem(directoryPath, current.Depth + 1));
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
application.ManifestTruncated = true;
|
||||
AddFinding(findings, "Warnung", "IIS/Webinhalt",
|
||||
"Verzeichnis konnte nicht vollständig gelesen werden: " + current.Path,
|
||||
exception.GetType().Name + ": " + exception.Message,
|
||||
"Collector erhöht ausführen oder ACL gezielt prüfen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst Metadaten und optional SHA-256 eines einzelnen Webinhalts.
|
||||
/// </summary>
|
||||
/// <param name="application">Zielanwendung.</param>
|
||||
/// <param name="root">Normalisierter Web-Stammpfad.</param>
|
||||
/// <param name="filePath">Vollständiger Dateipfad.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
private void InspectFile(
|
||||
WebApplicationRecord application,
|
||||
string root,
|
||||
string filePath,
|
||||
IList<Finding> findings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
application.TotalFiles++;
|
||||
application.TotalBytes += info.Length;
|
||||
if (!application.LatestWriteUtc.HasValue || info.LastWriteTimeUtc > application.LatestWriteUtc.Value)
|
||||
{
|
||||
application.LatestWriteUtc = info.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
if (application.ContentFiles.Count >= options.MaxFilesPerApplication)
|
||||
{
|
||||
application.ManifestTruncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
application.ContentFiles.Add(new ContentFileRecord
|
||||
{
|
||||
RelativePath = filePath.Length > root.Length
|
||||
? filePath.Substring(root.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
: info.Name,
|
||||
SizeBytes = info.Length,
|
||||
LastWriteUtc = info.LastWriteTimeUtc,
|
||||
Sha256 = options.IncludeFileHashes ? HashFile(filePath) : string.Empty
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
application.ManifestTruncated = true;
|
||||
AddFinding(findings, "Warnung", "IIS/Webinhalt",
|
||||
"Datei konnte nicht inventarisiert werden: " + filePath,
|
||||
exception.GetType().Name + ": " + exception.Message,
|
||||
"Dateisperre und Leseberechtigung prüfen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die expliziten und geerbten ACL-Regeln eines Web-Stammverzeichnisses.
|
||||
/// </summary>
|
||||
/// <param name="application">Zielanwendung.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
private static void CollectDirectoryAcl(WebApplicationRecord application, IList<Finding> findings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var security = Directory.GetAccessControl(
|
||||
application.PhysicalPath,
|
||||
AccessControlSections.Access | AccessControlSections.Owner);
|
||||
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
|
||||
foreach (FileSystemAccessRule rule in rules)
|
||||
{
|
||||
application.AccessRules.Add(new AccessRuleRecord
|
||||
{
|
||||
Target = application.PhysicalPath,
|
||||
Identity = rule.IdentityReference.Value,
|
||||
Rights = rule.FileSystemRights.ToString(),
|
||||
AccessType = rule.AccessControlType.ToString(),
|
||||
IsInherited = rule.IsInherited,
|
||||
Inheritance = rule.InheritanceFlags + " / " + rule.PropagationFlags
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
AddFinding(findings, "Warnung", "NTFS",
|
||||
"ACL konnte nicht gelesen werden: " + application.PhysicalPath,
|
||||
exception.GetType().Name + ": " + exception.Message,
|
||||
"Mit erhöhten Leserechten erneut ausführen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet SHA-256 einer Datei mit geteilter Lesefreigabe.
|
||||
/// </summary>
|
||||
/// <param name="path">Vollständiger Dateipfad.</param>
|
||||
/// <returns>Hexadezimaler SHA-256-Hash.</returns>
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
using (var algorithm = SHA256.Create())
|
||||
{
|
||||
return ToHex(algorithm.ComputeHash(stream));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut den vollständigen Pfad einer Section-Deklaration auf.
|
||||
/// </summary>
|
||||
/// <param name="section">Section-XML-Element.</param>
|
||||
/// <returns>Pfad mit Slash-Trennung.</returns>
|
||||
private static string BuildSectionPath(XElement section)
|
||||
{
|
||||
var names = new Stack<string>();
|
||||
names.Push(Attribute(section, "name"));
|
||||
var parent = section.Parent;
|
||||
while (parent != null && string.Equals(parent.Name.LocalName, "sectionGroup", StringComparison.Ordinal))
|
||||
{
|
||||
names.Push(Attribute(parent, "name"));
|
||||
parent = parent.Parent;
|
||||
}
|
||||
|
||||
return string.Join("/", names.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Section-Pfad gegen die Konfigurationswurzel auf.
|
||||
/// </summary>
|
||||
/// <param name="root">Konfigurationswurzel.</param>
|
||||
/// <param name="path">Slash-getrennter Section-Pfad.</param>
|
||||
/// <returns>Gefundenes Element oder <c>null</c>.</returns>
|
||||
private static XElement ResolveElementPath(XElement root, string path)
|
||||
{
|
||||
var current = root;
|
||||
foreach (var part in path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
current = current == null ? null : current.Element(part);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine begrenzte, secret-bereinigte Attributübersicht.
|
||||
/// </summary>
|
||||
/// <param name="element">Konfigurationselement.</param>
|
||||
/// <returns>Kurze Attributübersicht.</returns>
|
||||
private static string BuildSafeAttributeSummary(XElement element)
|
||||
{
|
||||
if (element == null)
|
||||
{
|
||||
return "[nicht global konfiguriert]";
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
"; ",
|
||||
element.Attributes()
|
||||
.Take(20)
|
||||
.Select(attribute =>
|
||||
attribute.Name.LocalName + "="
|
||||
+ (SensitiveDataSanitizer.IsSensitiveName(attribute.Name.LocalName)
|
||||
? "[ENTFERNT]"
|
||||
: Limit(attribute.Value, 160))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen Attributwert nullsicher.
|
||||
/// </summary>
|
||||
/// <param name="element">XML-Element oder <c>null</c>.</param>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns>Attributwert oder leerer Text.</returns>
|
||||
private static string Attribute(XElement element, string name)
|
||||
{
|
||||
var attribute = element == null ? null : element.Attribute(name);
|
||||
return attribute == null ? string.Empty : attribute.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen lokalen Attributwert oder den Wert des Defaults.
|
||||
/// </summary>
|
||||
/// <param name="element">Lokales XML-Element.</param>
|
||||
/// <param name="defaults">Default-XML-Element.</param>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns>Effektiver Wert oder leerer Text.</returns>
|
||||
private static string EffectiveAttribute(XElement element, XElement defaults, string name)
|
||||
{
|
||||
var local = Attribute(element, name);
|
||||
return string.IsNullOrWhiteSpace(local) ? Attribute(defaults, name) : local;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt eine verständliche Anwendungspool-Identität her.
|
||||
/// </summary>
|
||||
/// <param name="identityType">IIS-Identitätstyp.</param>
|
||||
/// <param name="userName">Optionales Custom-Konto.</param>
|
||||
/// <param name="poolName">Name des Anwendungspools.</param>
|
||||
/// <returns>Effektiv zu erwartende Identität.</returns>
|
||||
private static string NormalizeIdentity(string identityType, string userName, string poolName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
return userName;
|
||||
}
|
||||
|
||||
if (string.Equals(identityType, "ApplicationPoolIdentity", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.IsNullOrWhiteSpace(identityType))
|
||||
{
|
||||
return @"IIS APPPOOL\" + poolName;
|
||||
}
|
||||
|
||||
return identityType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expandiert Umgebungsvariablen und normalisiert IIS-Pfade.
|
||||
/// </summary>
|
||||
/// <param name="path">IIS-Pfad.</param>
|
||||
/// <returns>Expandierter Pfad.</returns>
|
||||
private static string ExpandIisPath(string path)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(path)
|
||||
? string.Empty
|
||||
: Environment.ExpandEnvironmentVariables(path.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Zertifikat-Fingerprint.
|
||||
/// </summary>
|
||||
/// <param name="value">Fingerprint aus IIS.</param>
|
||||
/// <returns>Großgeschriebener Fingerprint ohne Leerzeichen.</returns>
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begrenzt sehr lange Berichtswerte.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingabewert.</param>
|
||||
/// <param name="maximum">Maximale Zeichenzahl.</param>
|
||||
/// <returns>Original oder gekürzter Wert.</returns>
|
||||
private static string Limit(string value, int maximum)
|
||||
{
|
||||
return value != null && value.Length > maximum
|
||||
? value.Substring(0, maximum) + "…"
|
||||
: value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt Bytes in einen Hexadezimaltext um.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Eingabebytes.</param>
|
||||
/// <returns>Großgeschriebener Hexadezimaltext.</returns>
|
||||
private static string ToHex(byte[] bytes)
|
||||
{
|
||||
return BitConverter.ToString(bytes).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt ein Finding standardisiert hinzu.
|
||||
/// </summary>
|
||||
/// <param name="findings">Zielliste.</param>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <param name="area">Fachlicher Bereich.</param>
|
||||
/// <param name="message">Benutzerlesbare Aussage.</param>
|
||||
/// <param name="detail">Technisches Detail.</param>
|
||||
/// <param name="recommendation">Empfohlene Folgemaßnahme.</param>
|
||||
private static void AddFinding(
|
||||
IList<Finding> findings,
|
||||
string severity,
|
||||
string area,
|
||||
string message,
|
||||
string detail,
|
||||
string recommendation)
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = severity,
|
||||
Area = area,
|
||||
Message = message,
|
||||
TechnicalDetail = detail,
|
||||
Recommendation = recommendation
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class DirectoryWorkItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert einen Eintrag für die iterative Verzeichnisbegehung.
|
||||
/// </summary>
|
||||
/// <param name="path">Zu lesender Verzeichnispfad.</param>
|
||||
/// <param name="depth">Tiefe relativ zum Web-Stamm.</param>
|
||||
public DirectoryWorkItem(string path, int depth)
|
||||
{
|
||||
Path = path;
|
||||
Depth = depth;
|
||||
}
|
||||
|
||||
public string Path { get; private set; }
|
||||
public int Depth { get; private set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using System.Text;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst relevante Windows-Dienste, Dienstkonten und lokale Sicherheitsrichtlinien.
|
||||
/// </summary>
|
||||
internal sealed class SecurityCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Security-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">WMI-Timeout und technische Grenzen.</param>
|
||||
public SecurityCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erfasst IIS-/BizTalk-Dienstidentitäten und einen lesbaren secedit-Snapshot.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
/// <param name="iis">IIS-Daten für Anwendungspoolkonten.</param>
|
||||
/// <param name="findings">Liste nicht fataler Auffälligkeiten.</param>
|
||||
public void Collect(SecurityInventory target, IisInventory iis, IList<Finding> findings)
|
||||
{
|
||||
foreach (var pool in iis.ApplicationPools)
|
||||
{
|
||||
target.ServiceAccounts.Add(new ServiceAccountRecord
|
||||
{
|
||||
Source = "IIS Application Pool",
|
||||
Name = pool.Name,
|
||||
DisplayName = pool.Name,
|
||||
Account = pool.UserName,
|
||||
State = "[Laufzeitstatus nicht aus applicationHost.config verfügbar]",
|
||||
StartMode = pool.StartMode,
|
||||
Path = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
CollectWindowsServices(target);
|
||||
CollectLocalSecurityPolicy(target, findings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest relevante Windows-Dienste per WMI.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
private void CollectWindowsServices(SecurityInventory target)
|
||||
{
|
||||
var scope = new ManagementScope(@"\\.\root\cimv2");
|
||||
scope.Connect();
|
||||
var query = "SELECT Name, DisplayName, StartName, State, StartMode, PathName FROM Win32_Service";
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
scope,
|
||||
new ObjectQuery(query),
|
||||
new EnumerationOptions
|
||||
{
|
||||
ReturnImmediately = false,
|
||||
Rewindable = false,
|
||||
Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds)
|
||||
}))
|
||||
using (var results = searcher.Get())
|
||||
{
|
||||
foreach (ManagementObject service in results)
|
||||
{
|
||||
var name = Value(service, "Name");
|
||||
var displayName = Value(service, "DisplayName");
|
||||
if (!IsRelevantService(name, displayName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target.ServiceAccounts.Add(new ServiceAccountRecord
|
||||
{
|
||||
Source = "Windows Service",
|
||||
Name = name,
|
||||
DisplayName = displayName,
|
||||
Account = Value(service, "StartName"),
|
||||
State = Value(service, "State"),
|
||||
StartMode = Value(service, "StartMode"),
|
||||
Path = Value(service, "PathName")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
target.ServiceAccounts.Sort((left, right) =>
|
||||
string.Compare(left.Source + left.Name, right.Source + right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exportiert lokale User Rights und System Access über das Windows-Bordmittel secedit.
|
||||
/// </summary>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
/// <param name="findings">Liste für Berechtigungs- oder Toolfehler.</param>
|
||||
private static void CollectLocalSecurityPolicy(SecurityInventory target, IList<Finding> findings)
|
||||
{
|
||||
var temporaryPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"BizTalkIisInventory-" + Guid.NewGuid().ToString("N") + ".inf");
|
||||
try
|
||||
{
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
var executable = Path.Combine(windows, "System32", "secedit.exe");
|
||||
if (!File.Exists(executable))
|
||||
{
|
||||
throw new FileNotFoundException("secedit.exe wurde nicht gefunden.", executable);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = "/export /cfg \"" + temporaryPath + "\" /areas USER_RIGHTS SECURITYPOLICY /quiet",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
WorkingDirectory = Path.GetTempPath()
|
||||
};
|
||||
|
||||
using (var process = Process.Start(startInfo))
|
||||
{
|
||||
if (process == null)
|
||||
{
|
||||
throw new InvalidOperationException("secedit.exe konnte nicht gestartet werden.");
|
||||
}
|
||||
|
||||
if (!process.WaitForExit(60000))
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Der Report enthält bereits den Timeout; Kill-Fehler ist sekundär.
|
||||
}
|
||||
|
||||
throw new TimeoutException("secedit.exe wurde nach 60 Sekunden beendet.");
|
||||
}
|
||||
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
if (process.ExitCode != 0 || !File.Exists(temporaryPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"secedit.exe ExitCode " + process.ExitCode + ": " + error.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
ParseSecurityPolicy(temporaryPath, target);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
findings.Add(new Finding
|
||||
{
|
||||
Severity = "Warnung",
|
||||
Area = "Lokale Sicherheitsrichtlinie",
|
||||
Message = "Die lokale Sicherheitsrichtlinie konnte nicht vollständig exportiert werden.",
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "Tool als lokaler Administrator starten und secedit-Verfügbarkeit prüfen."
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Temporäre Datei enthält nur Richtlinienmetadaten; Cleanup-Fehler darf Report nicht verhindern.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parst die relevanten Abschnitte eines secedit-Exports.
|
||||
/// </summary>
|
||||
/// <param name="path">Pfad zur temporären INF-Datei.</param>
|
||||
/// <param name="target">Security-Zielmodell.</param>
|
||||
internal static void ParseSecurityPolicy(string path, SecurityInventory target)
|
||||
{
|
||||
var section = string.Empty;
|
||||
foreach (var rawLine in File.ReadAllLines(path, Encoding.Unicode))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith(";", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal))
|
||||
{
|
||||
section = line.Substring(1, line.Length - 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = line.Substring(0, separator).Trim();
|
||||
var value = line.Substring(separator + 1).Trim();
|
||||
if (string.Equals(section, "Privilege Rights", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target.UserRights.Add(new UserRightRecord { Right = name, Accounts = value });
|
||||
}
|
||||
else if (string.Equals(section, "System Access", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(section, "Event Audit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target.LocalPolicy.Add(new NameValueRecord(section + " / " + name, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein Dienst IIS-, BizTalk- oder SSO-relevant ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Technischer Dienstname.</param>
|
||||
/// <param name="displayName">Anzeigename.</param>
|
||||
/// <returns><c>true</c> für relevante Dienste.</returns>
|
||||
private static bool IsRelevantService(string name, string displayName)
|
||||
{
|
||||
var value = (name ?? string.Empty) + " " + (displayName ?? string.Empty);
|
||||
var fragments = new[]
|
||||
{
|
||||
"BizTalk", "BTSSvc", "ENTSSO", "RuleEngine", "W3SVC", "WAS",
|
||||
"IISADMIN", "AppHostSvc", "MSMQ", "World Wide Web"
|
||||
};
|
||||
return fragments.Any(fragment =>
|
||||
value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine WMI-Property als Text.
|
||||
/// </summary>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
/// <returns>Invariant formatierter Wert.</returns>
|
||||
private static string Value(ManagementObject item, string property)
|
||||
{
|
||||
return Convert.ToString(item[property], CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using Microsoft.Win32;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Erfasst Betriebssystem, Prozesskontext und installierte IIS-nahe Windows-Rollen.
|
||||
/// </summary>
|
||||
internal sealed class SystemCollector
|
||||
{
|
||||
private readonly CollectorOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den System-Collector.
|
||||
/// </summary>
|
||||
/// <param name="options">Collector-Grenzen und Timeouts.</param>
|
||||
public SystemCollector(CollectorOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest lokale Systeminformationen und relevante Server Features.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell für die Ergebnisse.</param>
|
||||
public void Collect(SystemInventory target)
|
||||
{
|
||||
target.Properties.Add(new NameValueRecord("Computername", Environment.MachineName));
|
||||
target.Properties.Add(new NameValueRecord("Domäne", Environment.UserDomainName));
|
||||
target.Properties.Add(new NameValueRecord("Ausführungsidentität", GetIdentity()));
|
||||
target.Properties.Add(new NameValueRecord("64-Bit-Betriebssystem", Environment.Is64BitOperatingSystem.ToString()));
|
||||
target.Properties.Add(new NameValueRecord("64-Bit-Prozess", Environment.Is64BitProcess.ToString()));
|
||||
target.Properties.Add(new NameValueRecord(".NET-Laufzeit", RuntimeEnvironment.GetSystemVersion()));
|
||||
target.Properties.Add(new NameValueRecord("Zeitzone", TimeZoneInfo.Local.DisplayName));
|
||||
target.Properties.Add(new NameValueRecord("Erfassungszeit lokal", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture)));
|
||||
|
||||
ReadOperatingSystem(target);
|
||||
ReadDotNetRelease(target);
|
||||
ReadServerFeatures(target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt die aktuelle Windows-Identität.
|
||||
/// </summary>
|
||||
/// <returns>Kontoname oder ein erklärender Fallback.</returns>
|
||||
private static string GetIdentity()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
{
|
||||
return identity == null ? "[nicht ermittelbar]" : identity.Name;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return "[nicht ermittelbar: " + exception.GetType().Name + "]";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Betriebssystemdetails per WMI.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private void ReadOperatingSystem(SystemInventory target)
|
||||
{
|
||||
var scope = new ManagementScope(@"\\.\root\cimv2");
|
||||
scope.Connect();
|
||||
using (var searcher = CreateSearcher(
|
||||
scope,
|
||||
"SELECT Caption, Version, BuildNumber, OSArchitecture, InstallDate, LastBootUpTime FROM Win32_OperatingSystem"))
|
||||
using (var results = searcher.Get())
|
||||
{
|
||||
foreach (ManagementObject item in results)
|
||||
{
|
||||
AddWmiValue(target, "Betriebssystem", item, "Caption");
|
||||
AddWmiValue(target, "Version", item, "Version");
|
||||
AddWmiValue(target, "Build", item, "BuildNumber");
|
||||
AddWmiValue(target, "Architektur", item, "OSArchitecture");
|
||||
AddWmiDate(target, "Installationsdatum", item, "InstallDate");
|
||||
AddWmiDate(target, "Letzter Start", item, "LastBootUpTime");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den .NET-Framework-Releasewert aus der Registry.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private static void ReadDotNetRelease(SystemInventory target)
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full",
|
||||
false))
|
||||
{
|
||||
var release = key == null ? null : key.GetValue("Release");
|
||||
target.Properties.Add(new NameValueRecord(
|
||||
".NET Framework Release",
|
||||
release == null ? "[nicht gefunden]" : Convert.ToString(release, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest installierte Serverrollen über Win32_ServerFeature.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
private void ReadServerFeatures(SystemInventory target)
|
||||
{
|
||||
var scope = new ManagementScope(@"\\.\root\cimv2");
|
||||
scope.Connect();
|
||||
using (var searcher = CreateSearcher(
|
||||
scope,
|
||||
"SELECT ID, Name, ParentID FROM Win32_ServerFeature"))
|
||||
using (var results = searcher.Get())
|
||||
{
|
||||
foreach (ManagementObject item in results)
|
||||
{
|
||||
var name = Convert.ToString(item["Name"], CultureInfo.InvariantCulture);
|
||||
if (!IsRelevantFeature(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = Convert.ToString(item["ID"], CultureInfo.InvariantCulture);
|
||||
var parent = Convert.ToString(item["ParentID"], CultureInfo.InvariantCulture);
|
||||
target.InstalledFeatures.Add(new NameValueRecord(
|
||||
name,
|
||||
string.Format(CultureInfo.InvariantCulture, "Installiert (ID {0}, Parent {1})", id, parent)));
|
||||
}
|
||||
}
|
||||
|
||||
target.InstalledFeatures.Sort((left, right) =>
|
||||
string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen WMI-Searcher mit begrenztem Timeout.
|
||||
/// </summary>
|
||||
/// <param name="scope">Bereits verbundener WMI-Scope.</param>
|
||||
/// <param name="query">Schreibgeschützte WQL-Abfrage.</param>
|
||||
/// <returns>Konfigurierter Searcher.</returns>
|
||||
private ManagementObjectSearcher CreateSearcher(ManagementScope scope, string query)
|
||||
{
|
||||
return new ManagementObjectSearcher(
|
||||
scope,
|
||||
new ObjectQuery(query),
|
||||
new EnumerationOptions
|
||||
{
|
||||
ReturnImmediately = false,
|
||||
Rewindable = false,
|
||||
Timeout = TimeSpan.FromSeconds(options.WmiTimeoutSeconds)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein Feature für IIS/BizTalk-Dokumentation relevant ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Anzeigename des Server Features.</param>
|
||||
/// <returns><c>true</c> bei IIS-, WAS-, HTTP-, MSMQ-, .NET- oder COM+-Bezug.</returns>
|
||||
private static bool IsRelevantFeature(string name)
|
||||
{
|
||||
var value = name ?? string.Empty;
|
||||
var fragments = new[] { "IIS", "Web", "HTTP", "WAS", "ASP.NET", ".NET", "MSMQ", "Message Queuing", "COM+" };
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt eine WMI-Property als Text hinzu.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="label">Berichtsbezeichnung.</param>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
private static void AddWmiValue(SystemInventory target, string label, ManagementObject item, string property)
|
||||
{
|
||||
target.Properties.Add(new NameValueRecord(
|
||||
label,
|
||||
Convert.ToString(item[property], CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen WMI-DMTF-Zeitwert formatiert hinzu.
|
||||
/// </summary>
|
||||
/// <param name="target">Zielmodell.</param>
|
||||
/// <param name="label">Berichtsbezeichnung.</param>
|
||||
/// <param name="item">WMI-Objekt.</param>
|
||||
/// <param name="property">Propertyname.</param>
|
||||
private static void AddWmiDate(SystemInventory target, string label, ManagementObject item, string property)
|
||||
{
|
||||
var raw = Convert.ToString(item[property], CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
target.Properties.Add(new NameValueRecord(
|
||||
label,
|
||||
ManagementDateTimeConverter.ToDateTime(raw).ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
target.Properties.Add(new NameValueRecord(label, raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Configuration;
|
||||
using System.Linq;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enthält die technischen Grenzen und Schalter der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
internal sealed class CollectorOptions
|
||||
{
|
||||
public int MaxFilesPerApplication { get; private set; }
|
||||
public int MaxContentDepth { get; private set; }
|
||||
public bool IncludeFileHashes { get; private set; }
|
||||
public bool IncludeAllPersonalCertificates { get; private set; }
|
||||
public int WmiTimeoutSeconds { get; private set; }
|
||||
public IList<string> ExpectedApplications { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Einstellungen aus der Anwendungskonfiguration und verwendet bei ungültigen Werten sichere Defaults.
|
||||
/// </summary>
|
||||
/// <param name="commandLine">Optionale Überschreibungen aus der Kommandozeile.</param>
|
||||
/// <returns>Validierte Collector-Einstellungen.</returns>
|
||||
public static CollectorOptions Load(Infrastructure.CommandLineOptions commandLine)
|
||||
{
|
||||
var result = new CollectorOptions
|
||||
{
|
||||
MaxFilesPerApplication = ReadInt("MaxFilesPerApplication", 10000, 0, 100000),
|
||||
MaxContentDepth = ReadInt("MaxContentDepth", 30, 1, 100),
|
||||
IncludeFileHashes = ReadBool("IncludeFileHashes", false),
|
||||
IncludeAllPersonalCertificates = ReadBool("IncludeAllPersonalCertificates", true),
|
||||
WmiTimeoutSeconds = ReadInt("WmiTimeoutSeconds", 30, 5, 300),
|
||||
ExpectedApplications = (ConfigurationManager.AppSettings["ExpectedApplications"] ?? string.Empty)
|
||||
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(item => item.Trim().Trim('/'))
|
||||
.Where(item => item.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
if (commandLine.IncludeFileHashes)
|
||||
{
|
||||
result.IncludeFileHashes = true;
|
||||
}
|
||||
|
||||
if (commandLine.SkipContentManifest)
|
||||
{
|
||||
result.MaxFilesPerApplication = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen begrenzten Ganzzahlwert aus appSettings.
|
||||
/// </summary>
|
||||
/// <param name="key">Name der Einstellung.</param>
|
||||
/// <param name="fallback">Fallback bei fehlendem oder ungültigem Wert.</param>
|
||||
/// <param name="minimum">Kleinster zulässiger Wert.</param>
|
||||
/// <param name="maximum">Größter zulässiger Wert.</param>
|
||||
/// <returns>Validierter Wert.</returns>
|
||||
private static int ReadInt(string key, int fallback, int minimum, int maximum)
|
||||
{
|
||||
int value;
|
||||
if (!int.TryParse(ConfigurationManager.AppSettings[key], out value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Max(minimum, Math.Min(maximum, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen booleschen Wert aus appSettings.
|
||||
/// </summary>
|
||||
/// <param name="key">Name der Einstellung.</param>
|
||||
/// <param name="fallback">Fallback bei fehlendem oder ungültigem Wert.</param>
|
||||
/// <returns>Gelesener oder vorgegebener Wert.</returns>
|
||||
private static bool ReadBool(string key, bool fallback)
|
||||
{
|
||||
bool value;
|
||||
return bool.TryParse(ConfigurationManager.AppSettings[key], out value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Beschreibt die validierten Kommandozeilenoptionen.
|
||||
/// </summary>
|
||||
internal sealed class CommandLineOptions
|
||||
{
|
||||
public string EnvironmentName { get; private set; }
|
||||
public string OutputDirectory { get; private set; }
|
||||
public string IisConfigPath { get; private set; }
|
||||
public bool IncludeFileHashes { get; private set; }
|
||||
public bool SkipContentManifest { get; private set; }
|
||||
public bool ShowHelp { get; private set; }
|
||||
public bool SelfTest { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysiert und validiert die Kommandozeile.
|
||||
/// </summary>
|
||||
/// <param name="args">Argumente des Prozesses.</param>
|
||||
/// <returns>Validierte Optionen.</returns>
|
||||
/// <exception cref="ArgumentException">Wird bei unbekannten oder unvollständigen Optionen ausgelöst.</exception>
|
||||
public static CommandLineOptions Parse(string[] args)
|
||||
{
|
||||
var result = new CommandLineOptions
|
||||
{
|
||||
EnvironmentName = "UNBEKANNT",
|
||||
OutputDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "reports")
|
||||
};
|
||||
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index].ToLowerInvariant())
|
||||
{
|
||||
case "--environment":
|
||||
case "-e":
|
||||
result.EnvironmentName = RequireValue(args, ref index);
|
||||
break;
|
||||
case "--output":
|
||||
case "-o":
|
||||
result.OutputDirectory = RequireValue(args, ref index);
|
||||
break;
|
||||
case "--iis-config":
|
||||
result.IisConfigPath = RequireValue(args, ref index);
|
||||
break;
|
||||
case "--include-file-hashes":
|
||||
result.IncludeFileHashes = true;
|
||||
break;
|
||||
case "--skip-content-manifest":
|
||||
result.SkipContentManifest = true;
|
||||
break;
|
||||
case "--self-test":
|
||||
result.SelfTest = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "/?":
|
||||
result.ShowHelp = true;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unbekannte Option: " + args[index]);
|
||||
}
|
||||
}
|
||||
|
||||
result.EnvironmentName = SanitizeEnvironment(result.EnvironmentName);
|
||||
result.OutputDirectory = Path.GetFullPath(
|
||||
Environment.ExpandEnvironmentVariables(result.OutputDirectory));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.IisConfigPath))
|
||||
{
|
||||
result.IisConfigPath = Path.GetFullPath(
|
||||
Environment.ExpandEnvironmentVariables(result.IisConfigPath));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert einen Umgebungsnamen für Dateiname und Bericht.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingegebener Umgebungsname.</param>
|
||||
/// <returns>Sicherer Umgebungsname.</returns>
|
||||
internal static string SanitizeEnvironment(string value)
|
||||
{
|
||||
var trimmed = string.IsNullOrWhiteSpace(value) ? "UNBEKANNT" : value.Trim();
|
||||
var sanitized = Regex.Replace(trimmed, @"[^A-Za-z0-9._-]+", "-").Trim('-', '.');
|
||||
return string.IsNullOrEmpty(sanitized) ? "UNBEKANNT" : sanitized.ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den Wert hinter einer Option.
|
||||
/// </summary>
|
||||
/// <param name="args">Alle Prozessargumente.</param>
|
||||
/// <param name="index">Aktueller Argumentindex; wird auf den Wert weitergeschaltet.</param>
|
||||
/// <returns>Optionswert.</returns>
|
||||
private static string RequireValue(string[] args, ref int index)
|
||||
{
|
||||
if (index + 1 >= args.Length || args[index + 1].StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Wert fehlt hinter " + args[index] + ".");
|
||||
}
|
||||
|
||||
index++;
|
||||
return args[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt sichtbare Konsolenmeldungen und ein dauerhaftes UTF-8-Protokoll.
|
||||
/// </summary>
|
||||
internal sealed class FileLogger : IDisposable
|
||||
{
|
||||
private readonly object sync = new object();
|
||||
private readonly StreamWriter writer;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert das Protokoll.
|
||||
/// </summary>
|
||||
/// <param name="logPath">Vollständiger Pfad zur Protokolldatei.</param>
|
||||
public FileLogger(string logPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logPath));
|
||||
writer = new StreamWriter(logPath, false, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
LogPath = logPath;
|
||||
}
|
||||
|
||||
public string LogPath { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Informationsmeldung.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Info(string message)
|
||||
{
|
||||
Write("INFO", message, ConsoleColor.Gray);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Warnung.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Warning(string message)
|
||||
{
|
||||
Write("WARN", message, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Fehler einschließlich technischer Details.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
/// <param name="exception">Optionale Ausnahme.</param>
|
||||
public void Error(string message, Exception exception = null)
|
||||
{
|
||||
var detail = exception == null ? message : message + " | " + exception;
|
||||
Write("ERROR", detail, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen erfolgreichen Arbeitsschritt.
|
||||
/// </summary>
|
||||
/// <param name="message">Benutzerlesbarer Meldungstext.</param>
|
||||
public void Success(string message)
|
||||
{
|
||||
Write("OK", message, ConsoleColor.Green);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt den Dateihandle des Protokolls frei.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
writer.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt atomar auf Konsole und in Datei.
|
||||
/// </summary>
|
||||
/// <param name="level">Kurzbezeichnung der Meldungsstufe.</param>
|
||||
/// <param name="message">Meldung.</param>
|
||||
/// <param name="color">Konsolenfarbe.</param>
|
||||
private void Write(string level, string message, ConsoleColor color)
|
||||
{
|
||||
var line = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}",
|
||||
DateTime.Now,
|
||||
level,
|
||||
message);
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
writer.WriteLine(line);
|
||||
var previous = Console.ForegroundColor;
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.ForegroundColor = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Isoliert Collector-Fehler und überführt sie in Status und Report-Finding.
|
||||
/// </summary>
|
||||
internal static class SafeCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Führt einen Collector aus, protokolliert Laufzeit und fängt nicht behandelte Fehler ab.
|
||||
/// </summary>
|
||||
/// <param name="document">Zieldokument für Status und Findings.</param>
|
||||
/// <param name="logger">Gemeinsames Laufprotokoll.</param>
|
||||
/// <param name="step">Aktueller Schritt beginnend bei eins.</param>
|
||||
/// <param name="totalSteps">Gesamtzahl der Schritte.</param>
|
||||
/// <param name="name">Benutzerlesbarer Abschnittsname.</param>
|
||||
/// <param name="action">Auszuführender Collector.</param>
|
||||
internal static void Run(
|
||||
InventoryDocument document,
|
||||
FileLogger logger,
|
||||
int step,
|
||||
int totalSteps,
|
||||
string name,
|
||||
Action action)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
logger.Info(string.Format("[{0}/{1}] {2} wird erfasst ...", step, totalSteps, name));
|
||||
try
|
||||
{
|
||||
action();
|
||||
stopwatch.Stop();
|
||||
document.SectionStatuses.Add(new SectionStatus
|
||||
{
|
||||
Name = name,
|
||||
Status = "Erfolgreich",
|
||||
Message = "Abschnitt wurde vollständig ausgeführt.",
|
||||
DurationMilliseconds = stopwatch.ElapsedMilliseconds
|
||||
});
|
||||
logger.Success(string.Format(
|
||||
"[{0}/{1}] {2} abgeschlossen ({3} ms).",
|
||||
step,
|
||||
totalSteps,
|
||||
name,
|
||||
stopwatch.ElapsedMilliseconds));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
document.SectionStatuses.Add(new SectionStatus
|
||||
{
|
||||
Name = name,
|
||||
Status = "Teilweise",
|
||||
Message = exception.Message,
|
||||
DurationMilliseconds = stopwatch.ElapsedMilliseconds
|
||||
});
|
||||
document.Findings.Add(new Finding
|
||||
{
|
||||
Severity = "Fehler",
|
||||
Area = name,
|
||||
Message = "Der Abschnitt konnte nicht vollständig erfasst werden.",
|
||||
TechnicalDetail = exception.GetType().Name + ": " + exception.Message,
|
||||
Recommendation = "Berechtigungen, lokale Datenquelle und Laufprotokoll prüfen; die übrigen Abschnitte sind weiterhin gültig."
|
||||
});
|
||||
logger.Error(string.Format("[{0}/{1}] {2} fehlgeschlagen.", step, totalSteps, name), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Entfernt Kennwörter, Tokens und verschlüsselte Nutzdaten aus Konfigurationsdarstellungen.
|
||||
/// </summary>
|
||||
internal static class SensitiveDataSanitizer
|
||||
{
|
||||
private static readonly string[] SensitiveAttributeFragments =
|
||||
{
|
||||
"password",
|
||||
"pwd",
|
||||
"secret",
|
||||
"token",
|
||||
"connectionstring",
|
||||
"privatekey",
|
||||
"validationkey",
|
||||
"decryptionkey"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine bereinigte Kopie eines XML-Elements.
|
||||
/// </summary>
|
||||
/// <param name="source">Zu bereinigendes Element.</param>
|
||||
/// <returns>Neue XML-Struktur ohne sensible Attributwerte oder verschlüsselte Blobs.</returns>
|
||||
internal static XElement SanitizeXml(XElement source)
|
||||
{
|
||||
var copy = new XElement(source);
|
||||
foreach (var element in copy.DescendantsAndSelf())
|
||||
{
|
||||
foreach (var attribute in element.Attributes().ToList())
|
||||
{
|
||||
if (IsSensitiveName(attribute.Name.LocalName))
|
||||
{
|
||||
attribute.Value = "[ENTFERNT]";
|
||||
}
|
||||
}
|
||||
|
||||
if (IsEncryptedPayloadElement(element.Name.LocalName))
|
||||
{
|
||||
element.RemoveNodes();
|
||||
element.Value = "[VERSCHLUESSELTE NUTZDATEN NICHT DOKUMENTIERT]";
|
||||
}
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Attributname typischerweise ein Secret bezeichnet.
|
||||
/// </summary>
|
||||
/// <param name="name">Attributname.</param>
|
||||
/// <returns><c>true</c>, wenn der Wert entfernt werden muss.</returns>
|
||||
internal static bool IsSensitiveName(string name)
|
||||
{
|
||||
var normalized = (name ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty);
|
||||
return SensitiveAttributeFragments.Any(
|
||||
item => normalized.IndexOf(item, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erkennt XML-Elemente, deren Inhalt ein geschützter Konfigurationsblob ist.
|
||||
/// </summary>
|
||||
/// <param name="name">Lokaler XML-Elementname.</param>
|
||||
/// <returns><c>true</c> für bekannte verschlüsselte Payload-Elemente.</returns>
|
||||
private static bool IsEncryptedPayloadElement(string name)
|
||||
{
|
||||
return string.Equals(name, "EncryptedData", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "CipherData", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "CipherValue", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Vollständiges Ergebnis einer lokalen Bestandsaufnahme.
|
||||
/// </summary>
|
||||
internal sealed class InventoryDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle Abschnittslisten der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
public InventoryDocument()
|
||||
{
|
||||
System = new SystemInventory();
|
||||
Iis = new IisInventory();
|
||||
Certificates = new List<CertificateRecord>();
|
||||
Security = new SecurityInventory();
|
||||
BizTalk = new BizTalkInventory();
|
||||
Findings = new List<Finding>();
|
||||
SectionStatuses = new List<SectionStatus>();
|
||||
}
|
||||
|
||||
public string EnvironmentName { get; set; }
|
||||
public string ComputerName { get; set; }
|
||||
public DateTime StartedUtc { get; set; }
|
||||
public DateTime CompletedUtc { get; set; }
|
||||
public SystemInventory System { get; set; }
|
||||
public IisInventory Iis { get; set; }
|
||||
public List<CertificateRecord> Certificates { get; set; }
|
||||
public SecurityInventory Security { get; set; }
|
||||
public BizTalkInventory BizTalk { get; set; }
|
||||
public List<Finding> Findings { get; private set; }
|
||||
public List<SectionStatus> SectionStatuses { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class SystemInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert die Listen für Systemeigenschaften und Features.
|
||||
/// </summary>
|
||||
public SystemInventory()
|
||||
{
|
||||
Properties = new List<NameValueRecord>();
|
||||
InstalledFeatures = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public List<NameValueRecord> Properties { get; private set; }
|
||||
public List<NameValueRecord> InstalledFeatures { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class IisInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle IIS-Ergebnislisten.
|
||||
/// </summary>
|
||||
public IisInventory()
|
||||
{
|
||||
ApplicationPools = new List<ApplicationPoolRecord>();
|
||||
Sites = new List<SiteRecord>();
|
||||
GlobalSections = new List<ConfigurationSectionRecord>();
|
||||
EncryptionProviders = new List<EncryptionProviderRecord>();
|
||||
}
|
||||
|
||||
public string ConfigurationPath { get; set; }
|
||||
public DateTime? ConfigurationLastWriteUtc { get; set; }
|
||||
public string SanitizedConfigurationSha256 { get; set; }
|
||||
public List<ApplicationPoolRecord> ApplicationPools { get; private set; }
|
||||
public List<SiteRecord> Sites { get; private set; }
|
||||
public List<ConfigurationSectionRecord> GlobalSections { get; private set; }
|
||||
public List<EncryptionProviderRecord> EncryptionProviders { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class ApplicationPoolRecord
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string ManagedRuntimeVersion { get; set; }
|
||||
public string ManagedPipelineMode { get; set; }
|
||||
public string AutoStart { get; set; }
|
||||
public string StartMode { get; set; }
|
||||
public string IdentityType { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Enable32BitAppOnWin64 { get; set; }
|
||||
public List<NameValueRecord> AdditionalSettings { get; set; } = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
internal sealed class SiteRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Binding- und Anwendungslisten einer Site.
|
||||
/// </summary>
|
||||
public SiteRecord()
|
||||
{
|
||||
Bindings = new List<BindingRecord>();
|
||||
Applications = new List<WebApplicationRecord>();
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string ServerAutoStart { get; set; }
|
||||
public string LogDirectory { get; set; }
|
||||
public List<BindingRecord> Bindings { get; private set; }
|
||||
public List<WebApplicationRecord> Applications { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class BindingRecord
|
||||
{
|
||||
public string SiteName { get; set; }
|
||||
public string Protocol { get; set; }
|
||||
public string BindingInformation { get; set; }
|
||||
public string CertificateHash { get; set; }
|
||||
public string CertificateStoreName { get; set; }
|
||||
public string SslFlags { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class WebApplicationRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Listen für virtuelle Verzeichnisse, Dateien und ACLs.
|
||||
/// </summary>
|
||||
public WebApplicationRecord()
|
||||
{
|
||||
VirtualDirectories = new List<VirtualDirectoryRecord>();
|
||||
ContentFiles = new List<ContentFileRecord>();
|
||||
AccessRules = new List<AccessRuleRecord>();
|
||||
}
|
||||
|
||||
public string SiteName { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string ApplicationPool { get; set; }
|
||||
public string EnabledProtocols { get; set; }
|
||||
public string PhysicalPath { get; set; }
|
||||
public bool PhysicalPathExists { get; set; }
|
||||
public int TotalFiles { get; set; }
|
||||
public long TotalBytes { get; set; }
|
||||
public bool ManifestTruncated { get; set; }
|
||||
public DateTime? LatestWriteUtc { get; set; }
|
||||
public List<VirtualDirectoryRecord> VirtualDirectories { get; private set; }
|
||||
public List<ContentFileRecord> ContentFiles { get; private set; }
|
||||
public List<AccessRuleRecord> AccessRules { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class VirtualDirectoryRecord
|
||||
{
|
||||
public string Path { get; set; }
|
||||
public string PhysicalPath { get; set; }
|
||||
public string UserName { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ContentFileRecord
|
||||
{
|
||||
public string RelativePath { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTime LastWriteUtc { get; set; }
|
||||
public string Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class AccessRuleRecord
|
||||
{
|
||||
public string Target { get; set; }
|
||||
public string Identity { get; set; }
|
||||
public string Rights { get; set; }
|
||||
public string AccessType { get; set; }
|
||||
public bool IsInherited { get; set; }
|
||||
public string Inheritance { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ConfigurationSectionRecord
|
||||
{
|
||||
public string Path { get; set; }
|
||||
public string OverrideModeDefault { get; set; }
|
||||
public string AllowDefinition { get; set; }
|
||||
public bool IsEncrypted { get; set; }
|
||||
public int ElementCount { get; set; }
|
||||
public string SafeSummary { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class EncryptionProviderRecord
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string KeyContainerName { get; set; }
|
||||
public string UseMachineContainer { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class CertificateRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert Key-Usage- und ACL-Listen eines Zertifikats.
|
||||
/// </summary>
|
||||
public CertificateRecord()
|
||||
{
|
||||
EnhancedKeyUsages = new List<string>();
|
||||
KeyAccessRules = new List<AccessRuleRecord>();
|
||||
}
|
||||
|
||||
public string StoreLocation { get; set; }
|
||||
public string StoreName { get; set; }
|
||||
public string Subject { get; set; }
|
||||
public string Issuer { get; set; }
|
||||
public string Thumbprint { get; set; }
|
||||
public string SerialNumber { get; set; }
|
||||
public DateTime NotBefore { get; set; }
|
||||
public DateTime NotAfter { get; set; }
|
||||
public string SignatureAlgorithm { get; set; }
|
||||
public string PublicKeyAlgorithm { get; set; }
|
||||
public int PublicKeySize { get; set; }
|
||||
public bool HasPrivateKey { get; set; }
|
||||
public string PrivateKeyExportable { get; set; }
|
||||
public string PrivateKeyProvider { get; set; }
|
||||
public string PrivateKeyContainer { get; set; }
|
||||
public string PrivateKeyFile { get; set; }
|
||||
public bool UsedByIisBinding { get; set; }
|
||||
public List<string> EnhancedKeyUsages { get; private set; }
|
||||
public List<AccessRuleRecord> KeyAccessRules { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class SecurityInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert die Listen der lokalen Sicherheitsaufnahme.
|
||||
/// </summary>
|
||||
public SecurityInventory()
|
||||
{
|
||||
ServiceAccounts = new List<ServiceAccountRecord>();
|
||||
UserRights = new List<UserRightRecord>();
|
||||
LocalPolicy = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public List<ServiceAccountRecord> ServiceAccounts { get; private set; }
|
||||
public List<UserRightRecord> UserRights { get; private set; }
|
||||
public List<NameValueRecord> LocalPolicy { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class ServiceAccountRecord
|
||||
{
|
||||
public string Source { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Account { get; set; }
|
||||
public string State { get; set; }
|
||||
public string StartMode { get; set; }
|
||||
public string Path { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class UserRightRecord
|
||||
{
|
||||
public string Right { get; set; }
|
||||
public string Accounts { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class BizTalkInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert alle Listen des BizTalk-Inventars.
|
||||
/// </summary>
|
||||
public BizTalkInventory()
|
||||
{
|
||||
RegistryValues = new List<NameValueRecord>();
|
||||
InstalledProducts = new List<NameValueRecord>();
|
||||
Components = new List<NameValueRecord>();
|
||||
WmiClasses = new List<NameValueRecord>();
|
||||
}
|
||||
|
||||
public bool WmiNamespaceAvailable { get; set; }
|
||||
public List<NameValueRecord> RegistryValues { get; private set; }
|
||||
public List<NameValueRecord> InstalledProducts { get; private set; }
|
||||
public List<NameValueRecord> Components { get; private set; }
|
||||
public List<NameValueRecord> WmiClasses { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class NameValueRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialisiert einen leeren Name/Wert-Datensatz.
|
||||
/// </summary>
|
||||
public NameValueRecord()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert einen Name/Wert-Datensatz.
|
||||
/// </summary>
|
||||
/// <param name="name">Eigenschaftsname.</param>
|
||||
/// <param name="value">Eigenschaftswert.</param>
|
||||
public NameValueRecord(string name, string value)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class Finding
|
||||
{
|
||||
public string Severity { get; set; }
|
||||
public string Area { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string TechnicalDetail { get; set; }
|
||||
public string Recommendation { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SectionStatus
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string Message { get; set; }
|
||||
public long DurationMilliseconds { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using BizTalkIisEnvironmentInventory.Collectors;
|
||||
using BizTalkIisEnvironmentInventory.Configuration;
|
||||
using BizTalkIisEnvironmentInventory.Infrastructure;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
using BizTalkIisEnvironmentInventory.Reporting;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt und Ablaufsteuerung der Bestandsaufnahme.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Analysiert Optionen, führt alle read-only Collectoren aus und erzeugt das Word-Dokument.
|
||||
/// </summary>
|
||||
/// <param name="args">Kommandozeilenargumente.</param>
|
||||
/// <returns>0 bei vollständigem Erfolg, 1 bei Teilfehler mit Report, 2 bei fatalem Fehler.</returns>
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
CommandLineOptions commandLine;
|
||||
try
|
||||
{
|
||||
commandLine = CommandLineOptions.Parse(args);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
Console.Error.WriteLine("Fehler: " + exception.Message);
|
||||
PrintHelp();
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (commandLine.ShowHelp)
|
||||
{
|
||||
PrintHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (commandLine.SelfTest)
|
||||
{
|
||||
return RunSelfTest();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(commandLine.OutputDirectory);
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
|
||||
var baseName = "IIS-Dokumentation-" + commandLine.EnvironmentName + "-"
|
||||
+ CommandLineOptions.SanitizeEnvironment(Environment.MachineName) + "-" + stamp;
|
||||
var logPath = Path.Combine(commandLine.OutputDirectory, baseName + ".log");
|
||||
using (var logger = new FileLogger(logPath))
|
||||
{
|
||||
return Run(commandLine, logger, baseName);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine("Fataler Fehler: " + exception);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt die Collector-Pipeline aus und schreibt den Bericht.
|
||||
/// </summary>
|
||||
/// <param name="commandLine">Validierte Laufoptionen.</param>
|
||||
/// <param name="logger">Gemeinsames Konsolen-/Dateiprotokoll.</param>
|
||||
/// <param name="baseName">Eindeutiger Basisdateiname.</param>
|
||||
/// <returns>0 bei vollständigem Erfolg, sonst 1 bei verwertbarem Teilreport.</returns>
|
||||
private static int Run(CommandLineOptions commandLine, FileLogger logger, string baseName)
|
||||
{
|
||||
const int totalSteps = 6;
|
||||
var options = CollectorOptions.Load(commandLine);
|
||||
var document = new InventoryDocument
|
||||
{
|
||||
EnvironmentName = commandLine.EnvironmentName,
|
||||
ComputerName = Environment.MachineName,
|
||||
StartedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
logger.Info("BizTalk IIS Environment Inventory 1.0 startet.");
|
||||
logger.Info("Modus: read-only; keine Kennwörter, verschlüsselten Payloads oder privaten Schlüssel im Report.");
|
||||
logger.Info("Umgebung: " + commandLine.EnvironmentName);
|
||||
logger.Info("Ausgabe: " + commandLine.OutputDirectory);
|
||||
logger.Info("Dateimanifest: " + (options.MaxFilesPerApplication > 0
|
||||
? "max. " + options.MaxFilesPerApplication + " Einträge je Anwendung"
|
||||
: "deaktiviert"));
|
||||
logger.Info("Datei-Hashes: " + (options.IncludeFileHashes ? "aktiv" : "deaktiviert"));
|
||||
|
||||
SafeCollector.Run(document, logger, 1, totalSteps, "Windows und Rollen",
|
||||
() => new SystemCollector(options).Collect(document.System));
|
||||
SafeCollector.Run(document, logger, 2, totalSteps, "IIS und Webinhalte",
|
||||
() => new IisCollector(options).Collect(document.Iis, document.Findings, commandLine.IisConfigPath));
|
||||
SafeCollector.Run(document, logger, 3, totalSteps, "Zertifikate und Schlüsselmetadaten",
|
||||
() => new CertificateCollector(options).Collect(document.Certificates, document.Iis, document.Findings));
|
||||
SafeCollector.Run(document, logger, 4, totalSteps, "Dienstkonten und lokale Sicherheit",
|
||||
() => new SecurityCollector(options).Collect(document.Security, document.Iis, document.Findings));
|
||||
SafeCollector.Run(document, logger, 5, totalSteps, "BizTalk-Komponenten",
|
||||
() => new BizTalkCollector(options).Collect(document.BizTalk, document.Findings));
|
||||
|
||||
document.CompletedUtc = DateTime.UtcNow;
|
||||
var reportPath = Path.Combine(commandLine.OutputDirectory, baseName + ".docx");
|
||||
SafeCollector.Run(document, logger, 6, totalSteps, "Word-Dokument",
|
||||
() => new DocxReportWriter().Write(document, reportPath));
|
||||
|
||||
var partial = document.SectionStatuses.Any(
|
||||
item => !string.Equals(item.Status, "Erfolgreich", StringComparison.OrdinalIgnoreCase));
|
||||
logger.Info("Auffälligkeiten: " + document.Findings.Count.ToString(CultureInfo.InvariantCulture));
|
||||
logger.Success("Word-Dokument: " + reportPath);
|
||||
logger.Info("Laufprotokoll: " + logger.LogPath);
|
||||
logger.Info(partial
|
||||
? "Erfassung mit Teilfehlern abgeschlossen (ExitCode 1)."
|
||||
: "Erfassung vollständig abgeschlossen (ExitCode 0).");
|
||||
return partial ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt plattformneutrale interne Prüfungen ohne Zugriff auf IIS oder BizTalk aus.
|
||||
/// </summary>
|
||||
/// <returns>0 bei Erfolg, sonst 2.</returns>
|
||||
private static int RunSelfTest()
|
||||
{
|
||||
string temporaryDocument = null;
|
||||
try
|
||||
{
|
||||
var environment = CommandLineOptions.SanitizeEnvironment(" acc / produkt ");
|
||||
if (!string.Equals(environment, "ACC-PRODUKT", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Umgebungsnormalisierung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
var xml = System.Xml.Linq.XElement.Parse(
|
||||
"<root password=\"secret\"><child token=\"abc\"/><EncryptedData>payload</EncryptedData></root>");
|
||||
var sanitized = SensitiveDataSanitizer.SanitizeXml(xml).ToString();
|
||||
if (sanitized.Contains("secret") || sanitized.Contains("payload") || sanitized.Contains("abc"))
|
||||
{
|
||||
throw new InvalidOperationException("Secret-Bereinigung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
var document = new InventoryDocument
|
||||
{
|
||||
EnvironmentName = "<ACC>",
|
||||
ComputerName = "TEST",
|
||||
StartedUtc = DateTime.UtcNow,
|
||||
CompletedUtc = DateTime.UtcNow
|
||||
};
|
||||
temporaryDocument = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"BizTalkIisInventory-SelfTest-" + Guid.NewGuid().ToString("N") + ".docx");
|
||||
new DocxReportWriter().Write(document, temporaryDocument);
|
||||
using (var archive = ZipFile.OpenRead(temporaryDocument))
|
||||
{
|
||||
var documentEntry = archive.GetEntry("word/document.xml");
|
||||
var stylesEntry = archive.GetEntry("word/styles.xml");
|
||||
if (documentEntry == null || stylesEntry == null)
|
||||
{
|
||||
throw new InvalidOperationException("Erforderliche DOCX-Parts fehlen.");
|
||||
}
|
||||
|
||||
using (var stream = documentEntry.Open())
|
||||
{
|
||||
XDocument.Load(stream);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Self-Test erfolgreich.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine("Self-Test fehlgeschlagen: " + exception);
|
||||
return 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(temporaryDocument) && File.Exists(temporaryDocument))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temporaryDocument);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eine fehlgeschlagene Temp-Bereinigung ändert das Self-Test-Ergebnis nicht.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die deutsche Kommandozeilenhilfe aus.
|
||||
/// </summary>
|
||||
private static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("BizTalk IIS Environment Inventory");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Aufruf:");
|
||||
Console.WriteLine(" BizTalkIisEnvironmentInventory.exe --environment ACC --output C:\\IIS-Doku\\ACC");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Optionen:");
|
||||
Console.WriteLine(" -e, --environment NAME Umgebungsname, z. B. ACC oder PROD");
|
||||
Console.WriteLine(" -o, --output PFAD Ausgabeordner für DOCX und Log");
|
||||
Console.WriteLine(" --iis-config DATEI Alternative applicationHost.config (Offline-Test)");
|
||||
Console.WriteLine(" --include-file-hashes SHA-256 für jede manifestierte Webdatei");
|
||||
Console.WriteLine(" --skip-content-manifest Nur Pfade/ACLs, keine Dateiliste");
|
||||
Console.WriteLine(" --self-test Prüft Parser und DOCX-Paketstruktur");
|
||||
Console.WriteLine(" -h, --help Diese Hilfe");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BizTalk IIS Environment Inventory")]
|
||||
[assembly: AssemblyDescription("Read-only IIS and BizTalk environment documentation collector")]
|
||||
[assembly: AssemblyCompany("JR IT Services")]
|
||||
[assembly: AssemblyProduct("BizTalk IIS Environment Inventory")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("66e7a524-f96b-46b9-a8b1-8f967c9fb77c")]
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
[assembly: InternalsVisibleTo("BizTalkIisEnvironmentInventory.Tests")]
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using BizTalkIisEnvironmentInventory.Models;
|
||||
|
||||
namespace BizTalkIisEnvironmentInventory.Reporting
|
||||
{
|
||||
/// <summary>
|
||||
/// Erzeugt ein standardkonformes Microsoft-Word-Dokument im Office-Open-XML-Format ohne Office-Installation.
|
||||
/// </summary>
|
||||
internal sealed class DocxReportWriter
|
||||
{
|
||||
private const string WordNamespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private const string RelationshipNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
private const string PackageRelationshipNamespace = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
private const string ContentTypeNamespace = "http://schemas.openxmlformats.org/package/2006/content-types";
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt den vollständigen DOCX-Bericht atomar in die Zieldatei.
|
||||
/// </summary>
|
||||
/// <param name="document">Abgeschlossene Bestandsaufnahme.</param>
|
||||
/// <param name="path">Vollständiger Zielpfad mit Erweiterung <c>.docx</c>.</param>
|
||||
public void Write(InventoryDocument document, string path)
|
||||
{
|
||||
if (document == null)
|
||||
{
|
||||
throw new ArgumentNullException("document");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None))
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, false, Encoding.UTF8))
|
||||
{
|
||||
WriteContentTypes(archive);
|
||||
WritePackageRelationships(archive);
|
||||
WriteDocumentRelationships(archive);
|
||||
WriteCoreProperties(archive, document);
|
||||
WriteApplicationProperties(archive);
|
||||
WriteStyles(archive);
|
||||
WriteMainDocument(archive, document);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Replace(temporaryPath, path, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(temporaryPath, path);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die MIME-Zuordnungen des Office-Open-XML-Pakets.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
private static void WriteContentTypes(ZipArchive archive)
|
||||
{
|
||||
WriteXmlEntry(archive, "[Content_Types].xml", writer =>
|
||||
{
|
||||
writer.WriteStartElement("Types", ContentTypeNamespace);
|
||||
WriteContentTypeDefault(writer, "rels", "application/vnd.openxmlformats-package.relationships+xml");
|
||||
WriteContentTypeDefault(writer, "xml", "application/xml");
|
||||
WriteContentTypeOverride(writer, "/word/document.xml",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml");
|
||||
WriteContentTypeOverride(writer, "/word/styles.xml",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml");
|
||||
WriteContentTypeOverride(writer, "/docProps/core.xml",
|
||||
"application/vnd.openxmlformats-package.core-properties+xml");
|
||||
WriteContentTypeOverride(writer, "/docProps/app.xml",
|
||||
"application/vnd.openxmlformats-officedocument.extended-properties+xml");
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Default-MIME-Zuordnung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="extension">Dateierweiterung.</param>
|
||||
/// <param name="contentType">MIME-Typ.</param>
|
||||
private static void WriteContentTypeDefault(XmlWriter writer, string extension, string contentType)
|
||||
{
|
||||
writer.WriteStartElement("Default", ContentTypeNamespace);
|
||||
writer.WriteAttributeString("Extension", extension);
|
||||
writer.WriteAttributeString("ContentType", contentType);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine part-spezifische MIME-Zuordnung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="partName">Absoluter Paketpart.</param>
|
||||
/// <param name="contentType">MIME-Typ.</param>
|
||||
private static void WriteContentTypeOverride(XmlWriter writer, string partName, string contentType)
|
||||
{
|
||||
writer.WriteStartElement("Override", ContentTypeNamespace);
|
||||
writer.WriteAttributeString("PartName", partName);
|
||||
writer.WriteAttributeString("ContentType", contentType);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Beziehungen von der Paketwurzel zu Dokument und Eigenschaften.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
private static void WritePackageRelationships(ZipArchive archive)
|
||||
{
|
||||
WriteXmlEntry(archive, "_rels/.rels", writer =>
|
||||
{
|
||||
writer.WriteStartElement("Relationships", PackageRelationshipNamespace);
|
||||
WriteRelationship(writer, "rId1",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
|
||||
"word/document.xml");
|
||||
WriteRelationship(writer, "rId2",
|
||||
"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
|
||||
"docProps/core.xml");
|
||||
WriteRelationship(writer, "rId3",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
|
||||
"docProps/app.xml");
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Beziehungen des Hauptdokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
private static void WriteDocumentRelationships(ZipArchive archive)
|
||||
{
|
||||
WriteXmlEntry(archive, "word/_rels/document.xml.rels", writer =>
|
||||
{
|
||||
writer.WriteStartElement("Relationships", PackageRelationshipNamespace);
|
||||
WriteRelationship(writer, "rIdStyles",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
|
||||
"styles.xml");
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine OOXML-Beziehung.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="id">Beziehungs-ID.</param>
|
||||
/// <param name="type">Beziehungstyp.</param>
|
||||
/// <param name="target">Relatives Ziel.</param>
|
||||
private static void WriteRelationship(XmlWriter writer, string id, string type, string target)
|
||||
{
|
||||
writer.WriteStartElement("Relationship", PackageRelationshipNamespace);
|
||||
writer.WriteAttributeString("Id", id);
|
||||
writer.WriteAttributeString("Type", type);
|
||||
writer.WriteAttributeString("Target", target);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Titel, Ersteller und Zeitstempel des Dokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
private static void WriteCoreProperties(ZipArchive archive, InventoryDocument document)
|
||||
{
|
||||
WriteXmlEntry(archive, "docProps/core.xml", writer =>
|
||||
{
|
||||
writer.WriteStartElement("cp", "coreProperties",
|
||||
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties");
|
||||
writer.WriteAttributeString("xmlns", "dc", null, "http://purl.org/dc/elements/1.1/");
|
||||
writer.WriteAttributeString("xmlns", "dcterms", null, "http://purl.org/dc/terms/");
|
||||
writer.WriteAttributeString("xmlns", "dcmitype", null, "http://purl.org/dc/dcmitype/");
|
||||
writer.WriteAttributeString("xmlns", "xsi", null, "http://www.w3.org/2001/XMLSchema-instance");
|
||||
writer.WriteElementString("dc", "title", "http://purl.org/dc/elements/1.1/",
|
||||
"IIS- und BizTalk-Dokumentation " + document.EnvironmentName);
|
||||
writer.WriteElementString("dc", "creator", "http://purl.org/dc/elements/1.1/",
|
||||
"BizTalk IIS Environment Inventory");
|
||||
writer.WriteElementString("cp", "lastModifiedBy",
|
||||
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
|
||||
"BizTalk IIS Environment Inventory");
|
||||
WriteDublinCoreDate(writer, "created", document.StartedUtc);
|
||||
WriteDublinCoreDate(writer, "modified", document.CompletedUtc);
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen typisierten Dublin-Core-Zeitwert.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="name">Elementname.</param>
|
||||
/// <param name="value">UTC-Zeitwert.</param>
|
||||
private static void WriteDublinCoreDate(XmlWriter writer, string name, DateTime value)
|
||||
{
|
||||
writer.WriteStartElement("dcterms", name, "http://purl.org/dc/terms/");
|
||||
writer.WriteAttributeString("xsi", "type", "http://www.w3.org/2001/XMLSchema-instance", "dcterms:W3CDTF");
|
||||
writer.WriteString(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture));
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Office-Anwendungseigenschaften.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
private static void WriteApplicationProperties(ZipArchive archive)
|
||||
{
|
||||
WriteXmlEntry(archive, "docProps/app.xml", writer =>
|
||||
{
|
||||
const string ns = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
|
||||
writer.WriteStartElement("Properties", ns);
|
||||
writer.WriteAttributeString("xmlns", "vt", null,
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes");
|
||||
writer.WriteElementString("Application", ns, "BizTalk IIS Environment Inventory");
|
||||
writer.WriteElementString("AppVersion", ns, "1.0");
|
||||
writer.WriteElementString("Company", ns, "JR IT Services");
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die im Bericht verwendeten Word-Formatvorlagen.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
private static void WriteStyles(ZipArchive archive)
|
||||
{
|
||||
WriteXmlEntry(archive, "word/styles.xml", writer =>
|
||||
{
|
||||
writer.WriteStartElement("w", "styles", WordNamespace);
|
||||
WriteParagraphStyle(writer, "Normal", "Standard", 20, "1F2937", false, 0, 0);
|
||||
WriteParagraphStyle(writer, "Title", "Titel", 42, "0B4F78", true, 220, 160);
|
||||
WriteParagraphStyle(writer, "Subtitle", "Untertitel", 22, "526575", false, 0, 160);
|
||||
WriteParagraphStyle(writer, "Heading1", "Überschrift 1", 32, "0B4F78", true, 360, 160);
|
||||
WriteParagraphStyle(writer, "Heading2", "Überschrift 2", 26, "176B96", true, 280, 120);
|
||||
WriteParagraphStyle(writer, "Heading3", "Überschrift 3", 22, "1F2937", true, 220, 80);
|
||||
WriteTableStyle(writer);
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Absatzformatvorlage.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="styleId">Interne Style-ID.</param>
|
||||
/// <param name="name">Anzeigename.</param>
|
||||
/// <param name="fontSizeHalfPoints">Schriftgröße in halben Punkten.</param>
|
||||
/// <param name="color">RGB-Farbe.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
/// <param name="spaceBefore">Abstand davor in Twips.</param>
|
||||
/// <param name="spaceAfter">Abstand danach in Twips.</param>
|
||||
private static void WriteParagraphStyle(
|
||||
XmlWriter writer,
|
||||
string styleId,
|
||||
string name,
|
||||
int fontSizeHalfPoints,
|
||||
string color,
|
||||
bool bold,
|
||||
int spaceBefore,
|
||||
int spaceAfter)
|
||||
{
|
||||
writer.WriteStartElement("w", "style", WordNamespace);
|
||||
writer.WriteAttributeString("w", "type", WordNamespace, "paragraph");
|
||||
writer.WriteAttributeString("w", "styleId", WordNamespace, styleId);
|
||||
writer.WriteStartElement("w", "name", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, name);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "pPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "spacing", WordNamespace);
|
||||
writer.WriteAttributeString("w", "before", WordNamespace, spaceBefore.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteAttributeString("w", "after", WordNamespace, spaceAfter.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "rPr", WordNamespace);
|
||||
if (bold)
|
||||
{
|
||||
writer.WriteElementString("w", "b", WordNamespace, string.Empty);
|
||||
}
|
||||
|
||||
writer.WriteStartElement("w", "color", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, color);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "sz", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace,
|
||||
fontSizeHalfPoints.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definiert den Tabellenstil mit sichtbaren Gitternetzlinien.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
private static void WriteTableStyle(XmlWriter writer)
|
||||
{
|
||||
writer.WriteStartElement("w", "style", WordNamespace);
|
||||
writer.WriteAttributeString("w", "type", WordNamespace, "table");
|
||||
writer.WriteAttributeString("w", "styleId", WordNamespace, "InventoryTable");
|
||||
writer.WriteStartElement("w", "name", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, "Inventartabelle");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "tblPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "tblBorders", WordNamespace);
|
||||
foreach (var edge in new[] { "top", "left", "bottom", "right", "insideH", "insideV" })
|
||||
{
|
||||
writer.WriteStartElement("w", edge, WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, "single");
|
||||
writer.WriteAttributeString("w", "sz", WordNamespace, "4");
|
||||
writer.WriteAttributeString("w", "color", WordNamespace, "CBD5E1");
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt den fachlichen Inhalt des Word-Dokuments.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
private static void WriteMainDocument(ZipArchive archive, InventoryDocument document)
|
||||
{
|
||||
WriteXmlEntry(archive, "word/document.xml", writer =>
|
||||
{
|
||||
writer.WriteStartElement("w", "document", WordNamespace);
|
||||
writer.WriteAttributeString("xmlns", "r", null, RelationshipNamespace);
|
||||
writer.WriteStartElement("w", "body", WordNamespace);
|
||||
|
||||
WriteParagraph(writer, "IIS- und BizTalk-Dokumentation", "Title");
|
||||
WriteParagraph(writer, document.EnvironmentName + " · " + document.ComputerName, "Subtitle");
|
||||
WriteParagraph(writer,
|
||||
"Erstellt am " + document.CompletedUtc.ToLocalTime()
|
||||
.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture),
|
||||
"Subtitle");
|
||||
WriteNotice(writer,
|
||||
"Schutz sensibler Daten: Kennwörter, Tokens, Connection Strings, verschlüsselte Payloads "
|
||||
+ "und private Schlüssel werden nicht ausgegeben. Dokumentiert werden ausschließlich "
|
||||
+ "migrationsrelevante Metadaten, Fingerprints, Container und Berechtigungen.");
|
||||
|
||||
WriteOverview(writer, document);
|
||||
WriteFindings(writer, document.Findings);
|
||||
WriteSystem(writer, document.System);
|
||||
WriteIis(writer, document.Iis);
|
||||
WriteCertificates(writer, document.Certificates);
|
||||
WriteSecurity(writer, document.Security);
|
||||
WriteBizTalk(writer, document.BizTalk);
|
||||
|
||||
WriteParagraph(writer,
|
||||
"Erzeugt durch BizTalk IIS Environment Inventory · read-only · keine Office-Installation erforderlich",
|
||||
"Subtitle");
|
||||
WriteSectionProperties(writer);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Zusammenfassung und Collector-Status.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="document">Bestandsaufnahme.</param>
|
||||
private static void WriteOverview(XmlWriter writer, InventoryDocument document)
|
||||
{
|
||||
WriteHeading(writer, 1, "1. Übersicht");
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("Umgebung", document.EnvironmentName),
|
||||
new NameValueRecord("Computer", document.ComputerName),
|
||||
new NameValueRecord("IIS-Sites", document.Iis.Sites.Count.ToString(CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("IIS-Anwendungen",
|
||||
document.Iis.Sites.Sum(site => site.Applications.Count).ToString(CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Application Pools",
|
||||
document.Iis.ApplicationPools.Count.ToString(CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Zertifikate", document.Certificates.Count.ToString(CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Auffälligkeiten", document.Findings.Count.ToString(CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Start UTC", FormatUtc(document.StartedUtc)),
|
||||
new NameValueRecord("Ende UTC", FormatUtc(document.CompletedUtc)),
|
||||
new NameValueRecord("Dauer",
|
||||
(document.CompletedUtc - document.StartedUtc).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture))
|
||||
});
|
||||
WriteHeading(writer, 2, "Collector-Status");
|
||||
WriteTable(writer,
|
||||
new[] { "Abschnitt", "Status", "Dauer", "Meldung" },
|
||||
document.SectionStatuses.Select(status => new[]
|
||||
{
|
||||
status.Name,
|
||||
status.Status,
|
||||
status.DurationMilliseconds.ToString(CultureInfo.InvariantCulture) + " ms",
|
||||
status.Message
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Auffälligkeiten und Maßnahmen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="findings">Auffälligkeiten.</param>
|
||||
private static void WriteFindings(XmlWriter writer, IList<Finding> findings)
|
||||
{
|
||||
WriteHeading(writer, 1, "2. Auffälligkeiten und Hinweise");
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
WriteParagraph(writer, "Keine technischen Auffälligkeiten während der Erfassung.", "Normal");
|
||||
return;
|
||||
}
|
||||
|
||||
WriteTable(writer,
|
||||
new[] { "Stufe", "Bereich", "Aussage", "Technik", "Empfehlung" },
|
||||
findings.OrderBy(item => SeverityOrder(item.Severity)).ThenBy(item => item.Area).Select(item => new[]
|
||||
{
|
||||
item.Severity, item.Area, item.Message, item.TechnicalDetail, item.Recommendation
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Windows- und Rolleninformationen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="system">Systeminventar.</param>
|
||||
private static void WriteSystem(XmlWriter writer, SystemInventory system)
|
||||
{
|
||||
WriteHeading(writer, 1, "3. Windows und installierte Rollen");
|
||||
WriteHeading(writer, 2, "System");
|
||||
WriteNameValueTable(writer, system.Properties);
|
||||
WriteHeading(writer, 2, "IIS-/BizTalk-relevante Windows Server Features");
|
||||
WriteNameValueTable(writer, system.InstalledFeatures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt IIS-Konfiguration, Topologie, Webinhalte und ACLs.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="iis">IIS-Inventar.</param>
|
||||
private static void WriteIis(XmlWriter writer, IisInventory iis)
|
||||
{
|
||||
WriteHeading(writer, 1, "4. IIS-Konfiguration");
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("applicationHost.config", iis.ConfigurationPath),
|
||||
new NameValueRecord("Letzte Änderung UTC",
|
||||
iis.ConfigurationLastWriteUtc.HasValue ? FormatUtc(iis.ConfigurationLastWriteUtc.Value) : string.Empty),
|
||||
new NameValueRecord("SHA-256 der bereinigten Konfiguration", iis.SanitizedConfigurationSha256)
|
||||
});
|
||||
|
||||
WriteHeading(writer, 2, "Application Pools");
|
||||
WriteTable(writer,
|
||||
new[] { "Name", "Identitätstyp", "Effektives Konto", ".NET", "Pipeline", "StartMode", "32 Bit" },
|
||||
iis.ApplicationPools.Select(pool => new[]
|
||||
{
|
||||
pool.Name, pool.IdentityType, pool.UserName, pool.ManagedRuntimeVersion,
|
||||
pool.ManagedPipelineMode, pool.StartMode, pool.Enable32BitAppOnWin64
|
||||
}));
|
||||
|
||||
WriteHeading(writer, 2, "Sites, Anwendungen und Webinhalte");
|
||||
foreach (var site in iis.Sites)
|
||||
{
|
||||
WriteHeading(writer, 3, "Site: " + site.Name + " (ID " + site.Id + ")");
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("Automatischer Start", site.ServerAutoStart),
|
||||
new NameValueRecord("Logverzeichnis", site.LogDirectory)
|
||||
});
|
||||
WriteParagraph(writer, "Bindings", "Heading3");
|
||||
WriteTable(writer,
|
||||
new[] { "Protokoll", "Binding", "Zertifikat", "Store", "SSL Flags" },
|
||||
site.Bindings.Select(binding => new[]
|
||||
{
|
||||
binding.Protocol, binding.BindingInformation, binding.CertificateHash,
|
||||
binding.CertificateStoreName, binding.SslFlags
|
||||
}));
|
||||
|
||||
foreach (var application in site.Applications)
|
||||
{
|
||||
WriteApplication(writer, application);
|
||||
}
|
||||
}
|
||||
|
||||
WriteHeading(writer, 2, "Globale Konfigurationsabschnitte");
|
||||
WriteTable(writer,
|
||||
new[] { "Section", "Override", "Allow Definition", "Verschlüsselt", "Elemente", "Sichere Übersicht" },
|
||||
iis.GlobalSections.Select(section => new[]
|
||||
{
|
||||
section.Path, section.OverrideModeDefault, section.AllowDefinition,
|
||||
section.IsEncrypted ? "Ja" : "Nein",
|
||||
section.ElementCount.ToString(CultureInfo.InvariantCulture), section.SafeSummary
|
||||
}));
|
||||
|
||||
WriteHeading(writer, 2, "Konfigurationsverschlüsselung");
|
||||
WriteTable(writer,
|
||||
new[] { "Provider", "Typ", "Key Container", "Machine Container", "Hinweis" },
|
||||
iis.EncryptionProviders.Select(provider => new[]
|
||||
{
|
||||
provider.Name, provider.Type, provider.KeyContainerName,
|
||||
provider.UseMachineContainer, provider.Description
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine IIS-Anwendung einschließlich Dateimanifest.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="application">IIS-Anwendung.</param>
|
||||
private static void WriteApplication(XmlWriter writer, WebApplicationRecord application)
|
||||
{
|
||||
WriteParagraph(writer, "Anwendung: " + application.SiteName + application.Path, "Heading3");
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("Application Pool", application.ApplicationPool),
|
||||
new NameValueRecord("Protokolle", application.EnabledProtocols),
|
||||
new NameValueRecord("Physischer Pfad", application.PhysicalPath),
|
||||
new NameValueRecord("Pfad vorhanden", application.PhysicalPathExists ? "Ja" : "Nein"),
|
||||
new NameValueRecord("Dateien gesamt", application.TotalFiles.ToString("N0",
|
||||
CultureInfo.GetCultureInfo("de-DE"))),
|
||||
new NameValueRecord("Größe gesamt", FormatBytes(application.TotalBytes)),
|
||||
new NameValueRecord("Letzte Dateiänderung UTC",
|
||||
application.LatestWriteUtc.HasValue ? FormatUtc(application.LatestWriteUtc.Value) : string.Empty),
|
||||
new NameValueRecord("Manifest begrenzt/unvollständig", application.ManifestTruncated ? "Ja" : "Nein")
|
||||
});
|
||||
WriteParagraph(writer, "Virtuelle Verzeichnisse", "Heading3");
|
||||
WriteTable(writer,
|
||||
new[] { "Pfad", "Physical Path", "Zugriffsidentität" },
|
||||
application.VirtualDirectories.Select(item => new[] { item.Path, item.PhysicalPath, item.UserName }));
|
||||
WriteParagraph(writer, "NTFS-Berechtigungen des Web-Stamms", "Heading3");
|
||||
WriteAclTable(writer, application.AccessRules);
|
||||
WriteParagraph(writer,
|
||||
"Dateimanifest (" + application.ContentFiles.Count.ToString("N0",
|
||||
CultureInfo.GetCultureInfo("de-DE")) + " Einträge)",
|
||||
"Heading3");
|
||||
WriteTable(writer,
|
||||
new[] { "Relativer Pfad", "Größe", "Letzte Änderung UTC", "SHA-256" },
|
||||
application.ContentFiles.Select(file => new[]
|
||||
{
|
||||
file.RelativePath, FormatBytes(file.SizeBytes), FormatUtc(file.LastWriteUtc), file.Sha256
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Zertifikate und private Key-ACLs.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="certificates">Zertifikatsinventar.</param>
|
||||
private static void WriteCertificates(XmlWriter writer, IList<CertificateRecord> certificates)
|
||||
{
|
||||
WriteHeading(writer, 1, "5. Zertifikate und private Schlüssel");
|
||||
WriteNotice(writer,
|
||||
"Es werden keine Schlüssel exportiert. „Exportierbar“ wird ausschließlich aus der Provider-Policy gelesen.");
|
||||
if (certificates.Count == 0)
|
||||
{
|
||||
WriteParagraph(writer, "Keine Zertifikate erfasst.", "Normal");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var certificate in certificates)
|
||||
{
|
||||
WriteHeading(writer, 3,
|
||||
(certificate.UsedByIisBinding ? "[IIS] " : string.Empty) + certificate.Subject);
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("IIS-Binding", certificate.UsedByIisBinding ? "Ja" : "Nein"),
|
||||
new NameValueRecord("Store", certificate.StoreLocation + "\\" + certificate.StoreName),
|
||||
new NameValueRecord("Subject", certificate.Subject),
|
||||
new NameValueRecord("Issuer", certificate.Issuer),
|
||||
new NameValueRecord("Thumbprint", certificate.Thumbprint),
|
||||
new NameValueRecord("Serial", certificate.SerialNumber),
|
||||
new NameValueRecord("Gültig von",
|
||||
certificate.NotBefore.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Gültig bis",
|
||||
certificate.NotAfter.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
new NameValueRecord("Public Key",
|
||||
certificate.PublicKeyAlgorithm + " / " + certificate.PublicKeySize + " Bit"),
|
||||
new NameValueRecord("Signature", certificate.SignatureAlgorithm),
|
||||
new NameValueRecord("Enhanced Key Usage", string.Join("; ", certificate.EnhancedKeyUsages)),
|
||||
new NameValueRecord("Privater Schlüssel vorhanden", certificate.HasPrivateKey ? "Ja" : "Nein"),
|
||||
new NameValueRecord("Exportierbar", certificate.PrivateKeyExportable),
|
||||
new NameValueRecord("Key Provider", certificate.PrivateKeyProvider),
|
||||
new NameValueRecord("Key Container", certificate.PrivateKeyContainer),
|
||||
new NameValueRecord("Key-Datei", certificate.PrivateKeyFile)
|
||||
});
|
||||
WriteParagraph(writer, "ACL des privaten Schlüssels", "Heading3");
|
||||
WriteAclTable(writer, certificate.KeyAccessRules);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Dienstidentitäten und lokale Sicherheitsrichtlinien.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="security">Security-Inventar.</param>
|
||||
private static void WriteSecurity(XmlWriter writer, SecurityInventory security)
|
||||
{
|
||||
WriteHeading(writer, 1, "6. NTFS, Dienstkonten und lokale Sicherheit");
|
||||
WriteHeading(writer, 2, "Dienstidentitäten");
|
||||
WriteTable(writer,
|
||||
new[] { "Quelle", "Name", "Anzeigename", "Konto", "Status", "Startmodus", "Pfad" },
|
||||
security.ServiceAccounts.Select(item => new[]
|
||||
{
|
||||
item.Source, item.Name, item.DisplayName, item.Account,
|
||||
item.State, item.StartMode, item.Path
|
||||
}));
|
||||
WriteHeading(writer, 2, "User Rights Assignment");
|
||||
WriteTable(writer,
|
||||
new[] { "Benutzerrecht", "Konten/SIDs" },
|
||||
security.UserRights.Select(item => new[] { item.Right, item.Accounts }));
|
||||
WriteHeading(writer, 2, "Lokale Security-/Audit-Policy");
|
||||
WriteNameValueTable(writer, security.LocalPolicy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt BizTalk-Installations- und WMI-Daten.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="bizTalk">BizTalk-Inventar.</param>
|
||||
private static void WriteBizTalk(XmlWriter writer, BizTalkInventory bizTalk)
|
||||
{
|
||||
WriteHeading(writer, 1, "7. BizTalk-spezifische Komponenten");
|
||||
WriteNameValueTable(writer, new[]
|
||||
{
|
||||
new NameValueRecord("WMI root\\MicrosoftBizTalkServer",
|
||||
bizTalk.WmiNamespaceAvailable ? "Erreichbar" : "Nicht erreichbar")
|
||||
});
|
||||
WriteHeading(writer, 2, "Installierte Produkte");
|
||||
WriteNameValueTable(writer, bizTalk.InstalledProducts);
|
||||
WriteHeading(writer, 2, "Registry-Metadaten");
|
||||
WriteNameValueTable(writer, bizTalk.RegistryValues);
|
||||
WriteHeading(writer, 2, "Zentrale Binärversionen");
|
||||
WriteNameValueTable(writer, bizTalk.Components);
|
||||
WriteHeading(writer, 2, "BizTalk-WMI-Inventar");
|
||||
WriteNameValueTable(writer, bizTalk.WmiClasses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine ACL-Tabelle.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="rules">ACL-Regeln.</param>
|
||||
private static void WriteAclTable(XmlWriter writer, IEnumerable<AccessRuleRecord> rules)
|
||||
{
|
||||
WriteTable(writer,
|
||||
new[] { "Identität", "Rechte", "Typ", "Geerbt", "Vererbung", "Ziel" },
|
||||
rules.Select(rule => new[]
|
||||
{
|
||||
rule.Identity, rule.Rights, rule.AccessType,
|
||||
rule.IsInherited ? "Ja" : "Nein", rule.Inheritance, rule.Target
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Name/Wert-Tabelle.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="items">Name/Wert-Datensätze.</param>
|
||||
private static void WriteNameValueTable(XmlWriter writer, IEnumerable<NameValueRecord> items)
|
||||
{
|
||||
WriteTable(writer,
|
||||
new[] { "Eigenschaft", "Wert" },
|
||||
items.Select(item => new[] { item.Name, item.Value }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Word-Tabelle und wiederholt die Kopfzeile auf Folgeseiten.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="headers">Spaltenüberschriften.</param>
|
||||
/// <param name="rows">Tabellenzeilen.</param>
|
||||
private static void WriteTable(XmlWriter writer, string[] headers, IEnumerable<string[]> rows)
|
||||
{
|
||||
var materialized = rows == null ? new List<string[]>() : rows.ToList();
|
||||
if (materialized.Count == 0)
|
||||
{
|
||||
WriteParagraph(writer, "Keine Daten erfasst.", "Normal");
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WriteStartElement("w", "tbl", WordNamespace);
|
||||
writer.WriteStartElement("w", "tblPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "tblStyle", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, "InventoryTable");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "tblW", WordNamespace);
|
||||
writer.WriteAttributeString("w", "w", WordNamespace, "0");
|
||||
writer.WriteAttributeString("w", "type", WordNamespace, "auto");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "tblLayout", WordNamespace);
|
||||
writer.WriteAttributeString("w", "type", WordNamespace, "autofit");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
|
||||
WriteTableRow(writer, headers, true);
|
||||
foreach (var row in materialized)
|
||||
{
|
||||
var normalized = new string[headers.Length];
|
||||
for (var index = 0; index < headers.Length; index++)
|
||||
{
|
||||
normalized[index] = index < row.Length ? row[index] : string.Empty;
|
||||
}
|
||||
|
||||
WriteTableRow(writer, normalized, false);
|
||||
}
|
||||
|
||||
writer.WriteEndElement();
|
||||
WriteParagraph(writer, string.Empty, "Normal");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Tabellenzeile.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="values">Zellwerte.</param>
|
||||
/// <param name="header">Gibt eine wiederholbare Kopfzeile an.</param>
|
||||
private static void WriteTableRow(XmlWriter writer, IEnumerable<string> values, bool header)
|
||||
{
|
||||
writer.WriteStartElement("w", "tr", WordNamespace);
|
||||
if (header)
|
||||
{
|
||||
writer.WriteStartElement("w", "trPr", WordNamespace);
|
||||
writer.WriteElementString("w", "tblHeader", WordNamespace, string.Empty);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStartElement("w", "tc", WordNamespace);
|
||||
writer.WriteStartElement("w", "tcPr", WordNamespace);
|
||||
if (header)
|
||||
{
|
||||
writer.WriteStartElement("w", "shd", WordNamespace);
|
||||
writer.WriteAttributeString("w", "fill", WordNamespace, "DCEEF7");
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
writer.WriteEndElement();
|
||||
WriteParagraphWithRun(writer, LimitCellText(value), header);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine Überschrift mit Word-Navigationsebene.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="level">Ebene eins bis drei.</param>
|
||||
/// <param name="text">Überschriftstext.</param>
|
||||
private static void WriteHeading(XmlWriter writer, int level, string text)
|
||||
{
|
||||
WriteParagraph(writer, text, "Heading" + Math.Max(1, Math.Min(3, level)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen hervorgehobenen Hinweis.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Hinweistext.</param>
|
||||
private static void WriteNotice(XmlWriter writer, string text)
|
||||
{
|
||||
writer.WriteStartElement("w", "p", WordNamespace);
|
||||
writer.WriteStartElement("w", "pPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "shd", WordNamespace);
|
||||
writer.WriteAttributeString("w", "fill", WordNamespace, "FFF2CC");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "spacing", WordNamespace);
|
||||
writer.WriteAttributeString("w", "before", WordNamespace, "120");
|
||||
writer.WriteAttributeString("w", "after", WordNamespace, "160");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
WriteRun(writer, text, true);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Absatz in einer benannten Formatvorlage.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Absatztext.</param>
|
||||
/// <param name="style">Word-Style-ID.</param>
|
||||
private static void WriteParagraph(XmlWriter writer, string text, string style)
|
||||
{
|
||||
writer.WriteStartElement("w", "p", WordNamespace);
|
||||
writer.WriteStartElement("w", "pPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "pStyle", WordNamespace);
|
||||
writer.WriteAttributeString("w", "val", WordNamespace, style);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
WriteRun(writer, text, false);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen einfachen Tabellenzellenabsatz.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Zelltext.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
private static void WriteParagraphWithRun(XmlWriter writer, string text, bool bold)
|
||||
{
|
||||
writer.WriteStartElement("w", "p", WordNamespace);
|
||||
WriteRun(writer, text, bold);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Textlauf und bewahrt Leerzeichen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
/// <param name="text">Unvertrauenswürdiger Text; der XML-Writer kodiert ihn.</param>
|
||||
/// <param name="bold">Fettdruck.</param>
|
||||
private static void WriteRun(XmlWriter writer, string text, bool bold)
|
||||
{
|
||||
writer.WriteStartElement("w", "r", WordNamespace);
|
||||
if (bold)
|
||||
{
|
||||
writer.WriteStartElement("w", "rPr", WordNamespace);
|
||||
writer.WriteElementString("w", "b", WordNamespace, string.Empty);
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
writer.WriteStartElement("w", "t", WordNamespace);
|
||||
writer.WriteAttributeString("xml", "space", "http://www.w3.org/XML/1998/namespace", "preserve");
|
||||
writer.WriteString(NormalizeText(text));
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Seitenformat und Ränder; Querformat verbessert breite Inventartabellen.
|
||||
/// </summary>
|
||||
/// <param name="writer">XML-Writer.</param>
|
||||
private static void WriteSectionProperties(XmlWriter writer)
|
||||
{
|
||||
writer.WriteStartElement("w", "sectPr", WordNamespace);
|
||||
writer.WriteStartElement("w", "pgSz", WordNamespace);
|
||||
writer.WriteAttributeString("w", "w", WordNamespace, "16838");
|
||||
writer.WriteAttributeString("w", "h", WordNamespace, "11906");
|
||||
writer.WriteAttributeString("w", "orient", WordNamespace, "landscape");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("w", "pgMar", WordNamespace);
|
||||
writer.WriteAttributeString("w", "top", WordNamespace, "900");
|
||||
writer.WriteAttributeString("w", "right", WordNamespace, "720");
|
||||
writer.WriteAttributeString("w", "bottom", WordNamespace, "900");
|
||||
writer.WriteAttributeString("w", "left", WordNamespace, "720");
|
||||
writer.WriteAttributeString("w", "header", WordNamespace, "360");
|
||||
writer.WriteAttributeString("w", "footer", WordNamespace, "360");
|
||||
writer.WriteAttributeString("w", "gutter", WordNamespace, "0");
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen komprimierten XML-Part mit sicheren Writer-Einstellungen.
|
||||
/// </summary>
|
||||
/// <param name="archive">Geöffnetes DOCX-ZIP-Archiv.</param>
|
||||
/// <param name="path">Partpfad innerhalb des Archivs.</param>
|
||||
/// <param name="writeAction">Aktion zum Schreiben des XML-Inhalts.</param>
|
||||
private static void WriteXmlEntry(ZipArchive archive, string path, Action<XmlWriter> writeAction)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
using (var stream = entry.Open())
|
||||
using (var writer = XmlWriter.Create(stream, new XmlWriterSettings
|
||||
{
|
||||
Encoding = new UTF8Encoding(false),
|
||||
Indent = false,
|
||||
CloseOutput = false,
|
||||
CheckCharacters = true
|
||||
}))
|
||||
{
|
||||
writer.WriteStartDocument(true);
|
||||
writeAction(writer);
|
||||
writer.WriteEndDocument();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt Zeichen, die in XML 1.0 nicht zulässig sind.
|
||||
/// </summary>
|
||||
/// <param name="value">Eingabetext.</param>
|
||||
/// <returns>XML-kompatibler Text.</returns>
|
||||
private static string NormalizeText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (XmlConvert.IsXmlChar(character))
|
||||
{
|
||||
builder.Append(character);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('\uFFFD');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begrenzt Zelltext auf einen Word-kompatiblen und bedienbaren Umfang.
|
||||
/// </summary>
|
||||
/// <param name="value">Zelltext.</param>
|
||||
/// <returns>Unveränderter oder gekürzter Text.</returns>
|
||||
private static string LimitCellText(string value)
|
||||
{
|
||||
const int maximum = 30000;
|
||||
var normalized = NormalizeText(value);
|
||||
return normalized.Length <= maximum
|
||||
? normalized
|
||||
: normalized.Substring(0, maximum) + " … [GEKÜRZT]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert UTC-Zeit konsistent.
|
||||
/// </summary>
|
||||
/// <param name="value">Zeitwert.</param>
|
||||
/// <returns>UTC-Darstellung.</returns>
|
||||
private static string FormatUtc(DateTime value)
|
||||
{
|
||||
return value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert eine Bytezahl lesbar.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Bytezahl.</param>
|
||||
/// <returns>Lesbare Größenangabe.</returns>
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
var suffixes = new[] { "B", "KiB", "MiB", "GiB", "TiB" };
|
||||
double value = bytes;
|
||||
var index = 0;
|
||||
while (value >= 1024 && index < suffixes.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
index++;
|
||||
}
|
||||
|
||||
return value.ToString(index == 0 ? "N0" : "N2",
|
||||
CultureInfo.GetCultureInfo("de-DE")) + " " + suffixes[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert eine Sortierzahl für Findings.
|
||||
/// </summary>
|
||||
/// <param name="severity">Schweregrad.</param>
|
||||
/// <returns>Kleinere Zahl für höhere Priorität.</returns>
|
||||
private static int SeverityOrder(string severity)
|
||||
{
|
||||
if (string.Equals(severity, "Fehler", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return string.Equals(severity, "Warnung", StringComparison.OrdinalIgnoreCase) ? 1 : 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user