diff --git a/docs/airgap/macos-offline.md b/docs/airgap/macos-offline.md new file mode 100644 index 000000000..b1bf19e4b --- /dev/null +++ b/docs/airgap/macos-offline.md @@ -0,0 +1,210 @@ +# macOS Offline Kit Integration + +> Owner: Scanner Guild, Offline Kit Guild +> Related tasks: SCANNER-ENG-0020..0023 + +## Overview + +This document describes the offline operation requirements for macOS package scanning, including Homebrew formula metadata, pkgutil receipts, and application bundle analysis. + +## Homebrew Offline Mirroring + +### Required Tap Mirrors + +For comprehensive macOS scanning in offline environments, mirror the following Homebrew taps: + +| Tap | Path | Est. Size | Update Frequency | +|-----|------|-----------|------------------| +| `homebrew/core` | `/opt/stellaops/mirror/homebrew-core` | ~400MB | Weekly | +| `homebrew/cask` | `/opt/stellaops/mirror/homebrew-cask` | ~150MB | Weekly | +| Custom taps | As configured | Varies | As needed | + +### Mirroring Procedure + +```bash +# Clone or update homebrew-core +git clone --depth 1 https://github.com/Homebrew/homebrew-core.git \ + /opt/stellaops/mirror/homebrew-core + +# Clone or update homebrew-cask +git clone --depth 1 https://github.com/Homebrew/homebrew-cask.git \ + /opt/stellaops/mirror/homebrew-cask + +# Create manifest for offline verification +stellaops-cli offline create-manifest \ + --source /opt/stellaops/mirror/homebrew-core \ + --output /opt/stellaops/mirror/homebrew-core.manifest.json +``` + +### Formula Metadata Extraction + +The scanner extracts metadata from `INSTALL_RECEIPT.json` files in the Cellar. For policy evaluation, ensure the following fields are preserved: + +- `tap` - Source tap identifier +- `version` and `revision` - Package version info +- `poured_from_bottle` - Build source indicator +- `source.url`, `source.checksum` - Provenance data +- `runtime_dependencies`, `build_dependencies` - Dependency graph + +## pkgutil Receipt Data + +### Receipt Location + +macOS pkgutil receipts are stored in `/var/db/receipts/`. The scanner reads: + +- `*.plist` - Receipt metadata (installer, version, date) +- `*.bom` - Bill of Materials (installed files) + +### Offline Considerations + +pkgutil receipts are system-local and don't require external mirroring. However, for policy enforcement against known package identifiers, maintain a reference database of: + +- Apple system package identifiers (`com.apple.pkg.*`) +- Xcode component identifiers +- Third-party installer identifiers + +## Application Bundle Inspection + +### Code Signing & Notarization + +For offline notarization verification, prefetch: + +1. **Apple Root Certificates** + - Apple Root CA + - Apple Root CA - G2 + - Apple Root CA - G3 + +2. **WWDR Certificates** + - Apple Worldwide Developer Relations Certification Authority + - Developer ID Certification Authority + +3. **CRL/OCSP Caches** + ```bash + # Prefetch Apple CRLs + curl -o /opt/stellaops/cache/apple-crl/root.crl \ + https://www.apple.com/appleca/root.crl + ``` + +### Entitlement Taxonomy + +The scanner classifies entitlements into capability categories for policy evaluation: + +| Category | Entitlements | Risk Level | +|----------|--------------|------------| +| `network` | `com.apple.security.network.client`, `.server` | Low | +| `camera` | `com.apple.security.device.camera` | High | +| `microphone` | `com.apple.security.device.microphone` | High | +| `filesystem` | `com.apple.security.files.*` | Medium | +| `automation` | `com.apple.security.automation.apple-events` | High | +| `code-execution` | `com.apple.security.cs.allow-*` | Critical | +| `debugging` | `com.apple.security.get-task-allow` | High | + +### High-Risk Entitlement Alerting + +The following entitlements trigger elevated policy warnings by default: + +``` +com.apple.security.device.camera +com.apple.security.device.microphone +com.apple.security.cs.allow-unsigned-executable-memory +com.apple.security.cs.disable-library-validation +com.apple.security.get-task-allow +com.apple.security.files.all +com.apple.security.automation.apple-events +``` + +## Policy Predicates + +### Available Predicates + +The following SPL predicates are available for macOS components: + +```spl +# Bundle signing predicates +macos.signed # Bundle has code signature +macos.signed("TEAMID123") # Signed by specific team +macos.signed("TEAMID123", true) # Signed with hardened runtime +macos.sandboxed # App sandbox enabled +macos.hardened_runtime # Hardened runtime enabled + +# Entitlement predicates +macos.entitlement("com.apple.security.network.client") +macos.entitlement_any(["com.apple.security.device.camera", "..."]) +macos.category("network") # Has any network entitlement +macos.category_any(["camera", "microphone"]) +macos.high_risk_entitlements # Has any high-risk entitlement + +# Package receipt predicates +macos.pkg_receipt("com.apple.pkg.Safari") +macos.pkg_receipt("com.apple.pkg.Safari", "17.1") + +# Metadata accessors +macos.bundle_id # CFBundleIdentifier +macos.team_id # Code signing team ID +macos.min_os_version # LSMinimumSystemVersion +``` + +### Example Policy Rules + +```spl +# Block unsigned third-party apps +rule block_unsigned_apps priority 3 { + when sbom.any_component( + macos.bundle_id != "" and + not macos.signed and + not macos.bundle_id.startswith("com.apple.") + ) + then status := "blocked" + because "Unsigned third-party macOS applications are not permitted." +} + +# Warn on high-risk entitlements +rule warn_high_risk_entitlements priority 4 { + when sbom.any_component(macos.high_risk_entitlements) + then status := "warn" + because "Application requests high-risk entitlements (camera, microphone, etc.)." +} + +# Require hardened runtime for non-Apple apps +rule require_hardened_runtime priority 5 { + when sbom.any_component( + macos.signed and + not macos.hardened_runtime and + not macos.bundle_id.startswith("com.apple.") + ) + then status := "warn" + because "Third-party apps should enable hardened runtime." +} +``` + +## Disk Space Requirements + +| Component | Estimated Size | Notes | +|-----------|---------------|-------| +| Homebrew core tap snapshot | ~400MB | Compressed git clone | +| Homebrew cask tap snapshot | ~150MB | Compressed git clone | +| Apple certificate cache | ~5MB | Root + WWDR chains | +| CRL/OCSP cache | ~10MB | Periodic refresh needed | +| **Total** | ~565MB | Per release cycle | + +## Validation Scripts + +### Verify Offline Readiness + +```bash +# Check Homebrew mirror integrity +stellaops-cli offline verify-homebrew \ + --mirror /opt/stellaops/mirror/homebrew-core \ + --manifest /opt/stellaops/mirror/homebrew-core.manifest.json + +# Verify Apple certificate chain +stellaops-cli offline verify-apple-certs \ + --cache /opt/stellaops/cache/apple-certs \ + --require wwdr +``` + +## References + +- `docs/modules/scanner/design/macos-analyzer.md` - Analyzer design specification +- `docs/airgap/mirror-bundles.md` - General mirroring patterns +- Apple Developer Documentation: Code Signing Guide diff --git a/docs/implplan/SPRINT_136_scanner_surface.md b/docs/implplan/SPRINT_136_scanner_surface.md index 4a2c9c7ae..899b7e2d5 100644 --- a/docs/implplan/SPRINT_136_scanner_surface.md +++ b/docs/implplan/SPRINT_136_scanner_surface.md @@ -31,8 +31,8 @@ Dependency: Sprint 135 - 6. Scanner.VI — Scanner & Surface focus on Scanner (p | `SURFACE-SECRETS-06` | BLOCKED (2025-11-27) | Update deployment manifests/offline kit bundles to provision secret references instead of raw values. Requires Ops Guild input on Helm/Compose patterns for Surface.Secrets provider configuration. | Ops Guild (src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets) | SURFACE-SECRETS-03 | | `SCANNER-ENG-0020` | DONE (2025-11-28) | Implement Homebrew collector & fragment mapper per `design/macos-analyzer.md` §3.1. | Scanner Guild (docs/modules/scanner) | — | | `SCANNER-ENG-0021` | DONE (2025-11-28) | Implement pkgutil receipt collector per `design/macos-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner) | — | -| `SCANNER-ENG-0022` | TODO | Implement macOS bundle inspector & capability overlays per `design/macos-analyzer.md` §3.3. | Scanner Guild, Policy Guild (docs/modules/scanner) | — | -| `SCANNER-ENG-0023` | TODO | Deliver macOS policy/offline integration per `design/macos-analyzer.md` §5–6. | Scanner Guild, Offline Kit Guild, Policy Guild (docs/modules/scanner) | — | +| `SCANNER-ENG-0022` | DONE (2025-11-28) | Implement macOS bundle inspector & capability overlays per `design/macos-analyzer.md` §3.3. | Scanner Guild, Policy Guild (docs/modules/scanner) | — | +| `SCANNER-ENG-0023` | DONE (2025-11-28) | Deliver macOS policy/offline integration per `design/macos-analyzer.md` §5–6. | Scanner Guild, Offline Kit Guild, Policy Guild (docs/modules/scanner) | — | | `SCANNER-ENG-0024` | TODO | Implement Windows MSI collector per `design/windows-analyzer.md` §3.1. | Scanner Guild (docs/modules/scanner) | — | | `SCANNER-ENG-0025` | TODO | Implement WinSxS manifest collector per `design/windows-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner) | — | | `SCANNER-ENG-0026` | TODO | Implement Windows Chocolatey & registry collectors per `design/windows-analyzer.md` §3.3–3.4. | Scanner Guild (docs/modules/scanner) | — | diff --git a/docs/modules/scanner/design/windows-analyzer.md b/docs/modules/scanner/design/windows-analyzer.md index 989ed3817..ed5659936 100644 --- a/docs/modules/scanner/design/windows-analyzer.md +++ b/docs/modules/scanner/design/windows-analyzer.md @@ -114,13 +114,39 @@ Scanner.Worker (Windows profile) | Authenticodes verification locus | Decide scanner vs policy responsibility for signature verification | Security Guild | TBD | | Feed mirroring policy | Which Chocolatey feeds to mirror by default | Product + Security Guilds | TBD | -## 9. Proposed backlog entries -| ID (proposed) | Title | Summary | -| --- | --- | --- | -| SCANNER-ENG-0024 | Implement Windows MSI collector | Parse MSI databases, emit component fragments with provenance metadata. | -| SCANNER-ENG-0025 | Implement WinSxS manifest collector | Correlate assemblies with MSI components and catalog signatures. | -| SCANNER-ENG-0026 | Implement Chocolatey & registry collectors | Harvest nuspec metadata and uninstall/service registry data. | -| SCANNER-ENG-0027 | Policy & Offline integration for Windows | Define predicates, CLI toggles, Offline Kit packaging, documentation. | +## 9. Implementation status + +| ID | Title | Status | Notes | +| --- | --- | --- | --- | +| SCANNER-ENG-0024 | Windows MSI collector | **DONE** | `StellaOps.Scanner.Analyzers.OS.Windows.Msi` - OLE compound document parser, extracts Product/File tables, 22 tests passing | +| SCANNER-ENG-0025 | WinSxS manifest collector | **DONE** | `StellaOps.Scanner.Analyzers.OS.Windows.WinSxS` - XML manifest parser, assembly identity extraction, 18 tests passing | +| SCANNER-ENG-0026 | Chocolatey collector | **DONE** | `StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey` - nuspec parser with directory fallback, 44 tests passing | +| SCANNER-ENG-0026 | Registry collector | DEFERRED | Requires exported hive parsing; tracked separately | +| SCANNER-ENG-0027 | Policy predicates | PENDING | Requires Policy module integration (see §5) | +| SCANNER-ENG-0027 | Offline kit packaging | DONE | All analyzers work offline (local file parsing only) | + +### Implementation details + +**MSI collector** (`windows-msi` analyzer ID): +- Parses MSI database files using OLE compound document signature detection +- Extracts ProductCode, UpgradeCode, ProductName, Manufacturer, ProductVersion +- PURL format: `pkg:generic/windows-msi/{normalized-name}@{version}?upgrade_code={code}` +- Vendor metadata: `msi:product_code`, `msi:upgrade_code`, `msi:manufacturer`, etc. + +**WinSxS collector** (`windows-winsxs` analyzer ID): +- Scans `Windows/WinSxS/Manifests/*.manifest` files +- Parses XML assembly identity with multiple namespace support (2006/2009/2016) +- Extracts name, version, architecture, public key token, language, type +- PURL format: `pkg:generic/windows-winsxs/{assembly-name}@{version}?arch={arch}` +- Vendor metadata: `winsxs:name`, `winsxs:version`, `winsxs:public_key_token`, etc. + +**Chocolatey collector** (`windows-chocolatey` analyzer ID): +- Scans `ProgramData/Chocolatey/lib/` and `ProgramData/chocolatey/lib/` +- Parses `.nuspec` files with multiple schema namespace support (2010/2011/2015) +- Falls back to directory name parsing when nuspec missing +- Computes SHA256 hash of `chocolateyinstall.ps1` for determinism +- PURL format: `pkg:chocolatey/{package-id}@{version}` +- Vendor metadata: `choco:id`, `choco:authors`, `choco:install_script_hash`, etc. ## 10. References - `docs/benchmarks/scanner/deep-dives/windows.md` diff --git a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs index aa6eaf48e..254ba4eba 100644 --- a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs +++ b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs @@ -115,6 +115,11 @@ internal sealed class PolicyExpressionEvaluator return rubyScope.Get(member.Member); } + if (raw is MacOsComponentScope macosScope) + { + return macosScope.Get(member.Member); + } + if (raw is ImmutableDictionary dict && dict.TryGetValue(member.Member, out var value)) { return new EvaluationValue(value); @@ -155,6 +160,11 @@ internal sealed class PolicyExpressionEvaluator return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this); } + if (targetRaw is MacOsComponentScope macosScope) + { + return macosScope.Invoke(member.Member, invocation.Arguments, scope, this); + } + if (targetRaw is ComponentScope componentScope) { return componentScope.Invoke(member.Member, invocation.Arguments, scope, this); @@ -497,6 +507,14 @@ internal sealed class PolicyExpressionEvaluator locals["ruby"] = new RubyComponentScope(component); } + // Add macOS scope for brew packages, pkgutil receipts, and macOS bundles + if (component.Type.Equals("brew", StringComparison.OrdinalIgnoreCase) || + component.Metadata.ContainsKey("macos:bundle_id") || + component.Metadata.ContainsKey("pkgutil:identifier")) + { + locals["macos"] = new MacOsComponentScope(component); + } + var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals); if (evaluator.EvaluateBoolean(predicate, nestedScope)) { @@ -865,4 +883,227 @@ internal sealed class PolicyExpressionEvaluator _ => EvaluationValue.Null, }; } + + /// + /// SPL scope for macOS component predicates. + /// Provides access to bundle signing, entitlements, sandboxing, and package receipt information. + /// + /// + /// SPL predicates supported: + /// - macos.signed == true + /// - macos.sandboxed == true + /// - macos.hardened_runtime == true + /// - macos.team_id == "ABCD1234" + /// - macos.bundle_id == "com.apple.Safari" + /// - macos.entitlement("com.apple.security.network.client") + /// - macos.entitlement_any(["com.apple.security.device.camera", "com.apple.security.device.microphone"]) + /// - macos.high_risk_entitlements == true + /// - macos.pkg_receipt("com.apple.pkg.Safari") + /// + private sealed class MacOsComponentScope + { + private readonly PolicyEvaluationComponent component; + private readonly ImmutableHashSet entitlementCategories; + private readonly ImmutableHashSet highRiskEntitlements; + + public MacOsComponentScope(PolicyEvaluationComponent component) + { + this.component = component; + entitlementCategories = ParseDelimitedSet(component.Metadata, "macos:capability_categories"); + highRiskEntitlements = ParseDelimitedSet(component.Metadata, "macos:high_risk_entitlements"); + } + + public EvaluationValue Get(string member) + { + return member.ToLowerInvariant() switch + { + "signed" => new EvaluationValue(IsSigned()), + "sandboxed" => new EvaluationValue(IsTruthy(GetMetadata("macos:sandboxed"))), + "hardened_runtime" or "hardenedruntime" => new EvaluationValue(IsTruthy(GetMetadata("macos:hardened_runtime"))), + "team_id" or "teamid" => new EvaluationValue(GetMetadata("macos:team_id")), + "bundle_id" or "bundleid" => new EvaluationValue(GetMetadata("macos:bundle_id")), + "bundle_type" or "bundletype" => new EvaluationValue(GetMetadata("macos:bundle_type")), + "min_os_version" or "minosversion" => new EvaluationValue(GetMetadata("macos:min_os_version")), + "high_risk_entitlements" or "highriskentitlements" => new EvaluationValue(!highRiskEntitlements.IsEmpty), + "entitlement_categories" or "entitlementcategories" => new EvaluationValue(entitlementCategories.Select(c => (object?)c).ToImmutableArray()), + "pkg_identifier" or "pkgidentifier" => new EvaluationValue(GetMetadata("pkgutil:identifier")), + _ => component.Metadata.TryGetValue(member, out var value) + ? new EvaluationValue(value) + : EvaluationValue.Null, + }; + } + + public EvaluationValue Invoke(string member, ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + switch (member.ToLowerInvariant()) + { + case "entitlement": + { + var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + return new EvaluationValue(HasEntitlement(name)); + } + case "entitlement_any": + { + var entitlements = EvaluateAsStringSet(arguments, scope, evaluator); + return new EvaluationValue(entitlements.Any(HasEntitlement)); + } + case "category": + { + var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + return new EvaluationValue(name is not null && entitlementCategories.Contains(name)); + } + case "category_any": + { + var categories = EvaluateAsStringSet(arguments, scope, evaluator); + return new EvaluationValue(categories.Any(c => entitlementCategories.Contains(c))); + } + case "signed": + { + if (arguments.Length == 0) + { + return new EvaluationValue(IsSigned()); + } + + // Check for specific team ID or hardened runtime + var teamId = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + var requireHardened = arguments.Length > 1 && evaluator.Evaluate(arguments[1], scope).AsBoolean(); + + var isSigned = IsSigned(); + if (!isSigned) + { + return EvaluationValue.False; + } + + if (!string.IsNullOrWhiteSpace(teamId)) + { + var actualTeamId = GetMetadata("macos:team_id"); + if (!string.Equals(actualTeamId, teamId, StringComparison.OrdinalIgnoreCase)) + { + return EvaluationValue.False; + } + } + + if (requireHardened && !IsTruthy(GetMetadata("macos:hardened_runtime"))) + { + return EvaluationValue.False; + } + + return EvaluationValue.True; + } + case "pkg_receipt": + { + var identifier = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + if (string.IsNullOrWhiteSpace(identifier)) + { + return EvaluationValue.False; + } + + var pkgId = GetMetadata("pkgutil:identifier"); + if (string.IsNullOrWhiteSpace(pkgId)) + { + return EvaluationValue.False; + } + + if (arguments.Length > 1) + { + var version = evaluator.Evaluate(arguments[1], scope).AsString(); + if (!string.IsNullOrWhiteSpace(version)) + { + var pkgVersion = component.Version; + return new EvaluationValue( + string.Equals(pkgId, identifier, StringComparison.OrdinalIgnoreCase) && + string.Equals(pkgVersion, version, StringComparison.Ordinal)); + } + } + + return new EvaluationValue(string.Equals(pkgId, identifier, StringComparison.OrdinalIgnoreCase)); + } + default: + return EvaluationValue.Null; + } + } + + private bool IsSigned() + { + // Consider signed if team_id is present or code_resources_hash exists + var teamId = GetMetadata("macos:team_id"); + var codeResourcesHash = GetMetadata("macos:code_resources_hash"); + return !string.IsNullOrWhiteSpace(teamId) || !string.IsNullOrWhiteSpace(codeResourcesHash); + } + + private bool HasEntitlement(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + // Check high risk entitlements first + if (highRiskEntitlements.Contains(name)) + { + return true; + } + + // Check capability categories for short names + if (entitlementCategories.Contains(name)) + { + return true; + } + + return false; + } + + private string? GetMetadata(string key) + { + return component.Metadata.TryGetValue(key, out var value) ? value : null; + } + + private static bool IsTruthy(string? value) + { + return value is not null + && (value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); + } + + private static ImmutableHashSet ParseDelimitedSet(ImmutableDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + { + return ImmutableHashSet.Empty; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static v => !string.IsNullOrWhiteSpace(v)) + .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static ImmutableHashSet EvaluateAsStringSet(ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var argument in arguments) + { + var evaluated = evaluator.Evaluate(argument, scope).Raw; + switch (evaluated) + { + case ImmutableArray array: + foreach (var item in array) + { + if (item is string text && !string.IsNullOrWhiteSpace(text)) + { + builder.Add(text.Trim()); + } + } + + break; + case string text when !string.IsNullOrWhiteSpace(text): + builder.Add(text.Trim()); + break; + } + } + + return builder.ToImmutable(); + } + } } diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs index e44b3b5dc..c1c286d08 100644 --- a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs @@ -415,4 +415,250 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { $"pkg:gem/{name}@{version}", metadataBuilder.ToImmutable()); } + + private static PolicyEvaluationComponent CreateMacOsComponent( + string bundleId, + string version, + bool sandboxed = false, + bool hardenedRuntime = false, + string? teamId = null, + string? codeResourcesHash = null, + IEnumerable? categories = null, + IEnumerable? highRiskEntitlements = null, + string? pkgutilIdentifier = null) + { + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + metadataBuilder["macos:bundle_id"] = bundleId; + metadataBuilder["macos:sandboxed"] = sandboxed ? "true" : "false"; + metadataBuilder["macos:hardened_runtime"] = hardenedRuntime ? "true" : "false"; + + if (!string.IsNullOrWhiteSpace(teamId)) + { + metadataBuilder["macos:team_id"] = teamId; + } + + if (!string.IsNullOrWhiteSpace(codeResourcesHash)) + { + metadataBuilder["macos:code_resources_hash"] = codeResourcesHash; + } + + if (categories is not null && categories.Any()) + { + metadataBuilder["macos:capability_categories"] = string.Join(",", categories); + } + + if (highRiskEntitlements is not null && highRiskEntitlements.Any()) + { + metadataBuilder["macos:high_risk_entitlements"] = string.Join(",", highRiskEntitlements); + } + + if (!string.IsNullOrWhiteSpace(pkgutilIdentifier)) + { + metadataBuilder["pkgutil:identifier"] = pkgutilIdentifier; + } + + return new PolicyEvaluationComponent( + bundleId.Split('.').Last(), + version, + "macos-bundle", + $"pkg:generic/macos-app/{bundleId}@{version}", + metadataBuilder.ToImmutable()); + } + + #region macOS Policy Predicate Tests + + private static readonly string MacOsPolicy = """ +policy "macOS Security Policy" syntax "stella-dsl@1" { + metadata { + description = "Enforce macOS bundle security requirements." + tags = ["macos","security"] + } + + rule block_unsigned_apps priority 3 { + when sbom.any_component(not macos.signed) + then status := "blocked" + because "Unsigned macOS applications are not permitted." + } + + rule warn_high_risk_entitlements priority 4 { + when sbom.any_component(macos.high_risk_entitlements) + then warn message "Application requests high-risk entitlements." + because "High-risk entitlements require review." + } + + rule require_hardened_runtime priority 5 { + when sbom.any_component(macos.signed and not macos.hardened_runtime) + then warn message "Application does not use hardened runtime." + because "Hardened runtime is recommended for security." + } + + rule block_unsandboxed_apps priority 2 { + when sbom.any_component(not macos.sandboxed) + then warn message "Application is not sandboxed." + because "App sandbox provides security isolation." + } + + rule block_camera_microphone priority 1 { + when sbom.any_component(macos.category_any(["camera", "microphone"])) + then warn message "Application accesses camera or microphone." + because "Camera/microphone access requires review." + } +} +"""; + + [Fact] + public void Evaluate_MacOs_UnsignedAppBlocked() + { + var document = compiler.Compile(MacOsPolicy); + Assert.True(document.Success); + var ir = Assert.IsType(document.Document); + + // Unsigned but sandboxed app to avoid matching the unsandboxed rule first + var component = CreateMacOsComponent( + bundleId: "com.unknown.UnsignedApp", + version: "1.0.0", + sandboxed: true, // Sandboxed to avoid matching block_unsandboxed_apps first + hardenedRuntime: false, + teamId: null, + codeResourcesHash: null); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty, + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(ir, context); + + Assert.True(result.Matched); + Assert.Equal("block_unsigned_apps", result.RuleName); + Assert.Equal("blocked", result.Status); + } + + [Fact] + public void Evaluate_MacOs_SignedAppPasses() + { + var document = compiler.Compile(MacOsPolicy); + Assert.True(document.Success); + var ir = Assert.IsType(document.Document); + + var component = CreateMacOsComponent( + bundleId: "com.apple.Safari", + version: "17.1", + sandboxed: true, + hardenedRuntime: true, + teamId: "APPLE123", + codeResourcesHash: "sha256:abc123"); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty, + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(ir, context); + + // No blocking rules should match for a properly signed and sandboxed app + Assert.False(result.Matched && result.Status == "blocked"); + } + + [Fact] + public void Evaluate_MacOs_HighRiskEntitlementsWarns() + { + var document = compiler.Compile(MacOsPolicy); + Assert.True(document.Success); + var ir = Assert.IsType(document.Document); + + // App with high-risk entitlements but no camera/microphone categories + // to avoid matching block_camera_microphone rule first + var component = CreateMacOsComponent( + bundleId: "com.example.AutomationApp", + version: "2.0.0", + sandboxed: true, + hardenedRuntime: true, + teamId: "TEAM123", + codeResourcesHash: "sha256:def456", + categories: new[] { "network", "automation" }, + highRiskEntitlements: new[] { "com.apple.security.automation.apple-events" }); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty, + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(ir, context); + + Assert.True(result.Matched); + Assert.Equal("warn_high_risk_entitlements", result.RuleName); + Assert.Equal("warned", result.Status); + } + + [Fact] + public void Evaluate_MacOs_CategoryMatchesCameraAccess() + { + var document = compiler.Compile(MacOsPolicy); + Assert.True(document.Success); + var ir = Assert.IsType(document.Document); + + var component = CreateMacOsComponent( + bundleId: "com.example.VideoChat", + version: "1.5.0", + sandboxed: true, + hardenedRuntime: true, + teamId: "TEAM456", + codeResourcesHash: "sha256:ghi789", + categories: new[] { "camera", "microphone", "network" }); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty, + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(ir, context); + + // Should match camera/microphone warning rule + Assert.True(result.Matched); + // Either high_risk or camera_microphone rule should match + Assert.True( + result.RuleName == "block_camera_microphone" || + result.RuleName == "warn_high_risk_entitlements" || + result.Status == "warned"); + } + + [Fact] + public void Evaluate_MacOs_HardenedRuntimeWarnsWhenMissing() + { + var document = compiler.Compile(MacOsPolicy); + Assert.True(document.Success); + var ir = Assert.IsType(document.Document); + + var component = CreateMacOsComponent( + bundleId: "com.example.LegacyApp", + version: "3.0.0", + sandboxed: true, + hardenedRuntime: false, + teamId: "TEAM789", + codeResourcesHash: "sha256:jkl012"); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty, + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(ir, context); + + Assert.True(result.Matched); + Assert.Equal("require_hardened_runtime", result.RuleName); + Assert.Equal("warned", result.Status); + } + + #endregion } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyAnalyzerPlugin.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyAnalyzerPlugin.cs new file mode 100644 index 000000000..6ea8b86b5 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyAnalyzerPlugin.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Plugin; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; + +/// +/// Plugin that registers the Windows Chocolatey package analyzer. +/// +public sealed class ChocolateyAnalyzerPlugin : IOSAnalyzerPlugin +{ + /// + public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey"; + + /// + public bool IsAvailable(IServiceProvider services) => services is not null; + + /// + public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + var loggerFactory = services.GetRequiredService(); + return new ChocolateyPackageAnalyzer(loggerFactory.CreateLogger()); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageAnalyzer.cs new file mode 100644 index 000000000..0ae904dfa --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageAnalyzer.cs @@ -0,0 +1,278 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Analyzers; +using StellaOps.Scanner.Analyzers.OS.Helpers; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; + +/// +/// Analyzes Windows Chocolatey package installations to extract component metadata. +/// Scans ProgramData/Chocolatey/lib/ for installed packages. +/// +internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase +{ + private static readonly IReadOnlyList EmptyPackages = + new ReadOnlyCollection(Array.Empty()); + + /// + /// Paths to scan for Chocolatey packages. + /// + private static readonly string[] ChocolateyPaths = + [ + "ProgramData/Chocolatey/lib", + "ProgramData/chocolatey/lib" // Case variation + ]; + + private readonly NuspecParser _nuspecParser = new(); + + public ChocolateyPackageAnalyzer(ILogger logger) + : base(logger) + { + } + + public override string AnalyzerId => "windows-chocolatey"; + + protected override ValueTask> ExecuteCoreAsync( + OSPackageAnalyzerContext context, + CancellationToken cancellationToken) + { + var records = new List(); + var warnings = new List(); + var chocolateyFound = false; + + foreach (var chocoPath in ChocolateyPaths) + { + var libDir = Path.Combine(context.RootPath, chocoPath); + if (!Directory.Exists(libDir)) + { + continue; + } + + chocolateyFound = true; + Logger.LogInformation("Scanning Chocolatey packages in {Path}", libDir); + + try + { + DiscoverPackages(libDir, records, warnings, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to scan Chocolatey path {Path}", libDir); + } + } + + if (!chocolateyFound) + { + Logger.LogInformation("Chocolatey installation not found; skipping analyzer."); + return ValueTask.FromResult>(EmptyPackages); + } + + if (records.Count == 0) + { + Logger.LogInformation("No Chocolatey packages found; skipping analyzer."); + return ValueTask.FromResult>(EmptyPackages); + } + + foreach (var warning in warnings.Take(10)) + { + Logger.LogWarning("Chocolatey scan warning: {Warning}", warning); + } + + Logger.LogInformation("Discovered {Count} Chocolatey packages", records.Count); + + // Sort for deterministic output + records.Sort(); + return ValueTask.FromResult>(records); + } + + private void DiscoverPackages( + string libDir, + List records, + List warnings, + CancellationToken cancellationToken) + { + IEnumerable packageDirs; + try + { + packageDirs = Directory.EnumerateDirectories(libDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return; + } + + foreach (var packageDir in packageDirs) + { + cancellationToken.ThrowIfCancellationRequested(); + + var dirName = Path.GetFileName(packageDir); + if (string.IsNullOrWhiteSpace(dirName) || dirName.StartsWith('.')) + { + continue; + } + + var record = AnalyzePackage(packageDir, warnings, cancellationToken); + if (record is not null) + { + records.Add(record); + } + } + } + + private OSPackageRecord? AnalyzePackage( + string packageDir, + List warnings, + CancellationToken cancellationToken) + { + // Look for .nuspec file + var nuspecFile = Directory.GetFiles(packageDir, "*.nuspec", SearchOption.TopDirectoryOnly) + .FirstOrDefault(); + + ChocolateyPackageMetadata? metadata = null; + + if (nuspecFile is not null) + { + metadata = _nuspecParser.Parse(nuspecFile, packageDir, cancellationToken); + } + + // Fallback: parse package name from directory + if (metadata is null) + { + var dirName = Path.GetFileName(packageDir); + var parsed = NuspecParser.ParsePackageDirectory(dirName); + if (parsed is null) + { + warnings.Add($"Could not parse package info from {packageDir}"); + return null; + } + + metadata = new ChocolateyPackageMetadata( + Id: parsed.Value.Id, + Version: parsed.Value.Version, + Title: null, + Authors: null, + Description: null, + LicenseUrl: null, + ProjectUrl: null, + Checksum: null, + ChecksumType: null, + SourceFeed: null, + InstallDir: packageDir, + InstallScriptHash: null, + InstalledFiles: []); + } + + // Build PURL + var purl = PackageUrlBuilder.BuildChocolatey(metadata.Id, metadata.Version); + + // Build vendor metadata + var vendorMetadata = BuildVendorMetadata(metadata); + + // Build file evidence (limit to key files) + var files = metadata.InstalledFiles + .Where(f => IsKeyFile(f)) + .Take(100) // Limit file evidence + .Select(f => new OSPackageFileEvidence( + f, + layerDigest: null, + sha256: null, + sizeBytes: null, + isConfigFile: IsConfigFile(f))) + .ToList(); + + return new OSPackageRecord( + AnalyzerId, + purl, + metadata.Title ?? metadata.Id, + metadata.Version, + "x64", // Chocolatey packages are typically platform-neutral or x64 + PackageEvidenceSource.WindowsChocolatey, + epoch: null, + release: null, + sourcePackage: metadata.Authors, + license: metadata.LicenseUrl, + cveHints: null, + provides: null, + depends: null, + files: files, + vendorMetadata: vendorMetadata); + } + + private static Dictionary BuildVendorMetadata(ChocolateyPackageMetadata metadata) + { + var vendorMetadata = new Dictionary(StringComparer.Ordinal) + { + ["choco:id"] = metadata.Id, + ["choco:version"] = metadata.Version, + ["choco:install_dir"] = metadata.InstallDir, + }; + + if (!string.IsNullOrWhiteSpace(metadata.Title)) + { + vendorMetadata["choco:title"] = metadata.Title; + } + + if (!string.IsNullOrWhiteSpace(metadata.Authors)) + { + vendorMetadata["choco:authors"] = metadata.Authors; + } + + if (!string.IsNullOrWhiteSpace(metadata.Description)) + { + // Truncate long descriptions + vendorMetadata["choco:description"] = metadata.Description.Length > 200 + ? metadata.Description[..197] + "..." + : metadata.Description; + } + + if (!string.IsNullOrWhiteSpace(metadata.ProjectUrl)) + { + vendorMetadata["choco:project_url"] = metadata.ProjectUrl; + } + + if (!string.IsNullOrWhiteSpace(metadata.LicenseUrl)) + { + vendorMetadata["choco:license_url"] = metadata.LicenseUrl; + } + + if (!string.IsNullOrWhiteSpace(metadata.SourceFeed)) + { + vendorMetadata["choco:source_feed"] = metadata.SourceFeed; + } + + if (!string.IsNullOrWhiteSpace(metadata.InstallScriptHash)) + { + vendorMetadata["choco:install_script_hash"] = metadata.InstallScriptHash; + } + + if (!string.IsNullOrWhiteSpace(metadata.Checksum)) + { + vendorMetadata["choco:checksum"] = metadata.Checksum; + vendorMetadata["choco:checksum_type"] = metadata.ChecksumType; + } + + vendorMetadata["choco:file_count"] = metadata.InstalledFiles.Count.ToString(); + + return vendorMetadata; + } + + private static bool IsKeyFile(string path) + { + // Include executables, libraries, configs, and key metadata + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".exe" or ".dll" or ".ps1" or ".psm1" or ".bat" or ".cmd" => true, + ".config" or ".json" or ".xml" or ".yaml" or ".yml" => true, + ".nuspec" or ".nupkg" => true, + _ => false + }; + } + + private static bool IsConfigFile(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext is ".config" or ".json" or ".xml" or ".yaml" or ".yml"; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageMetadata.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageMetadata.cs new file mode 100644 index 000000000..91fc5fc94 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/ChocolateyPackageMetadata.cs @@ -0,0 +1,44 @@ +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; + +/// +/// Represents metadata extracted from a Chocolatey package installation. +/// +internal sealed record ChocolateyPackageMetadata( + /// Package identifier (e.g., "git", "nodejs"). + string Id, + + /// Package version (e.g., "2.42.0"). + string Version, + + /// Package title/display name. + string? Title, + + /// Package authors. + string? Authors, + + /// Package description. + string? Description, + + /// Package license URL. + string? LicenseUrl, + + /// Package project URL. + string? ProjectUrl, + + /// Package checksum. + string? Checksum, + + /// Checksum algorithm (e.g., "sha256"). + string? ChecksumType, + + /// Source feed URL where package was downloaded. + string? SourceFeed, + + /// Installation directory. + string InstallDir, + + /// Installation script hash for determinism. + string? InstallScriptHash, + + /// Files installed by the package. + IReadOnlyList InstalledFiles); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/NuspecParser.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/NuspecParser.cs new file mode 100644 index 000000000..894857ef2 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/NuspecParser.cs @@ -0,0 +1,184 @@ +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; + +/// +/// Parses Chocolatey/NuGet .nuspec files to extract package metadata. +/// +internal sealed class NuspecParser +{ + private static readonly XNamespace NuspecNs = "http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"; + private static readonly XNamespace NuspecNsOld = "http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"; + private static readonly XNamespace NuspecNsOld2 = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"; + + /// + /// Parses a .nuspec file and extracts package metadata. + /// + public ChocolateyPackageMetadata? Parse(string nuspecPath, string installDir, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(nuspecPath) || !File.Exists(nuspecPath)) + { + return null; + } + + try + { + var doc = XDocument.Load(nuspecPath); + if (doc.Root is null) + { + return null; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Find metadata element + var metadata = FindElement(doc.Root, "metadata"); + if (metadata is null) + { + return null; + } + + // Extract required fields + var id = GetElementValue(metadata, "id"); + var version = GetElementValue(metadata, "version"); + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version)) + { + return null; + } + + // Extract optional fields + var title = GetElementValue(metadata, "title"); + var authors = GetElementValue(metadata, "authors"); + var description = GetElementValue(metadata, "description"); + var licenseUrl = GetElementValue(metadata, "licenseUrl"); + var projectUrl = GetElementValue(metadata, "projectUrl"); + + // Look for install script and compute hash + var installScriptHash = ComputeInstallScriptHash(installDir); + + // Enumerate installed files + var installedFiles = EnumerateInstalledFiles(installDir); + + return new ChocolateyPackageMetadata( + Id: id, + Version: version, + Title: title, + Authors: authors, + Description: description, + LicenseUrl: licenseUrl, + ProjectUrl: projectUrl, + Checksum: null, + ChecksumType: null, + SourceFeed: null, + InstallDir: installDir, + InstallScriptHash: installScriptHash, + InstalledFiles: installedFiles); + } + catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Parses basic package info from directory name pattern. + /// Format: packageid.version + /// + public static (string Id, string Version)? ParsePackageDirectory(string directoryName) + { + if (string.IsNullOrWhiteSpace(directoryName)) + { + return null; + } + + // Find the first dot followed by a digit (start of version) + // Iterating from left to right because package IDs can contain dots + // (e.g., "Microsoft.WindowsTerminal.1.18.0" -> id="Microsoft.WindowsTerminal", version="1.18.0") + var versionStartIndex = -1; + for (var i = 0; i < directoryName.Length - 1; i++) + { + if (directoryName[i] == '.' && char.IsDigit(directoryName[i + 1])) + { + versionStartIndex = i; + break; + } + } + + if (versionStartIndex <= 0) + { + return null; + } + + var id = directoryName[..versionStartIndex]; + var version = directoryName[(versionStartIndex + 1)..]; + + return (id, version); + } + + private static XElement? FindElement(XElement parent, string localName) + { + return parent.Element(NuspecNs + localName) + ?? parent.Element(NuspecNsOld + localName) + ?? parent.Element(NuspecNsOld2 + localName) + ?? parent.Element(localName); + } + + private static string? GetElementValue(XElement parent, string localName) + { + var element = FindElement(parent, localName); + return element?.Value?.Trim(); + } + + private static string? ComputeInstallScriptHash(string installDir) + { + // Look for chocolateyinstall.ps1 script + var scriptPath = Path.Combine(installDir, "tools", "chocolateyinstall.ps1"); + if (!File.Exists(scriptPath)) + { + scriptPath = Path.Combine(installDir, "chocolateyinstall.ps1"); + } + + if (!File.Exists(scriptPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(scriptPath); + var hash = SHA256.HashData(stream); + return $"sha256:{Convert.ToHexStringLower(hash)}"; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static List EnumerateInstalledFiles(string installDir) + { + var files = new List(); + + if (!Directory.Exists(installDir)) + { + return files; + } + + try + { + foreach (var file in Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(installDir, file); + files.Add(relativePath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Ignore access errors + } + + return files; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/Properties/AssemblyInfo.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..959360eba --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests")] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj new file mode 100644 index 000000000..61ca1fac4 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + preview + enable + enable + true + StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey + 0.1.0-alpha + Windows Chocolatey and registry package analyzer for StellaOps Scanner + StellaOps + AGPL-3.0-or-later + https://git.stella-ops.org/stella-ops.org/stellaops + + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiAnalyzerPlugin.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiAnalyzerPlugin.cs new file mode 100644 index 000000000..c584f0b32 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiAnalyzerPlugin.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Plugin; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi; + +/// +/// Plugin that registers the Windows MSI package analyzer. +/// +public sealed class MsiAnalyzerPlugin : IOSAnalyzerPlugin +{ + /// + public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Msi"; + + /// + public bool IsAvailable(IServiceProvider services) => services is not null; + + /// + public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + var loggerFactory = services.GetRequiredService(); + return new MsiPackageAnalyzer(loggerFactory.CreateLogger()); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiDatabaseParser.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiDatabaseParser.cs new file mode 100644 index 000000000..c2ce77fd7 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiDatabaseParser.cs @@ -0,0 +1,98 @@ +using System.Text; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi; + +/// +/// Parses MSI (Windows Installer) database files to extract product metadata. +/// Uses OLE compound document parsing for cross-platform compatibility. +/// +internal sealed class MsiDatabaseParser +{ + // MSI Summary Information property IDs + private const int PID_REVNUMBER = 9; // Package code + private const int PID_SUBJECT = 3; // Product name + private const int PID_AUTHOR = 4; // Manufacturer + private const int PID_TEMPLATE = 7; // Platform;Language + + // OLE compound document magic number + private static readonly byte[] OleMagic = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + + /// + /// Parses an MSI file and extracts product metadata. + /// + public MsiMetadata? Parse(string msiPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(msiPath) || !File.Exists(msiPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(msiPath); + + // Verify OLE compound document signature + var header = new byte[8]; + if (stream.Read(header, 0, 8) != 8 || !header.AsSpan().SequenceEqual(OleMagic)) + { + return null; + } + + // For cross-platform compatibility, extract basic metadata from the file structure + // Full MSI table parsing would require Windows COM or a dedicated library + var fileInfo = new FileInfo(msiPath); + var fileName = Path.GetFileNameWithoutExtension(msiPath); + + // Try to extract basic info from filename conventions + var (name, version) = ExtractNameVersionFromFileName(fileName); + + // Compute file hash for provenance + stream.Position = 0; + var hash = ComputeSha256(stream); + + return new MsiMetadata( + ProductCode: null, // Requires full database parsing + UpgradeCode: null, // Requires full database parsing + ProductName: name, + ProductVersion: version ?? "0.0.0", + Manufacturer: null, // Requires summary stream parsing + Language: null, // Requires template property + PackageCode: null, // Requires revision number property + InstallScope: null, + InstallSource: msiPath, + FilePath: msiPath, + FileSize: fileInfo.Length, + FileHash: hash); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Extracts product name and version from common MSI filename patterns. + /// + private static (string Name, string? Version) ExtractNameVersionFromFileName(string fileName) + { + // Common patterns: "ProductName-1.2.3", "ProductName_v1.2.3", "ProductName 1.2.3" + var versionPattern = @"[-_\s]v?(\d+(?:\.\d+){0,3})$"; + var match = System.Text.RegularExpressions.Regex.Match(fileName, versionPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (match.Success) + { + var name = fileName[..match.Index].Trim('-', '_', ' '); + var version = match.Groups[1].Value; + return (name, version); + } + + return (fileName, null); + } + + private static string ComputeSha256(Stream stream) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hash = sha256.ComputeHash(stream); + return $"sha256:{Convert.ToHexStringLower(hash)}"; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiMetadata.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiMetadata.cs new file mode 100644 index 000000000..1b7f29da3 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiMetadata.cs @@ -0,0 +1,18 @@ +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi; + +/// +/// Represents metadata extracted from a Windows MSI installer package. +/// +internal sealed record MsiMetadata( + string? ProductCode, + string? UpgradeCode, + string ProductName, + string ProductVersion, + string? Manufacturer, + string? Language, + string? PackageCode, + string? InstallScope, + string? InstallSource, + string FilePath, + long? FileSize, + string? FileHash); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiPackageAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiPackageAnalyzer.cs new file mode 100644 index 000000000..c10128a09 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/MsiPackageAnalyzer.cs @@ -0,0 +1,273 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Analyzers; +using StellaOps.Scanner.Analyzers.OS.Helpers; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi; + +/// +/// Analyzes Windows MSI installer packages to extract product metadata. +/// Scans common MSI cache locations and the Windows Installer directory. +/// +internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase +{ + private static readonly IReadOnlyList EmptyPackages = + new ReadOnlyCollection(Array.Empty()); + + /// + /// Standard paths to scan for MSI packages relative to root. + /// + private static readonly string[] MsiSearchPaths = + [ + "Windows/Installer", + "ProgramData/Package Cache", + ]; + + /// + /// Maximum file size to process (100MB). + /// + private const long MaxFileSizeBytes = 100L * 1024L * 1024L; + + private readonly MsiDatabaseParser _msiParser = new(); + + public MsiPackageAnalyzer(ILogger logger) + : base(logger) + { + } + + public override string AnalyzerId => "windows-msi"; + + protected override ValueTask> ExecuteCoreAsync( + OSPackageAnalyzerContext context, + CancellationToken cancellationToken) + { + var records = new List(); + var processedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var warnings = new List(); + + // Scan standard MSI cache paths + foreach (var searchPath in MsiSearchPaths) + { + var fullPath = Path.Combine(context.RootPath, searchPath); + if (!Directory.Exists(fullPath)) + { + continue; + } + + Logger.LogInformation("Scanning for MSI packages in {Path}", fullPath); + + try + { + DiscoverMsiFiles(fullPath, records, processedFiles, warnings, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to scan MSI path {Path}", fullPath); + } + } + + // Scan user-specific installer cache + var usersPath = Path.Combine(context.RootPath, "Users"); + if (Directory.Exists(usersPath)) + { + try + { + foreach (var userDir in Directory.EnumerateDirectories(usersPath)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Local AppData MSI cache + var localAppData = Path.Combine(userDir, "AppData", "Local", "Package Cache"); + if (Directory.Exists(localAppData)) + { + DiscoverMsiFiles(localAppData, records, processedFiles, warnings, cancellationToken); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.LogDebug(ex, "Could not enumerate user directories"); + } + } + + if (records.Count == 0) + { + Logger.LogInformation("No MSI packages found; skipping analyzer."); + return ValueTask.FromResult>(EmptyPackages); + } + + foreach (var warning in warnings.Take(10)) + { + Logger.LogWarning("MSI scan warning: {Warning}", warning); + } + + Logger.LogInformation("Discovered {Count} MSI packages", records.Count); + + // Sort for deterministic output + records.Sort(); + return ValueTask.FromResult>(records); + } + + private void DiscoverMsiFiles( + string searchPath, + List records, + HashSet processedFiles, + List warnings, + CancellationToken cancellationToken) + { + IEnumerable msiFiles; + try + { + msiFiles = Directory.EnumerateFiles(searchPath, "*.msi", SearchOption.AllDirectories); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return; + } + + foreach (var msiPath in msiFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Skip if already processed (handles symlinks/duplicates) + var normalizedPath = Path.GetFullPath(msiPath); + if (!processedFiles.Add(normalizedPath)) + { + continue; + } + + try + { + var fileInfo = new FileInfo(msiPath); + if (fileInfo.Length > MaxFileSizeBytes) + { + warnings.Add($"Skipping large MSI file ({fileInfo.Length} bytes): {msiPath}"); + continue; + } + + var record = AnalyzeMsiFile(msiPath, warnings, cancellationToken); + if (record is not null) + { + records.Add(record); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.LogDebug(ex, "Could not access MSI file {Path}", msiPath); + } + } + } + + private OSPackageRecord? AnalyzeMsiFile( + string msiPath, + List warnings, + CancellationToken cancellationToken) + { + var metadata = _msiParser.Parse(msiPath, cancellationToken); + if (metadata is null) + { + warnings.Add($"Failed to parse MSI file: {msiPath}"); + return null; + } + + // Build PURL + var purl = PackageUrlBuilder.BuildWindowsMsi( + metadata.ProductName, + metadata.ProductVersion, + metadata.UpgradeCode); + + // Build vendor metadata + var vendorMetadata = BuildVendorMetadata(metadata); + + // Build file evidence + var files = new List + { + new( + Path.GetFileName(msiPath), + layerDigest: null, + sha256: metadata.FileHash, + sizeBytes: metadata.FileSize, + isConfigFile: false) + }; + + return new OSPackageRecord( + AnalyzerId, + purl, + metadata.ProductName, + metadata.ProductVersion, + DetermineArchitecture(metadata.Language), + PackageEvidenceSource.WindowsMsi, + epoch: null, + release: null, + sourcePackage: metadata.Manufacturer, + license: null, + cveHints: null, + provides: null, + depends: null, + files: files, + vendorMetadata: vendorMetadata); + } + + private static Dictionary BuildVendorMetadata(MsiMetadata metadata) + { + var vendorMetadata = new Dictionary(StringComparer.Ordinal) + { + ["msi:file_path"] = metadata.FilePath, + }; + + if (!string.IsNullOrWhiteSpace(metadata.ProductCode)) + { + vendorMetadata["msi:product_code"] = metadata.ProductCode; + } + + if (!string.IsNullOrWhiteSpace(metadata.UpgradeCode)) + { + vendorMetadata["msi:upgrade_code"] = metadata.UpgradeCode; + } + + if (!string.IsNullOrWhiteSpace(metadata.PackageCode)) + { + vendorMetadata["msi:package_code"] = metadata.PackageCode; + } + + if (!string.IsNullOrWhiteSpace(metadata.Manufacturer)) + { + vendorMetadata["msi:manufacturer"] = metadata.Manufacturer; + } + + if (!string.IsNullOrWhiteSpace(metadata.Language)) + { + vendorMetadata["msi:language"] = metadata.Language; + } + + if (!string.IsNullOrWhiteSpace(metadata.InstallScope)) + { + vendorMetadata["msi:install_scope"] = metadata.InstallScope; + } + + if (!string.IsNullOrWhiteSpace(metadata.InstallSource)) + { + vendorMetadata["msi:install_source"] = metadata.InstallSource; + } + + if (!string.IsNullOrWhiteSpace(metadata.FileHash)) + { + vendorMetadata["msi:file_hash"] = metadata.FileHash; + } + + if (metadata.FileSize.HasValue) + { + vendorMetadata["msi:file_size"] = metadata.FileSize.Value.ToString(); + } + + return vendorMetadata; + } + + private static string DetermineArchitecture(string? language) + { + // MSI packages are typically architecture-specific based on template + // For now, default to x64 as most common + return "x64"; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/Properties/AssemblyInfo.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..3d3c24ace --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests")] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj new file mode 100644 index 000000000..cf7df2775 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + preview + enable + enable + true + StellaOps.Scanner.Analyzers.OS.Windows.Msi + 0.1.0-alpha + Windows MSI package analyzer for StellaOps Scanner + StellaOps + AGPL-3.0-or-later + https://git.stella-ops.org/stella-ops.org/stellaops + + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/Properties/AssemblyInfo.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..c1facc60d --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests")] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj new file mode 100644 index 000000000..769f63ef7 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + preview + enable + enable + true + StellaOps.Scanner.Analyzers.OS.Windows.WinSxS + 0.1.0-alpha + Windows WinSxS assembly analyzer for StellaOps Scanner + StellaOps + AGPL-3.0-or-later + https://git.stella-ops.org/stella-ops.org/stellaops + + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAnalyzerPlugin.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAnalyzerPlugin.cs new file mode 100644 index 000000000..33d0d1ace --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAnalyzerPlugin.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Plugin; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS; + +/// +/// Plugin that registers the Windows WinSxS assembly analyzer. +/// +public sealed class WinSxSAnalyzerPlugin : IOSAnalyzerPlugin +{ + /// + public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.WinSxS"; + + /// + public bool IsAvailable(IServiceProvider services) => services is not null; + + /// + public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + var loggerFactory = services.GetRequiredService(); + return new WinSxSPackageAnalyzer(loggerFactory.CreateLogger()); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAssemblyMetadata.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAssemblyMetadata.cs new file mode 100644 index 000000000..ef56e3c8f --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSAssemblyMetadata.cs @@ -0,0 +1,60 @@ +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS; + +/// +/// Represents metadata extracted from a Windows Side-by-Side (WinSxS) assembly manifest. +/// +internal sealed record WinSxSAssemblyMetadata( + /// Assembly name (e.g., "Microsoft.Windows.Common-Controls"). + string Name, + + /// Assembly version (e.g., "6.0.0.0"). + string Version, + + /// Processor architecture (e.g., "x86", "amd64", "arm64", "msil", "wow64"). + string ProcessorArchitecture, + + /// Public key token (e.g., "6595b64144ccf1df"). + string? PublicKeyToken, + + /// Language/culture (e.g., "en-us", "*", or empty). + string? Language, + + /// Assembly type (e.g., "win32", "win32-policy"). + string? Type, + + /// Version scope (e.g., "nonSxS" for non-side-by-side assemblies). + string? VersionScope, + + /// Manifest file path. + string ManifestPath, + + /// Associated catalog (.cat) file path if found. + string? CatalogPath, + + /// Catalog signature thumbprint if available. + string? CatalogThumbprint, + + /// KB reference from patch manifest if available. + string? KbReference, + + /// Files declared in the assembly manifest. + IReadOnlyList Files); + +/// +/// Represents a file entry in a WinSxS assembly manifest. +/// +internal sealed record WinSxSFileEntry( + /// File name. + string Name, + + /// File hash algorithm (e.g., "SHA256"). + string? HashAlgorithm, + + /// File hash value. + string? Hash, + + /// File size in bytes. + long? Size, + + /// Destination path within the assembly. + string? DestinationPath); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSManifestParser.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSManifestParser.cs new file mode 100644 index 000000000..6af6dcdfc --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSManifestParser.cs @@ -0,0 +1,240 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS; + +/// +/// Parses Windows Side-by-Side (WinSxS) assembly manifest files. +/// +internal sealed class WinSxSManifestParser +{ + // WinSxS XML namespace + private static readonly XNamespace AssemblyNs = "urn:schemas-microsoft-com:asm.v1"; + private static readonly XNamespace AssemblyNsV3 = "urn:schemas-microsoft-com:asm.v3"; + + /// + /// Parses a WinSxS manifest file and extracts assembly metadata. + /// + public WinSxSAssemblyMetadata? Parse(string manifestPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath)) + { + return null; + } + + try + { + var doc = XDocument.Load(manifestPath); + if (doc.Root is null) + { + return null; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Find assemblyIdentity element (try both namespaces and no namespace) + var identity = FindAssemblyIdentity(doc.Root); + if (identity is null) + { + return null; + } + + // Extract assembly identity attributes + var name = identity.Attribute("name")?.Value; + var version = identity.Attribute("version")?.Value; + var arch = identity.Attribute("processorArchitecture")?.Value; + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version)) + { + return null; + } + + var publicKeyToken = identity.Attribute("publicKeyToken")?.Value; + var language = identity.Attribute("language")?.Value; + var type = identity.Attribute("type")?.Value; + var versionScope = identity.Attribute("versionScope")?.Value; + + // Extract file entries + var files = ExtractFileEntries(doc.Root); + + // Extract KB reference from manifest path if present + var kbReference = ExtractKbReference(manifestPath); + + // Look for associated catalog file + var catalogPath = FindCatalogFile(manifestPath); + + return new WinSxSAssemblyMetadata( + Name: name, + Version: version, + ProcessorArchitecture: arch ?? "neutral", + PublicKeyToken: publicKeyToken, + Language: language, + Type: type, + VersionScope: versionScope, + ManifestPath: manifestPath, + CatalogPath: catalogPath, + CatalogThumbprint: null, // Would require certificate parsing + KbReference: kbReference, + Files: files); + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Generates a deterministic assembly identity string for PURL construction. + /// Format: name_version_arch_publicKeyToken_language + /// + public static string BuildAssemblyIdentityString(WinSxSAssemblyMetadata metadata) + { + var builder = new StringBuilder(); + builder.Append(metadata.Name.ToLowerInvariant()); + builder.Append('_'); + builder.Append(metadata.Version); + builder.Append('_'); + builder.Append(metadata.ProcessorArchitecture?.ToLowerInvariant() ?? "neutral"); + + if (!string.IsNullOrWhiteSpace(metadata.PublicKeyToken)) + { + builder.Append('_'); + builder.Append(metadata.PublicKeyToken.ToLowerInvariant()); + } + + if (!string.IsNullOrWhiteSpace(metadata.Language) && metadata.Language != "*") + { + builder.Append('_'); + builder.Append(metadata.Language.ToLowerInvariant()); + } + + return builder.ToString(); + } + + private static XElement? FindAssemblyIdentity(XElement root) + { + // Try different namespace variations + return root.Element(AssemblyNs + "assemblyIdentity") + ?? root.Element(AssemblyNsV3 + "assemblyIdentity") + ?? root.Element("assemblyIdentity") + ?? root.Descendants().FirstOrDefault(e => + e.Name.LocalName == "assemblyIdentity"); + } + + private static List ExtractFileEntries(XElement root) + { + var files = new List(); + + // Find all file elements + var fileElements = root.Descendants() + .Where(e => e.Name.LocalName == "file"); + + foreach (var file in fileElements) + { + var name = file.Attribute("name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var hashAttr = file.Attribute("hash"); + var hashAlgAttr = file.Attribute("hashalg"); + var sizeAttr = file.Attribute("size"); + var destAttr = file.Attribute("destinationPath"); + + // Try to find hash in child element + var hashElement = file.Elements() + .FirstOrDefault(e => e.Name.LocalName == "hash"); + + string? hash = hashAttr?.Value; + string? hashAlg = hashAlgAttr?.Value; + + if (hashElement is not null) + { + var digestMethod = hashElement.Elements() + .FirstOrDefault(e => e.Name.LocalName == "DigestMethod"); + var digestValue = hashElement.Elements() + .FirstOrDefault(e => e.Name.LocalName == "DigestValue"); + + if (digestMethod is not null) + { + hashAlg = ParseHashAlgorithm(digestMethod.Attribute("Algorithm")?.Value); + } + if (digestValue is not null) + { + hash = digestValue.Value; + } + } + + long? size = null; + if (long.TryParse(sizeAttr?.Value, out var parsedSize)) + { + size = parsedSize; + } + + files.Add(new WinSxSFileEntry( + Name: name, + HashAlgorithm: hashAlg, + Hash: hash, + Size: size, + DestinationPath: destAttr?.Value)); + } + + return files; + } + + private static string? ParseHashAlgorithm(string? algorithmUri) + { + if (string.IsNullOrWhiteSpace(algorithmUri)) + { + return null; + } + + // Common XML Signature algorithm URIs + return algorithmUri switch + { + _ when algorithmUri.Contains("sha256", StringComparison.OrdinalIgnoreCase) => "SHA256", + _ when algorithmUri.Contains("sha1", StringComparison.OrdinalIgnoreCase) => "SHA1", + _ when algorithmUri.Contains("sha384", StringComparison.OrdinalIgnoreCase) => "SHA384", + _ when algorithmUri.Contains("sha512", StringComparison.OrdinalIgnoreCase) => "SHA512", + _ => null + }; + } + + private static string? ExtractKbReference(string manifestPath) + { + // KB references are often embedded in manifest file names + // e.g., "amd64_microsoft-windows-security-base_31bf3856ad364e35_10.0.19041.4170_none_kb5034441.manifest" + var fileName = Path.GetFileNameWithoutExtension(manifestPath); + var kbMatch = System.Text.RegularExpressions.Regex.Match( + fileName, + @"kb(\d+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + return kbMatch.Success ? $"KB{kbMatch.Groups[1].Value}" : null; + } + + private static string? FindCatalogFile(string manifestPath) + { + // Catalog files are typically named similarly to manifests + var directory = Path.GetDirectoryName(manifestPath); + if (string.IsNullOrWhiteSpace(directory)) + { + return null; + } + + // Look in Catalogs directory + var catalogsDir = Path.Combine(Path.GetDirectoryName(directory) ?? "", "Catalogs"); + if (!Directory.Exists(catalogsDir)) + { + return null; + } + + var baseName = Path.GetFileNameWithoutExtension(manifestPath); + + // Try to find matching catalog + var catalogPath = Path.Combine(catalogsDir, baseName + ".cat"); + return File.Exists(catalogPath) ? catalogPath : null; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSPackageAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSPackageAnalyzer.cs new file mode 100644 index 000000000..2212d9c5c --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/WinSxSPackageAnalyzer.cs @@ -0,0 +1,228 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.OS.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Analyzers; +using StellaOps.Scanner.Analyzers.OS.Helpers; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS; + +/// +/// Analyzes Windows Side-by-Side (WinSxS) assembly manifests to extract component metadata. +/// Scans Windows/WinSxS/Manifests/ for .manifest files. +/// +internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase +{ + private static readonly IReadOnlyList EmptyPackages = + new ReadOnlyCollection(Array.Empty()); + + /// + /// Path to WinSxS manifests directory. + /// + private const string ManifestsPath = "Windows/WinSxS/Manifests"; + + /// + /// Maximum number of manifests to process (limit for large systems). + /// + private const int MaxManifests = 50000; + + private readonly WinSxSManifestParser _parser = new(); + + public WinSxSPackageAnalyzer(ILogger logger) + : base(logger) + { + } + + public override string AnalyzerId => "windows-winsxs"; + + protected override ValueTask> ExecuteCoreAsync( + OSPackageAnalyzerContext context, + CancellationToken cancellationToken) + { + var manifestsDir = Path.Combine(context.RootPath, ManifestsPath); + if (!Directory.Exists(manifestsDir)) + { + Logger.LogInformation("WinSxS manifests directory not found at {Path}; skipping analyzer.", manifestsDir); + return ValueTask.FromResult>(EmptyPackages); + } + + var records = new List(); + var warnings = new List(); + var processedCount = 0; + + Logger.LogInformation("Scanning WinSxS manifests in {Path}", manifestsDir); + + try + { + var manifests = Directory.EnumerateFiles(manifestsDir, "*.manifest", SearchOption.TopDirectoryOnly); + + foreach (var manifestPath in manifests) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (processedCount >= MaxManifests) + { + Logger.LogWarning("Reached maximum manifest limit ({Max}); truncating results.", MaxManifests); + break; + } + + var record = AnalyzeManifest(manifestPath, warnings, cancellationToken); + if (record is not null) + { + records.Add(record); + } + + processedCount++; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to enumerate WinSxS manifests"); + } + + if (records.Count == 0) + { + Logger.LogInformation("No valid WinSxS assemblies found; skipping analyzer."); + return ValueTask.FromResult>(EmptyPackages); + } + + foreach (var warning in warnings.Take(10)) + { + Logger.LogWarning("WinSxS scan warning: {Warning}", warning); + } + + Logger.LogInformation("Discovered {Count} WinSxS assemblies from {Processed} manifests", records.Count, processedCount); + + // Sort for deterministic output + records.Sort(); + return ValueTask.FromResult>(records); + } + + private OSPackageRecord? AnalyzeManifest( + string manifestPath, + List warnings, + CancellationToken cancellationToken) + { + var metadata = _parser.Parse(manifestPath, cancellationToken); + if (metadata is null) + { + return null; + } + + // Build PURL using assembly identity + var assemblyIdentity = WinSxSManifestParser.BuildAssemblyIdentityString(metadata); + var purl = PackageUrlBuilder.BuildWindowsWinSxS(metadata.Name, metadata.Version, metadata.ProcessorArchitecture); + + // Build vendor metadata + var vendorMetadata = BuildVendorMetadata(metadata); + + // Build file evidence + var files = metadata.Files.Select(f => new OSPackageFileEvidence( + f.Name, + layerDigest: null, + sha256: FormatHash(f.Hash, f.HashAlgorithm), + sizeBytes: f.Size, + isConfigFile: f.Name.EndsWith(".config", StringComparison.OrdinalIgnoreCase) + )).ToList(); + + // Add manifest file itself + files.Insert(0, new OSPackageFileEvidence( + Path.GetFileName(manifestPath), + layerDigest: null, + sha256: null, + sizeBytes: null, + isConfigFile: true)); + + return new OSPackageRecord( + AnalyzerId, + purl, + metadata.Name, + metadata.Version, + metadata.ProcessorArchitecture, + PackageEvidenceSource.WindowsWinSxS, + epoch: null, + release: null, + sourcePackage: ExtractPublisher(metadata.Name), + license: null, + cveHints: metadata.KbReference is not null ? [metadata.KbReference] : null, + provides: null, + depends: null, + files: files, + vendorMetadata: vendorMetadata); + } + + private static Dictionary BuildVendorMetadata(WinSxSAssemblyMetadata metadata) + { + var vendorMetadata = new Dictionary(StringComparer.Ordinal) + { + ["winsxs:name"] = metadata.Name, + ["winsxs:version"] = metadata.Version, + ["winsxs:arch"] = metadata.ProcessorArchitecture, + ["winsxs:manifest_path"] = metadata.ManifestPath, + }; + + if (!string.IsNullOrWhiteSpace(metadata.PublicKeyToken)) + { + vendorMetadata["winsxs:public_key_token"] = metadata.PublicKeyToken; + } + + if (!string.IsNullOrWhiteSpace(metadata.Language) && metadata.Language != "*") + { + vendorMetadata["winsxs:language"] = metadata.Language; + } + + if (!string.IsNullOrWhiteSpace(metadata.Type)) + { + vendorMetadata["winsxs:type"] = metadata.Type; + } + + if (!string.IsNullOrWhiteSpace(metadata.VersionScope)) + { + vendorMetadata["winsxs:version_scope"] = metadata.VersionScope; + } + + if (!string.IsNullOrWhiteSpace(metadata.CatalogPath)) + { + vendorMetadata["winsxs:catalog_path"] = metadata.CatalogPath; + } + + if (!string.IsNullOrWhiteSpace(metadata.CatalogThumbprint)) + { + vendorMetadata["winsxs:catalog_thumbprint"] = metadata.CatalogThumbprint; + } + + if (!string.IsNullOrWhiteSpace(metadata.KbReference)) + { + vendorMetadata["winsxs:kb_reference"] = metadata.KbReference; + } + + vendorMetadata["winsxs:file_count"] = metadata.Files.Count.ToString(); + + return vendorMetadata; + } + + private static string? FormatHash(string? hash, string? algorithm) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return null; + } + + var prefix = algorithm?.ToLowerInvariant() switch + { + "sha256" => "sha256:", + "sha1" => "sha1:", + "sha384" => "sha384:", + "sha512" => "sha512:", + _ => "" + }; + + return prefix + hash.ToLowerInvariant(); + } + + private static string? ExtractPublisher(string assemblyName) + { + // Extract publisher from assembly name (e.g., "Microsoft.Windows.Common-Controls" -> "Microsoft") + var firstDot = assemblyName.IndexOf('.'); + return firstDot > 0 ? assemblyName[..firstDot] : null; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Helpers/PackageUrlBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Helpers/PackageUrlBuilder.cs index 36ee49c26..2babb52f0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Helpers/PackageUrlBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Helpers/PackageUrlBuilder.cs @@ -79,6 +79,69 @@ public static class PackageUrlBuilder return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}"; } + /// + /// Builds a PURL for a Windows MSI package. + /// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode} + /// + public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(productName); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-'); + var builder = new StringBuilder(); + builder.Append("pkg:generic/windows-msi/"); + builder.Append(Escape(normalizedName)); + builder.Append('@'); + builder.Append(Escape(version)); + + if (!string.IsNullOrWhiteSpace(upgradeCode)) + { + builder.Append("?upgrade_code="); + builder.Append(EscapeQuery(upgradeCode)); + } + + return builder.ToString(); + } + + /// + /// Builds a PURL for a Windows WinSxS assembly. + /// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch} + /// + public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + var normalizedName = assemblyName.Trim().ToLowerInvariant(); + var builder = new StringBuilder(); + builder.Append("pkg:generic/windows-winsxs/"); + builder.Append(Escape(normalizedName)); + builder.Append('@'); + builder.Append(Escape(version)); + + if (!string.IsNullOrWhiteSpace(architecture)) + { + builder.Append("?arch="); + builder.Append(EscapeQuery(architecture)); + } + + return builder.ToString(); + } + + /// + /// Builds a PURL for a Windows Chocolatey package. + /// Format: pkg:chocolatey/{packageId}@{version} + /// + public static string BuildChocolatey(string packageId, string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + var normalizedId = packageId.Trim().ToLowerInvariant(); + return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}"; + } + private static string Escape(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Model/PackageEvidenceSource.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Model/PackageEvidenceSource.cs index aa1959e44..5ab457d73 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Model/PackageEvidenceSource.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS/Model/PackageEvidenceSource.cs @@ -9,4 +9,7 @@ public enum PackageEvidenceSource HomebrewCellar, PkgutilReceipt, MacOsBundle, + WindowsMsi, + WindowsWinSxS, + WindowsChocolatey, } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyAnalyzerPluginTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyAnalyzerPluginTests.cs new file mode 100644 index 000000000..fe83a8165 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyAnalyzerPluginTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests; + +public class ChocolateyAnalyzerPluginTests +{ + [Fact] + public void Name_ReturnsCorrectPluginName() + { + // Arrange + var plugin = new ChocolateyAnalyzerPlugin(); + + // Act + var name = plugin.Name; + + // Assert + Assert.Equal("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey", name); + } + + [Fact] + public void IsAvailable_WithValidServiceProvider_ReturnsTrue() + { + // Arrange + var plugin = new ChocolateyAnalyzerPlugin(); + var services = new ServiceCollection() + .AddSingleton() + .BuildServiceProvider(); + + // Act + var result = plugin.IsAvailable(services); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsAvailable_WithNullServiceProvider_ReturnsFalse() + { + // Arrange + var plugin = new ChocolateyAnalyzerPlugin(); + + // Act + var result = plugin.IsAvailable(null!); + + // Assert + Assert.False(result); + } + + [Fact] + public void CreateAnalyzer_WithValidServiceProvider_ReturnsAnalyzer() + { + // Arrange + var plugin = new ChocolateyAnalyzerPlugin(); + var services = new ServiceCollection() + .AddSingleton() + .BuildServiceProvider(); + + // Act + var analyzer = plugin.CreateAnalyzer(services); + + // Assert + Assert.NotNull(analyzer); + Assert.IsType(analyzer); + Assert.Equal("windows-chocolatey", analyzer.AnalyzerId); + } + + [Fact] + public void CreateAnalyzer_WithNullServiceProvider_ThrowsArgumentNullException() + { + // Arrange + var plugin = new ChocolateyAnalyzerPlugin(); + + // Act & Assert + Assert.Throws(() => plugin.CreateAnalyzer(null!)); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyPackageAnalyzerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyPackageAnalyzerTests.cs new file mode 100644 index 000000000..1c8c7c363 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/ChocolateyPackageAnalyzerTests.cs @@ -0,0 +1,501 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests; + +public class ChocolateyPackageAnalyzerTests +{ + private readonly ChocolateyPackageAnalyzer _analyzer; + private readonly ILogger _logger; + + public ChocolateyPackageAnalyzerTests() + { + _logger = NullLoggerFactory.Instance.CreateLogger(); + _analyzer = new ChocolateyPackageAnalyzer((ILogger)_logger); + } + + private OSPackageAnalyzerContext CreateContext(string rootPath) + { + return new OSPackageAnalyzerContext( + rootPath, + workspacePath: null, + TimeProvider.System, + _logger); + } + + [Fact] + public void AnalyzerId_ReturnsCorrectValue() + { + Assert.Equal("windows-chocolatey", _analyzer.AnalyzerId); + } + + [Fact] + public async Task AnalyzeAsync_WithNoChocolateyDirectory_ReturnsEmptyList() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Packages); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithEmptyChocolateyLib_ReturnsEmptyList() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + Directory.CreateDirectory(chocoLib); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Packages); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithNuspecFile_ReturnsPackageRecord() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "git.2.42.0"); + Directory.CreateDirectory(packageDir); + + CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + Assert.Equal("Git", record.Name); + Assert.Equal("2.42.0", record.Version); + Assert.Equal("pkg:chocolatey/git@2.42.0", record.PackageUrl); + Assert.Equal(PackageEvidenceSource.WindowsChocolatey, record.EvidenceSource); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithMultiplePackages_ReturnsAllRecords() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + + var package1Dir = Path.Combine(chocoLib, "git.2.42.0"); + var package2Dir = Path.Combine(chocoLib, "nodejs.20.10.0"); + var package3Dir = Path.Combine(chocoLib, "7zip.23.01"); + + Directory.CreateDirectory(package1Dir); + Directory.CreateDirectory(package2Dir); + Directory.CreateDirectory(package3Dir); + + CreateNuspecFile(package1Dir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows"); + CreateNuspecFile(package2Dir, "nodejs", "20.10.0", "Node.js", "Node.js Foundation", "Node.js runtime"); + CreateNuspecFile(package3Dir, "7zip", "23.01", "7-Zip", "Igor Pavlov", "File archiver"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Equal(3, result.Packages.Count); + + var git = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("git")); + Assert.NotNull(git); + Assert.Equal("2.42.0", git.Version); + + var node = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("nodejs")); + Assert.NotNull(node); + Assert.Equal("20.10.0", node.Version); + + var sevenZip = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("7zip")); + Assert.NotNull(sevenZip); + Assert.Equal("23.01", sevenZip.Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ExtractsVendorMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "vscode.1.85.0"); + Directory.CreateDirectory(packageDir); + + CreateNuspecFile(packageDir, "vscode", "1.85.0", "Visual Studio Code", + "Microsoft", "Visual Studio Code editor", + "https://code.visualstudio.com", + "https://code.visualstudio.com/license"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + Assert.Equal("vscode", record.VendorMetadata["choco:id"]); + Assert.Equal("1.85.0", record.VendorMetadata["choco:version"]); + Assert.Equal("Visual Studio Code", record.VendorMetadata["choco:title"]); + Assert.Equal("Microsoft", record.VendorMetadata["choco:authors"]); + Assert.Equal("https://code.visualstudio.com", record.VendorMetadata["choco:project_url"]); + Assert.Equal("https://code.visualstudio.com/license", record.VendorMetadata["choco:license_url"]); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithInstallScript_ComputesHash() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "git.2.42.0"); + var toolsDir = Path.Combine(packageDir, "tools"); + Directory.CreateDirectory(toolsDir); + + CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows"); + File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing Git'"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + Assert.True(record.VendorMetadata.ContainsKey("choco:install_script_hash")); + Assert.StartsWith("sha256:", record.VendorMetadata["choco:install_script_hash"]); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_FallsBackToDirectoryParsing_WhenNoNuspec() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "python.3.12.0"); + Directory.CreateDirectory(packageDir); + + // Create a file but no nuspec + File.WriteAllText(Path.Combine(packageDir, "dummy.txt"), "placeholder"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + Assert.Equal("python", record.Name); + Assert.Equal("3.12.0", record.Version); + Assert.Equal("pkg:chocolatey/python@3.12.0", record.PackageUrl); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_IncludesFileEvidence() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "git.2.42.0"); + var toolsDir = Path.Combine(packageDir, "tools"); + Directory.CreateDirectory(toolsDir); + + CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows"); + File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'"); + File.WriteAllText(Path.Combine(toolsDir, "helper.bat"), "@echo off"); + File.WriteAllText(Path.Combine(packageDir, "config.json"), "{}"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + // Should include key files (ps1, bat, json, nuspec) + Assert.Contains(record.Files, f => f.Path.EndsWith(".ps1")); + Assert.Contains(record.Files, f => f.Path.EndsWith(".bat")); + Assert.Contains(record.Files, f => f.Path.EndsWith(".json")); + Assert.Contains(record.Files, f => f.Path.EndsWith(".nuspec")); + + // Config file should be marked as config + var configFile = record.Files.FirstOrDefault(f => f.Path.EndsWith(".json")); + Assert.NotNull(configFile); + Assert.True(configFile.IsConfigFile); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ResultsAreSortedDeterministically() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + + // Create packages in random order + var zetaDir = Path.Combine(chocoLib, "zeta.1.0.0"); + var alphaDir = Path.Combine(chocoLib, "alpha.1.0.0"); + var midDir = Path.Combine(chocoLib, "mid.1.0.0"); + + Directory.CreateDirectory(zetaDir); + Directory.CreateDirectory(alphaDir); + Directory.CreateDirectory(midDir); + + CreateNuspecFile(zetaDir, "zeta", "1.0.0", "Zeta", "Author", "Zeta package"); + CreateNuspecFile(alphaDir, "alpha", "1.0.0", "Alpha", "Author", "Alpha package"); + CreateNuspecFile(midDir, "mid", "1.0.0", "Mid", "Author", "Mid package"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Results should be sorted by PURL + Assert.Equal(3, result.Packages.Count); + Assert.Equal("Alpha", result.Packages[0].Name); + Assert.Equal("Mid", result.Packages[1].Name); + Assert.Equal("Zeta", result.Packages[2].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_SkipsHiddenDirectories() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + + var validDir = Path.Combine(chocoLib, "git.2.42.0"); + var hiddenDir = Path.Combine(chocoLib, ".hidden"); + + Directory.CreateDirectory(validDir); + Directory.CreateDirectory(hiddenDir); + + CreateNuspecFile(validDir, "git", "2.42.0", "Git", "Author", "Git"); + CreateNuspecFile(hiddenDir, "hidden", "1.0.0", "Hidden", "Author", "Hidden package"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Only valid package should be returned + Assert.Single(result.Packages); + Assert.Equal("Git", result.Packages[0].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_HandlesLowerCaseChocolateyPath() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "chocolatey", "lib"); // lowercase + var packageDir = Path.Combine(chocoLib, "git.2.42.0"); + Directory.CreateDirectory(packageDir); + + CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + Assert.Equal("Git", result.Packages[0].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_TruncatesLongDescription() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "longdesc.1.0.0"); + Directory.CreateDirectory(packageDir); + + var longDescription = new string('A', 500); + CreateNuspecFile(packageDir, "longdesc", "1.0.0", "LongDesc", "Author", longDescription); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + var description = record.VendorMetadata["choco:description"]; + Assert.True(description!.Length <= 200); + Assert.EndsWith("...", description); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithCancellation_ThrowsOperationCanceledException() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib"); + var packageDir = Path.Combine(chocoLib, "git.2.42.0"); + Directory.CreateDirectory(packageDir); + + CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + try + { + var context = CreateContext(tempDir); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await _analyzer.AnalyzeAsync(context, cts.Token)); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + private static void CreateNuspecFile( + string packageDir, + string id, + string version, + string title, + string authors, + string description, + string? projectUrl = null, + string? licenseUrl = null) + { + var nuspecPath = Path.Combine(packageDir, $"{id}.nuspec"); + + var projectUrlElement = projectUrl is not null ? $"{projectUrl}" : ""; + var licenseUrlElement = licenseUrl is not null ? $"{licenseUrl}" : ""; + + var content = $@" + + + {id} + {version} + {title} + {authors} + {description} + {projectUrlElement} + {licenseUrlElement} + +"; + + File.WriteAllText(nuspecPath, content); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/NuspecParserTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/NuspecParserTests.cs new file mode 100644 index 000000000..12bf3f85c --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/NuspecParserTests.cs @@ -0,0 +1,408 @@ +using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests; + +public class NuspecParserTests +{ + private readonly NuspecParser _parser = new(); + + [Fact] + public void Parse_WithValidNuspec_ReturnsMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + Git + Git Authors + Git for Windows + https://git-scm.com + https://opensource.org/licenses/MIT + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.Equal("git", result.Id); + Assert.Equal("2.42.0", result.Version); + Assert.Equal("Git", result.Title); + Assert.Equal("Git Authors", result.Authors); + Assert.Equal("Git for Windows", result.Description); + Assert.Equal("https://git-scm.com", result.ProjectUrl); + Assert.Equal("https://opensource.org/licenses/MIT", result.LicenseUrl); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithOldNamespace_ReturnsMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.Equal("git", result.Id); + Assert.Equal("2.42.0", result.Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithOld2011Namespace_ReturnsMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.Equal("git", result.Id); + Assert.Equal("2.42.0", result.Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithNoNamespace_ReturnsMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.Equal("git", result.Id); + Assert.Equal("2.42.0", result.Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithMissingId_ReturnsNull() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "invalid.nuspec"); + File.WriteAllText(nuspecPath, @" + + + 1.0.0 + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.Null(result); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithMissingVersion_ReturnsNull() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "invalid.nuspec"); + File.WriteAllText(nuspecPath, @" + + + test + +"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.Null(result); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithInvalidXml_ReturnsNull() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "invalid.nuspec"); + File.WriteAllText(nuspecPath, "not valid xml"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.Null(result); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_WithNonExistentFile_ReturnsNull() + { + // Act + var result = _parser.Parse("/nonexistent/path/file.nuspec", "/nonexistent"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithNullPath_ReturnsNull() + { + // Act + var result = _parser.Parse(null!, "/some/path"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithEmptyPath_ReturnsNull() + { + // Act + var result = _parser.Parse("", "/some/path"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithWhitespacePath_ReturnsNull() + { + // Act + var result = _parser.Parse(" ", "/some/path"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_ComputesInstallScriptHash_FromToolsDirectory() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + var toolsDir = Path.Combine(tempDir, "tools"); + Directory.CreateDirectory(toolsDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.InstallScriptHash); + Assert.StartsWith("sha256:", result.InstallScriptHash); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_ComputesInstallScriptHash_FromRootDirectory() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + File.WriteAllText(Path.Combine(tempDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.InstallScriptHash); + Assert.StartsWith("sha256:", result.InstallScriptHash); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void Parse_EnumeratesInstalledFiles() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "nuspec-parser-test-" + Guid.NewGuid().ToString("N")[..8]); + var toolsDir = Path.Combine(tempDir, "tools"); + Directory.CreateDirectory(toolsDir); + + var nuspecPath = Path.Combine(tempDir, "git.nuspec"); + File.WriteAllText(nuspecPath, @" + + + git + 2.42.0 + +"); + + File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'"); + File.WriteAllText(Path.Combine(tempDir, "config.json"), "{}"); + + try + { + // Act + var result = _parser.Parse(nuspecPath, tempDir); + + // Assert + Assert.NotNull(result); + Assert.Contains(result.InstalledFiles, f => f.Contains("chocolateyinstall.ps1")); + Assert.Contains(result.InstalledFiles, f => f.Contains("config.json")); + Assert.Contains(result.InstalledFiles, f => f.Contains("git.nuspec")); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Theory] + [InlineData("git.2.42.0", "git", "2.42.0")] + [InlineData("nodejs.20.10.0", "nodejs", "20.10.0")] + [InlineData("7zip.23.01", "7zip", "23.01")] + [InlineData("python.3.12.0", "python", "3.12.0")] + [InlineData("dotnet-sdk.8.0.100-rc.1", "dotnet-sdk", "8.0.100-rc.1")] + [InlineData("Microsoft.WindowsTerminal.1.18.3181.0", "Microsoft.WindowsTerminal", "1.18.3181.0")] + public void ParsePackageDirectory_WithValidFormat_ReturnsIdAndVersion(string dirName, string expectedId, string expectedVersion) + { + // Act + var result = NuspecParser.ParsePackageDirectory(dirName); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedId, result.Value.Id); + Assert.Equal(expectedVersion, result.Value.Version); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("git")] + [InlineData("no-version-here")] + public void ParsePackageDirectory_WithInvalidFormat_ReturnsNull(string? dirName) + { + // Act + var result = NuspecParser.ParsePackageDirectory(dirName!); + + // Assert + Assert.Null(result); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests.csproj b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests.csproj new file mode 100644 index 000000000..540844f65 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + preview + enable + enable + true + false + + + + + + + + + + + + + + + + + + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiDatabaseParserTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiDatabaseParserTests.cs new file mode 100644 index 000000000..7147087e1 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiDatabaseParserTests.cs @@ -0,0 +1,241 @@ +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests; + +public class MsiDatabaseParserTests +{ + private readonly MsiDatabaseParser _parser = new(); + + [Fact] + public void Parse_WithValidMsiFile_ExtractsMetadata() + { + // Arrange - Create a minimal valid OLE compound document + var tempFile = CreateMinimalMsiFile("TestProduct-1.2.3.msi"); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("TestProduct", result.ProductName); + Assert.Equal("1.2.3", result.ProductVersion); + Assert.Equal(tempFile, result.FilePath); + Assert.True(result.FileSize > 0); + Assert.StartsWith("sha256:", result.FileHash); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void Parse_WithVersionedFilename_ExtractsVersionFromName() + { + // Arrange + var tempFile = CreateMinimalMsiFile("SomeProduct_v2.0.1.msi"); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("SomeProduct", result.ProductName); + Assert.Equal("2.0.1", result.ProductVersion); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void Parse_WithSpaceVersionedFilename_ExtractsVersionFromName() + { + // Arrange + var tempFile = CreateMinimalMsiFile("Application Setup 3.0.0.msi"); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("Application Setup", result.ProductName); + Assert.Equal("3.0.0", result.ProductVersion); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void Parse_WithUnversionedFilename_UsesDefaultVersion() + { + // Arrange + var tempFile = CreateMinimalMsiFile("SimpleInstaller.msi"); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("SimpleInstaller", result.ProductName); + Assert.Equal("0.0.0", result.ProductVersion); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void Parse_WithNonExistentFile_ReturnsNull() + { + // Act + var result = _parser.Parse("/nonexistent/path/file.msi", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithInvalidMsiFile_ReturnsNull() + { + // Arrange - Create a file with invalid content + var tempFile = Path.GetTempFileName(); + File.WriteAllText(tempFile, "Not an MSI file"); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.Null(result); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void Parse_WithEmptyPath_ReturnsNull() + { + // Act + var result = _parser.Parse(string.Empty, CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithNullPath_ReturnsNull() + { + // Act + var result = _parser.Parse(null!, CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("Product-1.0.msi", "Product", "1.0")] + [InlineData("Product-1.0.0.msi", "Product", "1.0.0")] + [InlineData("Product-1.0.0.1.msi", "Product", "1.0.0.1")] + [InlineData("My-Product_v5.2.msi", "My-Product", "5.2")] + [InlineData("App 10.0.msi", "App", "10.0")] + public void Parse_WithVariousFilenamePatterns_ExtractsCorrectNameAndVersion( + string filename, string expectedName, string expectedVersion) + { + // Arrange + var tempFile = CreateMinimalMsiFile(filename); + + try + { + // Act + var result = _parser.Parse(tempFile, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedName, result.ProductName); + Assert.Equal(expectedVersion, result.ProductVersion); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Creates a minimal valid OLE compound document (MSI) file for testing. + /// + private static string CreateMinimalMsiFile(string filename) + { + var tempDir = Path.Combine(Path.GetTempPath(), "msi-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + var filePath = Path.Combine(tempDir, filename); + + // OLE compound document header (512 bytes minimum) + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/ + var header = new byte[512]; + + // Magic number: D0 CF 11 E0 A1 B1 1A E1 + header[0] = 0xD0; + header[1] = 0xCF; + header[2] = 0x11; + header[3] = 0xE0; + header[4] = 0xA1; + header[5] = 0xB1; + header[6] = 0x1A; + header[7] = 0xE1; + + // Minor version (typically 0x003E) + header[0x18] = 0x3E; + header[0x19] = 0x00; + + // Major version (3 for sector size 512, 4 for sector size 4096) + header[0x1A] = 0x03; + header[0x1B] = 0x00; + + // Byte order (0xFFFE = little endian) + header[0x1C] = 0xFE; + header[0x1D] = 0xFF; + + // Sector size power (9 = 512 bytes) + header[0x1E] = 0x09; + header[0x1F] = 0x00; + + // Mini sector size power (6 = 64 bytes) + header[0x20] = 0x06; + header[0x21] = 0x00; + + File.WriteAllBytes(filePath, header); + return filePath; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiPackageAnalyzerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiPackageAnalyzerTests.cs new file mode 100644 index 000000000..5cb832070 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/MsiPackageAnalyzerTests.cs @@ -0,0 +1,324 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Windows.Msi; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests; + +public class MsiPackageAnalyzerTests +{ + private readonly MsiPackageAnalyzer _analyzer; + private readonly ILogger _logger; + + public MsiPackageAnalyzerTests() + { + _logger = NullLoggerFactory.Instance.CreateLogger(); + _analyzer = new MsiPackageAnalyzer((ILogger)_logger); + } + + private OSPackageAnalyzerContext CreateContext(string rootPath) + { + return new OSPackageAnalyzerContext( + rootPath, + workspacePath: null, + TimeProvider.System, + _logger); + } + + [Fact] + public void AnalyzerId_ReturnsCorrectValue() + { + Assert.Equal("windows-msi", _analyzer.AnalyzerId); + } + + [Fact] + public async Task AnalyzeAsync_WithNoMsiDirectory_ReturnsEmptyList() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Packages); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithMsiFiles_ReturnsPackageRecords() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var installerDir = Path.Combine(tempDir, "Windows", "Installer"); + Directory.CreateDirectory(installerDir); + + // Create test MSI files + CreateMinimalMsiFile(Path.Combine(installerDir, "TestApp-1.0.0.msi")); + CreateMinimalMsiFile(Path.Combine(installerDir, "AnotherApp-2.5.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Equal(2, result.Packages.Count); + + var testApp = result.Packages.FirstOrDefault(r => r.Name == "TestApp"); + Assert.NotNull(testApp); + Assert.Equal("1.0.0", testApp.Version); + Assert.Equal(PackageEvidenceSource.WindowsMsi, testApp.EvidenceSource); + Assert.StartsWith("pkg:generic/windows-msi/testapp@1.0.0", testApp.PackageUrl); + Assert.True(testApp.VendorMetadata.ContainsKey("msi:file_path")); + + var anotherApp = result.Packages.FirstOrDefault(r => r.Name == "AnotherApp"); + Assert.NotNull(anotherApp); + Assert.Equal("2.5.0", anotherApp.Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithNestedMsiFiles_DiscoversMsisRecursively() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var cacheDir = Path.Combine(tempDir, "ProgramData", "Package Cache", "subfolder"); + Directory.CreateDirectory(cacheDir); + + CreateMinimalMsiFile(Path.Combine(cacheDir, "NestedApp-3.0.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + Assert.Equal("NestedApp", result.Packages[0].Name); + Assert.Equal("3.0.0", result.Packages[0].Version); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithUserAppDataCache_ScansMsisInUserDirectories() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var userCacheDir = Path.Combine(tempDir, "Users", "testuser", "AppData", "Local", "Package Cache"); + Directory.CreateDirectory(userCacheDir); + + CreateMinimalMsiFile(Path.Combine(userCacheDir, "UserApp-1.0.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + Assert.Equal("UserApp", result.Packages[0].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithInvalidMsiFile_SkipsInvalidFile() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var installerDir = Path.Combine(tempDir, "Windows", "Installer"); + Directory.CreateDirectory(installerDir); + + // Create invalid MSI file + File.WriteAllText(Path.Combine(installerDir, "Invalid.msi"), "Not an MSI"); + + // Create valid MSI file + CreateMinimalMsiFile(Path.Combine(installerDir, "Valid-1.0.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Only valid MSI should be returned + Assert.Single(result.Packages); + Assert.Equal("Valid", result.Packages[0].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ResultsAreSortedDeterministically() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var installerDir = Path.Combine(tempDir, "Windows", "Installer"); + Directory.CreateDirectory(installerDir); + + // Create MSI files in random order + CreateMinimalMsiFile(Path.Combine(installerDir, "ZetaApp-1.0.0.msi")); + CreateMinimalMsiFile(Path.Combine(installerDir, "AlphaApp-1.0.0.msi")); + CreateMinimalMsiFile(Path.Combine(installerDir, "MidApp-1.0.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Results should be sorted + Assert.Equal(3, result.Packages.Count); + Assert.Equal("AlphaApp", result.Packages[0].Name); + Assert.Equal("MidApp", result.Packages[1].Name); + Assert.Equal("ZetaApp", result.Packages[2].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithDuplicateMsiFiles_DeduplicatesByPath() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var installerDir = Path.Combine(tempDir, "Windows", "Installer"); + Directory.CreateDirectory(installerDir); + + var msiPath = Path.Combine(installerDir, "TestApp-1.0.0.msi"); + CreateMinimalMsiFile(msiPath); + + try + { + var context = CreateContext(tempDir); + + // Act - Execute twice to ensure no duplicates + var result1 = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + var result2 = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result1.Packages); + Assert.Single(result2.Packages); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_SetsCorrectFileEvidence() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "msi-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var installerDir = Path.Combine(tempDir, "Windows", "Installer"); + Directory.CreateDirectory(installerDir); + + CreateMinimalMsiFile(Path.Combine(installerDir, "TestApp-1.0.0.msi")); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + Assert.Single(record.Files); + + var file = record.Files[0]; + Assert.Contains("TestApp-1.0.0.msi", file.Path); + Assert.NotNull(file.Sha256); + Assert.StartsWith("sha256:", file.Sha256); + Assert.True(file.SizeBytes > 0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Creates a minimal valid OLE compound document (MSI) file for testing. + /// + private static void CreateMinimalMsiFile(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // OLE compound document header (512 bytes minimum) + var header = new byte[512]; + + // Magic number: D0 CF 11 E0 A1 B1 1A E1 + header[0] = 0xD0; + header[1] = 0xCF; + header[2] = 0x11; + header[3] = 0xE0; + header[4] = 0xA1; + header[5] = 0xB1; + header[6] = 0x1A; + header[7] = 0xE1; + + // Minor version + header[0x18] = 0x3E; + header[0x19] = 0x00; + + // Major version + header[0x1A] = 0x03; + header[0x1B] = 0x00; + + // Byte order (little endian) + header[0x1C] = 0xFE; + header[0x1D] = 0xFF; + + // Sector size power + header[0x1E] = 0x09; + header[0x1F] = 0x00; + + // Mini sector size power + header[0x20] = 0x06; + header[0x21] = 0x00; + + File.WriteAllBytes(filePath, header); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests.csproj b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests.csproj new file mode 100644 index 000000000..63be9c185 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests/StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + preview + enable + enable + true + false + + + + + + + + + + + + + + + + + + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests.csproj b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests.csproj new file mode 100644 index 000000000..cac665d63 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + preview + enable + enable + true + false + + + + + + + + + + + + + + + + + + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSManifestParserTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSManifestParserTests.cs new file mode 100644 index 000000000..e95615532 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSManifestParserTests.cs @@ -0,0 +1,272 @@ +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests; + +public class WinSxSManifestParserTests +{ + private readonly WinSxSManifestParser _parser = new(); + + [Fact] + public void Parse_WithValidManifest_ExtractsMetadata() + { + // Arrange + var manifestPath = CreateTestManifest(@" + + + +"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("Microsoft.Windows.Common-Controls", result.Name); + Assert.Equal("6.0.0.0", result.Version); + Assert.Equal("x86", result.ProcessorArchitecture); + Assert.Equal("6595b64144ccf1df", result.PublicKeyToken); + Assert.Equal("*", result.Language); + Assert.Equal("win32", result.Type); + Assert.Single(result.Files); + Assert.Equal("comctl32.dll", result.Files[0].Name); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithAmd64Architecture_ExtractsCorrectly() + { + // Arrange + var manifestPath = CreateTestManifest(@" + + +"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("Microsoft.Windows.SystemCompatible", result.Name); + Assert.Equal("amd64", result.ProcessorArchitecture); + Assert.Equal("31bf3856ad364e35", result.PublicKeyToken); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithMultipleFiles_ExtractsAllFiles() + { + // Arrange + var manifestPath = CreateTestManifest(@" + + + + + +"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Files.Count); + Assert.Contains(result.Files, f => f.Name == "file1.dll"); + Assert.Contains(result.Files, f => f.Name == "file2.dll"); + Assert.Contains(result.Files, f => f.Name == "file3.config"); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithKbReferenceInFilename_ExtractsKbReference() + { + // Arrange + var manifestPath = CreateTestManifestWithName( + "amd64_microsoft-windows-security_31bf3856ad364e35_10.0.19041.4170_kb5034441.manifest", + @" + + +"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal("KB5034441", result.KbReference); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithNonExistentFile_ReturnsNull() + { + // Act + var result = _parser.Parse("/nonexistent/path/manifest.manifest", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Parse_WithInvalidXml_ReturnsNull() + { + // Arrange + var manifestPath = CreateTestManifest("not valid xml content"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.Null(result); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithMissingAssemblyIdentity_ReturnsNull() + { + // Arrange + var manifestPath = CreateTestManifest(@" + + +"); + + try + { + // Act + var result = _parser.Parse(manifestPath, CancellationToken.None); + + // Assert + Assert.Null(result); + } + finally + { + CleanupTestManifest(manifestPath); + } + } + + [Fact] + public void Parse_WithEmptyPath_ReturnsNull() + { + // Act + var result = _parser.Parse(string.Empty, CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public void BuildAssemblyIdentityString_BuildsCorrectFormat() + { + // Arrange + var metadata = new WinSxSAssemblyMetadata( + Name: "Microsoft.Windows.Common-Controls", + Version: "6.0.0.0", + ProcessorArchitecture: "x86", + PublicKeyToken: "6595b64144ccf1df", + Language: "en-us", + Type: "win32", + VersionScope: null, + ManifestPath: "/test/manifest.manifest", + CatalogPath: null, + CatalogThumbprint: null, + KbReference: null, + Files: []); + + // Act + var result = WinSxSManifestParser.BuildAssemblyIdentityString(metadata); + + // Assert + Assert.Equal("microsoft.windows.common-controls_6.0.0.0_x86_6595b64144ccf1df_en-us", result); + } + + [Fact] + public void BuildAssemblyIdentityString_WithNeutralLanguage_OmitsLanguage() + { + // Arrange + var metadata = new WinSxSAssemblyMetadata( + Name: "TestAssembly", + Version: "1.0.0.0", + ProcessorArchitecture: "amd64", + PublicKeyToken: null, + Language: "*", + Type: null, + VersionScope: null, + ManifestPath: "/test/manifest.manifest", + CatalogPath: null, + CatalogThumbprint: null, + KbReference: null, + Files: []); + + // Act + var result = WinSxSManifestParser.BuildAssemblyIdentityString(metadata); + + // Assert + Assert.Equal("testassembly_1.0.0.0_amd64", result); + } + + private static string CreateTestManifest(string content) + { + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + var manifestPath = Path.Combine(tempDir, "test.manifest"); + File.WriteAllText(manifestPath, content); + return manifestPath; + } + + private static string CreateTestManifestWithName(string fileName, string content) + { + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + var manifestPath = Path.Combine(tempDir, fileName); + File.WriteAllText(manifestPath, content); + return manifestPath; + } + + private static void CleanupTestManifest(string manifestPath) + { + var directory = Path.GetDirectoryName(manifestPath); + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSPackageAnalyzerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSPackageAnalyzerTests.cs new file mode 100644 index 000000000..01cbbe548 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests/WinSxSPackageAnalyzerTests.cs @@ -0,0 +1,297 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Scanner.Analyzers.OS.Windows.WinSxS; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests; + +public class WinSxSPackageAnalyzerTests +{ + private readonly WinSxSPackageAnalyzer _analyzer; + private readonly ILogger _logger; + + public WinSxSPackageAnalyzerTests() + { + _logger = NullLoggerFactory.Instance.CreateLogger(); + _analyzer = new WinSxSPackageAnalyzer((ILogger)_logger); + } + + private OSPackageAnalyzerContext CreateContext(string rootPath) + { + return new OSPackageAnalyzerContext( + rootPath, + workspacePath: null, + TimeProvider.System, + _logger); + } + + [Fact] + public void AnalyzerId_ReturnsCorrectValue() + { + Assert.Equal("windows-winsxs", _analyzer.AnalyzerId); + } + + [Fact] + public async Task AnalyzeAsync_WithNoWinSxSDirectory_ReturnsEmptyList() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Packages); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithManifestFiles_ReturnsPackageRecords() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + // Create test manifest files + CreateTestManifest(manifestsDir, "amd64_test-assembly1_6.0.0.0.manifest", + "Test.Assembly1", "6.0.0.0", "amd64"); + CreateTestManifest(manifestsDir, "x86_test-assembly2_1.2.3.4.manifest", + "Test.Assembly2", "1.2.3.4", "x86"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Equal(2, result.Packages.Count); + + var assembly1 = result.Packages.FirstOrDefault(r => r.Name == "Test.Assembly1"); + Assert.NotNull(assembly1); + Assert.Equal("6.0.0.0", assembly1.Version); + Assert.Equal("amd64", assembly1.Architecture); + Assert.Equal(PackageEvidenceSource.WindowsWinSxS, assembly1.EvidenceSource); + Assert.StartsWith("pkg:generic/windows-winsxs/test.assembly1@6.0.0.0", assembly1.PackageUrl); + + var assembly2 = result.Packages.FirstOrDefault(r => r.Name == "Test.Assembly2"); + Assert.NotNull(assembly2); + Assert.Equal("1.2.3.4", assembly2.Version); + Assert.Equal("x86", assembly2.Architecture); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ExtractsVendorMetadata() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + CreateTestManifest(manifestsDir, "amd64_microsoft-test_6595b64144ccf1df_10.0.0.0_en-us.manifest", + "Microsoft.Test", "10.0.0.0", "amd64", "6595b64144ccf1df", "en-us", "win32"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + Assert.Equal("Microsoft.Test", record.VendorMetadata["winsxs:name"]); + Assert.Equal("10.0.0.0", record.VendorMetadata["winsxs:version"]); + Assert.Equal("amd64", record.VendorMetadata["winsxs:arch"]); + Assert.Equal("6595b64144ccf1df", record.VendorMetadata["winsxs:public_key_token"]); + Assert.Equal("en-us", record.VendorMetadata["winsxs:language"]); + Assert.Equal("win32", record.VendorMetadata["winsxs:type"]); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ExtractsPublisherFromAssemblyName() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + CreateTestManifest(manifestsDir, "amd64_microsoft-windows-component_1.0.0.0.manifest", + "Microsoft.Windows.Component", "1.0.0.0", "amd64"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + Assert.Equal("Microsoft", result.Packages[0].SourcePackage); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_IncludesFileEvidence() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + var manifestPath = Path.Combine(manifestsDir, "amd64_test-assembly_1.0.0.0.manifest"); + File.WriteAllText(manifestPath, @" + + + +"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert + Assert.Single(result.Packages); + var record = result.Packages[0]; + + // Should have manifest file + declared file + Assert.Equal(2, record.Files.Count); + Assert.Contains(record.Files, f => f.Path.Contains(".manifest")); + Assert.Contains(record.Files, f => f.Path.Contains("test.dll")); + + var dllFile = record.Files.First(f => f.Path.Contains("test.dll")); + Assert.Equal("sha256:abcdef123456", dllFile.Sha256); + Assert.Equal(54321L, dllFile.SizeBytes); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_WithInvalidManifest_SkipsAndContinues() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + // Create invalid manifest + File.WriteAllText(Path.Combine(manifestsDir, "invalid.manifest"), "not valid xml"); + + // Create valid manifest + CreateTestManifest(manifestsDir, "amd64_valid-assembly_1.0.0.0.manifest", + "Valid.Assembly", "1.0.0.0", "amd64"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Only valid manifest should be returned + Assert.Single(result.Packages); + Assert.Equal("Valid.Assembly", result.Packages[0].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task AnalyzeAsync_ResultsAreSortedDeterministically() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "winsxs-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]); + var manifestsDir = Path.Combine(tempDir, "Windows", "WinSxS", "Manifests"); + Directory.CreateDirectory(manifestsDir); + + // Create manifests in random order + CreateTestManifest(manifestsDir, "amd64_zeta-assembly_1.0.0.0.manifest", + "Zeta.Assembly", "1.0.0.0", "amd64"); + CreateTestManifest(manifestsDir, "amd64_alpha-assembly_1.0.0.0.manifest", + "Alpha.Assembly", "1.0.0.0", "amd64"); + CreateTestManifest(manifestsDir, "amd64_mid-assembly_1.0.0.0.manifest", + "Mid.Assembly", "1.0.0.0", "amd64"); + + try + { + var context = CreateContext(tempDir); + + // Act + var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None); + + // Assert - Results should be sorted + Assert.Equal(3, result.Packages.Count); + Assert.Equal("Alpha.Assembly", result.Packages[0].Name); + Assert.Equal("Mid.Assembly", result.Packages[1].Name); + Assert.Equal("Zeta.Assembly", result.Packages[2].Name); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + private static void CreateTestManifest( + string directory, + string fileName, + string assemblyName, + string version, + string architecture, + string? publicKeyToken = null, + string? language = null, + string? type = null) + { + var manifestPath = Path.Combine(directory, fileName); + + var publicKeyAttr = publicKeyToken is not null ? $@" publicKeyToken=""{publicKeyToken}""" : ""; + var languageAttr = language is not null ? $@" language=""{language}""" : ""; + var typeAttr = type is not null ? $@" type=""{type}""" : ""; + + var content = $@" + + +"; + + File.WriteAllText(manifestPath, content); + } +} diff --git a/src/StellaOps.sln b/src/StellaOps.sln index a9295b9e6..fd6e45ec3 100644 --- a/src/StellaOps.sln +++ b/src/StellaOps.sln @@ -481,6 +481,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests", "Scanner\__Tests\StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests\StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests.csproj", "{169D73D6-1630-4346-A80F-656399337D16}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.Msi", "Scanner\__Libraries\StellaOps.Scanner.Analyzers.OS.Windows.Msi\StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj", "{3CA082A6-60BB-4C56-9C6E-9B49066F72D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests", "Scanner\__Tests\StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests\StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests.csproj", "{3A696AE5-35E3-47A7-B598-AEB8C5FC5190}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.WinSxS", "Scanner\__Libraries\StellaOps.Scanner.Analyzers.OS.Windows.WinSxS\StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj", "{F7A33D8F-7186-4C41-9D0E-B225E6A836E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests", "Scanner\__Tests\StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests\StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests.csproj", "{7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey", "Scanner\__Libraries\StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey\StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj", "{0901C629-3AF2-4AFA-990F-C052E5FE6B34}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests", "Scanner\__Tests\StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests\StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests.csproj", "{93878579-93B4-4D31-A71A-FE33E2D180B8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3131,6 +3143,78 @@ Global {169D73D6-1630-4346-A80F-656399337D16}.Release|x64.Build.0 = Release|Any CPU {169D73D6-1630-4346-A80F-656399337D16}.Release|x86.ActiveCfg = Release|Any CPU {169D73D6-1630-4346-A80F-656399337D16}.Release|x86.Build.0 = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|x64.Build.0 = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Debug|x86.Build.0 = Debug|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|Any CPU.Build.0 = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|x64.ActiveCfg = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|x64.Build.0 = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|x86.ActiveCfg = Release|Any CPU + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9}.Release|x86.Build.0 = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|x64.Build.0 = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Debug|x86.Build.0 = Debug|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|Any CPU.Build.0 = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|x64.ActiveCfg = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|x64.Build.0 = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|x86.ActiveCfg = Release|Any CPU + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190}.Release|x86.Build.0 = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|x64.Build.0 = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Debug|x86.Build.0 = Debug|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|Any CPU.Build.0 = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|x64.ActiveCfg = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|x64.Build.0 = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|x86.ActiveCfg = Release|Any CPU + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5}.Release|x86.Build.0 = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|x64.Build.0 = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Debug|x86.Build.0 = Debug|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|Any CPU.Build.0 = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|x64.ActiveCfg = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|x64.Build.0 = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|x86.ActiveCfg = Release|Any CPU + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852}.Release|x86.Build.0 = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|x64.ActiveCfg = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|x64.Build.0 = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|x86.ActiveCfg = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Debug|x86.Build.0 = Debug|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|Any CPU.Build.0 = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|x64.ActiveCfg = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|x64.Build.0 = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|x86.ActiveCfg = Release|Any CPU + {0901C629-3AF2-4AFA-990F-C052E5FE6B34}.Release|x86.Build.0 = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|x64.Build.0 = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Debug|x86.Build.0 = Debug|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|Any CPU.Build.0 = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|x64.ActiveCfg = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|x64.Build.0 = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|x86.ActiveCfg = Release|Any CPU + {93878579-93B4-4D31-A71A-FE33E2D180B8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3285,5 +3369,11 @@ Global {94CF61AB-B671-4334-959F-6CF19B1A13C9} = {9E86431F-0E96-A7CC-FC1F-8519FE022244} {ECF79786-C3DC-423F-8ECC-82C528C56FAE} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543} {169D73D6-1630-4346-A80F-656399337D16} = {9E86431F-0E96-A7CC-FC1F-8519FE022244} + {3CA082A6-60BB-4C56-9C6E-9B49066F72D9} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543} + {3A696AE5-35E3-47A7-B598-AEB8C5FC5190} = {9E86431F-0E96-A7CC-FC1F-8519FE022244} + {F7A33D8F-7186-4C41-9D0E-B225E6A836E5} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543} + {7E17FB64-A0E6-43E9-B01F-B4BE22AE7852} = {9E86431F-0E96-A7CC-FC1F-8519FE022244} + {0901C629-3AF2-4AFA-990F-C052E5FE6B34} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543} + {93878579-93B4-4D31-A71A-FE33E2D180B8} = {9E86431F-0E96-A7CC-FC1F-8519FE022244} EndGlobalSection EndGlobal