Skip to content

Commit 0898d4e

Browse files
committed
Add user-authored check format (phase 1): JSON file-regex rules
Anyone can now add a TCPK detection without writing PowerShell. Drop a JSON rule under TCPK/Data/rules/ or pass -ExtraPath a directory of rules; the audit runs them alongside built-in checks. docs/EXTENDING.md carries the schema and worked examples. README gains a top-level Extending section. Phase 1 supports one check type: file-regex (path glob + .NET regex over contents). That covers roughly the secrets.json class: hardcoded credentials, dangerous config values, embedded URLs. Later phases can add registry, PE-import and IL call-site under the same schema. Sandboxed by design. Rules can match, they cannot execute. Enforced at load time by refusing every field the loader does not recognise, so an accidental 'run: powershell.exe' or 'type: script' fails at load rather than being silently ignored. Malformed rules become Skipped rules.malformed findings rather than being dropped, and a duplicate id is refused with a specific Skipped finding naming the collision. JSON, not YAML. PS 5.1 has ConvertFrom-Json built in; YAML would need vendoring YamlDotNet into a tool that flags non-system DLLs. Loader is Read-TcpkUserRule + Get-TcpkUserRules + Convert-TcpkGlobToRegex, in TCPK/Private/_UserRules.ps1. Public entry is Test-TcpkUserRules (A54) under TCPK/Public/Discovery/. Wired into Invoke-TcpkAudit and added to the explicit FunctionsToExport in the manifest. 17 Pester cases in UserRules.Tests.ps1 cover: schema refusals (unknown field, unknown severity, bad regex, duplicate id, malformed JSON, missing description/fix, uppercase id), glob semantics (** any depth including zero, * single segment, \ normalised to /), and end-to-end matching against synthetic files including the maxHits cap on report noise. One shipped example rule (user.hardcoded-slack-webhook) plus a README in TCPK/Data/rules/ so the extension surface is discoverable at the location where new rules go.
1 parent 4f385b2 commit 0898d4e

11 files changed

Lines changed: 662 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@ Release history for TCPK. Newest first.
44

55
## Unreleased
66

7+
**Test-TcpkUserRules (A54) - user-authored check format, phase 1.**
8+
9+
Anyone can now add a detection without touching PowerShell. Drop a JSON rule into
10+
`TCPK/Data/rules/` (or pass `-ExtraPath` a directory of engagement-specific rules) and
11+
TCPK runs it alongside the built-in checks. The full schema and worked examples live in
12+
`docs/EXTENDING.md`. `README.md` gains a top-level `## Extending` section pointing at it.
13+
14+
Phase 1 supports one check type: `file-regex`, which is glob + regex over file contents.
15+
That shape covers roughly the same ground as `secrets.json`: hardcoded credentials in
16+
resources, dangerous config values, embedded URLs. Future phases will add registry,
17+
PE-import and .NET IL call-site check types under the same schema.
18+
19+
Sandboxed by construction. A rule can match. A rule cannot execute anything, load a
20+
DLL, spawn a process or reach the network. That property is enforced by the loader
21+
refusing every field it does not recognise, so an accidental (or malicious) `"run":
22+
"powershell.exe"` line fails at load time rather than being silently ignored. Malformed
23+
rules surface as Skipped `rules.malformed` findings; a duplicate rule id is refused
24+
with a Skipped finding naming the duplicate.
25+
26+
JSON, not YAML. PowerShell 5.1 has `ConvertFrom-Json` built in; YAML would need
27+
vendoring YamlDotNet, which is a non-system DLL inside a tool that flags non-system
28+
DLLs.
29+
30+
`Read-TcpkUserRule`, `Get-TcpkUserRules` and `Convert-TcpkGlobToRegex` are the three
31+
private helpers, in `TCPK/Private/_UserRules.ps1`. 17 Pester cases in
32+
`UserRules.Tests.ps1` cover schema refusals (unknown field, unknown severity, bad
33+
regex, duplicate id, malformed JSON), glob semantics (`**` any depth, `*` single
34+
segment, `\` normalised to `/`), and end-to-end matching against synthetic files.
35+
736
**Manifest: FunctionsToExport is an explicit list of 279 names, ProjectUri filled in.**
837
Was @('*') + empty. Under Install-Module (or any consumer that reads the psd1 without
938
loading the module) a cmdlet not listed here is effectively invisible. New Pester test

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ with the evidence grade for every one. Nothing in it is fabricated.
5353
![TCPK GUI](assets/tcpk-gui.png)
5454

5555
Point it at an MSIX package, an installed folder, or a single `.exe`, click **Run Audit**, and
56-
TCPK runs 279 cmdlets across 19 buckets (174 of them detection checks), streams findings live, and writes HTML + Excel
56+
TCPK runs 280 cmdlets across 19 buckets (174 of them detection checks), streams findings live, and writes HTML + Excel
5757
reports. Every finding carries a confidence label, a **computed CVSS v4.0** base score, CWE,
5858
MITRE ATT&CK, and an OWASP TASVS mapping. The same engine drives the CLI, a native **MCP
5959
server**, and an **agentic AI workbench** (`TCPK-Agentic.bat` -- loopback, token-gated,
@@ -106,6 +106,10 @@ needs [Ollama](https://ollama.com) + a pulled model (e.g. `qwen2.5-coder:7b`).
106106
Optional tools per tab (Wireshark for pcap, mitmproxy for intercept, frida for runtime
107107
hooks) install separately: see [docs/INSTALL.md](docs/INSTALL.md). The static audit needs none of them.
108108

109+
## Extending
110+
111+
Add a detection without writing PowerShell: drop a JSON rule into `TCPK/Data/rules/`. See [docs/EXTENDING.md](docs/EXTENDING.md) for the schema and worked examples.
112+
109113
## Resources
110114

111115
- [Awesome Thick Client Pentesting](https://github.com/V33RU/Awesome-Thick-Client-Pentesting) - curated tooling, writeups and labs for this problem space.

TCPK/Data/rules/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# User-authored TCPK rules
2+
3+
Drop `*.json` rule files here. TCPK loads them at scan time and runs them alongside the
4+
built-in checks. See `docs/EXTENDING.md` for the schema and worked examples.
5+
6+
Phase 1 supports one check type: **file-regex**. File glob + regex over file contents.
7+
More types (registry, PE-import, .NET IL call-site) land in later phases if this format
8+
gets adoption.
9+
10+
Rules are sandboxed by construction: they can pattern-match, they cannot execute anything.
11+
If you want a check that spawns a process or loads a DLL, contribute a PowerShell cmdlet
12+
under `TCPK/Public/` instead.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"id": "user.hardcoded-slack-webhook",
3+
"title": "Hardcoded Slack incoming-webhook URL",
4+
"severity": "HIGH",
5+
"type": "file-regex",
6+
"cwe": [
7+
"CWE-798"
8+
],
9+
"description": "A Slack incoming-webhook URL is present in an application resource. Anyone with the URL can post to that channel until the webhook is rotated.",
10+
"fix": "Rotate the webhook in the Slack app configuration, then move it to a runtime secret (environment variable or a secret store) rather than a shipped resource.",
11+
"match": {
12+
"glob": "**/*.config",
13+
"regex": "https://hooks\\.slack\\.com/services/T[A-Z0-9]{8,}/B[A-Z0-9]{8,}/[A-Za-z0-9]{20,}",
14+
"prefilter": [
15+
"hooks.slack.com"
16+
]
17+
}
18+
}

TCPK/Private/_UserRules.ps1

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)