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
Sample
Preview the monthly report
12 pages, opens in your browser · PDF
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 syslogSonicWall 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
- 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.
- Make sure web site events are logged (Content Filter / web site hit events at priority Inform or lower).
- Add the client to the settings file with
"source": "syslog"and the IP address the firewall sends from. - Run
syslogon a machine the firewall can reach (at the client or at your office). It listens on UDP port 5514; use--port 514if you prefer the standard port (needs admin rights). Keep it running, for example as a Windows service or a systemd unit.
Your settings file
{
"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
$ pwsh -File atb-report.ps1 syslog --config atb-report.json
$ pwsh -File atb-report.ps1 report --config atb-report.json --month 2026-09pwsh -File C:\atb\atb-report.ps1 collect --config C:\atb\atb-report.json every day.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}
Every language and every device, with a download page and your report key already filled in.
I have the MSP planSee the MSP planThe 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.
The full code is part of the MSP plan
MSP plan customers download the complete program for every language and filter, with the report layout and a settings file that already holds their API key.
Without the plan you can download the preview: the first 30% of the code, to see how it works.
I have the MSP plan: log in for the full code →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.