YOUR CAPSTONE PROJECT · LESSON 17

Capstone: build a network audit tool

Combine addressing, DNS, port policy, file output, and error handling into a repeatable site audit.

45 min · learn at your pace

Your network review brief

Build Invoke-NetworkAudit -DataDirectory -OutputDirectory. Each supplied site directory contains policy.json, devices.csv, ports.csv, expected-ports.json, and dns.json. Use SubnetTools.ps1 for the tested address helpers. Do not contact or modify devices.

The normalised contracts matter. DeviceId is unique in inventory; Name is a host label. VRFs are explicit and there are no shared-address exceptions. Port observations and expectations have unique DeviceId/Port identities. Modes are access or trunk, and populated VLANs are valid integers. DNS owners are absolute ASCII names with at most one terminal dot; A values are strict dotted IPv4. The DNS file is declared a complete record snapshot. Missing port rows represent unavailable observations.

Policy supplies Site, ManagementCidr, and DnsSuffix. Assess only Active devices. Expected ports on retired or unlisted devices and orphan observations are outside scope. Validate the policy CIDR, active management addresses, and A-record addresses before writing output.

Findings contract

Write one findings.csv row with DeviceId, Rule, Resource, Value for each applicable condition:

  • DuplicateAddress: more than one active device has the same management address in the same VRF. Emit a finding for each affected device; Resource is VRF and Value is the address. Identical addresses in different VRFs are allowed.
  • ManagementOutsidePrefix: the valid management address is outside ManagementCidr. Resource is management; Value is the address. Membership includes both ends of the prefix.
  • DnsMissing: the device has no A record for Name plus DnsSuffix. Resource is the lowercase absolute owner without a root dot; Value is Missing.
  • DnsMismatch: that owner's unique A-value set is not exactly the device's management address. Value is the sorted unique observed addresses joined by commas. Ignore type/name case, a terminal root dot, duplicate records, and TTL.
  • PortUnknown: an expected port has no matching observation. Resource is Port and Value is Missing.
  • PortMode: the observed mode differs from the expected mode. Resource is Port; Value is the observed mode.
  • AccessVlan: matching access modes have different numeric VLANs. Resource is Port; Value is the observed VLAN as text. Ignore AccessVlan on matching trunks.

Apply port rules in that order so a port has at most one finding. DNS absence is a missing configuration in the complete DNS snapshot; PortUnknown is a collection gap. Duplicate allocations are inventory findings, not a claim that an ARP conflict was observed.

Reviewable outputs and coverage

Write change-plan.csv with DeviceId, Port, Action, DesiredMode, DesiredVlan for each port finding. PortUnknown proposes Investigate, PortMode proposes ReviewMode, and AccessVlan proposes SetAccessVlan. These are proposals only, not executable commands.

Write summary.json with Site, DeviceCount, FindingCount, UnknownCount, Coverage, Devices. UnknownCount counts PortUnknown findings. Coverage is Empty for no active devices, Incomplete when UnknownCount is positive, and Complete otherwise. Devices is a sorted unique array, including zero or one device. Complete means the expected port observations were available; it does not mean the configuration has no findings.

Create the destination if needed and keep both CSV headers even when there are no rows. Return one object with boolean Success, numeric FindingCount, and Error. A successfully generated report returns true and an empty Error even when it contains findings. Missing required files, malformed JSON, or invalid required IPv4/CIDR input must return false, count 0, and a non-empty diagnostic without an uncaught exception or a newly written successful summary. Read and validate inputs before writing output.

Prove it works beyond one export

Start with network-a. Network-b changes the site, DNS suffix, management prefix, identities, and VLAN policy, and deliberately reuses an address across two VRFs. Network-boundary contains the two endpoints of a /31. Network-empty has no active devices, while network-gaps lacks port and DNS observations. The checker also supplies invalid inputs and runs the same audit twice.

Replace outputs rather than append stale findings. Leave every source file unchanged. A failed attempt does not make an older report current; inspect the returned Success and Error before using existing files. Build the small helpers and findings first, then add the saved report and summary.

Try an example.ps1
Get-Content '/home/user/network-a/policy.json' -Raw | ConvertFrom-Json
Import-Csv '/home/user/network-a/devices.csv'
YOUR TURN

Put it into practice.

Implement Invoke-NetworkAudit with the contracts above. Try network-a into /home/user/network-report, then use the other supplied site directories. Derive results from each site’s policy and exports, preserve source files, make repeat runs replace outputs, and keep missing observations distinct from configuration findings.

