EndpointComplianceScan.psm1

# EndpointComplianceScan 1.0.0, built by tools/Build-Module.ps1 from the scripts
# beside this file. Each command runs its script; change the scripts, not this.

function Invoke-EndpointComplianceScan {
<#
.SYNOPSIS
    Scans the local Windows endpoint for evidence against one or more compliance
    frameworks, chosen at run time: CMMC, NIST SP 800-171 Rev 3, NIST SP 800-53
    Rev 5, the DISA STIGs for Windows 11, Windows Server 2019, 2022 and 2025,
    Windows Defender Firewall, Active Directory Domain and Forest, and Windows
    PAW, CIS Controls v8.1, NIST CSF 2.0, PCI DSS 4.0.1 or HIPAA.

.DESCRIPTION
    Read-only. The scan changes no setting and writes nothing to the registry. Its
    only network traffic is writing reports to a share when -OutputPath names
    one; on a domain controller it reads Active Directory from itself (localhost)
    over LDAP. Its only side effects are temporary files (the secedit and auditpol
    exports), deleted as soon as they are read, the report files, and, when
    -FallbackPath and -LogPath are given, the reports waiting there and the run
    log.

    It works in two stages so that every pass/fail rule can be tested anywhere:
        1. Collect: gather raw facts about the machine into a snapshot.
        2. Evaluate: judge the snapshot against each framework's requirements.
    Given several frameworks, the machine is collected once and evaluated against
    each in turn. One snapshot holds the facts for every framework, so a saved snapshot can be
    evaluated against any of them later with -FromSnapshot.

    Each framework is a catalog in the frameworks folder (frameworks/README.md
    describes the format). A requirement gets its evidence from named checks in
    this script, from setting expectations in the catalog (a registry value, a
    security policy value, an audit subcategory, a user right, a service, a
    Windows feature or a directory test), or from both. Its result is the worst of those results:
        Fail evidence collected and it misses the threshold
        Error a collector or check threw; the message is in the report
        Accepted a Fail that a deviation in the local settings file
                       accepts; the report keeps the evidence and the reason
        Skipped the check applies but could not run (usually: not elevated)
        Manual the endpoint cannot show this; the note says what can
        Pass evidence collected and it meets the threshold
        NotApplicable the thing checked is absent (for example, RDP is off, or
                       local accounts on a domain controller)
    Nothing passes by default. A fact missing from the snapshot is an Error, never
    a Pass. An accepted deviation is listed by rule, with its reason, under
    Deviations in ComplianceScan.local.psd1 (see ComplianceScan.example.psd1);
    it accepts a Fail, never an Error.

    A STIG catalog names the Windows edition and build it was written for. Run on
    any other, the scan warns, and the report carries the warning.

    This is evidence for an assessment, not a verdict. Most requirements in most
    frameworks are procedural or need evidence from outside the endpoint, so the
    report never states that a machine is compliant and never computes a score.
    Catalogs for copyrighted standards (CIS, PCI DSS) hold only the requirements
    an endpoint can show evidence for; the report says so.

    Writes these files per run into -OutputPath, named by scan ID (computer and
    collection time, UTC) so a shared folder of many machines' reports sorts by
    computer, then time:
        compliance-scan-<scan id>-<framework>.csv one row per requirement
        compliance-scan-<scan id>-<framework>.json requirements, checks and thresholds
        compliance-scan-<scan id>-<framework>.records.json flat records for Power BI
        compliance-scan-<scan id>-<framework>.html a self-contained report
        compliance-scan-<scan id>-<framework>.cklb STIG Viewer 3 checklist (STIGs, with -StigPath)
        compliance-scan-<scan id>-<framework>.ckl STIG Viewer 2 checklist (STIGs, with -StigPath)
    New-ComplianceRollup combines a folder of these reports from many
    machines into one page per framework.

    Exit codes: 0 no Fail and no Error (accepted deviations included); 1 at least
    one Fail; 2 at least one Error, or the scan could not start. Error wins over Fail because a run with errors
    can't be relied on either way. With several frameworks, the worst of them.

    For unattended runs, Register-ComplianceScanTask schedules this script.
    -FallbackPath keeps the reports when -OutputPath can't be written and
    delivers them on a later run; -LogPath keeps a row per run.

.PARAMETER Framework
    The frameworks to evaluate, by Id or catalog file name, for example CMMC,
    NIST-800-53r5 or disa-stig-ws2022-ms. Give several as a list or one
    comma-separated string. Required; run without it to list them.

.PARAMETER Level
    The framework level to assess, such as L1 (CMMC), Low (NIST SP 800-53), CAT I
    (STIG) or IG1 (CIS). A level includes the levels before it. Defaults to the
    framework's highest level. For CMMC, 1 and 2 also work. With several
    frameworks, the level must belong to each.

.PARAMETER OutputPath
    Folder for the report files. Defaults to a Reports folder in the current
    PowerShell folder, created if needed.

.PARAMETER FallbackPath
    Folder for the reports when -OutputPath can't be written, such as a share
    that's away. A later run that can write to -OutputPath first moves the
    waiting reports there, oldest first. Without it, a folder that can't be
    written stops the scan. A fallback folder that is a junction or link, or
    that another account owns, stops the scan; a waiting file that is a link,
    or that another account owns, stays where it is and isn't delivered.

.PARAMETER KeepDays
    Days a report may wait in -FallbackPath before it's removed. Defaults to 90.

.PARAMETER LogPath
    A CSV file that gets one row per run: the time (UTC), computer, frameworks,
    exit code, where the reports went, how many waiting files were delivered,
    are still waiting or were removed, and any error. Rows older than a year are
    dropped.

.PARAMETER ProfilePath
    Thresholds file to use on top of ComplianceScan.example.psd1 and the
    framework's own thresholds, which can also list accepted deviations. Without
    it, a ComplianceScan.local.psd1 beside the script is used if present, else
    %ProgramData%\EndpointComplianceScan\ComplianceScan.local.psd1. That one
    stops the scan if it or its folder is a junction or link, or is owned by an
    account other than SYSTEM, Administrators, TrustedInstaller or the one
    running the scan, since any user can create a folder in ProgramData.

.PARAMETER SnapshotPath
    Also save the collected snapshot to this JSON file, for review elsewhere or as
    a test fixture. The snapshot holds the computer name and local account names.

.PARAMETER FromSnapshot
    Evaluate a saved snapshot instead of scanning this machine. Works on any
    operating system.

.PARAMETER StigPath
    The DISA STIG zip from cyber.mil, or the XCCDF file inside it, for the
    STIG being scanned. With several frameworks it applies to the STIG among
    them. With it, a STIG scan also writes STIG Viewer
    checklists (.cklb for STIG Viewer 3, .ckl for STIG Viewer 2) holding every
    rule's text and the scan's results.

.PARAMETER IncludeSnapshot
    Embed the snapshot in the JSON report.

.PARAMETER PassThru
    Also write the requirement results to the pipeline.

.EXAMPLE
    Invoke-EndpointComplianceScan -Framework CMMC
    Full CMMC Level 2 scan. Run elevated, or the checks that need elevation are Skipped.

.EXAMPLE
    Invoke-EndpointComplianceScan -Framework DISA-STIG-Win11 -Level 'CAT I' -SnapshotPath .\pc01.snapshot.json
    STIG CAT I rules only, and keep the raw snapshot.

.EXAMPLE
    Invoke-EndpointComplianceScan -Framework DISA-STIG-WS2022-MS -StigPath .\U_MS_Windows_Server_2022_V2R11_STIG.zip -OutputPath \\fileserver\scans
    Server 2022 member server STIG, with a STIG Viewer checklist, into a shared folder.

.EXAMPLE
    Invoke-EndpointComplianceScan -Framework NIST-800-53r5 -Level Low -FromSnapshot ./pc01.snapshot.json
    Evaluate a saved snapshot against another framework.

.EXAMPLE
    Invoke-EndpointComplianceScan -Framework CMMC, DISA-STIG-Win11 -OutputPath \\fileserver\scans -FallbackPath C:\ProgramData\EndpointComplianceScan\Pending
    One collection, evaluated against CMMC and the Windows 11 STIG. If the share
    is away, the reports wait locally until a later run.

.NOTES
    Version notes
    0.1.0 2026-10-23 First draft: catalog of all 110 requirements, 30 endpoint
                       checks, CSV and JSON reports.
    0.2.0 2026-10-23 Multiple frameworks, chosen with -Framework: CMMC, NIST SP
                       800-171 Rev 3, NIST SP 800-53 Rev 5, DISA Windows 11 STIG,
                       CIS Controls v8.1, NIST CSF 2.0, PCI DSS 4.0.1, HIPAA.
                       Setting expectations in catalogs, an optional features
                       collector, three new checks. Renamed from
                       Invoke-CmmcEndpointScan.ps1.
    0.3.0 2026-10-23 Windows Server: DISA STIGs for Server 2019, 2022 and 2025,
                       member server and domain controller. User rights
                       expectations and collector, Kerberos policy, Server
                       features, platform warning when a catalog is for another
                       Windows, local accounts NotApplicable on a domain
                       controller. Snapshot schema 3.
    0.4.0 2026-10-24 Reports: a self-contained HTML report, flat JSON records
                       for Power BI, and a STIG Viewer checklist (.ckl) with
                       -StigPath. Reports are named by scan ID.
    0.4.1 2026-10-24 STIG Viewer checklist: characters XML can't hold, such as
                       NUL from a registry value, no longer stop the .ckl being
                       written. Registry strings drop trailing NULs.
    0.5.0 2026-10-24 STIG Viewer 3 checklist (.cklb) beside the .ckl, which
                       STIG Viewer 3 doesn't open.
    0.6.0 2026-10-24 Catalog generator tools/Update-FrameworkCatalog.ps1, with
                       pinned sources and failover. The Windows 11 STIG catalog
                       follows DISA's V2R11.
    0.7.0 2026-10-24 Server 2022 and 2025 STIG catalogs follow DISA's V2R11
                       and V1R4. DomainOnly expectations are NotApplicable off
                       a domain.
    0.8.0 2026-10-24 DISA Windows Defender Firewall STIG V2R2. Registry
                       expectations can fall back from the Group Policy value
                       to the local setting (FallbackPath).
    0.8.1 2026-10-25 STIG fixes from a domain controller scan: SMB1 settings
                       are NotApplicable when SMB1 is removed (NotApplicableIf),
                       StandaloneOnly expectations, and DISA's full value lists
                       for diagnostic data, Delivery Optimization and the
                       administrator UAC prompt.
    0.9.0 2026-10-25 DISA Active Directory Domain and Forest STIGs, with a
                       directory collector on domain controllers (ADSI, or the
                       ActiveDirectory module). Snapshot schema 4.
    0.9.1 2026-10-25 Directory findings name well-known SIDs (Everyone,
                       Anonymous Logon) instead of showing the SID.
    0.10.0 2026-10-26 DISA Windows PAW STIG. Device Guard code integrity and
                       local privileged group collectors, and endpoint tests
                       for rules that need judgement. Snapshot schema 5.
    0.10.1 2026-10-26 Optional features fall back to CIM (Win32_OptionalFeature)
                       when DISM fails, as it can under PowerShell 7.
    0.11.0 2026-10-27 Accepted deviations, listed with a reason in the local
                       settings file, report as Accepted instead of Fail.
                       Progress per collector and a time estimate at launch.
    0.11.1 2026-10-27 Console output: the launch notice and the results
                       header are short blocks set apart in colour, notes
                       and findings wrap between words to the window, and
                       the report files are listed one per line.
    0.11.2 2026-10-27 Test scripts install Pester and PSScriptAnalyzer from
                       the PowerShell Gallery, then a second site checked
                       against a pinned SHA-256, then a local package.
    0.12.0 2026-10-27 New-ComplianceRollup: one page per framework from a
                       folder of many machines' reports, with each machine's
                       latest counts and a requirement by machine grid.
    0.12.1 2026-10-27 A relative -OutputPath (and the roll-up's -Path) is
                       written under the current PowerShell folder, not the
                       process's starting folder.
    0.13.0 2026-10-28 HIPAA catalog complete (all 65 standards and
                       specifications). Retired catalogs, such as the Server
                       2019 STIGs, say so in the scan output and HTML report.
    0.14.0 2026-10-28 Roll-up trends: per-machine trend lines and changes
                       since the previous scan, a weekly chart for all
                       machines, and a trend CSV with one row per scan.
    0.15.0 2026-10-29 Power BI template (powerbi\, .pbit on the Release)
                       reading every .records.json in a folder.
    0.15.1 2026-10-30 README: the template's parameters open empty; the
                       table gives suggested values.
    0.16.0 2026-10-30 Scheduled scans: several frameworks per run,
                       -FallbackPath, -KeepDays and -LogPath, and
                       Register-ComplianceScanTask.
    0.16.1 2026-10-30 README: screenshots of the HTML report, the Power BI
                       template and the roll-up, from the fictional WESTMARK
                       test machines.
    0.17.0 2026-11-01 Fixes before 1.0. Waiting reports that are links, or
                       that another account owns, aren't delivered, and a
                       fallback folder that is a link stops the scan. A scan
                       run as SYSTEM skips per-user (HKCU) settings instead
                       of failing them. Snapshot schema 6 (reads 5).
                       BitLocker not installed (Windows Server without the
                       feature) is a Fail, not a collector Error.
    1.0.0 2026-11-02 First stable release, also published to the PowerShell
                       Gallery as the EndpointComplianceScan module. Reports
                       default to .\Reports in the current folder. A default
                       local profile under %ProgramData%, refused when another
                       account owns it.
#>

[CmdletBinding()]
param(
    [string[]]$Framework,
    [string]$Level,
    [string]$OutputPath,
    [string]$FallbackPath,
    [ValidateRange(1, 3650)][int]$KeepDays = 90,
    [string]$LogPath,
    [string]$ProfilePath,
    [string]$SnapshotPath,
    [string]$FromSnapshot,
    [string]$StigPath,
    [switch]$IncludeSnapshot,
    [switch]$PassThru
)

    & (Join-Path $PSScriptRoot 'Invoke-EndpointComplianceScan.ps1') @PSBoundParameters
}

function New-ComplianceRollup {
<#
.SYNOPSIS
    Combines the scan reports in a folder into one roll-up page per framework.

.DESCRIPTION
    Reads the compliance-scan-*.json reports that Invoke-EndpointComplianceScan
    writes, typically from many machines into one shared folder, and writes for
    each framework found:

        compliance-rollup-<framework>.html machines, trends, changes, and
                                                 requirements by machine
        compliance-rollup-<framework>.csv the requirements by machine grid
        compliance-rollup-<framework>-trend.csv one row per scan in the trend
                                                 window, with its counts

    Each computer counts once per framework, by its latest scan in the folder.
    Nothing is scanned or re-evaluated: the roll-up shows what the reports say.

    Like the scan, the roll-up is evidence for an assessment, not a verdict. It
    shows counts and states, never a score or a percentage.

    The page lists each machine with its counts, scan date, level and tool
    version, flagging a scan older than -MaxAge days, one run without elevation
    and one with a platform warning. Below that, a grid shows each requirement's
    state on each machine, with the requirements that fail on the most machines
    first. Machines scanned at another level or with another catalog version can
    have a different list of requirements; the page says so, and leaves a cell
    blank where a machine's scan doesn't include the requirement.

    Earlier scans in the folder, back to -TrendDays days, give each machine a
    small Fail and Error trend line and its change since the previous scan, a
    week-by-week chart for all machines, and a list of the requirements that
    newly Fail or Error, or no longer do, since each machine's previous scan.
    Counts compare only between scans at the same level with the same
    requirements; a trend line breaks where that changes, and the page says so.

    Exit code 0 when every framework's roll-up was written; 2 when the folder holds
    no scan reports, or the roll-up could not be written.

.PARAMETER Path
    The folder of scan reports.

.PARAMETER OutputPath
    Folder for the roll-up files. Defaults to -Path.

.PARAMETER Framework
    Only roll up these framework Ids, such as CMMC or DISA-STIG-WS2025-MS.

.PARAMETER MaxAge
    Days after which a machine's latest scan is flagged as stale, from 1 to
    3650. Default 30.

.PARAMETER AsOf
    The date to judge scan age against, in UTC. Defaults to now.

.PARAMETER TrendDays
    Days of earlier scans to show trends from, counting back from -AsOf. Default 90.

.PARAMETER Recurse
    Also read reports in subfolders of -Path.

.EXAMPLE
    New-ComplianceRollup -Path \\fileserver\scans
    One roll-up per framework scanned, written into the same folder.

.EXAMPLE
    New-ComplianceRollup -Path .\Reports -Framework DISA-STIG-WS2025-MS -MaxAge 7 -OutputPath .\Rollup

.EXAMPLE
    New-ComplianceRollup -Path \\fileserver\scans -Recurse -TrendDays 180
    Trends from the last six months of scans, including subfolders.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Runs New-ComplianceRollup.ps1, which only writes report files and has no -WhatIf.')]
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$Path,
    [string]$OutputPath,
    [string[]]$Framework = @(),
    [ValidateRange(1, 3650)][int]$MaxAge = 30,
    [Nullable[datetime]]$AsOf,
    [ValidateRange(1, 3650)][int]$TrendDays = 90,
    [switch]$Recurse
)

    & (Join-Path $PSScriptRoot 'New-ComplianceRollup.ps1') @PSBoundParameters
}

