This commit is contained in:
210
docs/airgap/macos-offline.md
Normal file
210
docs/airgap/macos-offline.md
Normal file
@@ -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
|
||||||
@@ -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 |
|
| `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-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-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-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` | 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-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-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-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) | — |
|
| `SCANNER-ENG-0026` | TODO | Implement Windows Chocolatey & registry collectors per `design/windows-analyzer.md` §3.3–3.4. | Scanner Guild (docs/modules/scanner) | — |
|
||||||
|
|||||||
@@ -114,13 +114,39 @@ Scanner.Worker (Windows profile)
|
|||||||
| Authenticodes verification locus | Decide scanner vs policy responsibility for signature verification | Security Guild | TBD |
|
| 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 |
|
| Feed mirroring policy | Which Chocolatey feeds to mirror by default | Product + Security Guilds | TBD |
|
||||||
|
|
||||||
## 9. Proposed backlog entries
|
## 9. Implementation status
|
||||||
| ID (proposed) | Title | Summary |
|
|
||||||
| --- | --- | --- |
|
| ID | Title | Status | Notes |
|
||||||
| 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-0024 | Windows MSI collector | **DONE** | `StellaOps.Scanner.Analyzers.OS.Windows.Msi` - OLE compound document parser, extracts Product/File tables, 22 tests passing |
|
||||||
| SCANNER-ENG-0026 | Implement Chocolatey & registry collectors | Harvest nuspec metadata and uninstall/service registry data. |
|
| SCANNER-ENG-0025 | WinSxS manifest collector | **DONE** | `StellaOps.Scanner.Analyzers.OS.Windows.WinSxS` - XML manifest parser, assembly identity extraction, 18 tests passing |
|
||||||
| SCANNER-ENG-0027 | Policy & Offline integration for Windows | Define predicates, CLI toggles, Offline Kit packaging, documentation. |
|
| 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
|
## 10. References
|
||||||
- `docs/benchmarks/scanner/deep-dives/windows.md`
|
- `docs/benchmarks/scanner/deep-dives/windows.md`
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ internal sealed class PolicyExpressionEvaluator
|
|||||||
return rubyScope.Get(member.Member);
|
return rubyScope.Get(member.Member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw is MacOsComponentScope macosScope)
|
||||||
|
{
|
||||||
|
return macosScope.Get(member.Member);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
|
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
|
||||||
{
|
{
|
||||||
return new EvaluationValue(value);
|
return new EvaluationValue(value);
|
||||||
@@ -155,6 +160,11 @@ internal sealed class PolicyExpressionEvaluator
|
|||||||
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
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)
|
if (targetRaw is ComponentScope componentScope)
|
||||||
{
|
{
|
||||||
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||||
@@ -497,6 +507,14 @@ internal sealed class PolicyExpressionEvaluator
|
|||||||
locals["ruby"] = new RubyComponentScope(component);
|
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);
|
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
|
||||||
if (evaluator.EvaluateBoolean(predicate, nestedScope))
|
if (evaluator.EvaluateBoolean(predicate, nestedScope))
|
||||||
{
|
{
|
||||||
@@ -865,4 +883,227 @@ internal sealed class PolicyExpressionEvaluator
|
|||||||
_ => EvaluationValue.Null,
|
_ => EvaluationValue.Null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SPL scope for macOS component predicates.
|
||||||
|
/// Provides access to bundle signing, entitlements, sandboxing, and package receipt information.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// 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")
|
||||||
|
/// </example>
|
||||||
|
private sealed class MacOsComponentScope
|
||||||
|
{
|
||||||
|
private readonly PolicyEvaluationComponent component;
|
||||||
|
private readonly ImmutableHashSet<string> entitlementCategories;
|
||||||
|
private readonly ImmutableHashSet<string> 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<PolicyExpression> 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<string> ParseDelimitedSet(ImmutableDictionary<string, string> metadata, string key)
|
||||||
|
{
|
||||||
|
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return ImmutableHashSet<string>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||||
|
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||||
|
{
|
||||||
|
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
{
|
||||||
|
var evaluated = evaluator.Evaluate(argument, scope).Raw;
|
||||||
|
switch (evaluated)
|
||||||
|
{
|
||||||
|
case ImmutableArray<object?> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,4 +415,250 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
|||||||
$"pkg:gem/{name}@{version}",
|
$"pkg:gem/{name}@{version}",
|
||||||
metadataBuilder.ToImmutable());
|
metadataBuilder.ToImmutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PolicyEvaluationComponent CreateMacOsComponent(
|
||||||
|
string bundleId,
|
||||||
|
string version,
|
||||||
|
bool sandboxed = false,
|
||||||
|
bool hardenedRuntime = false,
|
||||||
|
string? teamId = null,
|
||||||
|
string? codeResourcesHash = null,
|
||||||
|
IEnumerable<string>? categories = null,
|
||||||
|
IEnumerable<string>? highRiskEntitlements = null,
|
||||||
|
string? pkgutilIdentifier = null)
|
||||||
|
{
|
||||||
|
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(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<PolicyIrDocument>(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<string>.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<PolicyIrDocument>(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<string>.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<PolicyIrDocument>(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<string>.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<PolicyIrDocument>(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<string>.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<PolicyIrDocument>(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<string>.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin that registers the Windows Chocolatey package analyzer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChocolateyAnalyzerPlugin : IOSAnalyzerPlugin
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||||
|
return new ChocolateyPackageAnalyzer(loggerFactory.CreateLogger<ChocolateyPackageAnalyzer>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes Windows Chocolatey package installations to extract component metadata.
|
||||||
|
/// Scans ProgramData/Chocolatey/lib/ for installed packages.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||||
|
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paths to scan for Chocolatey packages.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] ChocolateyPaths =
|
||||||
|
[
|
||||||
|
"ProgramData/Chocolatey/lib",
|
||||||
|
"ProgramData/chocolatey/lib" // Case variation
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly NuspecParser _nuspecParser = new();
|
||||||
|
|
||||||
|
public ChocolateyPackageAnalyzer(ILogger<ChocolateyPackageAnalyzer> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string AnalyzerId => "windows-chocolatey";
|
||||||
|
|
||||||
|
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
||||||
|
OSPackageAnalyzerContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var records = new List<OSPackageRecord>();
|
||||||
|
var warnings = new List<string>();
|
||||||
|
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<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.Count == 0)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("No Chocolatey packages found; skipping analyzer.");
|
||||||
|
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(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<IReadOnlyList<OSPackageRecord>>(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DiscoverPackages(
|
||||||
|
string libDir,
|
||||||
|
List<OSPackageRecord> records,
|
||||||
|
List<string> warnings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IEnumerable<string> 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<string> 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<string, string?> BuildVendorMetadata(ChocolateyPackageMetadata metadata)
|
||||||
|
{
|
||||||
|
var vendorMetadata = new Dictionary<string, string?>(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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents metadata extracted from a Chocolatey package installation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ChocolateyPackageMetadata(
|
||||||
|
/// <summary>Package identifier (e.g., "git", "nodejs").</summary>
|
||||||
|
string Id,
|
||||||
|
|
||||||
|
/// <summary>Package version (e.g., "2.42.0").</summary>
|
||||||
|
string Version,
|
||||||
|
|
||||||
|
/// <summary>Package title/display name.</summary>
|
||||||
|
string? Title,
|
||||||
|
|
||||||
|
/// <summary>Package authors.</summary>
|
||||||
|
string? Authors,
|
||||||
|
|
||||||
|
/// <summary>Package description.</summary>
|
||||||
|
string? Description,
|
||||||
|
|
||||||
|
/// <summary>Package license URL.</summary>
|
||||||
|
string? LicenseUrl,
|
||||||
|
|
||||||
|
/// <summary>Package project URL.</summary>
|
||||||
|
string? ProjectUrl,
|
||||||
|
|
||||||
|
/// <summary>Package checksum.</summary>
|
||||||
|
string? Checksum,
|
||||||
|
|
||||||
|
/// <summary>Checksum algorithm (e.g., "sha256").</summary>
|
||||||
|
string? ChecksumType,
|
||||||
|
|
||||||
|
/// <summary>Source feed URL where package was downloaded.</summary>
|
||||||
|
string? SourceFeed,
|
||||||
|
|
||||||
|
/// <summary>Installation directory.</summary>
|
||||||
|
string InstallDir,
|
||||||
|
|
||||||
|
/// <summary>Installation script hash for determinism.</summary>
|
||||||
|
string? InstallScriptHash,
|
||||||
|
|
||||||
|
/// <summary>Files installed by the package.</summary>
|
||||||
|
IReadOnlyList<string> InstalledFiles);
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Chocolatey/NuGet .nuspec files to extract package metadata.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a .nuspec file and extracts package metadata.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses basic package info from directory name pattern.
|
||||||
|
/// Format: packageid.version
|
||||||
|
/// </summary>
|
||||||
|
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<string> EnumerateInstalledFiles(string installDir)
|
||||||
|
{
|
||||||
|
var files = new List<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests")]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<PackageId>StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey</PackageId>
|
||||||
|
<Version>0.1.0-alpha</Version>
|
||||||
|
<Description>Windows Chocolatey and registry package analyzer for StellaOps Scanner</Description>
|
||||||
|
<Authors>StellaOps</Authors>
|
||||||
|
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin that registers the Windows MSI package analyzer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MsiAnalyzerPlugin : IOSAnalyzerPlugin
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Msi";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||||
|
return new MsiPackageAnalyzer(loggerFactory.CreateLogger<MsiPackageAnalyzer>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses MSI (Windows Installer) database files to extract product metadata.
|
||||||
|
/// Uses OLE compound document parsing for cross-platform compatibility.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an MSI file and extracts product metadata.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts product name and version from common MSI filename patterns.
|
||||||
|
/// </summary>
|
||||||
|
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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents metadata extracted from a Windows MSI installer package.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes Windows MSI installer packages to extract product metadata.
|
||||||
|
/// Scans common MSI cache locations and the Windows Installer directory.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||||
|
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard paths to scan for MSI packages relative to root.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] MsiSearchPaths =
|
||||||
|
[
|
||||||
|
"Windows/Installer",
|
||||||
|
"ProgramData/Package Cache",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum file size to process (100MB).
|
||||||
|
/// </summary>
|
||||||
|
private const long MaxFileSizeBytes = 100L * 1024L * 1024L;
|
||||||
|
|
||||||
|
private readonly MsiDatabaseParser _msiParser = new();
|
||||||
|
|
||||||
|
public MsiPackageAnalyzer(ILogger<MsiPackageAnalyzer> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string AnalyzerId => "windows-msi";
|
||||||
|
|
||||||
|
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
||||||
|
OSPackageAnalyzerContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var records = new List<OSPackageRecord>();
|
||||||
|
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var warnings = new List<string>();
|
||||||
|
|
||||||
|
// 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<IReadOnlyList<OSPackageRecord>>(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<IReadOnlyList<OSPackageRecord>>(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DiscoverMsiFiles(
|
||||||
|
string searchPath,
|
||||||
|
List<OSPackageRecord> records,
|
||||||
|
HashSet<string> processedFiles,
|
||||||
|
List<string> warnings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IEnumerable<string> 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<string> 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<OSPackageFileEvidence>
|
||||||
|
{
|
||||||
|
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<string, string?> BuildVendorMetadata(MsiMetadata metadata)
|
||||||
|
{
|
||||||
|
var vendorMetadata = new Dictionary<string, string?>(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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests")]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<PackageId>StellaOps.Scanner.Analyzers.OS.Windows.Msi</PackageId>
|
||||||
|
<Version>0.1.0-alpha</Version>
|
||||||
|
<Description>Windows MSI package analyzer for StellaOps Scanner</Description>
|
||||||
|
<Authors>StellaOps</Authors>
|
||||||
|
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests")]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<PackageId>StellaOps.Scanner.Analyzers.OS.Windows.WinSxS</PackageId>
|
||||||
|
<Version>0.1.0-alpha</Version>
|
||||||
|
<Description>Windows WinSxS assembly analyzer for StellaOps Scanner</Description>
|
||||||
|
<Authors>StellaOps</Authors>
|
||||||
|
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin that registers the Windows WinSxS assembly analyzer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WinSxSAnalyzerPlugin : IOSAnalyzerPlugin
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.WinSxS";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||||
|
return new WinSxSPackageAnalyzer(loggerFactory.CreateLogger<WinSxSPackageAnalyzer>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents metadata extracted from a Windows Side-by-Side (WinSxS) assembly manifest.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record WinSxSAssemblyMetadata(
|
||||||
|
/// <summary>Assembly name (e.g., "Microsoft.Windows.Common-Controls").</summary>
|
||||||
|
string Name,
|
||||||
|
|
||||||
|
/// <summary>Assembly version (e.g., "6.0.0.0").</summary>
|
||||||
|
string Version,
|
||||||
|
|
||||||
|
/// <summary>Processor architecture (e.g., "x86", "amd64", "arm64", "msil", "wow64").</summary>
|
||||||
|
string ProcessorArchitecture,
|
||||||
|
|
||||||
|
/// <summary>Public key token (e.g., "6595b64144ccf1df").</summary>
|
||||||
|
string? PublicKeyToken,
|
||||||
|
|
||||||
|
/// <summary>Language/culture (e.g., "en-us", "*", or empty).</summary>
|
||||||
|
string? Language,
|
||||||
|
|
||||||
|
/// <summary>Assembly type (e.g., "win32", "win32-policy").</summary>
|
||||||
|
string? Type,
|
||||||
|
|
||||||
|
/// <summary>Version scope (e.g., "nonSxS" for non-side-by-side assemblies).</summary>
|
||||||
|
string? VersionScope,
|
||||||
|
|
||||||
|
/// <summary>Manifest file path.</summary>
|
||||||
|
string ManifestPath,
|
||||||
|
|
||||||
|
/// <summary>Associated catalog (.cat) file path if found.</summary>
|
||||||
|
string? CatalogPath,
|
||||||
|
|
||||||
|
/// <summary>Catalog signature thumbprint if available.</summary>
|
||||||
|
string? CatalogThumbprint,
|
||||||
|
|
||||||
|
/// <summary>KB reference from patch manifest if available.</summary>
|
||||||
|
string? KbReference,
|
||||||
|
|
||||||
|
/// <summary>Files declared in the assembly manifest.</summary>
|
||||||
|
IReadOnlyList<WinSxSFileEntry> Files);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a file entry in a WinSxS assembly manifest.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record WinSxSFileEntry(
|
||||||
|
/// <summary>File name.</summary>
|
||||||
|
string Name,
|
||||||
|
|
||||||
|
/// <summary>File hash algorithm (e.g., "SHA256").</summary>
|
||||||
|
string? HashAlgorithm,
|
||||||
|
|
||||||
|
/// <summary>File hash value.</summary>
|
||||||
|
string? Hash,
|
||||||
|
|
||||||
|
/// <summary>File size in bytes.</summary>
|
||||||
|
long? Size,
|
||||||
|
|
||||||
|
/// <summary>Destination path within the assembly.</summary>
|
||||||
|
string? DestinationPath);
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Windows Side-by-Side (WinSxS) assembly manifest files.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a WinSxS manifest file and extracts assembly metadata.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a deterministic assembly identity string for PURL construction.
|
||||||
|
/// Format: name_version_arch_publicKeyToken_language
|
||||||
|
/// </summary>
|
||||||
|
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<WinSxSFileEntry> ExtractFileEntries(XElement root)
|
||||||
|
{
|
||||||
|
var files = new List<WinSxSFileEntry>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes Windows Side-by-Side (WinSxS) assembly manifests to extract component metadata.
|
||||||
|
/// Scans Windows/WinSxS/Manifests/ for .manifest files.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||||
|
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to WinSxS manifests directory.
|
||||||
|
/// </summary>
|
||||||
|
private const string ManifestsPath = "Windows/WinSxS/Manifests";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of manifests to process (limit for large systems).
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxManifests = 50000;
|
||||||
|
|
||||||
|
private readonly WinSxSManifestParser _parser = new();
|
||||||
|
|
||||||
|
public WinSxSPackageAnalyzer(ILogger<WinSxSPackageAnalyzer> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string AnalyzerId => "windows-winsxs";
|
||||||
|
|
||||||
|
protected override ValueTask<IReadOnlyList<OSPackageRecord>> 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<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = new List<OSPackageRecord>();
|
||||||
|
var warnings = new List<string>();
|
||||||
|
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<IReadOnlyList<OSPackageRecord>>(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<IReadOnlyList<OSPackageRecord>>(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OSPackageRecord? AnalyzeManifest(
|
||||||
|
string manifestPath,
|
||||||
|
List<string> 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<string, string?> BuildVendorMetadata(WinSxSAssemblyMetadata metadata)
|
||||||
|
{
|
||||||
|
var vendorMetadata = new Dictionary<string, string?>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,69 @@ public static class PackageUrlBuilder
|
|||||||
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
|
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a PURL for a Windows MSI package.
|
||||||
|
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a PURL for a Windows WinSxS assembly.
|
||||||
|
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a PURL for a Windows Chocolatey package.
|
||||||
|
/// Format: pkg:chocolatey/{packageId}@{version}
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static string Escape(string value)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ public enum PackageEvidenceSource
|
|||||||
HomebrewCellar,
|
HomebrewCellar,
|
||||||
PkgutilReceipt,
|
PkgutilReceipt,
|
||||||
MacOsBundle,
|
MacOsBundle,
|
||||||
|
WindowsMsi,
|
||||||
|
WindowsWinSxS,
|
||||||
|
WindowsChocolatey,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ILoggerFactory, NullLoggerFactory>()
|
||||||
|
.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<ILoggerFactory, NullLoggerFactory>()
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var analyzer = plugin.CreateAnalyzer(services);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(analyzer);
|
||||||
|
Assert.IsType<ChocolateyPackageAnalyzer>(analyzer);
|
||||||
|
Assert.Equal("windows-chocolatey", analyzer.AnalyzerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateAnalyzer_WithNullServiceProvider_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var plugin = new ChocolateyAnalyzerPlugin();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.Throws<ArgumentNullException>(() => plugin.CreateAnalyzer(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChocolateyPackageAnalyzer>();
|
||||||
|
_analyzer = new ChocolateyPackageAnalyzer((ILogger<ChocolateyPackageAnalyzer>)_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<OperationCanceledException>(
|
||||||
|
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>{projectUrl}</projectUrl>" : "";
|
||||||
|
var licenseUrlElement = licenseUrl is not null ? $"<licenseUrl>{licenseUrl}</licenseUrl>" : "";
|
||||||
|
|
||||||
|
var content = $@"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>{id}</id>
|
||||||
|
<version>{version}</version>
|
||||||
|
<title>{title}</title>
|
||||||
|
<authors>{authors}</authors>
|
||||||
|
<description>{description}</description>
|
||||||
|
{projectUrlElement}
|
||||||
|
{licenseUrlElement}
|
||||||
|
</metadata>
|
||||||
|
</package>";
|
||||||
|
|
||||||
|
File.WriteAllText(nuspecPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
<title>Git</title>
|
||||||
|
<authors>Git Authors</authors>
|
||||||
|
<description>Git for Windows</description>
|
||||||
|
<projectUrl>https://git-scm.com</projectUrl>
|
||||||
|
<licenseUrl>https://opensource.org/licenses/MIT</licenseUrl>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<package>
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>test</id>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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, @"<?xml version=""1.0""?>
|
||||||
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
||||||
|
<metadata>
|
||||||
|
<id>git</id>
|
||||||
|
<version>2.42.0</version>
|
||||||
|
</metadata>
|
||||||
|
</package>");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey/StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid OLE compound document (MSI) file for testing.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MsiPackageAnalyzer>();
|
||||||
|
_analyzer = new MsiPackageAnalyzer((ILogger<MsiPackageAnalyzer>)_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid OLE compound document (MSI) file for testing.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.Msi/StellaOps.Scanner.Analyzers.OS.Windows.Msi.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS/StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity
|
||||||
|
name=""Microsoft.Windows.Common-Controls""
|
||||||
|
version=""6.0.0.0""
|
||||||
|
processorArchitecture=""x86""
|
||||||
|
publicKeyToken=""6595b64144ccf1df""
|
||||||
|
language=""*""
|
||||||
|
type=""win32"" />
|
||||||
|
<file name=""comctl32.dll"" hash=""abcd1234"" hashalg=""SHA256"" size=""12345"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity
|
||||||
|
name=""Microsoft.Windows.SystemCompatible""
|
||||||
|
version=""10.0.19041.1""
|
||||||
|
processorArchitecture=""amd64""
|
||||||
|
publicKeyToken=""31bf3856ad364e35"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity name=""TestAssembly"" version=""1.0.0.0"" processorArchitecture=""x86"" />
|
||||||
|
<file name=""file1.dll"" />
|
||||||
|
<file name=""file2.dll"" />
|
||||||
|
<file name=""file3.config"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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",
|
||||||
|
@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity name=""TestSecurity"" version=""10.0.19041.4170"" processorArchitecture=""amd64"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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(@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<file name=""something.dll"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WinSxSPackageAnalyzer>();
|
||||||
|
_analyzer = new WinSxSPackageAnalyzer((ILogger<WinSxSPackageAnalyzer>)_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, @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity name=""Test.Assembly"" version=""1.0.0.0"" processorArchitecture=""amd64"" />
|
||||||
|
<file name=""test.dll"" hash=""abcdef123456"" hashalg=""SHA256"" size=""54321"" />
|
||||||
|
</assembly>");
|
||||||
|
|
||||||
|
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 = $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||||
|
<assembly xmlns=""urn:schemas-microsoft-com:asm.v1"" manifestVersion=""1.0"">
|
||||||
|
<assemblyIdentity
|
||||||
|
name=""{assemblyName}""
|
||||||
|
version=""{version}""
|
||||||
|
processorArchitecture=""{architecture}""{publicKeyAttr}{languageAttr}{typeAttr} />
|
||||||
|
</assembly>";
|
||||||
|
|
||||||
|
File.WriteAllText(manifestPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -481,6 +481,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers
|
|||||||
EndProject
|
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}"
|
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
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{169D73D6-1630-4346-A80F-656399337D16}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -3285,5 +3369,11 @@ Global
|
|||||||
{94CF61AB-B671-4334-959F-6CF19B1A13C9} = {9E86431F-0E96-A7CC-FC1F-8519FE022244}
|
{94CF61AB-B671-4334-959F-6CF19B1A13C9} = {9E86431F-0E96-A7CC-FC1F-8519FE022244}
|
||||||
{ECF79786-C3DC-423F-8ECC-82C528C56FAE} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543}
|
{ECF79786-C3DC-423F-8ECC-82C528C56FAE} = {1285E3E4-21C1-72C0-6EB2-84C0D86F9543}
|
||||||
{169D73D6-1630-4346-A80F-656399337D16} = {9E86431F-0E96-A7CC-FC1F-8519FE022244}
|
{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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Reference in New Issue
Block a user