|
| 1 | +#requires -Version 5.1 |
| 2 | +# User-authored check loader. |
| 3 | +# |
| 4 | +# WHY THIS EXISTS. Adding a detection to TCPK meant writing a full Test-TcpkX.ps1 cmdlet. |
| 5 | +# A stranger who spots a new class of hardcoded credential or a new dangerous config key |
| 6 | +# could not contribute without understanding PowerShell 5.1, the finding model and the load |
| 7 | +# order. Phase 1 lets them drop a JSON rule file into TCPK/Data/rules/ and have TCPK run it |
| 8 | +# alongside the built-in checks. |
| 9 | +# |
| 10 | +# JSON, not YAML, on purpose. PowerShell 5.1 has ConvertFrom-Json built-in. YAML would need |
| 11 | +# vendoring YamlDotNet, which is a non-system DLL inside a tool that flags non-system DLLs. |
| 12 | +# The community writes nuclei templates in YAML, but for a tool that lives on customer |
| 13 | +# machines the calculus is different: fewer moving pieces, smaller supply-chain surface. |
| 14 | +# |
| 15 | +# SANDBOXED. A rule can pattern-match. It cannot execute anything, load a DLL, spawn a |
| 16 | +# process, or reach the network. That property is enforced by construction: the schema |
| 17 | +# supports "match" and no "script" or "run" field exists. This is deliberate. A community |
| 18 | +# rule format that can shell out becomes a code-execution primitive that any TCPK user |
| 19 | +# would ship in their audit tool. |
| 20 | +# |
| 21 | +# Phase 1 supports the ONE check type that covers ~80% of what people would want: a file |
| 22 | +# glob plus a regex over the content. If Phase 1 gets any uptake, Phase 2 can add IL |
| 23 | +# call-site, registry, PE-import and MSIX-capability check types under the same schema. |
| 24 | + |
| 25 | +function Read-TcpkUserRule { |
| 26 | +<# |
| 27 | +.SYNOPSIS |
| 28 | + Load and VALIDATE one user rule from a JSON string. Never throws. |
| 29 | +.DESCRIPTION |
| 30 | + Returns @{ Rule = <object|$null>; Errors = <string[]> }. A rule with any error is refused |
| 31 | + whole, not partially loaded, so a bad rule never fires with default field values that read |
| 32 | + like an intentional finding. |
| 33 | +#> |
| 34 | + [CmdletBinding()] |
| 35 | + param([Parameter(Mandatory)][string]$Json, [string]$SourceLabel = '<inline>') |
| 36 | + |
| 37 | + if ([string]::IsNullOrWhiteSpace($Json)) { |
| 38 | + return @{ Rule = $null; Errors = @("$SourceLabel : rule is empty") } |
| 39 | + } |
| 40 | + $obj = $null |
| 41 | + try { $obj = ConvertFrom-Json $Json } |
| 42 | + catch { return @{ Rule = $null; Errors = @("$SourceLabel : not valid JSON ($($_.Exception.Message))") } } |
| 43 | + |
| 44 | + $errors = New-Object 'System.Collections.Generic.List[string]' |
| 45 | + $get = { param($n) if ($obj.PSObject.Properties[$n]) { $obj.$n } else { $null } } |
| 46 | + |
| 47 | + $id = "$(& $get 'id')".Trim() |
| 48 | + $sev = "$(& $get 'severity')".Trim().ToUpperInvariant() |
| 49 | + $type = "$(& $get 'type')".Trim().ToLowerInvariant() |
| 50 | + $desc = "$(& $get 'description')".Trim() |
| 51 | + $fix = "$(& $get 'fix')".Trim() |
| 52 | + $title = "$(& $get 'title')".Trim() |
| 53 | + $cwe = @(& $get 'cwe') |
| 54 | + $cwe = @($cwe | Where-Object { $_ }) |
| 55 | + $match = & $get 'match' |
| 56 | + |
| 57 | + if (-not $id) { $errors.Add("$SourceLabel : 'id' is required") } |
| 58 | + if ($id -and $id -notmatch '^[a-z][a-z0-9_.\-]{2,80}$') { |
| 59 | + $errors.Add("$SourceLabel : 'id' must be lowercase, contain a dot, and use only [a-z0-9_.-] (got '$id')") |
| 60 | + } |
| 61 | + if (-not $sev) { $errors.Add("$SourceLabel : 'severity' is required") } |
| 62 | + elseif ($sev -notin 'CRITICAL','HIGH','MEDIUM','LOW','INFO') { |
| 63 | + $errors.Add("$SourceLabel : 'severity' must be CRITICAL / HIGH / MEDIUM / LOW / INFO (got '$sev')") |
| 64 | + } |
| 65 | + if (-not $type) { $type = 'file-regex' } |
| 66 | + if ($type -ne 'file-regex') { |
| 67 | + $errors.Add("$SourceLabel : 'type' must be 'file-regex' (got '$type'). More types will land in later phases.") |
| 68 | + } |
| 69 | + if (-not $desc) { $errors.Add("$SourceLabel : 'description' is required so a report reader knows what the finding means") } |
| 70 | + if (-not $fix) { $errors.Add("$SourceLabel : 'fix' is required so a report reader knows what to do about it") } |
| 71 | + if (-not $title) { $title = $id } |
| 72 | + |
| 73 | + # Deliberately reject any field that could imply execution. If you add a new field later, |
| 74 | + # add it here first; refusing everything unknown is safer than allowing everything unknown. |
| 75 | + $allowed = @('id','severity','type','description','fix','title','cwe','match') |
| 76 | + foreach ($p in $obj.PSObject.Properties.Name) { |
| 77 | + if ($p -notin $allowed) { |
| 78 | + $errors.Add("$SourceLabel : unknown field '$p'. Allowed: $($allowed -join ', ')") |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + # Validate match block for file-regex |
| 83 | + $glob = ''; $regex = ''; $ignoreCase = $true; $maxHits = 8; $prefilter = @() |
| 84 | + if ($type -eq 'file-regex') { |
| 85 | + if (-not $match) { |
| 86 | + $errors.Add("$SourceLabel : 'match' block is required for type file-regex") |
| 87 | + } else { |
| 88 | + $glob = "$($match.glob)".Trim() |
| 89 | + $regex = "$($match.regex)" |
| 90 | + if ($match.PSObject.Properties['ignoreCase']) { $ignoreCase = [bool]$match.ignoreCase } |
| 91 | + if ($match.PSObject.Properties['maxHits']) { $maxHits = [int]$match.maxHits } |
| 92 | + if ($match.PSObject.Properties['prefilter']) { $prefilter = @($match.prefilter | Where-Object { $_ }) } |
| 93 | + if (-not $glob) { $errors.Add("$SourceLabel : match.glob is required (e.g. '**/*.config')") } |
| 94 | + if (-not $regex) { $errors.Add("$SourceLabel : match.regex is required") } |
| 95 | + # Attempt to compile the regex so a malformed one is refused now, not at scan time. |
| 96 | + if ($regex) { |
| 97 | + try { [void][regex]::new($regex) } |
| 98 | + catch { $errors.Add("$SourceLabel : match.regex is not a valid .NET regex ($($_.Exception.Message))") } |
| 99 | + } |
| 100 | + $allowedMatch = @('glob','regex','ignoreCase','maxHits','prefilter') |
| 101 | + foreach ($mp in $match.PSObject.Properties.Name) { |
| 102 | + if ($mp -notin $allowedMatch) { |
| 103 | + $errors.Add("$SourceLabel : match.'$mp' is not a recognised field. Allowed: $($allowedMatch -join ', ')") |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + if ($errors.Count) { return @{ Rule = $null; Errors = $errors.ToArray() } } |
| 110 | + |
| 111 | + $rule = [pscustomobject]@{ |
| 112 | + Id = $id |
| 113 | + Title = $title |
| 114 | + Severity = $sev |
| 115 | + Type = $type |
| 116 | + Description = $desc |
| 117 | + Fix = $fix |
| 118 | + Cwe = $cwe |
| 119 | + Glob = $glob |
| 120 | + Regex = $regex |
| 121 | + IgnoreCase = $ignoreCase |
| 122 | + MaxHits = $maxHits |
| 123 | + Prefilter = $prefilter |
| 124 | + Source = $SourceLabel |
| 125 | + } |
| 126 | + return @{ Rule = $rule; Errors = @() } |
| 127 | +} |
| 128 | + |
| 129 | +function Get-TcpkUserRules { |
| 130 | +<# |
| 131 | +.SYNOPSIS |
| 132 | + Load every user rule under TCPK/Data/rules/ (and optionally -ExtraPath). |
| 133 | +.DESCRIPTION |
| 134 | + Returns @{ Rules = <object[]>; Errors = <string[]> }. Errors are surfaced to the caller |
| 135 | + so the audit can emit a Skipped finding rather than silently dropping a broken rule. |
| 136 | +#> |
| 137 | + [CmdletBinding()] |
| 138 | + param([string[]]$ExtraPath = @()) |
| 139 | + |
| 140 | + $dirs = New-Object 'System.Collections.Generic.List[string]' |
| 141 | + if ($script:TcpkRoot) { |
| 142 | + $shipped = Join-Path $script:TcpkRoot 'Data\rules' |
| 143 | + if (Test-Path -LiteralPath $shipped -PathType Container) { $dirs.Add($shipped) } |
| 144 | + } |
| 145 | + foreach ($p in $ExtraPath) { |
| 146 | + if ($p -and (Test-Path -LiteralPath $p -PathType Container)) { $dirs.Add($p) } |
| 147 | + } |
| 148 | + |
| 149 | + $rules = New-Object 'System.Collections.Generic.List[object]' |
| 150 | + $errors = New-Object 'System.Collections.Generic.List[string]' |
| 151 | + $seenIds = New-Object 'System.Collections.Generic.HashSet[string]' |
| 152 | + |
| 153 | + foreach ($d in $dirs) { |
| 154 | + $files = @() |
| 155 | + try { $files = Get-ChildItem -LiteralPath $d -Recurse -File -Filter '*.json' -ErrorAction SilentlyContinue } |
| 156 | + catch { continue } |
| 157 | + foreach ($f in $files) { |
| 158 | + $body = '' |
| 159 | + try { $body = [IO.File]::ReadAllText($f.FullName) } catch { continue } |
| 160 | + $r = Read-TcpkUserRule -Json $body -SourceLabel $f.FullName |
| 161 | + foreach ($e in $r.Errors) { $errors.Add($e) } |
| 162 | + if ($r.Rule) { |
| 163 | + if (-not $seenIds.Add($r.Rule.Id)) { |
| 164 | + $errors.Add("$($f.FullName) : rule id '$($r.Rule.Id)' is already defined; second occurrence ignored") |
| 165 | + } else { |
| 166 | + $rules.Add($r.Rule) |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + return @{ Rules = $rules.ToArray(); Errors = $errors.ToArray() } |
| 173 | +} |
| 174 | + |
| 175 | +function Convert-TcpkGlobToRegex { |
| 176 | +<# |
| 177 | +.SYNOPSIS |
| 178 | + Turn a shell glob into a case-insensitive .NET regex over a forward-slashed path. |
| 179 | +.DESCRIPTION |
| 180 | + Recognised tokens: '**' any depth including zero, '*' one path segment, '?' one char. |
| 181 | + Everything else is escaped literally. Anchored to the whole path. |
| 182 | +#> |
| 183 | + [CmdletBinding()] param([Parameter(Mandatory)][string]$Glob) |
| 184 | + # Normalise separators |
| 185 | + $g = $Glob -replace '\\','/' |
| 186 | + # Tokenise around ** first, then * and ? |
| 187 | + $sb = New-Object System.Text.StringBuilder |
| 188 | + $i = 0; $n = $g.Length |
| 189 | + while ($i -lt $n) { |
| 190 | + if ($i + 1 -lt $n -and $g[$i] -eq '*' -and $g[$i+1] -eq '*') { |
| 191 | + # ** matches any depth including nothing (so '**/*.json' matches 'a.json' at root) |
| 192 | + [void]$sb.Append('.*'); $i += 2 |
| 193 | + # optional trailing / after ** just gets swallowed by .* |
| 194 | + if ($i -lt $n -and $g[$i] -eq '/') { $i++ } |
| 195 | + } elseif ($g[$i] -eq '*') { |
| 196 | + [void]$sb.Append('[^/]*'); $i++ |
| 197 | + } elseif ($g[$i] -eq '?') { |
| 198 | + [void]$sb.Append('[^/]'); $i++ |
| 199 | + } else { |
| 200 | + [void]$sb.Append([regex]::Escape([string]$g[$i])); $i++ |
| 201 | + } |
| 202 | + } |
| 203 | + return '(?i)^' + $sb.ToString() + '$' |
| 204 | +} |
0 commit comments