function Register-ComplianceScanTask {
<#
.SYNOPSIS
    Schedules Invoke-EndpointComplianceScan to scan this machine and write its
    reports to a shared folder.

.DESCRIPTION
    Copies the scanner, its frameworks folder and the profile files beside it to
    an install folder (by default %ProgramFiles%\EndpointComplianceScan), so the
    task never runs code from a user folder or a share. Then it registers one
    scheduled task that runs the scan as SYSTEM, elevated, so every collector can
    read what it needs. The scan only reads; the task, the installed copy and the
    files under %ProgramData%\EndpointComplianceScan are all this leaves.

    The data folder under %ProgramData% is made readable and writable by SYSTEM
    and Administrators only, since ProgramData lets any user create files in a
    subfolder and a file planted in the fallback folder would reach the share as
    this computer's report. A data folder that holds a junction or link, or
    anything another account owns, is refused before anything is installed.

    Each run writes to -OutputPath. When that can't be written (the share is down,
    or the machine is off the network), the reports go to a fallback folder,
    %ProgramData%\EndpointComplianceScan\Pending, and the next run that reaches
    the share moves them there, oldest first. Waiting reports older than
    -KeepDays are removed. Each run adds a row to
    %ProgramData%\EndpointComplianceScan\scan-log.csv.

    The task runs as the computer account, so on a share it writes as
    DOMAIN\COMPUTER$. The README gives share permissions that let machines add
    their reports without reading anyone else's.

    Run elevated. Running it again replaces the task and refreshes the installed
    copy, removing a profile or STIG file an earlier run installed and this one
    doesn't. -Unregister removes the task; add -RemoveFiles to remove the installed
    copy too. -WhatIf shows what would change.

    Exit codes: 0 done (or, with -WhatIf, shown); 2 nothing was scheduled or
    removed, or it stopped partway, with the error written to the error stream.

