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
$ ./atb-report syslog --config atb-report.json
$ ./atb-report report --config atb-report.json --month 2026-09
cron or systemd timer: run ./atb-report collect --config atb-report.json every day.
RustA ready-built program for Linux x64, or build it yourself with <code>cargo build --release</code> (Rust 1.70 or later).

src/main.rs 557 linesPreview
1//! AI Tools Blocklist report code, Rust version 1.0 (2026-09-28)
2//! https://www.aitoolsblocklist.com/ai-governance-for-msps.php#report-code
3//!
4//! Builds a monthly AI report for each client from the client's own firewall or DNS-filter logs. The logs stay on
5//! your machine: only the list of domain names is checked against the AI Tools Blocklist API (your plan's API key).
6//!
7//!   atb-report collect --config atb-report.json            pull the latest logs (run daily)
8//!   atb-report syslog  --config atb-report.json --port 5514 receive syslog (SonicWall, Cisco ASA / Firepower)
9//!   atb-report import  --config atb-report.json --client "Example Dental" --file export.csv
10//!   atb-report report  --config atb-report.json --month 2026-09 [--client "Example Dental"]
11//!
12//! Sources: umbrella, meraki, fortigate, paloalto, dnsfilter, cloudflare, defender, watchguard, syslog, file.
13//! One self-contained program; settings: see atb-report.example.json. It behaves exactly like the Python version
14//! (same settings file, same data folder, same reports), so either can be used, or both on the same data.
15//! Build: cargo build --release  ->  target/release/atb-report
16
17mod common;
18mod http;
19mod parse;
20mod platform;
21mod sources;
22mod store;
23
24use common::{
25    day_of, dumps, err, get, log, py_str, py_strip, req, slug, text, truthy, utcnow, DateTime, Error, Map, Result, MONTH_NAMES,
26};
27use http::{Http, Payload};
28use serde_json::{json, Value};
29use std::collections::{BTreeMap, BTreeSet};
30use std::fs;
31use std::path::PathBuf;
32use std::time::{Duration, Instant};
33use store::Store;
34
35pub const VERSION: &str = "1.0";
36
37/// The report page. The "template" setting can point to another copy.
38const DEFAULT_TEMPLATE: &str = include_str!("../../shared/report-template.html");
39
40// ------------------------------------------------------------------ command line
41
42/// Command-line options (the same as the Python version's).
43#[derive(Default)]
44struct Args {
45    cmd: String,
46    config: String,
47    client: Option<String>,
48    days: i64,
49    port: i64,
50    bind: String,
51    seconds: i64,
52    file: String,
53    month: String,
54}
55
56fn usage(cmd: &str) -> String {
57    let extra = match cmd {
58        "collect" => " [--days DAYS]",
59        "syslog" => " [--port PORT] [--bind BIND] [--seconds SECONDS]",
60        "import" => " --file FILE",
61        "report" => " [--month MONTH]",
62        _ => return "usage: atb-report [-h] {collect,syslog,import,report} ...".into(),
63    };
64    format!("usage: atb-report {} [-h] --config CONFIG [--client CLIENT]{}", cmd, extra)
65}
66
67/// Prints an argument error the way argparse does and exits with code 2.
68fn usage_error(cmd: &str, msg: &str) -> ! {
69    eprintln!("{}", usage(cmd));
70    let prog = if cmd.is_empty() { "atb-report".to_string() } else { format!("atb-report {}", cmd) };
71    eprintln!("{}: error: {}", prog, msg);
72    std::process::exit(2);
73}
74
75fn parse_args(argv: &[String]) -> Args {
76    let cmd = match argv.first().map(String::as_str) {
77        Some(c @ ("collect" | "syslog" | "import" | "report")) => c.to_string(),
78        Some("-h" | "--help") => {
79            println!("{}\n\nAI Tools Blocklist monthly report code", usage(""));
80            std::process::exit(0);
81        }
82        Some(other) => usage_error("", &format!("argument cmd: invalid choice: '{}' (choose from 'collect', 'syslog', 'import', 'report')", other)),
83        None => usage_error("", "the following arguments are required: cmd"),
84    };
85    let mut names = vec!["config", "client"];
86    names.extend(match cmd.as_str() {
87        "collect" => vec!["days"],
88        "syslog" => vec!["port", "bind", "seconds"],
89        "import" => vec!["file"],
90        _ => vec!["month"],
91    });
92    let mut vals: BTreeMap<&str, String> = BTreeMap::new();
93    let mut i = 1;
94    while i < argv.len() {
95        let tok = &argv[i];
96        if tok == "-h" || tok == "--help" {
97            println!("{}", usage(&cmd));
98            std::process::exit(0);
99        }
100        let Some(opt) = tok.strip_prefix("--") else {
101            usage_error(&cmd, &format!("unrecognized arguments: {}", tok));
102        };
103        let (name, inline) = match opt.split_once('=') {
104            Some((n, v)) => (n, Some(v.to_string())),
105            None => (opt, None),
106        };
107        // like argparse, accept an unambiguous abbreviation such as --conf
108        let matches: Vec<&str> = names.iter().copied().filter(|n| *n == name || n.starts_with(name)).collect();
109        let full = match matches.as_slice() {
110            _ if names.contains(&name) => name,
111            [one] if !name.is_empty() => *one,
112            [] | [_] => usage_error(&cmd, &format!("unrecognized arguments: {}", tok)),
113            many => usage_error(&cmd, &format!("ambiguous option: --{} could match {}", name, many.iter().map(|m| format!("--{}", m)).collect::<Vec<_>>().join(", "))),
114        };
115        let value = match inline {
116            Some(v) => v,
117            None => {
118                i += 1;
119                match argv.get(i) {
120                    Some(v) if !(v.starts_with('-') && v.parse::<f64>().is_err()) => v.clone(),
121                    _ => usage_error(&cmd, &format!("argument --{}: expected one argument", full)),
122                }
123            }
124        };
125        vals.insert(full, value);
126        i += 1;
127    }
128    let int = |name: &str, default: i64| -> i64 {
129        match vals.get(name) {
130            None => default,
131            Some(v) => py_strip(v).parse().unwrap_or_else(|_| usage_error(&cmd, &format!("argument --{}: invalid int value: '{}'", name, v))),
132        }
133    };
134    let mut required = vec!["config"];
135    if cmd == "import" {
136        required.push("file");
137    }
138    let missing: Vec<String> = required.iter().filter(|r| !vals.contains_key(**r)).map(|r| format!("--{}", r)).collect();
139    if !missing.is_empty() {
140        usage_error(&cmd, &format!("the following arguments are required: {}", missing.join(", ")));
141    }
142    Args {
143        days: int("days", 0),
144        port: int("port", 5514),
145        seconds: int("seconds", 0),
146        bind: vals.get("bind").cloned().unwrap_or_else(|| "0.0.0.0".into()),
147        month: vals.get("month").cloned().unwrap_or_else(|| prev_month(&utcnow().date()[..7]).unwrap_or_default()),
148        config: vals["config"].clone(),
149        client: vals.get("client").cloned(),
150        file: vals.get("file").cloned().unwrap_or_default(),
151        cmd,
152    }
153}
154
155fn main() {
156    if let Err(e) = common::init_clock() {
157        eprintln!("ERROR {}", e);
158        std::process::exit(1);
159    }
160    let argv: Vec<String> = std::env::args().skip(1).collect();
161    let args = parse_args(&argv);
162    let result = load_config(&args.config).and_then(|cfg| match args.cmd.as_str() {
163        "collect" => cmd_collect(&cfg, &args),
164        "syslog" => cmd_syslog(&cfg, &args),
165        "import" => cmd_import(&cfg, &args),
166        _ => cmd_report(&cfg, &args),
167    });
168    match result {
169        Ok(code) => std::process::exit(code),
170        Err(e) => {
171            log(&format!("ERROR {}", e));
172            std::process::exit(1);
173        }
174    }
175}
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.