See an explained solution
One way to solve it.ps1
. '/home/user/SubnetTools.ps1'
function Invoke-NetworkAudit {
    param([string]$DataDirectory,[string]$OutputDirectory)
    try {
        $policy = Get-Content (Join-Path $DataDirectory 'policy.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        $devices = @(Import-Csv (Join-Path $DataDirectory 'devices.csv') -ErrorAction Stop | Where-Object State -EQ Active)
        $ports = @(Import-Csv (Join-Path $DataDirectory 'ports.csv') -ErrorAction Stop)
        $expectedPorts = @(Get-Content (Join-Path $DataDirectory 'expected-ports.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
        $dns = @(Get-Content (Join-Path $DataDirectory 'dns.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
        $null = Test-IPv4Subnet '0.0.0.0' $policy.ManagementCidr
        foreach ($device in $devices) { $null = ConvertTo-IPv4Number $device.ManagementAddress }
        foreach ($record in $dns | Where-Object Type -EQ A) { $null = ConvertTo-IPv4Number $record.Value }
        $findings = [Collections.Generic.List[object]]::new()
        $plans = [Collections.Generic.List[object]]::new()
        function Add-NetworkFinding($Id,$Rule,$Resource,$Value) {
            $findings.Add([pscustomobject]@{ DeviceId=$Id; Rule=$Rule; Resource=$Resource; Value=$Value })
        }
        foreach ($group in $devices | Group-Object VRF,ManagementAddress) {
            if ($group.Count -gt 1) {
                foreach ($device in $group.Group) { Add-NetworkFinding $device.DeviceId 'DuplicateAddress' $device.VRF $device.ManagementAddress }
            }
        }
        foreach ($device in $devices) {
            if (-not (Test-IPv4Subnet $device.ManagementAddress $policy.ManagementCidr)) {
                Add-NetworkFinding $device.DeviceId 'ManagementOutsidePrefix' 'management' $device.ManagementAddress
            }
            $owner = ($device.Name + '.' + $policy.DnsSuffix.TrimEnd('.')).ToLowerInvariant()
            $values = @($dns | Where-Object { $_.Type -eq 'A' -and $_.Owner.TrimEnd('.').ToLowerInvariant() -eq $owner } | ForEach-Object { $_.Value } | Sort-Object -Unique)
            if ($values.Count -eq 0) { Add-NetworkFinding $device.DeviceId 'DnsMissing' $owner 'Missing' }
            elseif ($values.Count -ne 1 -or $values[0] -ne $device.ManagementAddress) { Add-NetworkFinding $device.DeviceId 'DnsMismatch' $owner ($values -join ',') }
            foreach ($expected in $expectedPorts | Where-Object DeviceId -EQ $device.DeviceId) {
                $observed = $ports | Where-Object { $_.DeviceId -eq $device.DeviceId -and $_.Port -eq $expected.Port }
                $action = ''
                if (-not $observed) {
                    Add-NetworkFinding $device.DeviceId 'PortUnknown' $expected.Port 'Missing'
                    $action = 'Investigate'
                } elseif ($observed.Mode -ne $expected.Mode) {
                    Add-NetworkFinding $device.DeviceId 'PortMode' $expected.Port $observed.Mode
                    $action = 'ReviewMode'
                } elseif ($expected.Mode -eq 'access' -and [int]$observed.AccessVlan -ne $expected.AccessVlan) {
                    Add-NetworkFinding $device.DeviceId 'AccessVlan' $expected.Port ([string]$observed.AccessVlan)
                    $action = 'SetAccessVlan'
                }
                if ($action) { $plans.Add([pscustomobject]@{ DeviceId=$device.DeviceId; Port=$expected.Port; Action=$action; DesiredMode=$expected.Mode; DesiredVlan=$expected.AccessVlan }) }
            }
        }
        $unknown = @($findings | Where-Object Rule -EQ PortUnknown).Count
        $coverage = if ($devices.Count -eq 0) { 'Empty' } elseif ($unknown -gt 0) { 'Incomplete' } else { 'Complete' }
        $summary = [pscustomobject]@{ Site=$policy.Site; DeviceCount=$devices.Count; FindingCount=$findings.Count; UnknownCount=$unknown; Coverage=$coverage; Devices=@($devices.DeviceId | Sort-Object -Unique) }
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
        $findingsPath = Join-Path $OutputDirectory 'findings.csv'
        $plansPath = Join-Path $OutputDirectory 'change-plan.csv'
        if ($findings.Count) { $findings | Export-Csv -LiteralPath $findingsPath -NoTypeInformation } else { 'DeviceId,Rule,Resource,Value' | Set-Content -LiteralPath $findingsPath }
        if ($plans.Count) { $plans | Export-Csv -LiteralPath $plansPath -NoTypeInformation } else { 'DeviceId,Port,Action,DesiredMode,DesiredVlan' | Set-Content -LiteralPath $plansPath }
        $summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json')
        [pscustomobject]@{ Success=$true; FindingCount=$findings.Count; Error='' }
    } catch {
        [pscustomobject]@{ Success=$false; FindingCount=0; Error=$_.Exception.Message }
    }
}

The active inventory and each site’s policy define scope. Address overlap is evaluated within a VRF, DNS records are compared as normalised sets, and missing port observations become investigation proposals. Input failures return an explicit result before report writes; successful repeats replace prior outputs.