.PARAMETER Framework
    One or more frameworks to evaluate, as for the scanner. The machine is
    collected once per run and evaluated against each.

.PARAMETER Level
    The level, as for the scanner. It applies to every framework given; without
    it each framework uses its highest level.

.PARAMETER OutputPath
    The shared folder for the reports, such as \\fileserver\scans.

.PARAMETER StigPath
    The DISA STIG zip or XCCDF file, as for the scanner. It is copied to the
    install folder, since the task can't read a user's folder.

.PARAMETER ProfilePath
    A thresholds file to install as the scanner's ComplianceScan.local.psd1.
    Without it, a ComplianceScan.local.psd1 beside this script is installed if
    there is one. With neither, the scanner uses
    %ProgramData%\EndpointComplianceScan\ComplianceScan.local.psd1 when that
    exists, whatever -DataPath says.

.PARAMETER Schedule
    Weekly (the default) or Daily.

.PARAMETER At
    The start time, as HH:mm in local time. Defaults to 02:00.

.PARAMETER DaysOfWeek
    The days a weekly task runs. Defaults to Monday.

.PARAMETER RandomDelayMinutes
    Up to this many minutes are added to each start at random, so a fleet doesn't
    reach the share at once. Defaults to 120; 0 turns it off.

.PARAMETER KeepDays
    Days a report may wait in the fallback folder before it's removed. Defaults to 90.

