Part of the MSP plan · Python, PowerShell, JavaScript, Rust

Each client’s monthly AI report,
built from its own firewall.

The code reads the client’s firewall or DNS filter with that device’s own API key, keeps the logs on your machine, and writes a usage report under your name. Only domain names are looked up with us.

12 firewalls and filters · 4 languages · no client logs sent to us

What your client receives
Summary page of the sample monthly AI report: AI tools in use, new tools, blocked tools and the monthly trend. Sample Preview the monthly report 12 pages, opens in your browser · PDF
Guides and code

Choose the device and the language

One program covers every device and all your clients. Each guide lists what you need, where to create the API key, your settings file and the commands to schedule.

SonicWall

Receives syslog

SonicWall firewalls do not offer their web log through the API, so the program receives the firewall’s syslog messages. SonicOS records each web site visit (“Web site hit”) and each blocked site (“Web site access denied”) with the site name, the user and the device.

What you need

  • Any SonicOS version that sends syslog
  • Content Filtering or App Control logging web site events
  • A machine that runs the program and can receive UDP from the firewall

Set it up

  1. In SonicOS, open the syslog settings (in SonicOS 7: Device → Log → Syslog; the menu can differ by version) and add a syslog server: the IP of the machine running the program, UDP port 5514.
  2. Make sure web site events are logged (Content Filter / web site hit events at priority Inform or lower).
  3. Add the client to the settings file with "source": "syslog" and the IP address the firewall sends from.
  4. Run syslog on a machine the firewall can reach (at the client or at your office). It listens on UDP port 5514; use --port 514 if you prefer the standard port (needs admin rights). Keep it running, for example as a Windows service or a systemd unit.

Your settings file

atb-report.json
{
    "api_key": "YOUR_AI_TOOLS_BLOCKLIST_API_KEY",
    "msp": {
        "name": "Your IT Company",
        "email": "[email protected]",
        "phone": "",
        "website": "",
        "logo_url": "",
        "color": "#0e7490"
    },
    "data_dir": "./atb-data",
    "output_dir": "./reports",
    "clients": [
        {
            "name": "Example Dental Inc.",
            "profile": "healthcare",
            "source": "syslog",
            "syslog": {
                "device_ip": "203.0.113.10"
            }
        }
    ]
}

One entry per client in clients. profile is the client’s sector policy (for example healthcare, legal, financial, accounting or msp) and decides which tools the report marks as blocked. The program only sees messages that arrive while it runs, so keep it running all month.

Run it

Terminal
$ pwsh -File atb-report.ps1 syslog --config atb-report.json
$ pwsh -File atb-report.ps1 report --config atb-report.json --month 2026-09
Windows Task Scheduler or your RMM: run pwsh -File C:\atb\atb-report.ps1 collect --config C:\atb\atb-report.json every day.
PowerShellPowerShell 7 (Windows, macOS, Linux); Windows PowerShell 5.1 also works for most sources. No modules.

