diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..6f1e0056
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# These inputs are pinned by exact byte hashes; Windows checkouts must keep LF.
+/config/security_rules.json text eol=lf
+/config/eid_subcategory_mapping.csv text eol=lf
+/config/rule_eligibility_manifest.json text eol=lf
diff --git a/.github/workflows/create-rule-meta.yml b/.github/workflows/create-rule-meta.yml
index fbd3f914..efe6939a 100644
--- a/.github/workflows/create-rule-meta.yml
+++ b/.github/workflows/create-rule-meta.yml
@@ -33,6 +33,10 @@ jobs:
- name: Run
run: cd wela-extractor && cargo run --release -- ../hayabusa-rules ../WELA/config/eid_subcategory_mapping.csv ../WELA/config/security_rules.json
+ - name: Record rule input revisions and hashes
+ shell: bash
+ run: python WELA/tools/update_rule_manifest.py --rules-commit "$(git -C hayabusa-rules rev-parse HEAD)" --generator-commit "$(git -C wela-extractor rev-parse HEAD)"
+
- name: Create Text
id: create-text
run: |
diff --git a/.github/workflows/rule-eligibility.yml b/.github/workflows/rule-eligibility.yml
new file mode 100644
index 00000000..c823c372
--- /dev/null
+++ b/.github/workflows/rule-eligibility.yml
@@ -0,0 +1,35 @@
+name: Native rule eligibility tests
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ eligibility:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Eligibility, output and native observations in powershell
+ shell: powershell
+ run: |
+ ./tests/RuleEligibility.Tests.ps1
+ ./tests/NativeProviders.Tests.ps1
+ ./tests/AuditProfileOutput.Tests.ps1
+ ./tests/DomainNtlmAuditOutput.Tests.ps1
+ ./WELA.ps1 rule-eligibility -ResultsPath "$env:RUNNER_TEMP/eligibility.json" -HtmlPath "$env:RUNNER_TEMP/eligibility.html"
+ $report = Get-Content "$env:RUNNER_TEMP/eligibility.json" -Raw | ConvertFrom-Json
+ if ($report.Summary.Ready -ne 0 -or -not $report.Corpus.Pinned) { throw 'Metadata-only CLI must not grant readiness.' }
+ ./tests/NativeProviders.Windows.Tests.ps1 -OutputDirectory "$env:RUNNER_TEMP/eligibility-native-observations"
+ - name: Eligibility, output and native observations in pwsh
+ shell: pwsh
+ run: |
+ ./tests/RuleEligibility.Tests.ps1
+ ./tests/NativeProviders.Tests.ps1
+ ./tests/AuditProfileOutput.Tests.ps1
+ ./tests/DomainNtlmAuditOutput.Tests.ps1
+ ./WELA.ps1 rule-eligibility -ResultsPath "$env:RUNNER_TEMP/eligibility.json" -HtmlPath "$env:RUNNER_TEMP/eligibility.html"
+ $report = Get-Content "$env:RUNNER_TEMP/eligibility.json" -Raw | ConvertFrom-Json
+ if ($report.Summary.Ready -ne 0 -or -not $report.Corpus.Pinned) { throw 'Metadata-only CLI must not grant readiness.' }
+ ./tests/NativeProviders.Windows.Tests.ps1 -OutputDirectory "$env:RUNNER_TEMP/eligibility-native-observations"
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index bed33de3..9167bedb 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- 読み取り専用の`rule-eligibility`を追加し、ルール・対応表のハッシュ、ルールごとの判定理由、対象外の理由、分子・分母を明示します。任意で取り込んだラボ資料を、対応範囲を限定した完全なルール定義、ネイティブXML、設定、収集・クエリ実行の証拠と照合し、未対応・未確認の項目はConditionalとします。監査のCSV/JSON/HTMLとNavigator出力では、設定が有効なだけでルールを利用可能と判定しません。取り込んだReady判定は記録された環境・時点に限られ、実環境での一連の検証を保証しません。 (#407) (@Shirofune-Security)
- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security)
- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ee80f7d..a7d062a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added read-only `rule-eligibility` reports with pinned corpus/mapping hashes, per-rule reasons, explicit scope exclusions and numerator/denominator totals. Optional imported lab artifacts are checked against a narrow complete-rule parser, native XML, configuration, ingestion and query evidence; unsupported or incomplete cases stay Conditional. Audit CSV/JSON/HTML and Navigator outputs no longer treat enabled settings as proven usable rules. Imported Ready results apply only to their recorded context/time; no live end-to-end validation is implied. (#407) (@Shirofune-Security)
- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security)
- Preserved LDAP 1644 diagnostics during normal configuration instead of automatically enabling Field Engineering level 5 on DCs. Added explicit `ldap-diagnostics` audit/plan/configure modes for preservation, tunable diagnostics and MDI legacy cleanup, with role/build checks, typed recovery snapshots, race guards, ordered readback and final drift checks. LDAP-only options are rejected before unrelated profile commands can run. Generated events, volume and forwarding remain isolated-DC validation. (#404) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index 4893a00a..e9f80d08 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -39,6 +39,9 @@
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
[string[]]$WmiNamespace,
[switch]$WmiIncludeChildren,
+ [string]$RuleEvidencePath,
+ [string]$RuleCorpusPath,
+ [string]$RuleManifestPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit',
[string]$TranscriptDirectory,
[ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit',
@@ -68,6 +71,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
+Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
@@ -643,6 +647,21 @@ function AuditLogSetting {
"Not configured", "Enable all (7) on domain controllers only", "",
"AuditNTLMInDomain; applicability is determined from Win32_OperatingSystem.ProductType."
)
+ # Policy/channel matches are configuration estimates, not executed rules.
+ # Imported lab Ready states are reviewed separately by rule-eligibility and
+ # never silently reused as evidence for this currently audited machine.
+ $eligibility = Get-WelaRuleEligibility -CorpusPath $script:SecurityRulesPath -Observations $auditResult
+ $eligibilityById = @{}
+ foreach ($entry in $eligibility.Results) { $eligibilityById[$entry.Id] = $entry }
+ foreach ($rule in $all_rules) {
+ $entry = $eligibilityById[$rule.id]
+ $rule | Add-Member NoteProperty ConfigurationEstimate ([bool]$rule.applicable) -Force
+ $rule | Add-Member NoteProperty IdealConfigurationEstimate ([bool]$rule.ideal) -Force
+ $rule | Add-Member NoteProperty EligibilityState $entry.State -Force
+ $rule | Add-Member NoteProperty EligibilityReasons ($entry.Reasons -join '; ') -Force
+ $rule.applicable = $entry.State -eq 'Ready'
+ $rule.ideal = $false # A future configuration plan is never execution evidence.
+ }
$auditResult | ForEach-Object { $_.CountByLevel() }
$auditResult | ForEach-Object {
@@ -665,6 +684,7 @@ function AuditLogSetting {
}
if ($outType -eq "std") {
+ Write-Host 'Configuration observations: category percentages below are policy-mapping estimates, not detection readiness.' -ForegroundColor DarkYellow
$auditResult | Group-Object -Property Category | ForEach-Object {
$notEnabled = @("No Auditing", "Disabled", "Unknown", "Conditional", "Not installed")
$summaryRows = @($_.Group | Where-Object { $_.CurrentSetting -ne 'Not applicable' })
@@ -732,16 +752,19 @@ function AuditLogSetting {
$auditCsv = Join-Path $script:ScriptRoot "WELA-Audit-Result.csv"
$usableCsv = Join-Path $script:ScriptRoot "UsableRules.csv"
$unusableCsv = Join-Path $script:ScriptRoot "UnusableRules.csv"
+ $eligibilityCsv = Join-Path $script:ScriptRoot "RuleEligibility.csv"
$currentJson = Join-Path $script:ScriptRoot "mitre-ttp-navigator-current.json"
$idealJson = Join-Path $script:ScriptRoot "mitre-ttp-navigator-ideal.json"
$auditResult | Select-Object -Property Category, SubCategory, RuleCount, RuleCountByLevel, DefaultSetting, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume, Note,
@{ Name = 'NativeSourceEvidence'; Expression = { if ($_.NativeSources.Count) { ConvertTo-Json -InputObject $_.NativeSources -Depth 12 -Compress } else { '' } } } |
Export-Csv -Path $auditCsv -NoTypeInformation
- $usableRules | Select-Object title, level, service, category, description, id | Export-Csv -Path $usableCsv -NoTypeInformation
- $unUsableRules | Select-Object title, level, service, category, description, id | Export-Csv -Path $unusableCsv -NoTypeInformation
+ $usableRules | Select-Object title, level, service, category, description, id, EligibilityState, EligibilityReasons | Export-Csv -Path $usableCsv -NoTypeInformation
+ $unUsableRules | Select-Object title, level, service, category, description, id, EligibilityState, EligibilityReasons | Export-Csv -Path $unusableCsv -NoTypeInformation
+ $eligibility.Results | Select-Object Id, Title, State, ScopeExclusion, ConfigurationEstimate, MetadataSha256,
+ @{Name='Reasons'; Expression={$_.Reasons -join '; '}} | Export-Csv -LiteralPath $eligibilityCsv -NoTypeInformation
if ($ResultsPath -or $HtmlPath) {
- Export-WelaAuditAssessment -Rows $auditResult -Rules @($uniqueRules) -Baseline $Baseline -ResultsPath $ResultsPath -HtmlPath $HtmlPath
+ Export-WelaAuditAssessment -Rows $auditResult -Rules @($uniqueRules) -Baseline $Baseline -ResultsPath $ResultsPath -HtmlPath $HtmlPath -Eligibility $eligibility
}
if ($outType -eq "gui") {
@@ -753,6 +776,7 @@ function AuditLogSetting {
Write-Output "Audit check result saved to: $auditCsv"
Write-Output "Usable detection rules list saved to: $usableCsv"
Write-Output "Unusable detection rules list saved to: $unusableCsv"
+ Write-Output "Per-rule readiness and reasons saved to: $eligibilityCsv"
if ($ResultsPath) { Write-Output "Audit assessment JSON saved to: $ResultsPath" }
if ($HtmlPath) { Write-Output "Audit assessment HTML saved to: $HtmlPath" }
if (@($auditResult | Where-Object { $_.NativeSources.Count -gt 0 }).Count) {
@@ -760,9 +784,9 @@ function AuditLogSetting {
}
Export-MitreHeatmap -sigmaRules $uniqueRules -OutputPath $currentJson
- Write-Output "MITRE ATT&CK Navigator data(based on current settings) saved to: $currentJson"
+ Write-Output "MITRE ATT&CK Navigator data (evidence-qualified Ready rules) saved to: $currentJson"
Export-MitreHeatmap -sigmaRules $uniqueRules -OutputPath $idealJson -UseIdealCount $true
- Write-Output "MITRE ATT&CK Navigator data(based on ideal settings) saved to: $idealJson"
+ Write-Output "MITRE ATT&CK Navigator ideal data (no readiness credit from configuration alone) saved to: $idealJson"
$totalRulesCount = @($uniqueRules).Count
$usableRulesCount = $usableRules.Count
@@ -773,7 +797,8 @@ function AuditLogSetting {
# 数値のまま閾値判定する。書式化した文字列で比較すると辞書順比較になる
$utilization = ($usableRulesCount / $totalRulesCount) * 100
$color = if ($utilization -ge 70) { "Green" } elseif ($utilization -ge 10) { "DarkYellow" } else { "Red" }
- Write-Host ("You can utilize {0:N2}% of your detection rules." -f $utilization) -ForegroundColor $color
+ Write-Host ("Evidence-qualified Ready: {0}/{1} native candidates ({2:N2}% of all {3} unique input rules)." -f $usableRulesCount, $eligibility.Summary.NativeCandidates, $utilization, $totalRulesCount) -ForegroundColor $color
+ Write-Host 'Configuration matches are estimates only. Use rule-eligibility to review complete imported lab evidence; Conditional rules are not counted as Ready.' -ForegroundColor DarkYellow
}
Write-Host ""
}
@@ -1063,7 +1088,8 @@ function UpdateRules {
$downloads = @(
@{ Url = "$baseUrl/eid_subcategory_mapping.csv"; Path = $script:EidMappingPath },
@{ Url = "$baseUrl/security_rules.json"; Path = $script:SecurityRulesPath },
- @{ Url = "$baseUrl/audit_sacl_targets.json"; Path = $script:SaclTargetsPath }
+ @{ Url = "$baseUrl/audit_sacl_targets.json"; Path = $script:SaclTargetsPath },
+ @{ Url = "$baseUrl/rule_eligibility_manifest.json"; Path = (Join-Path $script:ScriptRoot 'config/rule_eligibility_manifest.json') }
)
$failed = 0
@@ -1728,6 +1754,8 @@ Usage:
# Firewall text logging is opt-in; it does not change firewall enforcement or rules.
./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json
./WELA.ps1 smb-auditing -SmbAction Plan
+ ./WELA.ps1 rule-eligibility -ResultsPath eligibility.json -HtmlPath eligibility.html
+ ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json
./WELA.ps1 smb-auditing -SmbAction Configure -DryRun
./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json
./WELA.ps1 applocker-readiness -ResultsPath applocker.json
@@ -1764,6 +1792,9 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
+if ($Cmd -ne 'rule-eligibility' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('RuleEvidencePath', 'RuleCorpusPath', 'RuleManifestPath') }).Count) {
+ throw '-RuleEvidencePath, -RuleCorpusPath and -RuleManifestPath require the read-only rule-eligibility command. No command was run.'
+}
if (($PSBoundParameters.ContainsKey('AppLockerAction') -or $AppLockerPolicyPath) -and $Cmd -ne 'applocker-readiness') {
throw '-AppLockerAction and -AppLockerPolicyPath require applocker-readiness. No command was run.'
}
@@ -1827,6 +1858,21 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi
}
switch ($Cmd.ToLower()) {
+ 'rule-eligibility' {
+ if ($Profile -or $Baseline -or $Auto -or $PlanPath) { throw 'rule-eligibility reviews native rule metadata and optional lab artifacts; use -ResultsPath/-HtmlPath, not configuration options.' }
+ $arguments = @{}
+ if ($RuleCorpusPath) { $arguments.CorpusPath = $RuleCorpusPath }
+ if ($RuleManifestPath) { $arguments.ManifestPath = $RuleManifestPath }
+ if ($RuleEvidencePath) { $arguments.EvidencePath = $RuleEvidencePath }
+ if ($Role) { $arguments.Role = $Role }
+ if ($Build) { $arguments.Build = $Build }
+ $report = Get-WelaRuleEligibility @arguments
+ Export-WelaRuleEligibility -Report $report -ResultsPath $ResultsPath -HtmlPath $HtmlPath
+ Write-Host $report.AssessmentBasis
+ $report.Summary | Format-List
+ if ($ResultsPath) { Write-Host "Per-rule JSON: $ResultsPath" }
+ if ($HtmlPath) { Write-Host "HTML report: $HtmlPath" }
+ }
{ $_ -in @('wef-source','wec-collector') } {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 wef-source|wec-collector -WefConfigPath operator.json [-WefAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
diff --git a/config/rule_eligibility_manifest.json b/config/rule_eligibility_manifest.json
new file mode 100644
index 00000000..fa924730
--- /dev/null
+++ b/config/rule_eligibility_manifest.json
@@ -0,0 +1,17 @@
+{
+ "schemaVersion": 1,
+ "corpusKind": "WELA extracted Hayabusa metadata; not complete upstream Sigma",
+ "corpusSha256": "edffff132db9c9cd53d51db62b5bf7f9459d8bdcbbcac6ada806b2ca7881297f",
+ "mappingSha256": "fdb14ec0ff00a9bd1d5bb020102b9c8be9880edf241e9e5f24212af4dfa18436",
+ "recordCount": 2532,
+ "uniqueRuleCount": 2532,
+ "rulesRepository": "https://github.com/Yamato-Security/hayabusa-rules",
+ "rulesCommit": null,
+ "generatorRepository": "https://github.com/Yamato-Security/WELA-RulesGenerator",
+ "generatorCommit": null,
+ "metadataLimitations": [
+ "Detection expressions and required fields are absent.",
+ "Missing historical upstream revisions are unknown; file hashes pin the available inputs.",
+ "Channel/EventID/GUID candidates do not prove native field support, outcomes, ingestion or query execution."
+ ]
+}
diff --git a/docs/native-provider-assessment.md b/docs/native-provider-assessment.md
index bfe01d05..5e147182 100644
--- a/docs/native-provider-assessment.md
+++ b/docs/native-provider-assessment.md
@@ -30,9 +30,9 @@ Each exact channel is read independently. An enabled AppLocker EXE/DLL channel d
- Defender: WinDefend service state, runtime mode, antivirus/real-time/behavior/network inspection flags. Passive mode, ASR, network protection and controlled folder access have different prerequisites. An enabled channel is not proof of active protection or of all Defender events.
- Other native providers: exact channel registration and available service state, including BITS, printing, WMI, Terminal Services, DFSN, Firewall and SMB client. Provider policy, activity and required event fields remain conditional. Application and System channels can receive events from many independent providers.
-No rule receives current or ideal usable-rule credit merely because one of these channels is enabled. Their mapped rules remain in the complete, deduplicated corpus denominator and in `UnusableRules.csv` (meaning **not confirmed usable**, including conditional/unknown sources). A rule can still qualify through a separately assessed applicable source. The assessment does not claim a Sigma uplift or prove central ingestion. Existing advanced Security policy estimates retain their own SACL and other prerequisites.
+No rule receives current or ideal usable-rule credit merely because a channel or Security audit policy is enabled. Rules remain in the complete, deduplicated corpus inventory and in `UnusableRules.csv` (meaning **not confirmed usable**, including conditional/unknown sources). `RuleEligibility.csv` and JSON/HTML provide per-rule reasons and explicit native/full-corpus denominators. Configuration estimates remain separate from detection readiness. See [native rule eligibility](native-rule-eligibility.md) for the supported imported-evidence checks and their trust boundary.
-This conservative change can lower the reported percentage. A future event-specific readiness model can promote individual native provider rules when all required policies, event types and fields have been validated; broad channel-level promotion would recreate the original problem.
+Without complete lab evidence, the reported Ready count is zero. This describes missing validation, not the usefulness of logging. The separate `rule-eligibility` command can review supported imported evidence for its recorded context/time; it never silently applies old lab results to the currently audited host. Additional native provider adapters still require reviewed policy, event, field and backend evidence.
## Validation and outstanding integration evidence
diff --git a/docs/native-rule-eligibility.md b/docs/native-rule-eligibility.md
new file mode 100644
index 00000000..6c0c96a0
--- /dev/null
+++ b/docs/native-rule-eligibility.md
@@ -0,0 +1,71 @@
+# Native rule eligibility and imported lab evidence
+
+`rule-eligibility` is a read-only assessment of the bundled rule metadata and optional operator-supplied lab artifacts. It does not configure Windows, generate events, run queries, contact collectors or execute imported files. Sysmon and explicitly identified external-product sources are excluded.
+
+```powershell
+./WELA.ps1 rule-eligibility -ResultsPath eligibility.json -HtmlPath eligibility.html
+./WELA.ps1 rule-eligibility -Role ADCS -Build 26100 -ResultsPath ca-candidates.json
+./WELA.ps1 rule-eligibility -RuleEvidencePath C:\Lab\reviewed\evidence.json -ResultsPath reviewed.json
+```
+
+Without imported evidence, **Ready is zero**. This means detection readiness has not been demonstrated; it does not mean the configured logging is useless. The shipped `security_rules.json` contains extracted Hayabusa metadata, including candidate channels, EventIDs and subcategories. It omits full detection expressions and required fields. It is not the entire upstream Sigma corpus. Generic process-creation rules are retained as candidates, but mapping them to 4688 does not establish that Windows supplies every required field.
+
+The manifest records the exact corpus and EventID-mapping SHA256 values and counts. Historic upstream commits that were not recorded remain null. Future automated rule updates record the Hayabusa and generator commit IDs. `tools/update_rule_manifest.py` regenerates hashes; use its commit arguments only for the actual inputs that produced those bytes. Custom extracted metadata and a matching reviewed manifest can be supplied with `-RuleCorpusPath` and `-RuleManifestPath`. A hash identifies content; it does not authenticate its origin.
+
+## Reading the results
+
+| State | Meaning |
+| --- | --- |
+| Ready | All supported checks pass against imported, reviewed lab artifacts. Applies only to the recorded computer, role, build, patch, backend/version and test time. |
+| Conditional | Missing/unknown metadata or prerequisites, unsupported rule logic, or rejected/incomplete/stale evidence. |
+| Blocked | Every observed candidate source for the rule is explicitly disabled or absent. No missing observation is interpreted as disabled. |
+| NotApplicable | All unambiguous, canonical Security event sources belong to other roles than the explicitly selected role. |
+| Excluded | An explicit Sysmon or identified external-product source. Its ID and exclusion reason remain in the output. |
+
+Every unique rule has reasons and a metadata hash. `wela-metadata-framed-utf16le-base64-sha256-v1` uses an explicit ASCII framing independent of JSON serialization. It begins with `wela-metadata-framed-v1;`, followed by each name and value in the fixed field order `id,title,level,category,service,channel,event_ids,subcategory_guids,description,tags`. Strings are `s ' + [System.Net.WebUtility]::HtmlEncode($report.Scope) + ' ' + [System.Net.WebUtility]::HtmlEncode($report.Coverage.Note) + ' ' + (& $encode $Report.AssessmentBasis) + ' Corpus SHA256: Evidence as of UTC: Recorded context (not the current host):WELA audit assessment
')
$parts += 'Rule eligibility
' + [System.Net.WebUtility]::HtmlEncode(($Eligibility.Summary | ConvertTo-Json -Depth 6)) + '
'
+ $parts += 'All rule states and reasons
' + [System.Net.WebUtility]::HtmlEncode(($Eligibility.Results | ConvertTo-Json -Depth 8)) + '
' + [System.Net.WebUtility]::HtmlEncode(($row.Category + ' / ' + $row.SubCategory)) + '
'
$parts += [System.Net.WebUtility]::HtmlEncode(($row | ConvertTo-Json -Depth 14))
diff --git a/modules/RuleEligibility.psm1 b/modules/RuleEligibility.psm1
new file mode 100644
index 00000000..91fa5d94
--- /dev/null
+++ b/modules/RuleEligibility.psm1
@@ -0,0 +1,448 @@
+# Read-only, offline evidence assessment. Imported artifacts are never executed.
+function Get-WelaEligibilityTextHash {
+ param([string]$Text)
+ $hash = [Security.Cryptography.SHA256]::Create()
+ try { return ([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text)))).Replace('-', '').ToLowerInvariant() }
+ finally { $hash.Dispose() }
+}
+
+function ConvertFrom-WelaEligibilityJson {
+ param([string]$Text)
+ # Reject property collisions instead of depending on ConvertFrom-Json's
+ # different duplicate-key behavior across Windows PowerShell and PowerShell.
+ $tokens = [regex]::Matches($Text, '"(?:\\.|[^"\\])*"|[{}\[\]:,]')
+ $stack = New-Object 'System.Collections.Generic.Stack[object]'
+ for ($i = 0; $i -lt $tokens.Count; $i++) {
+ $token = $tokens[$i].Value
+ if ($token -eq '{') { $stack.Push(@{}) }
+ elseif ($token -eq '[') { $stack.Push($null) }
+ elseif ($token -in @('}', ']')) { if ($stack.Count -eq 0) { throw 'Unbalanced JSON.' }; $null = $stack.Pop() }
+ elseif ($token.StartsWith('"') -and $i + 1 -lt $tokens.Count -and $tokens[$i + 1].Value -eq ':') {
+ if ($stack.Count -eq 0 -or $null -eq $stack.Peek()) { throw 'JSON property outside an object.' }
+ $holder = ('{' + $token + ':null}' | ConvertFrom-Json -ErrorAction Stop)
+ $name = @($holder.PSObject.Properties.Name)[0]
+ if ($stack.Peek().ContainsKey($name)) { throw 'Duplicate or case-colliding JSON property.' }
+ $stack.Peek()[$name] = $true
+ }
+ if ($stack.Count -gt 32) { throw 'JSON nesting exceeds 32 levels.' }
+ }
+ $arguments = @{InputObject=$Text;ErrorAction='Stop'}
+ if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) { $arguments.DateKind = 'String' }
+ return (ConvertFrom-Json @arguments)
+}
+
+function Read-WelaEligibilityJson {
+ param([string]$Path, [long]$MaximumBytes = 16777216)
+ $file = Get-Item -LiteralPath $Path -ErrorAction Stop
+ if ($file.PSIsContainer -or $file.Length -gt $MaximumBytes) { throw 'JSON input is a directory or exceeds the supported size limit.' }
+ $text = [IO.File]::ReadAllText($file.FullName)
+ return ($text | ConvertFrom-Json -ErrorAction Stop)
+}
+
+function Read-WelaEligibilityInput {
+ param([string]$Path)
+ $file = Get-Item -LiteralPath $Path -ErrorAction Stop
+ if ($file.PSIsContainer -or $file.Length -gt 16777216) { throw 'Input is a directory or exceeds 16 MiB.' }
+ $bytes = [IO.File]::ReadAllBytes($file.FullName)
+ if ($bytes.Length -gt 16777216) { throw 'Input grew beyond 16 MiB.' }
+ $hash = [Security.Cryptography.SHA256]::Create()
+ try { $sha256 = ([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() }
+ finally { $hash.Dispose() }
+ [pscustomobject]@{ Sha256=$sha256; Text=[Text.Encoding]::UTF8.GetString($bytes).TrimStart([char]0xFEFF) }
+}
+
+function ConvertTo-WelaEligibilityCanonicalValue {
+ param($Value, [int]$Depth = 0)
+ if ($Depth -gt 12) { throw 'Metadata nesting exceeds 12 levels.' }
+ if ($null -eq $Value) { return 'n;' }
+ if ($Value -is [bool]) { if ($Value) { return 'b1;' }; return 'b0;' }
+ $invariant = [Globalization.CultureInfo]::InvariantCulture
+ if ($Value -is [string]) {
+ # Copy exact UTF-16 code units, including isolated surrogates. Encoding
+ # fallback must never turn distinct strings into the same identity.
+ $bytes = New-Object byte[] ($Value.Length * 2)
+ if ($bytes.Length) { [Buffer]::BlockCopy($Value.ToCharArray(), 0, $bytes, 0, $bytes.Length) }
+ if (-not [BitConverter]::IsLittleEndian) {
+ for ($i = 0; $i -lt $bytes.Length; $i += 2) { $first=$bytes[$i]; $bytes[$i]=$bytes[$i+1]; $bytes[$i+1]=$first }
+ }
+ return ('s' + $Value.Length.ToString($invariant) + ':' + [Convert]::ToBase64String($bytes) + ';')
+ }
+ if ($Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or
+ $Value -is [int32] -or $Value -is [uint32] -or $Value -is [int64] -or $Value -is [uint64]) {
+ return ('i' + $Value.ToString($invariant) + ';')
+ }
+ if ($Value -is [single] -or $Value -is [double]) {
+ return ('f' + [BitConverter]::DoubleToInt64Bits([double]$Value).ToString('x16', $invariant) + ';')
+ }
+ if ($Value -is [decimal]) { return ('m' + $Value.ToString('G29', $invariant) + ';') }
+ $text = New-Object Text.StringBuilder
+ if ($Value -is [array]) {
+ [void]$text.Append('a' + $Value.Count.ToString($invariant) + ':')
+ foreach ($item in $Value) { [void]$text.Append((ConvertTo-WelaEligibilityCanonicalValue -Value $item -Depth ($Depth + 1))) }
+ } elseif ($Value -is [Collections.IDictionary] -or $Value -is [pscustomobject]) {
+ $dictionary = $Value -is [Collections.IDictionary]
+ [string[]]$names = @()
+ if ($dictionary) { $names = @($Value.Keys) } else { $names = @($Value.PSObject.Properties | ForEach-Object { $_.Name }) }
+ [Array]::Sort($names, [StringComparer]::Ordinal)
+ [void]$text.Append('o' + $names.Count.ToString($invariant) + ':')
+ foreach ($name in $names) {
+ [void]$text.Append((ConvertTo-WelaEligibilityCanonicalValue -Value $name -Depth ($Depth + 1)))
+ if ($dictionary) { $member = $Value[$name] } else { $member = $Value.$name }
+ [void]$text.Append((ConvertTo-WelaEligibilityCanonicalValue -Value $member -Depth ($Depth + 1)))
+ }
+ } else { throw ('Unsupported metadata value type: ' + $Value.GetType().FullName) }
+ [void]$text.Append(';')
+ return $text.ToString()
+}
+
+function Get-WelaEligibilityRuleHash {
+ param($Rule)
+ $text = New-Object Text.StringBuilder
+ [void]$text.Append('wela-metadata-framed-v1;')
+ foreach ($name in @('id', 'title', 'level', 'category', 'service', 'channel', 'event_ids', 'subcategory_guids', 'description', 'tags')) {
+ [void]$text.Append((ConvertTo-WelaEligibilityCanonicalValue -Value $name))
+ [void]$text.Append((ConvertTo-WelaEligibilityCanonicalValue -Value $Rule.$name))
+ }
+ Get-WelaEligibilityTextHash $text.ToString()
+}
+
+function Get-WelaEligibilityArtifact {
+ param([string]$Root, $Reference)
+ if ($Reference.path -isnot [string] -or $Reference.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or
+ $Reference.path -match '^(?:[/\\]|[A-Za-z]:)' -or $Reference.path -match '[:*?\x00-\x1F]') { throw 'Invalid artifact path or SHA256.' }
+ $segments = @($Reference.path -split '[/\\]')
+ if (@($segments | Where-Object { -not $_ -or $_ -in @('.', '..') -or $_ -match '[ .]$' }).Count) { throw 'Artifact path has ambiguous components.' }
+ $rootPath = [IO.Path]::GetFullPath($Root)
+ if ($rootPath.StartsWith('\\')) { throw 'Remote evidence roots are not accessed.' }
+ # Inspect ancestors before descendants; never follow a link into another tree.
+ $cursor = [IO.Path]::GetPathRoot($rootPath)
+ $rootSegments = @($rootPath.Substring($cursor.Length) -split '[/\\]' | Where-Object { $_ })
+ foreach ($part in @($rootSegments) + @($segments)) {
+ $cursor = Join-Path $cursor $part
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked artifact paths require separate review.' }
+ }
+ if ($item.PSIsContainer -or $item.Length -eq 0 -or $item.Length -gt 4194304) { throw 'Artifact must be a nonempty file of at most 4 MiB.' }
+ # Read once and hash the same bytes that are parsed (no hash/read race).
+ $bytes = [IO.File]::ReadAllBytes($item.FullName)
+ if ($bytes.Length -gt 4194304) { throw 'Artifact grew beyond its size limit.' }
+ $hash = [Security.Cryptography.SHA256]::Create()
+ try { $actual = ([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() }
+ finally { $hash.Dispose() }
+ if ($actual -ne $Reference.sha256) { throw "Artifact hash mismatch: $($Reference.path)" }
+ [pscustomobject]@{ Path = $Reference.path; Sha256 = $actual; Text = [Text.Encoding]::UTF8.GetString($bytes).TrimStart([char]0xFEFF) }
+}
+
+function ConvertFrom-WelaEligibilityEvent {
+ param([string]$Text)
+ $settings = New-Object Xml.XmlReaderSettings
+ $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
+ $settings.XmlResolver = $null; $settings.MaxCharactersInDocument = 4194304
+ $reader = [Xml.XmlReader]::Create([IO.StringReader]::new($Text), $settings)
+ try { $xml = New-Object Xml.XmlDocument; $xml.XmlResolver = $null; $xml.Load($reader) }
+ finally { $reader.Dispose() }
+ if ($xml.DocumentElement.LocalName -ne 'Event' -or $xml.DocumentElement.NamespaceURI -ne 'http://schemas.microsoft.com/win/2004/08/events/event') { throw 'Expected one native Windows Event XML document.' }
+ $ns = New-Object Xml.XmlNamespaceManager($xml.NameTable)
+ $ns.AddNamespace('e', $xml.DocumentElement.NamespaceURI)
+ if (@($xml.SelectNodes('/e:Event/e:System', $ns)).Count -ne 1 -or @($xml.SelectNodes('/e:Event/e:EventData', $ns)).Count -ne 1 -or $xml.SelectSingleNode('/e:Event/e:UserData', $ns)) { throw 'Unsupported or ambiguous event payload.' }
+ $system = @{}
+ foreach ($name in @('EventID', 'Version', 'EventRecordID', 'Channel', 'Computer', 'Keywords')) {
+ $nodes = @($xml.SelectNodes('/e:Event/e:System/e:' + $name, $ns))
+ if ($nodes.Count -ne 1 -or -not $nodes[0].InnerText) { throw "Missing/duplicate event system field: $name" }
+ $system[$name] = $nodes[0].InnerText
+ }
+ $provider = @($xml.SelectNodes('/e:Event/e:System/e:Provider', $ns))
+ $time = @($xml.SelectNodes('/e:Event/e:System/e:TimeCreated', $ns))
+ if ($provider.Count -ne 1 -or $time.Count -ne 1) { throw 'Missing/duplicate event provider or timestamp.' }
+ $system.Provider = $provider[0].GetAttribute('Name'); $system.TimeCreated = $time[0].GetAttribute('SystemTime')
+ $data = @{}
+ foreach ($node in $xml.SelectNodes('/e:Event/e:EventData/e:Data', $ns)) {
+ $name = $node.GetAttribute('Name')
+ if (-not $name -or $data.ContainsKey($name)) { throw 'Unnamed or duplicate EventData field.' }
+ $data[$name] = $node.InnerText
+ }
+ [pscustomobject]@{ System = $system; Data = $data }
+}
+
+function ConvertTo-WelaEligibilityTime {
+ param($Value)
+ if ($Value -isnot [string] -or $Value -notmatch 'Z$') { throw 'Evidence timestamps must be explicit UTC strings ending in Z.' }
+ return [DateTimeOffset]::Parse($Value, [Globalization.CultureInfo]::InvariantCulture).UtcDateTime
+}
+
+function Test-WelaEligibilityEvidence {
+ param($Record, $Rule, [string]$MetadataHash, [string]$Root, [string]$CorpusHash, [string]$MappingHash,
+ $Policy, [string]$Role, [int]$Build, [DateTime]$Now = [DateTime]::UtcNow)
+ $reasons = New-Object 'System.Collections.Generic.List[string]'
+ $artifactNames = @('sourceRule', 'normalizedRule', 'review', 'beforeState', 'afterState', 'eventXml', 'ingestion', 'query', 'queryResult')
+ try {
+ if ($Record.metadataSha256 -ne $MetadataHash -or $Record.corpusSha256 -ne $CorpusHash -or $Record.mappingSha256 -ne $MappingHash) { throw 'Corpus, mapping or per-rule metadata identity mismatch.' }
+ if ($Record.adapter -ne 'security-single-event-exact-v1') { return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedEvidenceAdapter'); References = @() } }
+ $a = @{}
+ foreach ($name in $artifactNames) { $a[$name] = Get-WelaEligibilityArtifact $Root $Record.artifacts.$name }
+ $definition = ConvertFrom-WelaEligibilityJson $a.normalizedRule.Text
+ $review = ConvertFrom-WelaEligibilityJson $a.review.Text
+ if ($definition.id -ne $Rule.id -or $review.ruleId -ne $Rule.id -or $review.sourceRuleSha256 -ne $a.sourceRule.Sha256 -or
+ $review.normalizedRuleSha256 -ne $a.normalizedRule.Sha256 -or -not $review.reviewer -or
+ $review.statement -cne 'Complete rule normalization reviewed; no detection logic omitted.') { throw 'Missing complete normalization review bound to both rule artifacts.' }
+ $reviewed = ConvertTo-WelaEligibilityTime $review.reviewedAtUtc
+ # This is a deliberately small full-rule parser, never a partial Sigma compiler.
+ $detectionNames = @($definition.detection.PSObject.Properties.Name)
+ if ($definition.correlation -or $definition.logsource.product -ne 'windows' -or
+ $definition.detection.condition -cne 'selection' -or $detectionNames.Count -ne 2 -or
+ $detectionNames -notcontains 'selection' -or $definition.detection.selection -isnot [pscustomobject]) {
+ return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedRuleLogic'); References = @($artifactNames) }
+ }
+ if (($definition.logsource.service -and $definition.logsource.service -ne 'security') -or
+ ($definition.logsource.category -and $definition.logsource.category -ne 'process_creation') -or
+ ($definition.logsource.category -eq 'process_creation' -and @($Rule.event_ids) -notcontains '4688') -or
+ $definition.logsource.definition -or $definition.status -in @('deprecated', 'unsupported')) {
+ return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedRuleSourceOrPrerequisite'); References = @($artifactNames) }
+ }
+ $selectors = @($definition.detection.selection.PSObject.Properties)
+ if ($selectors.Count -eq 0) { throw 'Empty selection cannot demonstrate a rule match.' }
+ foreach ($selector in $selectors) {
+ if ($selector.Name -match '\|' -or $null -eq $selector.Value -or
+ $selector.Value -is [array] -or $selector.Value -is [pscustomobject] -or
+ $selector.Value -is [bool] -or [string]$selector.Value -match '[*?]') {
+ return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedRuleLogic'); References = @($artifactNames) }
+ }
+ }
+ if (-not $Policy -or ($Policy.prerequisites -and $Policy.id -ne 'Process Creation')) { return [pscustomobject]@{ State = 'Conditional'; Reasons = @('SaclOrProviderPrerequisiteAdapterRequired'); References = @($artifactNames) } }
+ $before = ConvertFrom-WelaEligibilityJson $a.beforeState.Text
+ $after = ConvertFrom-WelaEligibilityJson $a.afterState.Text
+ $ingestion = ConvertFrom-WelaEligibilityJson $a.ingestion.Text
+ $queryResult = ConvertFrom-WelaEligibilityJson $a.queryResult.Text
+ $event = ConvertFrom-WelaEligibilityEvent $a.eventXml.Text
+ $context = $after.context
+ if ($context.role -notin @('Client', 'MemberServer', 'DomainController', 'ADCS') -or
+ $context.build -isnot [ValueType] -or $context.build -is [bool] -or $context.build -lt 1 -or [double]$context.build -ne [math]::Floor([double]$context.build) -or
+ $context.computer -isnot [string] -or -not $context.computer.Trim() -or
+ $context.patch -isnot [string] -or -not $context.patch.Trim() -or $context.domainJoined -isnot [bool] -or
+ $context.installedRoles -isnot [array] -or $context.backend -isnot [string] -or -not $context.backend.Trim() -or
+ $context.backendVersion -isnot [string] -or -not $context.backendVersion.Trim()) { throw 'Incomplete source host/build/patch/backend context.' }
+ foreach ($name in @('computer', 'role', 'build', 'patch', 'domainJoined', 'backend', 'backendVersion')) {
+ if ([string]$before.context.$name -cne [string]$context.$name) { throw "Before/after context mismatch: $name" }
+ }
+ if ((@($before.context.installedRoles) -join '|') -cne (@($context.installedRoles) -join '|')) { throw 'Installed roles changed during the evidence window.' }
+ if (($Role -and $Role -ne $context.role) -or ($Build -and $Build -ne $context.build)) { throw 'Evidence does not match the requested role/build.' }
+ if ($Policy.roles -notcontains $context.role) { throw 'The event source belongs to a different Windows role.' }
+ $beforeTime = ConvertTo-WelaEligibilityTime $before.capturedAtUtc
+ $eventTime = ConvertTo-WelaEligibilityTime $event.System.TimeCreated
+ $afterTime = ConvertTo-WelaEligibilityTime $after.capturedAtUtc
+ $arrivalTime = ConvertTo-WelaEligibilityTime $ingestion.receivedAtUtc
+ $queryTime = ConvertTo-WelaEligibilityTime $queryResult.executedAtUtc
+ if ($beforeTime -gt $eventTime -or $eventTime -gt $afterTime -or $eventTime -gt $arrivalTime -or
+ $arrivalTime -gt $queryTime -or $queryTime -gt $reviewed -or $afterTime -gt $reviewed -or
+ @($beforeTime, $eventTime, $afterTime, $arrivalTime, $queryTime, $reviewed | Where-Object { $_ -gt $Now }).Count -gt 0 -or
+ $beforeTime -lt $Now.AddDays(-30)) { throw 'Evidence timing is stale, future-dated or inconsistent (30-day maximum window).' }
+ if ($event.System.Provider -cne 'Microsoft-Windows-Security-Auditing' -or $event.System.Channel -cne 'Security' -or
+ $event.System.Computer -ine $context.computer -or @($Rule.event_ids).Count -ne 1 -or
+ [string]$Rule.event_ids[0] -ne $event.System.EventID) { throw 'Native source event identity does not match the rule or host.' }
+ if (@($Rule.channel | Where-Object { $_ -notin @('sec', 'Security') }).Count -or @($Rule.channel).Count -eq 0) { throw 'Evidence adapter supports only an explicit native Security source.' }
+ if ($event.System.Keywords -notmatch '^0x[0-9a-fA-F]+$') { throw 'Unknown Security event outcome.' }
+ $keywords = [Convert]::ToUInt64($event.System.Keywords.Substring(2), 16)
+ $outcome = if ($keywords -band [uint64]0x0020000000000000) { 1 } elseif ($keywords -band [uint64]0x0010000000000000) { 2 } else { 0 }
+ if (-not $outcome -or (($keywords -band [uint64]0x0030000000000000) -eq [uint64]0x0030000000000000)) { throw 'Ambiguous Security event outcome.' }
+ $mask = $after.auditPolicies.($Policy.guid)
+ if ($mask -isnot [ValueType] -or $mask -is [bool] -or $mask -notin @(0, 1, 2, 3) -or ($mask -band $outcome) -ne $outcome -or
+ $after.auditPrecedence.kind -ne 'DWord' -or $after.auditPrecedence.value -isnot [ValueType] -or $after.auditPrecedence.value -is [bool] -or
+ $after.auditPrecedence.value -ne 1 -or $after.securityChannelEnabled -isnot [bool] -or -not $after.securityChannelEnabled) { throw 'Recorded effective policy does not verify the observed event outcome and precedence.' }
+ $beforeMask = $before.auditPolicies.($Policy.guid)
+ if ($beforeMask -isnot [ValueType] -or $beforeMask -is [bool] -or $beforeMask -notin @(0, 1, 2, 3)) { throw 'Before-state effective policy is missing or invalid.' }
+ if ($event.System.EventID -eq '4688' -and ($outcome -ne 1 -or $event.System.Version -notin @('0', '1', '2'))) { throw 'Unsupported 4688 outcome or event version.' }
+ $supported4688 = @('SubjectUserSid', 'SubjectUserName', 'SubjectDomainName', 'SubjectLogonId', 'NewProcessId', 'NewProcessName', 'TokenElevationType', 'ProcessId', 'CommandLine', 'TargetUserSid', 'TargetUserName', 'TargetDomainName', 'TargetLogonId', 'ParentProcessName', 'MandatoryLabel')
+ foreach ($selector in $selectors) {
+ $field = $selector.Name
+ $sourceField = [string]$Record.fieldMappings.$field
+ if ($field -eq 'EventID' -and $sourceField -eq 'System.EventID') { $actual = $event.System.EventID }
+ elseif ($sourceField.StartsWith('EventData.')) {
+ $nativeName = $sourceField.Substring(10)
+ # Reviewed aliases only: an imported map cannot turn Image into
+ # Hashes, or substitute an unrelated field that happens to match.
+ $aliases = @{}
+ if ($event.System.EventID -eq '4688') { $aliases = @{ Image = 'NewProcessName'; ParentImage = 'ParentProcessName'; ProcessId = 'NewProcessId'; ParentProcessId = 'ProcessId' } }
+ $expectedName = if ($aliases.ContainsKey($field)) { $aliases[$field] } else { $field }
+ if ($nativeName -cne $expectedName) { return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedFieldMapping'); References = @($artifactNames) } }
+ if ($event.System.EventID -eq '4688' -and ($supported4688 -notcontains $nativeName -or
+ ($nativeName -eq 'CommandLine' -and $event.System.Version -eq '0') -or
+ ($nativeName -in @('TargetUserSid', 'TargetUserName', 'TargetDomainName', 'TargetLogonId', 'ParentProcessName', 'MandatoryLabel') -and $event.System.Version -ne '2'))) { throw 'Required field is unsupported in this native 4688 schema/version.' }
+ if (-not $event.Data.ContainsKey($nativeName) -or [string]::IsNullOrEmpty($event.Data[$nativeName])) { throw "Required native field missing or empty: $field" }
+ $actual = $event.Data[$nativeName]
+ if ($event.System.EventID -eq '4688' -and $nativeName -eq 'CommandLine' -and
+ ($after.commandLineCapture.kind -ne 'DWord' -or $after.commandLineCapture.value -isnot [ValueType] -or
+ $after.commandLineCapture.value -is [bool] -or $after.commandLineCapture.value -ne 1)) { throw '4688 command-line capture policy is unverified.' }
+ } else { return [pscustomobject]@{ State = 'Conditional'; Reasons = @('UnsupportedFieldMapping'); References = @($artifactNames) } }
+ if ([string]$actual -ine [string]$selector.Value -or [string]$ingestion.normalizedFields.$field -cne [string]$actual) { throw "Rule selection or ingested normalized field did not match: $field" }
+ }
+ foreach ($name in @('backend', 'backendVersion')) {
+ if ($ingestion.$name -cne $context.$name -or $queryResult.$name -cne $context.$name) { throw 'Backend identity/version mismatch.' }
+ }
+ if ($ingestion.eventSha256 -ne $a.eventXml.Sha256 -or $queryResult.eventSha256 -ne $a.eventXml.Sha256 -or
+ $queryResult.ruleSha256 -ne $a.normalizedRule.Sha256 -or $queryResult.querySha256 -ne $a.query.Sha256 -or
+ $queryResult.matched -isnot [bool] -or -not $queryResult.matched -or $queryResult.exitCode -isnot [ValueType] -or
+ $queryResult.exitCode -is [bool] -or $queryResult.exitCode -ne 0 -or
+ $ingestion.computer -ine $event.System.Computer -or $ingestion.channel -cne 'Security' -or
+ [string]$ingestion.eventId -ne $event.System.EventID -or [string]$ingestion.recordId -ne $event.System.EventRecordID) { throw 'Ingestion or translated-query match evidence is incomplete or inconsistent.' }
+ [pscustomobject]@{ State = 'Ready'; Reasons = @('ImportedEvidenceVerified'); References = @($artifactNames); Context = $context; AsOfUtc = $queryResult.executedAtUtc }
+ } catch { [pscustomobject]@{ State = 'Conditional'; Reasons = @('EvidenceRejected', $_.Exception.Message); References = @() } }
+}
+
+function Get-WelaRuleEligibility {
+ [CmdletBinding()]
+ param([string]$CorpusPath = (Join-Path $PSScriptRoot '../config/security_rules.json'),
+ [string]$MappingPath = (Join-Path $PSScriptRoot '../config/eid_subcategory_mapping.csv'),
+ [string]$ManifestPath = (Join-Path $PSScriptRoot '../config/rule_eligibility_manifest.json'),
+ [string]$EvidencePath, [ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role,
+ [int]$Build, [array]$Observations = @(), [DateTime]$Now = [DateTime]::UtcNow)
+ $corpusInput = Read-WelaEligibilityInput $CorpusPath
+ $mappingInput = Read-WelaEligibilityInput $MappingPath
+ $corpusHash = $corpusInput.Sha256; $mappingHash = $mappingInput.Sha256
+ if (-not $corpusInput.Text.TrimStart().StartsWith('[')) { throw 'Extracted corpus must be a JSON array.' }
+ $parsed = $corpusInput.Text | ConvertFrom-Json -ErrorAction Stop
+ $raw = @($parsed)
+ $manifest = $null
+ if (Test-Path -LiteralPath $ManifestPath) { $manifest = Read-WelaEligibilityJson $ManifestPath }
+ $pinned = $manifest.schemaVersion -eq 1 -and $manifest.corpusSha256 -eq $corpusHash -and $manifest.mappingSha256 -eq $mappingHash
+ $profileData = Read-WelaEligibilityJson (Join-Path $PSScriptRoot '../config/audit_profiles.json')
+ $policies = @{}; foreach ($policy in $profileData.catalog) { $policies[$policy.guid] = $policy }
+ $nameAliases = @{
+ 'Non-Sensitive Privilege Use' = 'Non Sensitive Privilege Use'
+ 'User / Device Claims' = 'User/Device Claims'
+ 'Central Policy Staging' = 'Central Access Policy Staging'
+ }
+ $eventMap = @{}
+ foreach ($mapping in @($mappingInput.Text | ConvertFrom-Csv -ErrorAction Stop)) {
+ if ($mapping.'Event ID' -notmatch '^\d+$') { continue } # Category headers never match events.
+ $name = [string]$mapping.Subcategory
+ if ($nameAliases.ContainsKey($name)) { $name = $nameAliases[$name] }
+ $canonical = $policies.ContainsKey([string]$mapping.GUID) -and $policies[[string]$mapping.GUID].id -eq $name -and $policies[[string]$mapping.GUID].category -eq $mapping.Category
+ $mapping | Add-Member NoteProperty Canonical ([bool]$canonical)
+ $id = $mapping.'Event ID'
+ if (-not $eventMap.ContainsKey($id)) { $eventMap[$id] = @() }
+ $eventMap[$id] += $mapping
+ }
+ $evidence = @{}; $evidenceRoot = $null
+ if ($EvidencePath) {
+ $bundleFile = Get-Item -LiteralPath $EvidencePath -ErrorAction Stop
+ if ($bundleFile.Length -gt 16777216 -or $bundleFile.FullName.StartsWith('\\')) { throw 'Evidence bundle is remote or too large.' }
+ $bundle = ConvertFrom-WelaEligibilityJson ([IO.File]::ReadAllText($bundleFile.FullName))
+ if ($bundle.schemaVersion -ne 1 -or $bundle.kind -ne 'WelaNativeRuleEvidence' -or $bundle.records -isnot [array]) { throw 'Unsupported evidence bundle schema.' }
+ $evidenceRoot = Split-Path -Parent ([IO.Path]::GetFullPath($EvidencePath))
+ foreach ($record in $bundle.records) {
+ if (-not $record.id -or $evidence.ContainsKey([string]$record.id)) { throw 'Missing or duplicate evidence rule ID.' }
+ $evidence[[string]$record.id] = $record
+ }
+ }
+ $observedById = @{}
+ foreach ($observation in $Observations) {
+ foreach ($rule in $observation.Rules) {
+ if (-not $observedById.ContainsKey([string]$rule.id)) { $observedById[[string]$rule.id] = @() }
+ $observedById[[string]$rule.id] += $observation
+ }
+ }
+ $seen = @{}; $rows = New-Object 'System.Collections.Generic.List[object]'; $duplicateCount = 0
+ foreach ($rule in $raw) {
+ if ($rule -isnot [pscustomobject] -or $rule.id -isnot [string] -or [string]::IsNullOrWhiteSpace($rule.id)) { throw 'Corpus entries require a nonempty string rule ID.' }
+ $metadataHash = Get-WelaEligibilityRuleHash $rule
+ if ($seen.ContainsKey($rule.id)) {
+ if ($seen[$rule.id] -ne $metadataHash) { throw "Conflicting metadata for duplicate rule ID: $($rule.id)" }
+ $duplicateCount++; continue
+ }
+ $seen[$rule.id] = $metadataHash
+ $reasons = New-Object 'System.Collections.Generic.List[string]'
+ $state = 'Conditional'; $scopeReason = $null; $mappedPolicies = @(); $mappingComplete = $true
+ $channels = @($rule.channel); $eventIds = @($rule.event_ids)
+ $validMetadata = $rule.channel -is [array] -and $rule.event_ids -is [array] -and $rule.subcategory_guids -is [array] -and
+ @($channels | Where-Object { $_ -isnot [string] -or -not $_ }).Count -eq 0 -and
+ @($eventIds | Where-Object { $_ -isnot [string] -or $_ -notmatch '^\d+$' }).Count -eq 0 -and
+ @($rule.subcategory_guids | Where-Object { $_ -isnot [string] -or -not $_ }).Count -eq 0
+ if (-not $validMetadata) { $reasons.Add('UnsupportedMetadataShape') }
+ if (@($channels | Where-Object { [string]$_ -match '(?i)sysmon' }).Count -or $rule.service -eq 'sysmon') { $state = 'Excluded'; $scopeReason = 'ExplicitSysmonSource' }
+ elseif ($rule.service -in @('msexchange-management', 'mssql', 'sqlserver', 'microsoft-servicebus-client', 'screenconnect')) { $state = 'Excluded'; $scopeReason = 'ExternalProductSource' }
+ foreach ($id in $eventIds) {
+ $mappings = @($eventMap[[string]$id])
+ $canonical = @($mappings | Where-Object { $_ -and $_.Canonical })
+ $distinct = @($canonical.GUID | Sort-Object -Unique)
+ if ($distinct.Count -ne 1 -or @($mappings | Where-Object { $_ -and -not $_.Canonical }).Count) { $mappingComplete = $false }
+ foreach ($guid in $distinct) { $mappedPolicies += $policies[$guid] }
+ }
+ if (@($rule.subcategory_guids | Where-Object { -not $policies.ContainsKey([string]$_) -or ($mappedPolicies.Count -gt 0 -and $mappedPolicies.guid -notcontains $_) }).Count) { $mappingComplete = $false }
+ if ($eventIds.Count -eq 0) { $mappingComplete = $false; $reasons.Add('EventIdentityUnknown') }
+ elseif (-not $mappingComplete -and @($channels | Where-Object { $_ -in @('sec', 'Security') }).Count) { $reasons.Add('AuditMappingAmbiguousOrUnknown') }
+ if ($channels.Count -eq 0) { $reasons.Add('NativeSourceUnknown') }
+ $securityOnly = $channels.Count -gt 0 -and @($channels | Where-Object { $_ -notin @('sec', 'Security') }).Count -eq 0
+ if ($state -ne 'Excluded' -and $validMetadata -and $securityOnly -and $mappingComplete -and $Role -and
+ $mappedPolicies.Count -gt 0 -and @($mappedPolicies | Where-Object { $_.roles -contains $Role }).Count -eq 0) {
+ $state = 'NotApplicable'; $reasons.Add('AllKnownEventSourcesBelongToOtherRoles')
+ }
+ $matchingRows = @($observedById[$rule.id] | Where-Object { $null -ne $_ })
+ $configurationEstimate = @($matchingRows | Where-Object { $_.CurrentSetting -match 'Success|Failure|^Enabled$' }).Count -gt 0
+ if ($state -eq 'Conditional' -and $matchingRows.Count -gt 0 -and
+ @($matchingRows | Where-Object { $_.CurrentSetting -notin @('No Auditing', 'Disabled', 'Not installed') }).Count -eq 0) {
+ $state = 'Blocked'; $reasons.Add('ObservedSourcesDisabledOrAbsent')
+ }
+ $proof = $null
+ if ($evidence.ContainsKey($rule.id) -and $state -eq 'Conditional' -and $validMetadata) {
+ if (-not $pinned) { $reasons.Add('CorpusOrMappingNotPinned') }
+ elseif (-not $mappingComplete -or $eventIds.Count -ne 1) { $reasons.Add('EvidenceRequiresUnambiguousSingleEventMapping') }
+ else {
+ $proof = Test-WelaEligibilityEvidence -Record $evidence[$rule.id] -Rule $rule -MetadataHash $metadataHash -Root $evidenceRoot `
+ -CorpusHash $corpusHash -MappingHash $mappingHash -Policy $mappedPolicies[0] -Role $Role -Build $Build -Now $Now
+ $state = $proof.State; foreach ($reason in $proof.Reasons) { $reasons.Add($reason) }
+ }
+ } elseif ($state -eq 'Conditional') { $reasons.Add('FullRuleDefinitionAndLabEvidenceMissing') }
+ if ($state -eq 'Conditional') { $reasons.Add('EnabledPolicyOrChannelIsNotRuleReadiness') }
+ $rows.Add([pscustomobject][ordered]@{
+ Id = $rule.id; Title = $rule.title; State = $state; Reasons = @($reasons.ToArray() | Select-Object -Unique)
+ ScopeExclusion = $scopeReason; MetadataSha256 = $metadataHash; Channels = $channels; EventIds = $eventIds
+ AuditMapping = $(if ($mappingComplete) { 'Canonical candidates; outcome still requires event evidence' } else { 'Ambiguous or unknown' })
+ PolicyPrerequisites = @($mappedPolicies | ForEach-Object { $_.prerequisites } | Where-Object { $_ } | Select-Object -Unique)
+ ConfigurationEstimate = $configurationEstimate; EvidenceArtifacts = @($proof.References)
+ EvidenceAsOfUtc = $proof.AsOfUtc; EvidenceContext = $proof.Context
+ })
+ }
+ foreach ($id in $evidence.Keys) { if (-not $seen.ContainsKey($id)) { throw "Evidence references a rule outside this corpus: $id" } }
+ $counts = @{}; foreach ($state in @('Ready', 'Conditional', 'Blocked', 'NotApplicable', 'Excluded')) { $counts[$state] = @($rows | Where-Object State -eq $state).Count }
+ $native = $rows.Count - $counts.Excluded; $applicable = $native - $counts.NotApplicable
+ [pscustomobject][ordered]@{
+ SchemaVersion = 1; GeneratedAtUtc = $Now.ToString('o'); Scope = 'native-windows-rule-eligibility'
+ AssessmentBasis = $(if ($EvidencePath) { 'Imported lab artifacts; Ready applies only to the recorded context/time and is not a current-host or universal guarantee.' } else { 'Metadata/configuration assessment only; no event-generation, ingestion or query evidence imported.' })
+ Corpus = [pscustomobject]@{ Sha256 = $corpusHash; MappingSha256 = $mappingHash; Pinned = [bool]$pinned; Manifest = $manifest; MetadataHashAlgorithm = 'wela-metadata-framed-utf16le-base64-sha256-v1'; Kind = 'WELA extracted Hayabusa rule metadata; not the complete upstream Sigma corpus' }
+ RequestedContext = [pscustomobject]@{ Role = $Role; Build = $(if ($Build) { $Build } else { $null }) }
+ Summary = [pscustomobject]@{
+ InputRecords = $raw.Count; UniqueRules = $rows.Count; DuplicateRecords = $duplicateCount
+ NativeCandidates = $native; ApplicableCandidates = $applicable; Ready = $counts.Ready; Conditional = $counts.Conditional
+ Blocked = $counts.Blocked; NotApplicable = $counts.NotApplicable; Excluded = $counts.Excluded
+ ReadyPercentNative = $(if ($native) { 100.0 * $counts.Ready / $native } else { $null })
+ ReadyPercentApplicable = $(if ($applicable) { 100.0 * $counts.Ready / $applicable } else { $null })
+ ReadyPercentFullCorpus = $(if ($rows.Count) { 100.0 * $counts.Ready / $rows.Count } else { $null })
+ ConfigurationEstimateCount = @($rows | Where-Object ConfigurationEstimate).Count
+ ExclusionsByReason = @($rows | Where-Object State -eq 'Excluded' | Group-Object ScopeExclusion | Select-Object Name, Count)
+ DenominatorNote = 'Unknown/incomplete native candidates stay in the denominator. Only explicit Sysmon/external-product sources are excluded; excluded IDs remain in Results.'
+ }
+ Results = @($rows.ToArray())
+ }
+}
+
+function Export-WelaRuleEligibility {
+ param($Report, [string]$ResultsPath, [string]$HtmlPath)
+ if ($ResultsPath) { $Report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
+ if ($HtmlPath) {
+ $encode = { param($value) [Net.WebUtility]::HtmlEncode([string]$value) }
+ $html = New-Object Text.StringBuilder
+ [void]$html.Append('Native rule eligibility
')
+ [void]$html.Append('' + (& $encode ($Report.Summary | ConvertTo-Json -Depth 6)) + '
Requested context
' + (& $encode ($Report.RequestedContext | ConvertTo-Json -Depth 6)) + '
' + (& $encode $Report.Corpus.Sha256) + '
')
+ $html.ToString() | Set-Content -LiteralPath $HtmlPath -Encoding UTF8 -ErrorAction Stop
+ }
+}
+
+Export-ModuleMember -Function Get-WelaRuleEligibility, Export-WelaRuleEligibility
diff --git a/tests/AuditProfileOutput.Tests.ps1 b/tests/AuditProfileOutput.Tests.ps1
index c577ffff..68fcc27a 100644
--- a/tests/AuditProfileOutput.Tests.ps1
+++ b/tests/AuditProfileOutput.Tests.ps1
@@ -1,6 +1,7 @@
# Exercise the real profile, audit renderer, rule coverage and CSV output with
# injected audit observations. Only temporary files are written; no Windows policy changes.
$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot '../modules/RuleEligibility.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
$tokens = $null; $parseErrors = $null
@@ -88,16 +89,12 @@ try {
$usable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv'))
$unusable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv'))
- $expectedUsable = 3
- if ($observed -eq 'Success' -and $role -eq 'DomainController') { $expectedUsable += 2 }
- if ($observed -eq 'Success' -and $role -eq 'ADCS') { $expectedUsable++ }
- Assert-Equal $usable.Count $expectedUsable "$role/$observed does not rescue role-inapplicable GUIDs as usable"
- Assert-Equal ($usable.Count + $unusable.Count) 8 "$role/$observed retains all unique rules in the utilization denominator"
- Assert-Equal ($usable.id -contains 'alternative') $true "$role/$observed permits an applicable alternative source"
- Assert-Equal ($usable.id -contains 'fallback') $true "$role/$observed still rescues an enabled GUID outside the catalog"
- Assert-Equal ($usable.id -contains 'unknown') $false "$role/$observed leaves an unknown source unavailable"
- $expectedUtilization = 'You can utilize {0:N2}% of your detection rules.' -f ($expectedUsable / 8 * 100)
- Assert-Equal ($output.Contains($expectedUtilization)) $true "$role/$observed reports utilization from the complete deduplicated corpus"
+ Assert-Equal $usable.Count 0 "$role/$observed enabled policy alone never establishes usable rules"
+ Assert-Equal ($usable.Count + $unusable.Count) 8 "$role/$observed retains all unique rules in the corpus"
+ $eligibility = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'RuleEligibility.csv'))
+ Assert-Equal $eligibility.Count 8 "$role/$observed exports a reason for every rule"
+ Assert-Equal @($eligibility | Where-Object { $_.State -eq 'Ready' }).Count 0 "$role/$observed supplies no event/query evidence"
+ Assert-Equal ($output.Contains('Evidence-qualified Ready: 0/8 native candidates (0.00% of all 8 unique input rules).') -or $output.Contains('Evidence-qualified Ready: 0/8 native candidates (0,00% of all 8 unique input rules).')) $true "$role/$observed states the explicit numerator and denominator"
foreach ($ruleId in @('directory', 'kerberos', 'ca')) {
$rule = $script:heatmapRules | Where-Object id -eq $ruleId
$applicableRole = if ($ruleId -eq 'ca') { 'ADCS' } else { 'DomainController' }
diff --git a/tests/DomainNtlmAuditOutput.Tests.ps1 b/tests/DomainNtlmAuditOutput.Tests.ps1
index 0d3ab267..4c2d06ac 100644
--- a/tests/DomainNtlmAuditOutput.Tests.ps1
+++ b/tests/DomainNtlmAuditOutput.Tests.ps1
@@ -1,6 +1,7 @@
# Exercise the real audit renderer/CSV exports with injected observations and rules.
# Only a temporary directory is written; no Windows policy is read or changed.
$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot '../modules/RuleEligibility.psm1') -Force
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
@@ -63,8 +64,8 @@ try {
Assert-Equal $outgoingRow.Count 1 'CSV contains one outgoing NTLM setting row'
Assert-Equal $outgoingRow[0].CurrentSetting 'Audit all (1)' 'CSV retains the independent outgoing NTLM state'
Assert-Equal $outgoingRow[0].RuleCount '0' 'Outgoing configuration row claims no detection rules'
- Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv')).Count 1 'Configuration row does not change usable rule counts'
- Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv')).Count 1 'Configuration row does not change unusable rule counts'
+ Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv')).Count 0 'Configuration rows do not supply missing detection evidence'
+ Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv')).Count 2 'Both rules retain their missing-evidence gap'
}
Write-Host "PASS: $script:assertions domain NTLM output assertions (mocked observations; temporary CSV files only)."
} finally {
diff --git a/tests/NativeProviders.Tests.ps1 b/tests/NativeProviders.Tests.ps1
index de0a3b8b..ed87c0cb 100644
--- a/tests/NativeProviders.Tests.ps1
+++ b/tests/NativeProviders.Tests.ps1
@@ -1,5 +1,6 @@
# Real catalog + public audit renderer/exports; Windows reads are injected at the OS boundary.
$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot '../modules/RuleEligibility.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../modules/NativeProviders.psm1') -Force
@@ -181,9 +182,9 @@ try {
}
Assert-Equal @(& $module { $script:channelReads | Where-Object { $_ -match '[*?]' } }).Count 0 'Wildcard rules never trigger wildcard native channel reads'
Assert-Equal $report.Coverage.TotalRules $fixtures.Count 'Rules mapped to both native sources appear once in the full denominator'
- Assert-Equal $report.Coverage.UsableRules 1 'Only the independently enabled Security source receives usable credit'
- Assert-Equal @(Import-Csv (Join-Path $script:ScriptRoot 'UsableRules.csv')).Count 1 'Usable CSV matches conservative JSON coverage'
- Assert-Equal @(Import-Csv (Join-Path $script:ScriptRoot 'UnusableRules.csv')).Count ($fixtures.Count - 1) 'Unconfirmed native rules are retained once in CSV'
+ Assert-Equal $report.Coverage.UsableRules 0 'Enabled Security settings do not establish complete rule readiness'
+ Assert-Equal @(Import-Csv (Join-Path $script:ScriptRoot 'UsableRules.csv')).Count 0 'Usable CSV matches conservative JSON coverage'
+ Assert-Equal @(Import-Csv (Join-Path $script:ScriptRoot 'UnusableRules.csv')).Count $fixtures.Count 'Unconfirmed native rules are retained once in CSV'
Assert-Equal @($script:heatmapRules | Where-Object { $_.id -ne 'security' -and ($_.applicable -or $_.ideal) }).Count 0 'No current or ideal native provider uplift is fabricated'
$html = Get-Content -LiteralPath $htmlPath -Raw
Assert-Equal ($html -match 'Not installed') $true 'HTML retains absent-feature state'
@@ -211,7 +212,7 @@ try {
Assert-Equal ([bool]$provider.Error.Message) $true "$name provider read failure retains evidence"
Assert-Equal @($failed.Results | Where-Object { $_.NativeSources.Provider.Name -contains $name -and $_.CurrentSetting -ne 'Unknown' }).Count 0 "$name read failure is visible in the row state"
}
- Assert-Equal $failed.Coverage.UsableRules 1 'Provider read failures do not inflate rule coverage'
+ Assert-Equal $failed.Coverage.UsableRules 0 'Provider read failures do not inflate rule coverage'
Write-Host "PASS: $script:assertions native provider/output assertions; no Windows settings changed."
} finally {
Remove-Item -LiteralPath $script:ScriptRoot -Recurse -Force
diff --git a/tests/RuleEligibility.Tests.ps1 b/tests/RuleEligibility.Tests.ps1
new file mode 100644
index 00000000..5e621aa7
--- /dev/null
+++ b/tests/RuleEligibility.Tests.ps1
@@ -0,0 +1,191 @@
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot '../modules/RuleEligibility.psm1') -Force
+$checks = 0
+function Assert($Value, $Message) { if (-not $Value) { throw $Message }; $script:checks++ }
+function Assert-Throws($Action, $Message) { $caught=$false; try { & $Action | Out-Null } catch { $caught=$true }; Assert $caught $Message }
+$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-eligibility-' + [guid]::NewGuid().ToString('N'))
+$null = New-Item -ItemType Directory -Path $root
+$corpusPath = Join-Path $root 'rules.json'; $manifestPath = Join-Path $root 'manifest.json'; $bundlePath = Join-Path $root 'evidence.json'
+$mappingPath = Join-Path $PSScriptRoot '../config/eid_subcategory_mapping.csv'
+$now = [DateTime]::Parse('2026-09-19T12:00:00Z').ToUniversalTime()
+function Save-Json($Object, $Path) { ConvertTo-Json -InputObject $Object -Depth 20 | Set-Content -LiteralPath $Path -Encoding UTF8 }
+function Hash($Path) { (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }
+function Save-Corpus($Rules) {
+ Save-Json @($Rules) $corpusPath
+ Save-Json @{schemaVersion=1;corpusSha256=(Hash $corpusPath);mappingSha256=(Hash $mappingPath)} $manifestPath
+}
+function Fixture-Rule($Id='process') {
+ [pscustomobject]@{id=$Id;title='Benign process fixture';category='process_creation';service='';channel=@('sec');event_ids=@('4688');subcategory_guids=@('0CCE922B-69AE-11D9-BED3-505054503030');level='low';description='Synthetic evidence; not a live Windows test.';tags=@()}
+}
+function Report([switch]$Evidence) {
+ $args = @{CorpusPath=$corpusPath;ManifestPath=$manifestPath;MappingPath=$mappingPath;Now=$now}
+ if ($Evidence) { $args.EvidencePath=$bundlePath }
+ Get-WelaRuleEligibility @args
+}
+function Save-Artifact($Name, $Object, [switch]$Text) {
+ $path = Join-Path $root ($Name + '.txt')
+ if ($Text) { [IO.File]::WriteAllText($path, [string]$Object, [Text.UTF8Encoding]::new($false)) }
+ else { Save-Json $Object $path }
+ $script:record.artifacts.$Name = @{path=($Name+'.txt');sha256=(Hash $path)}
+}
+function Save-Bundle { Save-Json @{schemaVersion=1;kind='WelaNativeRuleEvidence';records=@($script:record)} $bundlePath }
+function Reset-Evidence {
+ Save-Corpus @(Fixture-Rule)
+ $base = Report
+ $script:record = @{id='process';metadataSha256=$base.Results[0].MetadataSha256;corpusSha256=(Hash $corpusPath);mappingSha256=(Hash $mappingPath);adapter='security-single-event-exact-v1';fieldMappings=@{EventID='System.EventID';Image='EventData.NewProcessName';CommandLine='EventData.CommandLine'};artifacts=@{}}
+ $script:definition = @{id='process';logsource=@{product='windows';category='process_creation'};detection=@{selection=@{EventID=4688;Image='C:\Windows\System32\notepad.exe';CommandLine='notepad.exe --wela-fixture'};condition='selection'}}
+ Save-Artifact sourceRule "id: process`nlogsource: {product: windows, category: process_creation}`ndetection:`n selection:`n EventID: 4688`n Image: C:\Windows\System32\notepad.exe`n CommandLine: notepad.exe --wela-fixture`n condition: selection`n" -Text
+ Save-Artifact normalizedRule $script:definition
+ $context=@{computer='lab.example.test';role='Client';build=26100;patch='fixture-1';domainJoined=$false;installedRoles=@();backend='fixture-backend';backendVersion='1'}
+ $script:before=@{context=$context;capturedAtUtc='2026-09-19T10:00:00Z';auditPolicies=@{'0CCE922B-69AE-11D9-BED3-505054503030'=0}}
+ $script:after=@{context=$context;capturedAtUtc='2026-09-19T10:02:00Z';auditPolicies=@{'0CCE922B-69AE-11D9-BED3-505054503030'=1};auditPrecedence=@{kind='DWord';value=1};securityChannelEnabled=$true;commandLineCapture=@{kind='DWord';value=1}}
+ Save-Artifact beforeState $script:before; Save-Artifact afterState $script:after
+ $script:eventXml=' ')
+ foreach ($row in $Report.Results) {
+ [void]$html.Append('Rule State Reasons and recorded evidence scope ')
+ }
+ [void]$html.Append('' + (& $encode ($row.Title + ' [' + $row.Id + ']')) + ' ' + (& $encode $row.State) + ' ' + (& $encode ((@($row.Reasons) + @($row.ScopeExclusion)) -join '; ')))
+ if ($row.State -eq 'Ready') {
+ [void]$html.Append(' ' + (& $encode $row.EvidenceAsOfUtc) + '' + (& $encode ($row.EvidenceContext | ConvertTo-Json -Depth 6)) + '
')
+ }
+ [void]$html.Append('
(.*?)') + Assert $contextBlock.Success 'The evidence context remains inside its HTML text block.' + $renderedContext=[Net.WebUtility]::HtmlDecode($contextBlock.Groups[1].Value) | ConvertFrom-Json + Assert ($renderedContext.computer -ceq $hostileContext) 'HTML text and JSON decoding preserve the exact context value without creating markup.' + foreach ($name in @('sourceRule','normalizedRule','review','beforeState','afterState','eventXml','ingestion','query','queryResult')) { + Reset-Evidence;$script:record.artifacts.Remove($name);Assert-NotReady "Missing $name prevents Ready." + } + Reset-Evidence;$script:record.metadataSha256='0'*64;Assert-NotReady 'Metadata identity mismatches cannot import readiness.' + Reset-Evidence;$script:record.mappingSha256='0'*64;Assert-NotReady 'Changed EventID mapping invalidates old evidence.' + Reset-Evidence;$script:record.artifacts.eventXml.sha256='0'*64;Assert-NotReady 'Changed native XML invalidates its evidence chain.' + Reset-Evidence;$script:record.artifacts.query.path='../outside.txt';Assert-NotReady 'Artifact traversal is rejected before access.' + Reset-Evidence;$script:record.artifacts.query.path='\\server\share\query.txt';Assert-NotReady 'UNC artifacts are rejected before access.' + foreach ($invalidNumber in @('1', $true)) { + Reset-Evidence;$script:after.auditPrecedence.value=$invalidNumber;Save-Artifact afterState $script:after;Assert-NotReady 'Precedence evidence requires a numeric DWORD, not a coercible string/boolean.' + Reset-Evidence;$script:after.commandLineCapture.value=$invalidNumber;Save-Artifact afterState $script:after;Assert-NotReady 'Command-line evidence requires a numeric DWORD.' + } + foreach ($invalidExit in @('0', $false)) { + Reset-Evidence;$script:queryResult.exitCode=$invalidExit;Save-Artifact queryResult $script:queryResult;Assert-NotReady 'Query exit code must be numeric, never a coercible string/boolean.' + } + Reset-Evidence;$script:after.auditPolicies.'0CCE922B-69AE-11D9-BED3-505054503030'=2;Save-Artifact afterState $script:after;Assert-NotReady 'Failure-only auditing cannot satisfy successful 4688 evidence.' + Reset-Evidence;$script:after.commandLineCapture.value=0;Save-Artifact afterState $script:after;Assert-NotReady 'Observed XML cannot substitute for unverified command-line capture policy.' + Reset-Evidence;$script:eventXml=$script:eventXml.Replace('notepad.exe --wela-fixture','');Save-Artifact eventXml $script:eventXml -Text;Assert-NotReady 'Empty 4688 command-line field prevents readiness.' + Reset-Evidence;$script:record.fieldMappings.Image='EventData.CommandLine';Assert-NotReady 'A supplied alias cannot substitute a different field for Image.' + Reset-Evidence;$script:definition.detection.condition='selection and not filter';Save-Artifact normalizedRule $script:definition;$script:review.normalizedRuleSha256=$script:record.artifacts.normalizedRule.sha256;Save-Artifact review $script:review;Assert-NotReady 'Unsupported complete Boolean logic is not partially evaluated.' + Reset-Evidence;$script:definition.detection.selection['Image|endswith']='notepad.exe';Save-Artifact normalizedRule $script:definition;$script:review.normalizedRuleSha256=$script:record.artifacts.normalizedRule.sha256;Save-Artifact review $script:review;Assert-NotReady 'Unsupported field modifiers cannot be silently ignored.' + Reset-Evidence;$script:definition.logsource.service='sysmon';Save-Artifact normalizedRule $script:definition;$script:review.normalizedRuleSha256=$script:record.artifacts.normalizedRule.sha256;Save-Artifact review $script:review;Assert-NotReady 'A Sysmon source cannot use native Security evidence.' + Reset-Evidence;$script:definition.detection.selection.Hashes='abc';$script:record.fieldMappings.Hashes='EventData.NewProcessName';Save-Artifact normalizedRule $script:definition;$script:review.normalizedRuleSha256=$script:record.artifacts.normalizedRule.sha256;Save-Artifact review $script:review;Assert-NotReady 'A fabricated hash alias cannot turn process names into rich 4688 fields.' + Reset-Evidence;$script:queryResult.matched=$false;Save-Artifact queryResult $script:queryResult;Assert-NotReady 'Successful collection without a query match is not Ready.' + Reset-Evidence;$script:queryResult.exitCode=1;Save-Artifact queryResult $script:queryResult;Assert-NotReady 'Failed query execution cannot be overridden by matched=true.' + Reset-Evidence;$script:ingestion.normalizedFields.Image='wrong.exe';Save-Artifact ingestion $script:ingestion;Assert-NotReady 'Backend normalization must preserve the required source field.' + Reset-Evidence;$script:before.capturedAtUtc='2020-01-01T00:00:00Z';Save-Artifact beforeState $script:before;Assert-NotReady 'Stale evidence cannot silently establish present eligibility.' + Reset-Evidence;$script:after.capturedAtUtc='2040-01-01T00:00:00Z';Save-Artifact afterState $script:after;Assert-NotReady 'A future after-state snapshot cannot grant readiness.' + Reset-Evidence;$script:after.capturedAtUtc='2026-09-19T10:06:00Z';Save-Artifact afterState $script:after;Assert-NotReady 'A review cannot certify a state snapshot captured later.' + Reset-Evidence;Save-Artifact eventXml ']>