Assess native rule eligibility with explicit evidence gates

This commit is contained in:
Shirofune-Security committed 2026-09-19 07:24:04 +09:00
1 parent 9d993a2a7e
commit 36ad97114c
17 files changed
+781 -27

No files matched your search

+4
View File
@@ -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: |
+32
View File
@@ -0,0 +1,32 @@
name: Native rule eligibility tests
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
eligibility:
runs-on: windows-latest
strategy:
matrix:
shell: [powershell, pwsh]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Evidence gates and full-corpus bounds
shell: ${{ matrix.shell }}
run: ./tests/RuleEligibility.Tests.ps1
- name: Existing audit output remains conservative
shell: ${{ matrix.shell }}
run: |
./tests/NativeProviders.Tests.ps1
./tests/AuditProfileOutput.Tests.ps1
./tests/DomainNtlmAuditOutput.Tests.ps1
- name: Public CLI and real read-only Windows observations
shell: ${{ matrix.shell }}
run: |
./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"
+1
View File
@@ -4,6 +4,7 @@
**改善:**
- 読み取り専用の`rule-eligibility`を追加し、ルール・対応表のハッシュ、ルールごとの判定理由、対象外の理由、分子・分母を明示します。任意で取り込んだラボ資料を、対応範囲を限定した完全なルール定義、ネイティブXML、設定、収集・クエリ実行の証拠と照合し、未対応・未確認の項目はConditionalとします。監査のCSV/JSON/HTMLとNavigator出力では、設定が有効なだけでルールを利用可能と判定しません。取り込んだReady判定は記録された環境・時点に限られ、実環境での一連の検証を保証しません。 (#387) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
+1
View File
@@ -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. (#387) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
+53 -7
View File
@@ -37,6 +37,9 @@
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
[string[]]$WmiNamespace,
[switch]$WmiIncludeChildren,
[string]$RuleEvidencePath,
[string]$RuleCorpusPath,
[string]$RuleManifestPath,
[switch]$Help
)
@@ -57,6 +60,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.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/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
. (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1")
@@ -627,6 +631,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 {
@@ -649,6 +668,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' })
@@ -716,16 +736,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") {
@@ -737,6 +760,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) {
@@ -744,9 +768,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
@@ -757,7 +781,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 ""
}
@@ -1047,7 +1072,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
@@ -1708,6 +1734,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 applocker-readiness -ResultsPath applocker.json
./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml
@@ -1743,6 +1771,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.'
}
@@ -1793,6 +1824,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" }
}
'channel-settings' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+17
View File
@@ -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."
]
}
+2 -2
View File
@@ -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
+71
View File
@@ -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. `PolicyPrerequisites` inventories the catalog's requirements; it is not a separate failed-check verdict. Identical duplicate IDs count once; conflicting duplicate IDs are rejected. The report provides input record count, unique rule count, native candidate count, role-applicable count and exclusion groups. It reports **Ready / native candidates**, **Ready / applicable candidates**, and **Ready / full unique corpus** separately. Empty denominators produce null percentages. Unknown and incomplete native candidates remain in the denominator, including generic categories lacking a verified adapter. These denominators therefore differ from the earlier standalone comparison report's explicitly narrower modeling boundary; do not compare their percentages without reconciling scope and corpus versions.
Category-only GUIDs, blank EventID rows and ambiguous mappings cannot establish subcategory/outcome readiness. In particular, the mapping lists both Token Right Adjusted Events and Authorization Policy Change for 4703; the importer does not arbitrarily choose one. Directory-service and certificate-template change events must be assessed on their source DC, not credited automatically to a member-server CA.
`audit-settings -Baseline` still reports actual configuration. Its `UsableRules.csv`, headline percentage, JSON/HTML and current/ideal Navigator outputs no longer promote rules solely from an enabled policy or channel. `RuleEligibility.csv` records all states/reasons. `ConfigurationEstimate` is retained separately and is not detection readiness. Without lab artifacts those outputs contain no Ready rules. Imported historical Ready results are deliberately reviewed in the separate command; they are never silently applied to the currently audited host. The familiar `UnusableRules.csv` includes unconfirmed rules, not only proven failures.
## Evidence trust and supported parsing boundary
The operator supplies trusted lab records. WELA verifies file hashes, schema consistency, source-event identity, supported rule logic, required field presence, timing, policy outcome and cross-references. It **does not independently authenticate who collected the artifacts, prove a reviewer's assertions, or run the backend**. A matching hash and `matched: true` alone are insufficient: all nine artifacts and their links must pass. A coherent imported record is still evidence from that recorded test, not a production SLA, broad attack validation or a guarantee after policy refresh.
Version 1 supports `security-single-event-exact-v1`: one native Security event from `Microsoft-Windows-Security-Auditing`, one unambiguous canonical audit mapping, and full normalized rule JSON whose detection consists solely of a nonempty `selection` mapping with scalar exact values and `condition: selection`. Product must be Windows; a service, if present, must be `security`; the only supported generic category is `process_creation` with 4688. Filters, wildcards, lists, field modifiers, correlation, extra detection clauses, unknown source prerequisites, unsupported/deprecated rules, other provider adapters and SACL-dependent rules remain Conditional. Unsupported syntax is never partially evaluated. Adding further adapters requires reviewed semantics and regression fixtures.
The normalized full rule is an operator-reviewed JSON representation of the original source rule. Both artifacts are hashed and linked by an explicit normalization review. The original source is retained as evidence, never executed or interpreted by a general YAML loader. This is a human attestation boundary; WELA cannot prove the normalization faithfully represents arbitrary YAML. A false review can invalidate the result.
Field mappings are limited to `System.EventID` and same-named `EventData` fields, plus reviewed 4688 aliases: `Image` → `NewProcessName`, `ParentImage` → `ParentProcessName`, `ProcessId` → `NewProcessId`, and `ParentProcessId` → `ProcessId`. The original event and ingested normalized values must match the exact rule selection. The importer checks 4688 event versions and its documented native field set; it does not invent hashes, original filenames or other rich telemetry. A required empty command line fails, and nonempty command-line evidence also requires a recorded DWORD command-line capture policy. See [Microsoft's 4688 schema](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4688).
## Bundle and artifact contract
The evidence bundle has `schemaVersion: 1`, `kind: WelaNativeRuleEvidence`, and a `records` array. Each record contains:
- `id`, `metadataSha256` from the per-rule report, and the exact `corpusSha256` / `mappingSha256`.
- `adapter: security-single-event-exact-v1` and `fieldMappings`, for example `{"EventID":"System.EventID","Image":"EventData.NewProcessName"}`.
- `artifacts`: the nine names below, each containing a relative local `path` and exact file `sha256`.
| Artifact | Required contents |
| --- | --- |
| `sourceRule` | Complete original rule artifact retained for review/provenance. |
| `normalizedRule` | Complete reviewed JSON rule: `id`, `logsource`, and supported `detection` described above. |
| `review` | `ruleId`, `reviewer`, `reviewedAtUtc`, both `sourceRuleSha256` and `normalizedRuleSha256`, and exact statement `Complete rule normalization reviewed; no detection logic omitted.` |
| `beforeState` | `context`, `capturedAtUtc`, and `auditPolicies` containing the canonical GUID's observed mask before the test. |
| `afterState` | Same context, capture time and policy map; `auditPrecedence: {kind: DWord, value: 1}`, boolean `securityChannelEnabled`; for required 4688 command lines, `commandLineCapture: {kind: DWord, value: 1}`. |
| `eventXml` | One complete native Event XML with provider, EventID, version, keywords, UTC time, record ID, channel, computer and named EventData. DTDs, external entities, duplicate fields and unsupported payloads are refused. |
| `ingestion` | `eventSha256`, source `computer`, `channel`, `eventId`, `recordId`, `receivedAtUtc`, backend identity/version and `normalizedFields` for every selector. |
| `query` | Exact translated query text used by the backend test; never run by WELA. |
| `queryResult` | `ruleSha256` for normalized rule, `querySha256`, `eventSha256`, `backend`, `backendVersion`, `executedAtUtc`, boolean `matched: true`, numeric `exitCode: 0`. |
Each state artifact's `context` must contain `computer`, `role`, integer `build`, nonempty `patch`, boolean `domainJoined`, `installedRoles` array, `backend` and `backendVersion`. Before/after identity must agree. All timestamps use UTC `Z`. Before-state precedes the event; event precedes after-state and ingestion; ingestion precedes query execution; review follows query execution. Evidence beginning more than 30 days before assessment, or future-dated/inconsistent evidence, stays Conditional. This fixed freshness limit is an assessment bound, not a claim that policy persists for 30 days. Record exact software versions, snapshots, test actions and capture procedure in the source lab notes.
Only relative files inside the bundle directory are read. Traversal, UNC paths, linked ancestors/files, empty files and artifacts over 4 MiB are rejected; bundle/corpus inputs are limited to 16 MiB. JSON property collisions are rejected in evidence. These checks are local observations, not an atomic defense against an administrator replacing directories during review; keep the evidence directory controlled and immutable during assessment.
`tests/RuleEligibility.Tests.ps1` builds a complete **synthetic** fixture and adversarial variants that illustrate this contract. It is not evidence of a real Windows event, source-policy change, ingestion or backend execution and must not be reused as such.
## Lab completion
Use separate Windows 11, member-server, DC and member-server CA snapshots. Record build/patch, domain membership, roles, corpus/mapping hashes and backend version. Capture before state, apply an approved source profile, generate a benign operation corresponding to one explicitly selected full rule, save native XML, then capture after state. Confirm field normalization and source identity at the collector/backend, execute the exact translated query, retain its match and independently review the normalization. SACL-dependent rules require additional reviewed adapters; a file/AD/WMI policy toggle alone never closes that gap. Measure event volume, loss/backlog and overhead separately and preserve those records; this importer does not infer EPS or storage capacity.
Hosted CI uses synthetic fixtures and read-only Windows observations. It supplies no clean-image before/after matrix, genuine backend-query/ingestion result or end-to-end detection claim. Issue #387 remains open for those acceptance tests and for additional rule/parser adapters.
+7 -2
View File
@@ -177,15 +177,16 @@ function Get-WelaNativeSourceState {
function Export-WelaAuditAssessment {
[CmdletBinding()]
param([array]$Rows, [array]$Rules, [string]$Baseline, [string]$ResultsPath, [string]$HtmlPath)
param([array]$Rows, [array]$Rules, [string]$Baseline, [string]$ResultsPath, [string]$HtmlPath, $Eligibility)
$report = [pscustomobject][ordered]@{
SchemaVersion = 1; AssessedAtUtc = [DateTime]::UtcNow.ToString('o'); Baseline = $Baseline
Scope = 'Built-in Windows functionality; Sysmon and external telemetry are excluded.'
Coverage = [pscustomobject]@{
TotalRules = @($Rules).Count
UsableRules = @($Rules | Where-Object applicable -eq $true).Count
Note = 'Native channel/provider observations do not prove event generation or forwarding. Conditional and unknown sources receive no usable-rule credit. Other audit-policy estimates retain their documented prerequisites.'
Note = 'Only evidence-qualified Ready rules are usable. Policy/channel matches are configuration estimates; missing full rule logic, fields, outcomes, SACL, ingestion or query evidence remains Conditional.'
}
Eligibility = $Eligibility
Results = @($Rows | Select-Object Category, SubCategory, CurrentSetting, DefaultSetting, RecommendedSetting, RuleCount, ChannelState, GenerationReadiness, NativeSources, Note)
}
if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
@@ -193,6 +194,10 @@ function Export-WelaAuditAssessment {
$parts = @('<!doctype html><html lang="en"><head><meta charset="utf-8"><title>WELA audit assessment</title><style>body{font:16px sans-serif;max-width:1100px;margin:2rem auto;padding:1rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f4f4f4;padding:1rem}section{border-top:1px solid #ccc;margin-top:2rem}</style></head><body><h1>WELA audit assessment</h1>')
$parts += '<p>' + [System.Net.WebUtility]::HtmlEncode($report.Scope) + '</p>'
$parts += '<p>' + [System.Net.WebUtility]::HtmlEncode($report.Coverage.Note) + '</p>'
if ($Eligibility) {
$parts += '<h2>Rule eligibility</h2><pre>' + [System.Net.WebUtility]::HtmlEncode(($Eligibility.Summary | ConvertTo-Json -Depth 6)) + '</pre>'
$parts += '<details><summary>All rule states and reasons</summary><pre>' + [System.Net.WebUtility]::HtmlEncode(($Eligibility.Results | ConvertTo-Json -Depth 8)) + '</pre></details>'
}
foreach ($row in $report.Results) {
$parts += '<section><h2>' + [System.Net.WebUtility]::HtmlEncode(($row.Category + ' / ' + $row.SubCategory)) + '</h2><pre>'
$parts += [System.Net.WebUtility]::HtmlEncode(($row | ConvertTo-Json -Depth 14))
+396
View File
@@ -0,0 +1,396 @@
# 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 Get-WelaEligibilityRuleHash {
param($Rule)
$ordered = [ordered]@{}
foreach ($name in @('id', 'title', 'level', 'category', 'service', 'channel', 'event_ids', 'subcategory_guids', 'description', 'tags')) {
$ordered[$name] = $Rule.$name
}
Get-WelaEligibilityTextHash (ConvertTo-Json -InputObject $ordered -Depth 12 -Compress)
}
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; 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('<!doctype html><html lang="en"><meta charset="utf-8"><title>WELA native rule eligibility</title><style>body{font:16px sans-serif;margin:2rem}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #ccc;padding:.5rem;text-align:left}code{overflow-wrap:anywhere}</style><h1>Native rule eligibility</h1>')
[void]$html.Append('<p>' + (& $encode $Report.AssessmentBasis) + '</p><pre>' + (& $encode ($Report.Summary | ConvertTo-Json -Depth 6)) + '</pre><p>Corpus SHA256: <code>' + (& $encode $Report.Corpus.Sha256) + '</code></p><table><tr><th>Rule</th><th>State</th><th>Reasons</th></tr>')
foreach ($row in $Report.Results) { [void]$html.Append('<tr><td>' + (& $encode ($row.Title + ' [' + $row.Id + ']')) + '</td><td>' + (& $encode $row.State) + '</td><td>' + (& $encode ((@($row.Reasons) + @($row.ScopeExclusion)) -join '; ')) + '</td></tr>') }
[void]$html.Append('</table></html>')
$html.ToString() | Set-Content -LiteralPath $HtmlPath -Encoding UTF8 -ErrorAction Stop
}
}
Export-ModuleMember -Function Get-WelaRuleEligibility, Export-WelaRuleEligibility
+7 -10
View File
@@ -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/AuditProfiles.psm1') -Force
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors)
@@ -87,16 +88,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' }
+3 -2
View File
@@ -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 {
+5 -4
View File
@@ -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/NativeProviders.psm1') -Force
$module = Get-Module NativeProviders
& $module {
@@ -179,9 +180,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'
@@ -209,7 +210,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
+152
View File
@@ -0,0 +1,152 @@
$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='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing"/><EventID>4688</EventID><Version>2</Version><Keywords>0x8020000000000000</Keywords><TimeCreated SystemTime="2026-09-19T10:01:00Z"/><EventRecordID>42</EventRecordID><Channel>Security</Channel><Computer>lab.example.test</Computer></System><EventData><Data Name="NewProcessName">C:\Windows\System32\notepad.exe</Data><Data Name="CommandLine">notepad.exe --wela-fixture</Data></EventData></Event>'
Save-Artifact eventXml $script:eventXml -Text
$script:ingestion=@{computer='lab.example.test';channel='Security';eventId=4688;recordId=42;eventSha256=$script:record.artifacts.eventXml.sha256;backend='fixture-backend';backendVersion='1';receivedAtUtc='2026-09-19T10:03:00Z';normalizedFields=@{EventID='4688';Image='C:\Windows\System32\notepad.exe';CommandLine='notepad.exe --wela-fixture'}}
Save-Artifact ingestion $script:ingestion
Save-Artifact query 'Fixture backend query artifact; this test never executes a query.' -Text
$script:queryResult=@{backend='fixture-backend';backendVersion='1';executedAtUtc='2026-09-19T10:04:00Z';eventSha256=$script:record.artifacts.eventXml.sha256;ruleSha256=$script:record.artifacts.normalizedRule.sha256;querySha256=$script:record.artifacts.query.sha256;matched=$true;exitCode=0}
Save-Artifact queryResult $script:queryResult
$script:review=@{ruleId='process';reviewer='Fixture author (synthetic evidence only)';reviewedAtUtc='2026-09-19T10:05:00Z';statement='Complete rule normalization reviewed; no detection logic omitted.';sourceRuleSha256=$script:record.artifacts.sourceRule.sha256;normalizedRuleSha256=$script:record.artifacts.normalizedRule.sha256}
Save-Artifact review $script:review
Save-Bundle
}
function Assert-NotReady($Message) { Save-Bundle; $r=Report -Evidence; Assert ($r.Summary.Ready -eq 0 -and $r.Results[0].Reasons.Count -gt 0) $Message }
try {
Save-Corpus @(Fixture-Rule)
$r=Report
Assert ($r.Summary.Ready -eq 0 -and $r.Results[0].State -eq 'Conditional') 'Lossy metadata cannot demonstrate usable rules.'
$observed=[pscustomobject]@{CurrentSetting='Success';Rules=@(Fixture-Rule)}
$r=Get-WelaRuleEligibility -CorpusPath $corpusPath -ManifestPath $manifestPath -Observations @($observed)
Assert ($r.Summary.Ready -eq 0 -and $r.Results[0].ConfigurationEstimate) 'Enabled Security audit policy is an estimate, never Ready.'
$observed.CurrentSetting='No Auditing'
$r=Get-WelaRuleEligibility -CorpusPath $corpusPath -ManifestPath $manifestPath -Observations @($observed)
Assert ($r.Results[0].State -eq 'Blocked') 'A positively observed disabled source is distinct from missing evidence.'
$sysmon=Fixture-Rule 'sysmon';$sysmon.channel=@('Microsoft-Windows-Sysmon/Operational')
$external=Fixture-Rule 'exchange';$external.service='msexchange-management'
Save-Corpus @((Fixture-Rule),(Fixture-Rule),$sysmon,$external)
$r=Report
Assert ($r.Summary.InputRecords -eq 4 -and $r.Summary.UniqueRules -eq 3 -and $r.Summary.DuplicateRecords -eq 1) 'Identical duplicate IDs count once with explicit raw input count.'
Assert ($r.Summary.NativeCandidates -eq 1 -and $r.Summary.Excluded -eq 2 -and $r.Results.Count -eq 3) 'Explicit exclusions remain inspectable and all denominator choices are visible.'
$conflict=Fixture-Rule;$conflict.title='Conflicting title';Save-Corpus @((Fixture-Rule),$conflict)
Assert-Throws { Report } 'Conflicting duplicate metadata cannot silently change the denominator.'
Save-Corpus @();$r=Report
Assert ($null -eq $r.Summary.ReadyPercentNative -and $null -eq $r.Summary.ReadyPercentFullCorpus) 'An empty denominator is unknown rather than fabricated zero percent.'
$dc=Fixture-Rule 'directory';$dc.event_ids=@('5136');$dc.subcategory_guids=@('0CCE923C-69AE-11D9-BED3-505054503030');Save-Corpus @($dc)
$r=Get-WelaRuleEligibility -CorpusPath $corpusPath -ManifestPath $manifestPath -Role ADCS
Assert ($r.Results[0].State -eq 'NotApplicable' -and $r.Summary.ApplicableCandidates -eq 0) 'Directory change events originate on the DC, not automatically on an AD CS member.'
$unknown=Fixture-Rule;$unknown.event_ids=@('4703');Save-Corpus @($unknown);$r=Report
Assert ($r.Results[0].Reasons -contains 'AuditMappingAmbiguousOrUnknown') 'Multiple canonical mappings for 4703 remain ambiguous.'
$unknown.event_ids=@('4608');Save-Corpus @($unknown);$r=Report
Assert ($r.Results[0].Reasons -contains 'AuditMappingAmbiguousOrUnknown') 'A category-level GUID beside a canonical mapping is not silently ignored.'
$unknown.event_ids=@('');Save-Corpus @($unknown);$r=Report
Assert ($r.Results[0].Reasons -contains 'UnsupportedMetadataShape') 'Blank event IDs never match category headers.'
$unknown.event_ids='4688';Save-Corpus @($unknown);$r=Report
Assert ($r.Results[0].Reasons -contains 'UnsupportedMetadataShape') 'Unexpected scalar metadata fails conservatively instead of being partially parsed.'
Reset-Evidence;$r=Report -Evidence
Assert ($r.Summary.Ready -eq 1 -and $r.Results[0].State -eq 'Ready') ('Coherent complete synthetic evidence demonstrates the importer gates: '+($r.Results[0].Reasons -join '; '))
Assert ($r.Results[0].EvidenceContext.computer -eq 'lab.example.test' -and $r.AssessmentBasis -like '*not a current-host*') 'Imported Ready states retain their recorded host/time and explicit limitations.'
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 '<!DOCTYPE Event [<!ENTITY x SYSTEM "file:///etc/passwd">]><Event>&x;</Event>' -Text;Assert-NotReady 'DTD/external entities are rejected.'
Reset-Evidence;Save-Artifact queryResult '{"matched":false,"matched":true}' -Text;Assert-NotReady 'Duplicate JSON keys are rejected on both PowerShell editions.'
Reset-Evidence;Save-Artifact queryResult '{"matched":false,"MATCHED":true}' -Text;Assert-NotReady 'Case-colliding JSON keys cannot change evidence meaning.'
Reset-Evidence;$r=Get-WelaRuleEligibility -CorpusPath $corpusPath -ManifestPath $manifestPath -EvidencePath $bundlePath -Role DomainController -Now $now
Assert ($r.Summary.Ready -eq 0) 'Imported host role must match an explicit assessment filter.'
Reset-Evidence;$script:record.id='unknown-rule';Save-Bundle;Assert-Throws { Report -Evidence } 'Evidence for a rule absent from the pinned corpus is rejected.'
$originalMappingPath=$mappingPath
try {
$mappingPath=Join-Path $root 'invalid-mapping.csv'
[IO.File]::WriteAllText($mappingPath, [IO.File]::ReadAllText($originalMappingPath).Replace('"4688","Detailed Tracking","Process Creation"','"4688","Detailed Tracking","RPC Events"'))
Reset-Evidence;$r=Report -Evidence
Assert ($r.Summary.Ready -eq 0 -and $r.Results[0].Reasons -contains 'AuditMappingAmbiguousOrUnknown') 'A known GUID with the wrong canonical name cannot grant Ready.'
} finally { $mappingPath=$originalMappingPath }
# Exercise only the option guard, never a mutating CLI dispatch.
$tokens=$null;$errors=$null
$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'),[ref]$tokens,[ref]$errors)
Assert ($errors.Count -eq 0) 'The public CLI parses with eligibility options.'
$guard=$ast.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if (`$Cmd -ne 'rule-eligibility'") } | Select-Object -First 1
Assert ($null -ne $guard) 'Dedicated eligibility options have a public pre-dispatch guard.'
$exercise=[scriptblock]::Create('param($Cmd,$RuleEvidencePath,$RuleCorpusPath,$RuleManifestPath)' + [Environment]::NewLine + $guard.Extent.Text)
Assert-Throws { & $exercise -Cmd configure -RuleEvidencePath '' } 'Even an explicitly empty evidence option is not silently ignored by configure.'
& $exercise -Cmd rule-eligibility -RuleEvidencePath 'operator.json'
$timer=[Diagnostics.Stopwatch]::StartNew();$full=Get-WelaRuleEligibility
Assert ($full.Corpus.Pinned -and $full.Summary.UniqueRules -eq $full.Corpus.Manifest.uniqueRuleCount) 'Full shipped corpus and mapping match the manifest.'
Assert ($full.Summary.Ready -eq 0 -and ($full.Summary.NativeCandidates+$full.Summary.Excluded) -eq $full.Summary.UniqueRules) 'Full corpus is partitioned without unverified detection credit.'
$json=Join-Path $root 'full.json';$html=Join-Path $root 'full.html'
Export-WelaRuleEligibility -Report $full -ResultsPath $json -HtmlPath $html
Assert ((Get-Item $json).Length -lt 8388608 -and (Get-Item $html).Length -lt 8388608) 'Full per-rule reports stay below 8 MiB each.'
Assert ($timer.Elapsed.TotalSeconds -lt 180) 'Full-corpus assessment/export completes within a bounded three-minute budget.'
Write-Host ('Full corpus: {0} unique rules, {1:N1}s, JSON {2} bytes.' -f $full.Summary.UniqueRules,$timer.Elapsed.TotalSeconds,(Get-Item $json).Length)
Write-Host "PASS: $checks native rule eligibility assertions. Evidence is synthetic; no Windows event or backend query was generated."
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
+28
View File
@@ -0,0 +1,28 @@
"""Record exact extracted inputs; never invent an upstream revision for old data."""
import argparse
import hashlib
import json
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("--rules-commit")
parser.add_argument("--generator-commit")
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
corpus = root / "config/security_rules.json"
mapping = root / "config/eid_subcategory_mapping.csv"
data = json.loads(corpus.read_text(encoding="utf-8-sig"))
manifest = {
"schemaVersion": 1,
"corpusKind": "WELA extracted Hayabusa metadata; not complete upstream Sigma",
"corpusSha256": hashlib.sha256(corpus.read_bytes()).hexdigest(),
"mappingSha256": hashlib.sha256(mapping.read_bytes()).hexdigest(),
"recordCount": len(data),
"uniqueRuleCount": len({item["id"] for item in data}),
"rulesRepository": "https://github.com/Yamato-Security/hayabusa-rules",
"rulesCommit": args.rules_commit,
"generatorRepository": "https://github.com/Yamato-Security/WELA-RulesGenerator",
"generatorCommit": args.generator_commit,
"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."],
}
(root / "config/rule_eligibility_manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+1
View File
@@ -7,6 +7,7 @@
**改善:**
- 読み取り専用の`rule-eligibility`を追加し、ルール・対応表のハッシュ、ルールごとの判定理由、対象外の理由、分子・分母を明示します。任意で取り込んだラボ資料を、対応範囲を限定した完全なルール定義、ネイティブXML、設定、収集・クエリ実行の証拠と照合し、未対応・未確認の項目はConditionalとします。監査のCSV/JSON/HTMLとNavigator出力では、設定が有効なだけでルールを利用可能と判定しません。取り込んだReady判定は記録された環境・時点に限られ、実環境での一連の検証を保証しません。 (#387) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
+1
View File
@@ -7,6 +7,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. (#387) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)