.PARAMETER InstallPath
    Where to install the scanner. Defaults to %ProgramFiles%\EndpointComplianceScan.

.PARAMETER DataPath
    Where the fallback folder and run log live. Defaults to
    %ProgramData%\EndpointComplianceScan.

.PARAMETER TaskName
    The task's name. Defaults to 'Endpoint compliance scan'.

.PARAMETER Unregister
    Remove the task instead of registering it.

.PARAMETER RemoveFiles
    With -Unregister, also remove the installed copy. The fallback folder and run
    log stay, since they may hold reports not yet delivered.

.EXAMPLE
    Register-ComplianceScanTask -Framework CMMC -OutputPath \\fileserver\scans
    Weekly CMMC scan, Mondays from 02:00 with up to two hours' random delay.

.EXAMPLE
    Register-ComplianceScanTask -Framework CMMC, DISA-STIG-WS2022-MS -StigPath .\U_MS_Windows_Server_2022_V2R11_STIG.zip -OutputPath \\fileserver\scans -Schedule Daily -At 22:00
    Daily scan against two frameworks, with STIG Viewer checklists for the STIG.

.EXAMPLE
    Register-ComplianceScanTask -Unregister -RemoveFiles
    Remove the task and the installed copy.

.NOTES
    Part of endpoint-compliance-scan. Windows PowerShell 5.1 or PowerShell 7 on
    Windows; the task itself runs Windows PowerShell 5.1, which every Windows has.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Justification = 'Passes -WhatIf and -Confirm to Register-ComplianceScanTask.ps1, which calls ShouldProcess.')]
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Register')]
param(
    [Parameter(ParameterSetName = 'Register', Mandatory)][string[]]$Framework,
    [Parameter(ParameterSetName = 'Register')][string]$Level,
    [Parameter(ParameterSetName = 'Register', Mandatory)][string]$OutputPath,
    [Parameter(ParameterSetName = 'Register')][string]$StigPath,
    [Parameter(ParameterSetName = 'Register')][string]$ProfilePath,
    [Parameter(ParameterSetName = 'Register')][ValidateSet('Weekly', 'Daily')][string]$Schedule = 'Weekly',
    [Parameter(ParameterSetName = 'Register')][ValidatePattern('^([01]?\d|2[0-3]):[0-5]\d$')][string]$At = '02:00',
    [Parameter(ParameterSetName = 'Register')][ValidateSet('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')][string[]]$DaysOfWeek = @('Monday'),
    [Parameter(ParameterSetName = 'Register')][ValidateRange(0, 1440)][int]$RandomDelayMinutes = 120,
    [Parameter(ParameterSetName = 'Register')][ValidateRange(1, 3650)][int]$KeepDays = 90,
    [string]$InstallPath,
    [Parameter(ParameterSetName = 'Register')][string]$DataPath,
    [string]$TaskName = 'Endpoint compliance scan',
    [Parameter(ParameterSetName = 'Unregister', Mandatory)][switch]$Unregister,
    [Parameter(ParameterSetName = 'Unregister')][switch]$RemoveFiles
)

    & (Join-Path $PSScriptRoot 'Register-ComplianceScanTask.ps1') @PSBoundParameters
}

Export-ModuleMember -Function Invoke-EndpointComplianceScan, New-ComplianceRollup, Register-ComplianceScanTask