atb-report.ps1 1,481 linesPreview
1<#
2AI Tools Blocklist report code, PowerShell version 1.0 (2026-09-28)
3https://www.aitoolsblocklist.com/ai-governance-for-msps.php#report-code
4
5Builds a monthly AI report for each client from the client's own firewall or DNS-filter logs. The logs stay on
6your machine: only the list of domain names is checked against the AI Tools Blocklist API (your plan's API key).
7
8  pwsh atb-report.ps1 collect --config atb-report.json            pull the latest logs (run daily)
9  pwsh atb-report.ps1 syslog  --config atb-report.json --port 5514 receive syslog (SonicWall, Cisco ASA / Firepower)
10  pwsh atb-report.ps1 import  --config atb-report.json --client "Example Dental" --file export.csv
11  pwsh atb-report.ps1 report  --config atb-report.json --month 2026-09 [--client "Example Dental"]
12
13Sources: umbrella, meraki, fortigate, paloalto, dnsfilter, cloudflare, defender, watchguard, syslog, file.
14Needs PowerShell 7 or later (Windows, macOS, Linux) or Windows PowerShell 5.1, and nothing else.
15Settings: see atb-report.example.json. This is a line-for-line port of atb_report.py: same settings file,
16same local store (data_dir/<client>/<YYYY-MM-DD>.json) and the same report files.
17#>
18
19$ErrorActionPreference = 'Stop'
20$ProgressPreference = 'SilentlyContinue'   # the progress bar of Invoke-WebRequest slows it down a lot
21
22$script:Version = '1.0'
23$script:UserAgent = 'atb-report/1.0 (powershell)'
24$script:ScriptDir = $PSScriptRoot
25$script:Inv = [Globalization.CultureInfo]::InvariantCulture
26$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
27$script:IsCore = $PSVersionTable.PSVersion.Major -ge 6
28$script:HasSkipHttpErrorCheck = $PSVersionTable.PSVersion.Major -ge 7
29$script:Ordinal = [StringComparison]::Ordinal
30
31$script:MonthAbbr = @('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
32$script:MonthNames = @('', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September',
33                       'October', 'November', 'December')
34$script:HostRe = [regex]'^(?=.{4,253}$)([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$'
35$script:Ipv4Re = [regex]'^\d+\.\d+\.\d+\.\d+$'
36$script:DigitsRe = [regex]'\A[0-9]+\z'
37
38
39# ------------------------------------------------------------------ small building blocks
40# PowerShell hashtables ignore upper/lower case in keys; Python dicts do not. Every dictionary in this script is
41# therefore an insertion-ordered, case-sensitive dictionary, so "Anna" and "anna" stay two different users.
42function New-Dict {
43    return , (New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal))
44}
45
46function New-List {
47    return , (New-Object System.Collections.ArrayList)
48}
49
50function Get-Val($Obj, [string]$Key, $Default = $null) {
51    # Python: obj.get(key, default). Anything that is not a dictionary counts as empty.
52    if ($Obj -is [Collections.IDictionary] -and $Obj.Contains($Key)) { return , $Obj[$Key] }
53    return , $Default
54}
55
56function Get-Required($Obj, [string]$Key) {
57    # Python: obj[key]. A missing key is reported as a missing setting, like the Python version does.
58    if ($Obj -is [Collections.IDictionary] -and $Obj.Contains($Key)) { return , $Obj[$Key] }
59    throw ("missing setting '" + $Key + "'")
60}
61
62function Test-IsNumber($Value) {
63    # True for booleans and all numeric types (Python: isinstance(value, (int, float)), bool included).
64    if ($null -eq $Value) { return $false }
65    $tc = [int][Type]::GetTypeCode($Value.GetType())
66    return ($tc -eq 3 -or ($tc -ge 5 -and $tc -le 15))
67}
68
69function Test-Truthy($Value) {
70    # Python truth rules: None, False, 0, '' and empty lists / dictionaries are false.
71    if ($null -eq $Value) { return $false }
72    if ($Value -is [bool]) { return $Value }
73    if ($Value -is [string]) { return $Value.Length -gt 0 }
74    if (Test-IsNumber $Value) { return $Value -ne 0 }
75    if ($Value -is [Collections.ICollection]) { return $Value.Count -gt 0 }
76    return $true
77}
78
79function Get-FirstTruthy {
80    # Python: a or b or c  (the first true value, else the last value).
81    foreach ($v in $args) { if (Test-Truthy $v) { return , $v } }
82    if ($args.Count) { return , $args[$args.Count - 1] }
83    return $null
84}
85
86function Test-StrEq($A, [string]$B) {
87    # Python: a == 'text' (exact, case-sensitive, and only true for strings).
88    return ($A -is [string]) -and [string]::Equals($A, $B, $script:Ordinal)
89}
90
91function Test-OneOf($Value, [string[]]$List) {
92    # Python: value in ('a', 'b'). Exact, ordinal comparison: PowerShell's -eq / -contains are culture-aware and
93    # treat invisible characters (such as a byte order mark) as if they were not there.
94    if (-not ($Value -is [string])) { return $false }
95    foreach ($item in $List) { if ([string]::Equals($Value, $item, [StringComparison]::Ordinal)) { return $true } }
96    return $false
97}
98
99function Get-Slice($Text, [int]$Start, [int]$End) {
100    # Python: text[start:end] for non-negative bounds (never fails on short strings).
101    $s = [string]$Text
102    if ($Start -gt $s.Length) { $Start = $s.Length }
103    if ($End -gt $s.Length) { $End = $s.Length }
104    if ($End -le $Start) { return '' }
105    return $s.Substring($Start, $End - $Start)
106}
107
108function Format-PyFloat([double]$D, [switch]$Json) {
109    # Python repr() of a float: shortest round-trip digits, "1.0", "1e+16", "1e-05".
110    if ([double]::IsNaN($D)) { if ($Json) { return 'NaN' } return 'nan' }
111    if ([double]::IsPositiveInfinity($D)) { if ($Json) { return 'Infinity' } return 'inf' }
112    if ([double]::IsNegativeInfinity($D)) { if ($Json) { return '-Infinity' } return '-inf' }
113    if ($D -eq 0) { if ([BitConverter]::DoubleToInt64Bits($D) -lt 0) { return '-0.0' } return '0.0' }
114    $r = $D.ToString('R', $script:Inv)
115    $neg = $r.StartsWith('-')
116    if ($neg) { $r = $r.Substring(1) }
117    $exp = 0
118    $e = $r.IndexOf('E')
119    if ($e -ge 0) { $exp = [int]::Parse($r.Substring($e + 1), $script:Inv); $r = $r.Substring(0, $e) }
120    $dot = $r.IndexOf('.')
121    if ($dot -lt 0) { $intPart = $r; $frac = '' } else { $intPart = $r.Substring(0, $dot); $frac = $r.Substring($dot + 1) }
122    $digits = $intPart + $frac
123    $decpt = $intPart.Length + $exp                       # the decimal point sits after $decpt digits
124    $lead = 0
125    while ($lead -lt $digits.Length - 1 -and $digits[$lead] -eq [char]'0') { $lead++ }
126    $digits = $digits.Substring($lead).TrimEnd([char]'0')
127    $decpt -= $lead
128    if (-not $digits) { $digits = '0' }
129    if ($decpt -gt 16 -or $decpt -le -4) {
130        $m = $digits.Substring(0, 1)
131        if ($digits.Length -gt 1) { $m += '.' + $digits.Substring(1) }
132        $x = $decpt - 1
133        $sign = '+'
134        if ($x -lt 0) { $sign = '-' }
135        $s = $m + 'e' + $sign + ([Math]::Abs($x)).ToString('00', $script:Inv)
136    } elseif ($decpt -le 0) {
137        $s = '0.' + ('0' * (-$decpt)) + $digits
138    } elseif ($decpt -ge $digits.Length) {
139        $s = $digits + ('0' * ($decpt - $digits.Length)) + '.0'
140    } else {
141        $s = $digits.Substring(0, $decpt) + '.' + $digits.Substring($decpt)
142    }
143    if ($neg) { return '-' + $s }
144    return $s
145}
146
147function ConvertTo-PyRepr($Value) {
148    if ($Value -is [string]) { return "'" + $Value.Replace('\', '\\').Replace("'", "\'") + "'" }
149    return ConvertTo-PyStr $Value
150}
151
152function ConvertTo-PyStr($Value) {
153    # Python: str(value). Used wherever the Python code turns a setting or an API value into text.
154    if ($null -eq $Value) { return 'None' }
155    if ($Value -is [string]) { return $Value }
156    if ($Value -is [bool]) { if ($Value) { return 'True' } return 'False' }
157    if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) { return Format-PyFloat ([double]$Value) }
158    if (Test-IsNumber $Value) { return $Value.ToString($script:Inv) }
159    if ($Value -is [Collections.IDictionary]) {
160        $parts = foreach ($k in $Value.Keys) { (ConvertTo-PyRepr $k) + ': ' + (ConvertTo-PyRepr $Value[$k]) }
161        return '{' + (@($parts) -join ', ') + '}'
162    }
163    if ($Value -is [Collections.IEnumerable]) {
164        $parts = foreach ($v in $Value) { ConvertTo-PyRepr $v }
165        return '[' + (@($parts) -join ', ') + ']'
166    }
167    return [string]$Value
168}
169
170function ConvertTo-PyInt($Value) {
171    # Python: int(value). Accepts integers, floats (truncated) and whole-number text; anything else is an error.
172    if ($Value -is [bool]) { if ($Value) { return [long]1 } return [long]0 }
173    if ($Value -is [string]) {
174        $t = $Value.Trim()
175        if ([regex]::IsMatch($t, '\A[+-]?[0-9]+\z')) { return [long]::Parse($t, $script:Inv) }
176        throw ("invalid literal for int() with base 10: " + (ConvertTo-PyRepr $Value))
177    }
178    if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) {
179        if ([double]::IsNaN([double]$Value) -or [double]::IsInfinity([double]$Value)) { throw 'cannot convert float to integer' }
180        return [long][Math]::Truncate([double]$Value)
181    }
182    if (Test-IsNumber $Value) { return [long]$Value }
183    throw ('int() argument must be a string or a number, not ' + (ConvertTo-PyStr $Value))
184}
185
186function Get-ErrorText($ErrorRecord) {
187    # The plain error message (without the "Exception calling ..." wrapper PowerShell adds around .NET errors).
188    $e = $ErrorRecord.Exception
189    while ($e -is [Management.Automation.MethodInvocationException] -and $null -ne $e.InnerException) { $e = $e.InnerException }
190    return $e.Message
191}
192
193function Write-Log([string]$Message) {
194    Write-Host ((Get-Date).ToString('yyyy-MM-dd HH:mm:ss', $script:Inv) + '  ' + $Message)
195}
196
197function Get-UtcNow {
198    # Current UTC time; ATB_NOW (e.g. 2026-09-28T12:00:00) fixes it for tests.
199    $v = $env:ATB_NOW
200    if ($v) { return [datetime]::ParseExact($v, "yyyy-M-d'T'H:m:s", $script:Inv, [Globalization.DateTimeStyles]::None) }
201    $n = [datetime]::UtcNow
202    return $n.AddTicks(-($n.Ticks % [TimeSpan]::TicksPerSecond))
203}
204
205function Format-Time([datetime]$When, [string]$Format) {
206    return $When.ToString($Format, $script:Inv)
207}
208
209function Get-EpochMs([datetime]$When) {
210    $epoch = New-Object DateTime 1970, 1, 1, 0, 0, 0
211    return [long][Math]::Truncate(($When - $epoch).TotalMilliseconds)
212}
213
214function Read-TextFile([string]$Path) {
215    return [IO.File]::ReadAllText($Path, $script:Utf8NoBom)
216}
217
218function Write-TextFile([string]$Path, [string]$Text) {
219    # UTF-8 without BOM; "\n" becomes the platform's line ending, as Python's text files do.
220    if ([Environment]::NewLine -ne "`n") { $Text = $Text.Replace("`n", [Environment]::NewLine) }
221    [IO.File]::WriteAllText($Path, $Text, $script:Utf8NoBom)
222}
223
224function Move-FileOver([string]$Source, [string]$Target) {
225    # Python: os.replace(source, target)
226    if (-not [IO.File]::Exists($Target)) { [IO.File]::Move($Source, $Target) }
227    elseif ($script:IsCore) { [IO.File]::Move($Source, $Target, $true) }
228    else { [IO.File]::Replace($Source, $Target, [NullString]::Value) }
229}
230
231function Get-SortedStrings($Items) {
232    # Python: sorted(items) for text (code point order, not the culture-aware order of Sort-Object).
233    $list = New-Object 'System.Collections.Generic.List[string]'
234    foreach ($i in $Items) { $list.Add([string]$i) }
235    $list.Sort([StringComparer]::Ordinal)
236    return , $list
237}
238
239
240# ------------------------------------------------------------------ JSON, read and written exactly like Python's json module
241# ConvertFrom-Json turns date-like text into [datetime] on PowerShell 7 and ConvertTo-Json changes number types and key
242# order, so the store and the reports use this small reader and writer instead.
243$script:JsonTokenRe = [regex]'\G[ \t\n\r]*("(?:[^"\\]|\\.)*"|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|true|false|null|NaN|-?Infinity|[{}\[\]:,])'
244$script:JsonUnescapeRe = [regex]'\\(?:u([0-9a-fA-F]{4})|(.))'
245$script:JsonUnescape = [Text.RegularExpressions.MatchEvaluator] {
246    param($m)
247    if ($m.Groups[1].Success) { return [string][char][Convert]::ToInt32($m.Groups[1].Value, 16) }
248    switch -CaseSensitive ($m.Groups[2].Value) {
249        'n' { return "`n" }  't' { return "`t" }  'r' { return "`r" }  'b' { return [string][char]8 }
250        'f' { return [string][char]12 }  '/' { return '/' }  '\' { return '\' }  '"' { return '"' }
251    }
252    throw ('Invalid \escape: ' + $m.Value)
253}
254$script:JsonEscapeRe = [regex]'[\\"]|[^\x20-\x7e]'
255$script:JsonEscape = [Text.RegularExpressions.MatchEvaluator] {
256    param($m)
257    $c = $m.Value[0]
258    switch ([int]$c) {
259        0x22 { return '\"' }  0x5C { return '\\' }  0x0A { return '\n' }  0x0D { return '\r' }
260        0x09 { return '\t' }  0x08 { return '\b' }  0x0C { return '\f' }
261    }
262    return '\u' + ([int]$c).ToString('x4')
263}
264
265function ConvertFrom-PyJson([string]$Text) {
266    # Objects become case-sensitive ordered dictionaries, arrays ArrayLists, integers [long], other numbers [double].
267    $stack = New-Object System.Collections.ArrayList
268    $top = $null; $isDict = $false; $key = $null
269    $root = $null; $haveRoot = $false; $end = 0
270    # what may come next: 0 value, 1 key or '}', 2 ',' or closing bracket, 3 ':', 4 value or ']', 5 key
271    $expect = 0
272    foreach ($m in $script:JsonTokenRe.Matches($Text)) {
273        $end = $m.Index + $m.Length
274        $tok = $m.Groups[1].Value
275        $c = $tok[0]
276        if ($haveRoot -and $stack.Count -eq 0) { throw ('Extra data at character ' + $m.Index) }
277        $isContainer = $false
278        if ($c -eq [char]'"') {
279            $val = $tok.Substring(1, $tok.Length - 2)
280            if ($val.IndexOf('\') -ge 0) { $val = $script:JsonUnescapeRe.Replace($val, $script:JsonUnescape) }
281            if ($expect -eq 1 -or $expect -eq 5) { $key = $val; $expect = 3; continue }
282            if ($expect -ne 0 -and $expect -ne 4) { throw ('Invalid JSON at character ' + $m.Index) }
283        } elseif ($c -eq [char]':') {
284            if ($expect -ne 3) { throw ('Invalid JSON at character ' + $m.Index) }
285            $expect = 0; continue
286        } elseif ($c -eq [char]',') {
287            if ($expect -ne 2) { throw ('Invalid JSON at character ' + $m.Index) }
288            if ($isDict) { $expect = 5 } else { $expect = 0 }
289            continue
290        } elseif ($c -eq [char]'}' -or $c -eq [char]']') {
291            $ok = ($stack.Count -gt 0) -and (($c -eq [char]'}' -and $isDict -and ($expect -eq 1 -or $expect -eq 2)) -or
292                                             ($c -eq [char]']' -and -not $isDict -and ($expect -eq 4 -or $expect -eq 2)))
293            if (-not $ok) { throw ('Invalid JSON at character ' + $m.Index) }
294            $stack.RemoveAt($stack.Count - 1)
295            if ($stack.Count) { $top = $stack[$stack.Count - 1]; $isDict = $top -is [Collections.IDictionary] } else { $top = $null }
296            $expect = 2; continue
297        } else {
298            if ($expect -ne 0 -and $expect -ne 4) { throw ('Invalid JSON at character ' + $m.Index) }
299            if ($c -eq [char]'{') { $val = New-Dict; $isContainer = $true }
300            elseif ($c -eq [char]'[') { $val = New-List; $isContainer = $true }
301            elseif ($tok -ceq 'true') { $val = $true }
302            elseif ($tok -ceq 'false') { $val = $false }
303            elseif ($tok -ceq 'null') { $val = $null }
304            elseif ($tok -ceq 'NaN') { $val = [double]::NaN }
305            elseif ($tok -ceq 'Infinity') { $val = [double]::PositiveInfinity }
306            elseif ($tok -ceq '-Infinity') { $val = [double]::NegativeInfinity }
307            elseif ($tok.IndexOfAny([char[]]'.eE') -ge 0) { $val = [double]::Parse($tok, $script:Inv) }
308            else {
309                $n = [long]0
310                if ([long]::TryParse($tok, [Globalization.NumberStyles]::AllowLeadingSign, $script:Inv, [ref]$n)) { $val = $n }
311                else { $val = [double]::Parse($tok, $script:Inv) }
312            }
313        }
314        if ($stack.Count -eq 0) { $root = $val; $haveRoot = $true }
315        elseif ($isDict) { $top[$key] = $val }
316        else { [void]$top.Add($val) }
317        if ($isContainer) {
318            [void]$stack.Add($val); $top = $val; $isDict = $c -eq [char]'{'
319            if ($isDict) { $expect = 1 } else { $expect = 4 }
320        } else { $expect = 2 }
321    }
322    if (-not $haveRoot -or $stack.Count -gt 0 -or $Text.Substring($end).Trim(" `t`n`r".ToCharArray()).Length -gt 0) {
323        throw ('Invalid JSON at character ' + $end)
324    }
325    return , $root
326}
327
328function Write-JsonValue([Text.StringBuilder]$Sb, $Value, [bool]$Sort, [int]$Indent, [int]$Level) {
329    if ($null -eq $Value) { [void]$Sb.Append('null'); return }
330    if ($Value -is [bool]) { if ($Value) { [void]$Sb.Append('true') } else { [void]$Sb.Append('false') }; return }
331    if ($Value -is [string] -or $Value -is [char]) {
332        $s = [string]$Value
333        if ($script:JsonEscapeRe.IsMatch($s)) { $s = $script:JsonEscapeRe.Replace($s, $script:JsonEscape) }
334        [void]$Sb.Append('"').Append($s).Append('"'); return
335    }
336    if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) { [void]$Sb.Append((Format-PyFloat ([double]$Value) -Json)); return }
337    if (Test-IsNumber $Value) { [void]$Sb.Append($Value.ToString($script:Inv)); return }
338    $isDict = $Value -is [Collections.IDictionary]
339    if ($isDict) {
340        $keys = @($Value.Keys)
341        if ($Sort) { $keys = Get-SortedStrings $keys }
342        $open = '{'; $close = '}'; $count = $keys.Count
343    } elseif ($Value -is [Collections.IEnumerable]) {
344        $items = @($Value)
345        $open = '['; $close = ']'; $count = $items.Count
346    } else {
347        Write-JsonValue $Sb (ConvertTo-PyStr $Value) $Sort $Indent $Level; return
348    }
349    if ($count -eq 0) { [void]$Sb.Append($open).Append($close); return }
350    [void]$Sb.Append($open)
351    if ($Indent -ge 0) { $sep = ",`n" + (' ' * ($Indent * ($Level + 1))); [void]$Sb.Append("`n" + (' ' * ($Indent * ($Level + 1)))) }
352    else { $sep = ', ' }
353    for ($i = 0; $i -lt $count; $i++) {
354        if ($i) { [void]$Sb.Append($sep) }
355        if ($isDict) {
356            Write-JsonValue $Sb ([string]$keys[$i]) $Sort $Indent ($Level + 1)
357            [void]$Sb.Append(': ')
358            Write-JsonValue $Sb $Value[$keys[$i]] $Sort $Indent ($Level + 1)
359        } else {
360            Write-JsonValue $Sb $items[$i] $Sort $Indent ($Level + 1)
361        }
362    }
363    if ($Indent -ge 0) { [void]$Sb.Append("`n" + (' ' * ($Indent * $Level))) }
364    [void]$Sb.Append($close)
365}
366
367function ConvertTo-PyJson($Value, [switch]$SortKeys, [int]$Indent = -1) {
368    # Python: json.dumps(value, sort_keys=..., indent=...) with its default separators and ASCII-only output.
369    $sb = New-Object System.Text.StringBuilder
370    Write-JsonValue $sb $Value ([bool]$SortKeys) $Indent 0
371    return $sb.ToString()
372}
373
374
375# ------------------------------------------------------------------ helpers
376function Get-NormHost($Raw) {
377    # URL, host:port or host -> lower-case host without www. and trailing dot, or '' if not a host name.
378    $s = ''
379    if (Test-Truthy $Raw) { $s = ConvertTo-PyStr $Raw }
380    $s = $s.Trim().Trim([char[]]@('"', "'")).ToLowerInvariant()
381    $s = [regex]::Replace($s, '^[a-z][a-z0-9+.-]*://', '')
382    $i = $s.IndexOf('/'); if ($i -ge 0) { $s = $s.Substring(0, $i) }
383    $i = $s.IndexOf('?'); if ($i -ge 0) { $s = $s.Substring(0, $i) }
384    $i = $s.LastIndexOf('@'); if ($i -ge 0) { $s = $s.Substring($i + 1) }
385    if (($s.Length - $s.Replace(':', '').Length) -eq 1) { $s = $s.Substring(0, $s.IndexOf(':')) }
386    $s = $s.Trim('.')
387    if ($s.StartsWith('www.', $script:Ordinal)) { $s = $s.Substring(4) }
388    if ($script:Ipv4Re.IsMatch($s) -or -not $script:HostRe.IsMatch($s)) { return '' }
389    return $s
390}
391
392function ConvertTo-UrlQuoted([string]$Text, [string]$Safe = '/', [switch]$Plus) {
393    # Python: urllib.parse.quote (or quote_plus with -Plus) on the UTF-8 bytes of the text.
394    $sb = New-Object System.Text.StringBuilder
395    foreach ($b in [Text.Encoding]::UTF8.GetBytes($Text)) {
396        $ch = [char]$b
397        if (($b -ge 0x30 -and $b -le 0x39) -or ($b -ge 0x41 -and $b -le 0x5A) -or ($b -ge 0x61 -and $b -le 0x7A) -or
398            $b -eq 0x5F -or $b -eq 0x2E -or $b -eq 0x2D -or $b -eq 0x7E -or ($b -lt 0x80 -and $Safe.IndexOf($ch) -ge 0)) {
399            [void]$sb.Append($ch)
400        } elseif ($Plus -and $b -eq 0x20) {
401            [void]$sb.Append('+')
402        } else {
403            [void]$sb.Append('%').Append($b.ToString('X2'))
404        }
405    }
406    return $sb.ToString()
407}
408
409function ConvertTo-UrlEncoded($Pairs) {
410    # Python: urllib.parse.urlencode(pairs), keeping the order the pairs were added in.
411    $parts = foreach ($k in $Pairs.Keys) {
412        (ConvertTo-UrlQuoted (ConvertTo-PyStr $k) '' -Plus) + '=' + (ConvertTo-UrlQuoted (ConvertTo-PyStr $Pairs[$k]) '' -Plus)
413    }
414    return (@($parts) -join '&')
415}
416
417function ConvertTo-Base64([string]$Text) {
418    return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Text))
419}
420
421function Get-HeaderValue($Headers, [string]$Name) {
422    # A response header, whatever shape this PowerShell version returns the headers in; '' if absent.
423    if ($null -eq $Headers) { return '' }
424    foreach ($k in @($Headers.Keys)) {
425        if ([string]::Equals([string]$k, $Name, [StringComparison]::OrdinalIgnoreCase)) {
426            $v = $Headers[$k]
427            if ($v -is [string]) { return $v }
428            return (@($v) -join ', ')
429        }
430    }
431    return ''
432}
433
434function Enable-TrustAllCertificates {
435    # Windows PowerShell 5.1 has no -SkipCertificateCheck; this is the classic stand-in. Returns the previous policy.
436    if (-not ('AtbTrustAllCertificates' -as [type])) {
437        Add-Type -TypeDefinition @'
438using System.Net;
439using System.Security.Cryptography.X509Certificates;
440public class AtbTrustAllCertificates : ICertificatePolicy {
441    public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
442}
443'@
444    }
445    $previous = [Net.ServicePointManager]::CertificatePolicy
446    [Net.ServicePointManager]::CertificatePolicy = New-Object AtbTrustAllCertificates
447    return , $previous
448}
The full code comes with the MSP plan

Every language and every device, with a download page and your report key already filled in.

I have the MSP planSee the MSP plan

The program runs on your own machine with your own access to each device, under your agreement with its vendor; we never connect to your clients’ devices. Only domain names are sent to the AI Tools Blocklist API, never who visited them. SonicWall, Fortinet, FortiGate, Palo Alto Networks, Cisco, Meraki, Umbrella, DNSFilter, Cloudflare, Microsoft, Defender and WatchGuard are trademarks of their owners; AI Tools Blocklist is not affiliated with or endorsed by them.

Get started

Block the risky AI services, then show each client the proof.

The MSP plan includes the block lists for up to 25 clients, the report code and the lookups it needs.