shadowaitools 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ """
2
+ shadowaitools: local shadow AI inventory from DNS, proxy and firewall exports.
3
+
4
+ from shadowaitools import scan, to_csv
5
+ inv = scan("umbrella.csv", api_key="...")
6
+ print(inv["summary"]["ai_tools_found"])
7
+
8
+ Hosted audit with the per-user breakdown, dated vendor training verdicts,
9
+ sanctioned split and PDF evidence pack: https://www.shadowaitools.com
10
+ """
11
+
12
+ from .client import (AuthenticationError, Client, QuotaError, RateLimitError, ShadowAIToolsError)
13
+ from .inventory import scan, to_csv, to_table
14
+ from .parser import (CLIENT_KEYS, DOMAIN_KEYS, extract_domains, norm_host, parse_log, registrable_domain)
15
+
16
+ __version__ = "1.0.0"
17
+
18
+ __all__ = [
19
+ "scan", "to_csv", "to_table", "parse_log", "extract_domains", "registrable_domain", "norm_host",
20
+ "Client", "ShadowAIToolsError", "AuthenticationError", "QuotaError", "RateLimitError",
21
+ "DOMAIN_KEYS", "CLIENT_KEYS", "__version__",
22
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
shadowaitools/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Command line: shadowaitools scan <file> [options]."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .inventory import scan, to_csv, to_table
10
+ from .parser import extract_domains, parse_log
11
+
12
+ FORMATS = """csv any delimited export with a header row; hostname column named domain, query, hostname, host, url, dest, destination, fqdn, site, sni or question
13
+ key-value syslog lines such as hostname="..." user="..." (Fortinet, SonicWall, Check Point, Sophos)
14
+ dnsmasq Pi-hole and dnsmasq query logs
15
+ squid Squid access.log (CONNECT host:443 lines)
16
+ windows-dns Windows DNS Server debug log packets
17
+ generic anything else: first hostname on each line, first private IP as the client
18
+ """
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ p = argparse.ArgumentParser(
23
+ prog="shadowaitools",
24
+ description="Shadow AI inventory from a DNS, proxy or firewall export. "
25
+ "Hosted audit with per-user breakdown, training verdicts and PDF evidence pack: "
26
+ "https://www.shadowaitools.com",
27
+ )
28
+ p.add_argument("--version", action="version", version="shadowaitools %s" % __version__)
29
+ sub = p.add_subparsers(dest="command")
30
+
31
+ s = sub.add_parser("scan", help="parse an export and look up every unique domain")
32
+ s.add_argument("file")
33
+ s.add_argument("--key", help="AI Tools Blocklist API key (or env SHADOWAITOOLS_API_KEY / ATB_API_KEY)")
34
+ s.add_argument("--json", help="write the inventory as JSON")
35
+ s.add_argument("--csv", help="write the inventory as CSV")
36
+ s.add_argument("--cache", help="reuse and extend a JSON cache of previous lookups")
37
+ s.add_argument("--sanctioned", help="comma-separated domains counted as sanctioned")
38
+ s.add_argument("--max-lines", type=int)
39
+ s.add_argument("--concurrency", type=int, default=1)
40
+ s.add_argument("--pause", type=float, default=0.0, help="seconds between lookups")
41
+ s.add_argument("--quiet", action="store_true", help="print only the table")
42
+
43
+ d = sub.add_parser("domains", help="list unique registrable domains without any lookup")
44
+ d.add_argument("file")
45
+ d.add_argument("--max-lines", type=int)
46
+
47
+ sub.add_parser("formats", help="show the accepted export formats")
48
+ return p
49
+
50
+
51
+ def main(argv=None) -> int:
52
+ p = build_parser()
53
+ args = p.parse_args(argv)
54
+ if not args.command:
55
+ p.print_help()
56
+ return 0
57
+ if args.command == "formats":
58
+ sys.stdout.write(FORMATS)
59
+ return 0
60
+ if not os.path.exists(args.file):
61
+ sys.stderr.write("file not found: %s\n" % args.file)
62
+ return 2
63
+ if args.command == "domains":
64
+ parsed = parse_log(args.file, max_lines=args.max_lines)
65
+ groups = extract_domains(parsed["records"])
66
+ sys.stdout.write("%s export, %d lines, %d unique domains\n" % (parsed["format"], parsed["lines"], len(groups)))
67
+ for g in groups:
68
+ sys.stdout.write("%7d %s\n" % (g["hits"], g["domain"]))
69
+ return 0
70
+
71
+ key = args.key or os.environ.get("SHADOWAITOOLS_API_KEY") or os.environ.get("ATB_API_KEY")
72
+ if not key:
73
+ sys.stderr.write("An API key is required: --key KEY or SHADOWAITOOLS_API_KEY.\n")
74
+ return 2
75
+ shown = {"n": 0}
76
+
77
+ def progress(done, total):
78
+ if done == total or done - shown["n"] >= 25:
79
+ sys.stderr.write("looked up %d/%d domains\n" % (done, total))
80
+ shown["n"] = done
81
+
82
+ try:
83
+ inv = scan(
84
+ args.file, api_key=key, concurrency=args.concurrency, pause=args.pause, max_lines=args.max_lines,
85
+ cache_file=args.cache,
86
+ sanctioned=[x.strip() for x in (args.sanctioned or "").split(",") if x.strip()],
87
+ on_progress=None if args.quiet else progress,
88
+ )
89
+ except Exception as exc: # surfaced as a one-line error for operators
90
+ sys.stderr.write("%s: %s\n" % (exc.__class__.__name__, exc))
91
+ return 1
92
+ if args.json:
93
+ with open(args.json, "w", encoding="utf-8") as fh:
94
+ json.dump(inv, fh, indent=2)
95
+ if args.csv:
96
+ with open(args.csv, "w", encoding="utf-8") as fh:
97
+ fh.write(to_csv(inv))
98
+ sys.stdout.write(to_table(inv) + "\n")
99
+ if args.json:
100
+ sys.stderr.write("JSON written to %s\n" % os.path.abspath(args.json))
101
+ if args.csv:
102
+ sys.stderr.write("CSV written to %s\n" % os.path.abspath(args.csv))
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1,98 @@
1
+ """
2
+ Lookup client for the AI Tools Blocklist API.
3
+
4
+ GET https://www.aitoolsblocklist.com/api/check?domain=<domain>
5
+ header X-API-Key: <key>
6
+
7
+ One lookup is charged per call. Subdomains resolve to their registrable
8
+ domain on the server, so the client reduces hosts before sending them and
9
+ never sends a full URL or any log line.
10
+ """
11
+
12
+ import time
13
+ from typing import Dict, Optional
14
+
15
+ import requests
16
+
17
+ from .parser import registrable_domain
18
+
19
+ VERSION = "1.0.0"
20
+ DEFAULT_BASE_URL = "https://www.aitoolsblocklist.com/api"
21
+ DEFAULT_TIMEOUT = 30
22
+ USER_AGENT = "shadowaitools-python/%s (+https://www.shadowaitools.com)" % VERSION
23
+
24
+
25
+ class ShadowAIToolsError(Exception):
26
+ """Base exception. ``status`` and ``body`` carry the HTTP status and parsed body when known."""
27
+
28
+ def __init__(self, message: str, status: Optional[int] = None, body=None):
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.body = body
32
+
33
+
34
+ class AuthenticationError(ShadowAIToolsError):
35
+ """401: missing or unknown API key."""
36
+
37
+
38
+ class QuotaError(ShadowAIToolsError):
39
+ """403: account not active or the monthly lookup quota is exhausted."""
40
+
41
+
42
+ class RateLimitError(ShadowAIToolsError):
43
+ """429: too many requests."""
44
+
45
+
46
+ class Client:
47
+ """
48
+ Args:
49
+ api_key: AI Tools Blocklist API key, sent as X-API-Key.
50
+ base_url: override for testing.
51
+ timeout: per-request timeout in seconds.
52
+ max_retries: retries on 429 and 503 with a short pause.
53
+ """
54
+
55
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: int = DEFAULT_TIMEOUT,
56
+ max_retries: int = 2):
57
+ if not api_key:
58
+ raise ValueError("api_key is required")
59
+ self.api_key = api_key
60
+ self.base_url = base_url.rstrip("/")
61
+ self.timeout = timeout
62
+ self.max_retries = max_retries
63
+ self._session = requests.Session()
64
+ self._session.headers.update({
65
+ "Accept": "application/json",
66
+ "User-Agent": USER_AGENT,
67
+ "X-API-Key": api_key,
68
+ })
69
+
70
+ def lookup(self, domain: str) -> Dict:
71
+ """Classify one domain. ``blocked`` is True for a known AI tool; ``categories`` may hold several rows."""
72
+ d = registrable_domain(domain)
73
+ last = None
74
+ for attempt in range(self.max_retries + 1):
75
+ resp = self._session.get(self.base_url + "/check", params={"domain": d}, timeout=self.timeout)
76
+ if resp.status_code in (429, 503) and attempt < self.max_retries:
77
+ last = resp
78
+ time.sleep(1.5 * (attempt + 1))
79
+ continue
80
+ return self._handle(resp)
81
+ return self._handle(last)
82
+
83
+ @staticmethod
84
+ def _handle(resp) -> Dict:
85
+ try:
86
+ data = resp.json()
87
+ except ValueError:
88
+ data = {"message": resp.text[:200]}
89
+ if resp.status_code == 200:
90
+ return data
91
+ msg = data.get("message") or data.get("error") or "HTTP %s" % resp.status_code
92
+ if resp.status_code == 401:
93
+ raise AuthenticationError(msg, 401, data)
94
+ if resp.status_code == 403:
95
+ raise QuotaError(msg, 403, data)
96
+ if resp.status_code == 429:
97
+ raise RateLimitError(msg, 429, data)
98
+ raise ShadowAIToolsError("HTTP %s: %s" % (resp.status_code, msg), resp.status_code, data)
@@ -0,0 +1,184 @@
1
+ """
2
+ Build the shadow AI inventory: parse locally, look up each unique registrable
3
+ domain once, and assemble tools, summary and per-category counts.
4
+ """
5
+
6
+ import csv
7
+ import io
8
+ import json
9
+ import os
10
+ import time
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from datetime import datetime, timezone
13
+ from typing import Callable, Dict, Iterable, List, Optional
14
+
15
+ from .client import Client
16
+ from .parser import extract_domains, parse_log, registrable_domain
17
+
18
+ TOOL_FIELDS = ("primary_category", "ai_type", "trains_on_data", "opt_out_available",
19
+ "enterprise_no_training", "api_no_training", "terms_checked")
20
+
21
+
22
+ def _load_cache(path: Optional[str]) -> Dict:
23
+ if not path or not os.path.exists(path):
24
+ return {}
25
+ try:
26
+ with open(path, "r", encoding="utf-8") as fh:
27
+ return json.load(fh)
28
+ except (OSError, ValueError):
29
+ return {}
30
+
31
+
32
+ def _save_cache(path: Optional[str], cache: Dict) -> None:
33
+ if not path:
34
+ return
35
+ with open(path, "w", encoding="utf-8") as fh:
36
+ json.dump(cache, fh, indent=1)
37
+
38
+
39
+ def scan(source, api_key: Optional[str] = None, concurrency: int = 1, pause: float = 0.0,
40
+ max_lines: Optional[int] = None, cache_file: Optional[str] = None,
41
+ sanctioned: Optional[Iterable[str]] = None, on_progress: Optional[Callable[[int, int], None]] = None,
42
+ client: Optional[Client] = None, **client_options) -> Dict:
43
+ """
44
+ Parse an export and return the inventory dict.
45
+
46
+ Args:
47
+ source: path, contents string, bytes or file object.
48
+ api_key: AI Tools Blocklist key (or env SHADOWAITOOLS_API_KEY / ATB_API_KEY).
49
+ concurrency: parallel lookups (default 1).
50
+ pause: seconds to wait after each lookup.
51
+ max_lines: stop parsing after this many lines.
52
+ cache_file: JSON file of previous lookups, reused and extended.
53
+ sanctioned: domains counted as sanctioned in the split.
54
+ on_progress: callback(done, total).
55
+ """
56
+ key = api_key or os.environ.get("SHADOWAITOOLS_API_KEY") or os.environ.get("ATB_API_KEY")
57
+ if not key and client is None:
58
+ raise ValueError("api_key is required (or set SHADOWAITOOLS_API_KEY)")
59
+ client = client or Client(key, **client_options)
60
+ parsed = parse_log(source, max_lines=max_lines)
61
+ groups = extract_domains(parsed["records"])
62
+ cache = _load_cache(cache_file)
63
+ sanctioned_set = {registrable_domain(d) for d in (sanctioned or [])}
64
+ state = {"done": 0, "lookups": 0, "quota": None}
65
+
66
+ def resolve(group):
67
+ d = group["domain"]
68
+ res = cache.get(d)
69
+ if res is None:
70
+ res = client.lookup(d)
71
+ state["lookups"] += 1
72
+ cache[d] = res
73
+ if pause:
74
+ time.sleep(pause)
75
+ if isinstance(res, dict) and "quota_remaining" in res:
76
+ state["quota"] = res["quota_remaining"]
77
+ state["done"] += 1
78
+ if on_progress:
79
+ on_progress(state["done"], len(groups))
80
+ return res
81
+
82
+ if concurrency > 1:
83
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
84
+ results = list(pool.map(resolve, groups))
85
+ else:
86
+ results = [resolve(g) for g in groups]
87
+ _save_cache(cache_file, cache)
88
+
89
+ tools: List[Dict] = []
90
+ users_all = set()
91
+ for g, r in zip(groups, results):
92
+ r = r or {}
93
+ if not r.get("blocked"):
94
+ continue
95
+ for u in g["users"]:
96
+ users_all.add(u["name"])
97
+ tool = {
98
+ "domain": r.get("domain") or g["domain"],
99
+ "hosts": g["hosts"],
100
+ "hits": g["hits"],
101
+ "users": g["users"],
102
+ "sanctioned": g["domain"] in sanctioned_set,
103
+ "categories": r.get("categories") or [],
104
+ }
105
+ for f in TOOL_FIELDS:
106
+ tool[f] = r.get(f) if f in ("primary_category", "ai_type", "terms_checked") else (r.get(f) or "unstated")
107
+ tools.append(tool)
108
+ tools.sort(key=lambda t: (-t["hits"], t["domain"]))
109
+
110
+ by_category: Dict[str, int] = {}
111
+ for t in tools:
112
+ c = t["primary_category"] or (t["categories"][0]["category"] if t["categories"] else "Uncategorized")
113
+ by_category[c] = by_category.get(c, 0) + 1
114
+
115
+ return {
116
+ "generated": datetime.now(timezone.utc).isoformat(),
117
+ "format": parsed["format"],
118
+ "summary": {
119
+ "lines": parsed["lines"],
120
+ "records": len(parsed["records"]),
121
+ "unique_domains": len(groups),
122
+ "lookups": state["lookups"],
123
+ "ai_tools_found": len(tools),
124
+ "sanctioned": sum(1 for t in tools if t["sanctioned"]),
125
+ "unsanctioned": sum(1 for t in tools if not t["sanctioned"]),
126
+ "users_involved": len(users_all),
127
+ "training_default_yes": sum(1 for t in tools if t["trains_on_data"] in ("yes", "opt_out_default")),
128
+ "training_no": sum(1 for t in tools if t["trains_on_data"] == "no"),
129
+ "unstated": sum(1 for t in tools if t["trains_on_data"] == "unstated"),
130
+ "quota_remaining": state["quota"],
131
+ },
132
+ "by_category": by_category,
133
+ "tools": tools,
134
+ }
135
+
136
+
137
+ def to_csv(inventory: Dict) -> str:
138
+ """One CSV row per AI tool."""
139
+ buf = io.StringIO()
140
+ w = csv.writer(buf, lineterminator="\n")
141
+ w.writerow(["domain", "hits", "users", "sanctioned", "primary_category", "categories", "ai_type",
142
+ "trains_on_data", "opt_out_available", "enterprise_no_training", "api_no_training",
143
+ "terms_checked", "hosts"])
144
+ for t in inventory["tools"]:
145
+ cats = "; ".join(
146
+ "%s / %s" % (c["category"], c["subcategory"]) if c.get("subcategory") else c["category"]
147
+ for c in t["categories"]
148
+ )
149
+ w.writerow([
150
+ t["domain"], t["hits"], "; ".join(u["name"] for u in t["users"]), "yes" if t["sanctioned"] else "no",
151
+ t["primary_category"] or "", cats, t["ai_type"] or "", t["trains_on_data"], t["opt_out_available"],
152
+ t["enterprise_no_training"], t["api_no_training"], t["terms_checked"] or "", "; ".join(t["hosts"]),
153
+ ])
154
+ return buf.getvalue()
155
+
156
+
157
+ def to_table(inventory: Dict) -> str:
158
+ """Fixed-width text table for terminals and tickets."""
159
+ s = inventory["summary"]
160
+ out = [
161
+ "Shadow AI inventory (%s export, %d lines, %d unique domains, %d lookups)"
162
+ % (inventory["format"], s["lines"], s["unique_domains"], s["lookups"]),
163
+ "AI tools found: %d users involved: %d train on your data by default: %d terms silent: %d"
164
+ % (s["ai_tools_found"], s["users_involved"], s["training_default_yes"], s["unstated"]),
165
+ "",
166
+ ]
167
+ cols = [("domain", 26), ("hits", 5), ("ai type", 10), ("category", 26), ("trains on data", 15),
168
+ ("sanctioned", 10), ("users", 22)]
169
+ out.append(" ".join(n.ljust(w) for n, w in cols))
170
+ out.append(" ".join("-" * w for _, w in cols))
171
+ for t in inventory["tools"]:
172
+ cat = t["primary_category"] or (t["categories"][0]["category"] if t["categories"] else "")
173
+ users = ", ".join(u["name"] for u in t["users"][:2])
174
+ if len(t["users"]) > 2:
175
+ users += " +%d" % (len(t["users"]) - 2)
176
+ out.append(" ".join([
177
+ t["domain"][:26].ljust(26), str(t["hits"]).ljust(5), str(t["ai_type"] or "")[:10].ljust(10),
178
+ cat[:26].ljust(26), t["trains_on_data"].ljust(15), ("yes" if t["sanctioned"] else "no").ljust(10),
179
+ users[:22].ljust(22),
180
+ ]))
181
+ out.append("")
182
+ out.append("Per-user breakdown, dated vendor training verdicts, sanctioned split and the PDF evidence pack: "
183
+ "https://www.shadowaitools.com")
184
+ return "\n".join(out)
@@ -0,0 +1,266 @@
1
+ """
2
+ Local parsing of DNS, proxy and firewall exports.
3
+
4
+ Nothing here touches the network. The export is read once, each line is
5
+ reduced to a hostname (and a user, device or client IP when the export
6
+ carries one), and hostnames are grouped by registrable domain.
7
+
8
+ Formats detected from the first non-empty line:
9
+
10
+ csv delimited export with a header row (comma, tab, semicolon, pipe)
11
+ key-value syslog style hostname="..." user="..." lines
12
+ dnsmasq Pi-hole and dnsmasq query logs
13
+ squid Squid access.log
14
+ windows-dns Windows DNS Server debug log packets
15
+ generic first hostname on each line, first private IP as the client
16
+ """
17
+
18
+ import csv
19
+ import io
20
+ import os
21
+ import re
22
+ from collections import OrderedDict
23
+ from typing import Dict, Iterable, List, Optional
24
+
25
+ HOST_RE = re.compile(
26
+ r"(?<![\w.:/-])((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,24}|xn--[a-z0-9]{2,40}))\.?(?![\w-])",
27
+ re.I,
28
+ )
29
+ IP_RE = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\d.])")
30
+ PRIVATE_IP_RE = re.compile(
31
+ r"^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.|169\.254\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)"
32
+ )
33
+ SKIP_TLD_RE = re.compile(r"\.(local|localdomain|internal|lan|home|arpa|corp|intranet|test|example|invalid)$")
34
+ SKIP_HOST_RE = re.compile(r"^(www\.)?(w3\.org|schema\.org|xmlsoap\.org)$")
35
+ KV_RE = re.compile(r'(\w+)=("([^"]*)"|(\S+))')
36
+ WINDOWS_NAME_RE = re.compile(r"\(\d+\)[a-z0-9-]+(?:\(\d+\)[a-z0-9-]*)+\(0\)", re.I)
37
+
38
+ DOMAIN_KEYS = [
39
+ "domain", "query", "query name", "query_name", "queryname", "hostname", "host", "url", "dest",
40
+ "destination", "dst", "dsthost", "dst_host", "fqdn", "site", "request", "name", "sni", "server name",
41
+ "domain name", "domainname", "question",
42
+ ]
43
+ CLIENT_KEYS = [
44
+ "client", "client_ip", "client ip", "clientip", "src", "srcip", "src_ip", "source", "source ip",
45
+ "source address", "internal ip", "internalip", "identities", "identity", "device", "device name",
46
+ "device_name", "user", "username", "user name", "login", "endpoint", "computer", "workstation", "cip",
47
+ "requester", "from", "source user", "email",
48
+ ]
49
+ KV_HOST_KEYS = ["hostname", "domain", "url", "dstname", "dsthost", "query", "fqdn", "sni", "site"]
50
+ KV_CLIENT_KEYS = ["user", "usr", "srcname", "devname", "device", "login", "srcip", "src", "client", "clientip"]
51
+
52
+ SECOND_LEVEL = {
53
+ "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "com.au", "net.au", "org.au", "edu.au", "co.nz",
54
+ "org.nz", "co.za", "co.in", "net.in", "org.in", "com.br", "net.br", "com.mx", "co.jp", "ne.jp", "or.jp",
55
+ "co.kr", "com.cn", "net.cn", "org.cn", "com.tw", "com.sg", "com.my", "co.id", "com.hk", "com.tr", "com.ar",
56
+ "com.co", "com.pe", "com.ve", "com.ua", "co.il", "com.eg", "co.ke", "com.ng", "com.pk", "com.bd", "com.pl",
57
+ "com.pt", "com.es", "com.de", "com.fr", "com.it", "com.ru", "github.io", "gitlab.io", "vercel.app",
58
+ "netlify.app", "herokuapp.com", "azurewebsites.net", "cloudfront.net", "amazonaws.com", "web.app",
59
+ "firebaseapp.com", "pages.dev", "workers.dev", "hf.space", "streamlit.app", "replit.app", "onrender.com",
60
+ "fly.dev", "railway.app", "glitch.me", "ngrok.io", "notion.site", "webflow.io",
61
+ }
62
+
63
+
64
+ def norm_host(value: str) -> str:
65
+ """Lower-case a hostname, strip scheme, path, port and surrounding punctuation."""
66
+ s = str(value or "").strip().lower().strip(" \t\r\n\"'<>[](),;")
67
+ s = re.sub(r"^[a-z][a-z0-9+.-]*://", "", s)
68
+ s = re.sub(r"[/?#].*$", "", s)
69
+ s = re.sub(r":\d+$", "", s)
70
+ return s.rstrip(".")
71
+
72
+
73
+ def is_private_ip(ip: str) -> bool:
74
+ return bool(PRIVATE_IP_RE.match(ip))
75
+
76
+
77
+ def skip_host(h: str) -> bool:
78
+ if not h or len(h) > 253:
79
+ return True
80
+ if SKIP_TLD_RE.search(h):
81
+ return True
82
+ return bool(SKIP_HOST_RE.match(h))
83
+
84
+
85
+ def is_host(h: str) -> bool:
86
+ m = HOST_RE.search(" " + h + " ")
87
+ return bool(m) and norm_host(m.group(1)) == h
88
+
89
+
90
+ def registrable_domain(host: str) -> str:
91
+ """chat.openai.com -> openai.com; news.bbc.co.uk -> bbc.co.uk; user.github.io stays three labels."""
92
+ h = norm_host(host)
93
+ labels = h.split(".")
94
+ if len(labels) <= 2:
95
+ return h
96
+ last_two = ".".join(labels[-2:])
97
+ if last_two in SECOND_LEVEL:
98
+ return ".".join(labels[-3:])
99
+ return last_two
100
+
101
+
102
+ def _decode_windows_name(s: str) -> str:
103
+ parts = []
104
+ for count, label in re.findall(r"\((\d+)\)([^()\s]*)", s):
105
+ if count == "0":
106
+ break
107
+ parts.append(label)
108
+ return ".".join(parts)
109
+
110
+
111
+ def _detect(first: str) -> Dict:
112
+ for d in (",", "\t", ";", "|"):
113
+ if first.count(d) >= 2:
114
+ cells = [c.strip().strip("\"'").lower() for c in next(csv.reader([first], delimiter=d))]
115
+ dc = cc = None
116
+ for i, c in enumerate(cells):
117
+ if dc is None and c in DOMAIN_KEYS:
118
+ dc = i
119
+ if cc is None and c in CLIENT_KEYS:
120
+ cc = i
121
+ if dc is not None:
122
+ return {"format": "csv", "delim": d, "domain_col": dc, "client_col": cc, "header": True}
123
+ if re.search(r"\b(hostname|url|domain|srcip|dstip|dstname)=", first, re.I):
124
+ return {"format": "key-value"}
125
+ if re.search(r"dnsmasq|query\[A{1,4}\]", first, re.I):
126
+ return {"format": "dnsmasq"}
127
+ if re.match(r"^\d+\.\d+\s+\d+\s+\S+\s+TCP_", first):
128
+ return {"format": "squid"}
129
+ if re.search(r"\(\d+\)[a-z0-9-]+\(\d+\)", first, re.I) and "PACKET" in first.upper():
130
+ return {"format": "windows-dns"}
131
+ return {"format": "generic"}
132
+
133
+
134
+ def _parse_kv(line: str) -> Dict[str, str]:
135
+ out = {}
136
+ for key, _, quoted, bare in KV_RE.findall(line):
137
+ out[key.lower()] = quoted if quoted else bare
138
+ return out
139
+
140
+
141
+ def _read_text(source) -> str:
142
+ if hasattr(source, "read"):
143
+ data = source.read()
144
+ return data.decode("utf-8", "replace") if isinstance(data, bytes) else data
145
+ if isinstance(source, bytes):
146
+ return source.decode("utf-8", "replace")
147
+ s = str(source)
148
+ if "\n" not in s and os.path.exists(s):
149
+ with open(s, "rb") as fh:
150
+ return fh.read().decode("utf-8", "replace")
151
+ return s
152
+
153
+
154
+ def parse_log(source, max_lines: Optional[int] = None) -> Dict:
155
+ """
156
+ Parse an export given as a path, a string of contents, bytes or a file object.
157
+
158
+ Returns {"format": str, "lines": int, "records": [{"host": str, "user": str|None}, ...]}.
159
+ """
160
+ text = _read_text(source)
161
+ lines = text.splitlines()
162
+ first = None
163
+ first_idx = 0
164
+ for i, raw in enumerate(lines):
165
+ t = raw.strip()
166
+ if t and not t.startswith("#"):
167
+ first, first_idx = t, i
168
+ break
169
+ if first is None:
170
+ return {"format": "empty", "lines": 0, "records": []}
171
+ det = _detect(first)
172
+ fmt = det["format"]
173
+ records: List[Dict[str, Optional[str]]] = []
174
+ count = 0
175
+ start = first_idx + 1 if det.get("header") else first_idx
176
+ for raw in lines[start:]:
177
+ t = raw.strip()
178
+ if not t or t.startswith("#"):
179
+ continue
180
+ count += 1
181
+ if max_lines and count > max_lines:
182
+ break
183
+ host = None
184
+ user = None
185
+ if fmt == "csv":
186
+ cells = next(csv.reader([t], delimiter=det["delim"]))
187
+ raw_cell = cells[det["domain_col"]].strip() if det["domain_col"] < len(cells) else ""
188
+ cand = norm_host(raw_cell)
189
+ if not cand or not is_host(cand):
190
+ m = HOST_RE.search(" " + raw_cell + " ")
191
+ cand = norm_host(m.group(1)) if m else ""
192
+ host = cand
193
+ cc = det.get("client_col")
194
+ if cc is not None and cc < len(cells):
195
+ user = cells[cc].strip().strip("\"'")
196
+ if "," in user:
197
+ user = user.split(",")[0].strip()
198
+ elif fmt == "key-value":
199
+ kv = _parse_kv(t)
200
+ for k in KV_HOST_KEYS:
201
+ if kv.get(k):
202
+ host = norm_host(kv[k])
203
+ break
204
+ for k in KV_CLIENT_KEYS:
205
+ if kv.get(k):
206
+ user = kv[k]
207
+ break
208
+ elif fmt == "dnsmasq":
209
+ m = re.search(r"query\[\w+\]\s+(\S+)\s+from\s+(\S+)", t, re.I)
210
+ if m:
211
+ host, user = norm_host(m.group(1)), m.group(2)
212
+ elif fmt == "squid":
213
+ parts = t.split()
214
+ user = parts[2] if len(parts) > 2 else None
215
+ host = norm_host(parts[6]) if len(parts) > 6 else None
216
+ if host and not is_host(host):
217
+ host = None
218
+ if len(parts) > 7 and parts[7] != "-" and not re.match(r"^[A-Z_]+/", parts[7]):
219
+ user = parts[7]
220
+ elif fmt == "windows-dns":
221
+ m = WINDOWS_NAME_RE.search(t)
222
+ if m:
223
+ host = norm_host(_decode_windows_name(m.group(0)))
224
+ for ip in IP_RE.findall(t):
225
+ if is_private_ip(ip):
226
+ user = ip
227
+ break
228
+ else:
229
+ for h in HOST_RE.findall(" " + t + " "):
230
+ n = norm_host(h)
231
+ if not skip_host(n):
232
+ host = n
233
+ break
234
+ for ip in IP_RE.findall(t):
235
+ if is_private_ip(ip):
236
+ user = ip
237
+ break
238
+ if not host or skip_host(host) or not is_host(host):
239
+ continue
240
+ if user and len(user) > 120:
241
+ user = None
242
+ records.append({"host": host, "user": user or None})
243
+ return {"format": fmt, "lines": count, "records": records}
244
+
245
+
246
+ def extract_domains(records: Iterable[Dict]) -> List[Dict]:
247
+ """Group records by registrable domain: [{domain, hosts, hits, users:[{name, hits}]}] sorted by hits."""
248
+ groups: "OrderedDict[str, Dict]" = OrderedDict()
249
+ for r in records:
250
+ d = registrable_domain(r["host"])
251
+ g = groups.setdefault(d, {"domain": d, "hosts": set(), "hits": 0, "users": {}})
252
+ g["hosts"].add(r["host"])
253
+ g["hits"] += 1
254
+ if r.get("user"):
255
+ g["users"][r["user"]] = g["users"].get(r["user"], 0) + 1
256
+ out = []
257
+ for g in groups.values():
258
+ users = sorted(g["users"].items(), key=lambda kv: -kv[1])
259
+ out.append({
260
+ "domain": g["domain"],
261
+ "hosts": sorted(g["hosts"]),
262
+ "hits": g["hits"],
263
+ "users": [{"name": n, "hits": h} for n, h in users],
264
+ })
265
+ out.sort(key=lambda g: (-g["hits"], g["domain"]))
266
+ return out
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alpha Quantum
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.1
2
+ Name: shadowaitools
3
+ Version: 1.0.0
4
+ Summary: Shadow AI detection from the logs you already have: parse a DNS, proxy or firewall export locally, look up each unique domain against the AI Tools Blocklist, and get an inventory of the AI tools in use with category, AI type and vendor training verdicts.
5
+ Home-page: https://www.shadowaitools.com
6
+ Author: Alpha Quantum
7
+ Author-email: info@alpha-quantum.com
8
+ License: MIT
9
+ Project-URL: Homepage, https://www.shadowaitools.com
10
+ Project-URL: Documentation, https://www.shadowaitools.com
11
+ Project-URL: Source, https://github.com/explainableaixai/shadowaitools
12
+ Project-URL: Tracker, https://www.shadowaitools.com/contact.php
13
+ Project-URL: AI Tools Blocklist, https://www.aitoolsblocklist.com
14
+ Project-URL: AI Agent Allow List, https://www.aiagentallowlist.com
15
+ Project-URL: Shadow AI Tools, https://www.shadowaitools.com
16
+ Keywords: shadow ai,shadow ai detection,shadow ai tools,shadow ai discovery,ai tools inventory,dns log analysis,proxy log analysis,firewall log,ai governance,ai acceptable use,data loss prevention,ai blocklist,generative ai usage,ai risk management
17
+ Classifier: Development Status :: 5 - Production/Stable
18
+ Classifier: Environment :: Console
19
+ Classifier: Intended Audience :: Information Technology
20
+ Classifier: Intended Audience :: System Administrators
21
+ Classifier: License :: OSI Approved :: MIT License
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.7
24
+ Classifier: Programming Language :: Python :: 3.8
25
+ Classifier: Programming Language :: Python :: 3.9
26
+ Classifier: Programming Language :: Python :: 3.10
27
+ Classifier: Programming Language :: Python :: 3.11
28
+ Classifier: Programming Language :: Python :: 3.12
29
+ Classifier: Topic :: Security
30
+ Classifier: Topic :: System :: Networking :: Monitoring
31
+ Classifier: Topic :: System :: Systems Administration
32
+ Requires-Python: >=3.7
33
+ Description-Content-Type: text/markdown
34
+ License-File: LICENSE
35
+ Requires-Dist: requests >=2.20.0
36
+
37
+ # shadowaitools
38
+
39
+ `shadowaitools` turns a DNS, proxy or firewall export into an inventory of the AI tools in use on your network. It runs on your own machine: the export is parsed locally, every hostname is reduced to its registrable domain, and each unique domain is looked up once against the [AI blocklist for web filtering](https://www.aitoolsblocklist.com) at aitoolsblocklist.com. What comes back is a list of tools with category, AI type, hit counts, the users or devices that reached them, and the vendor's position on training with your data, dated.
40
+
41
+ It is the command line and Python counterpart of the hosted service at shadowaitools.com, which lets you [find the AI tools employees use](https://www.shadowaitools.com) from the same export in a browser and produces the per-user breakdown, the sanctioned versus unsanctioned split and a PDF evidence pack.
42
+
43
+ Only `requests` is required. Python 3.7 and newer.
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install shadowaitools
51
+ ```
52
+
53
+ This installs the `shadowaitools` command and the `shadowaitools` package. The API key is an AI Tools Blocklist key from the account area at aitoolsblocklist.com; pass it as `--key` or export it once:
54
+
55
+ ```bash
56
+ export SHADOWAITOOLS_API_KEY=your_key
57
+ ```
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ shadowaitools scan nextdns-export.csv --csv inventory.csv --json inventory.json
63
+ ```
64
+
65
+ ```
66
+ Shadow AI inventory (csv export, 400 lines, 45 unique domains, 45 lookups)
67
+ AI tools found: 34 users involved: 10 train on your data by default: 16 terms silent: 15
68
+
69
+ domain hits ai type category trains on data sanctioned users
70
+ -------------------------- ----- ---------- -------------------------- --------------- ---------- ----------------------
71
+ character.ai 10 ai_native Text & Language yes no laptop-marketing-02 +2
72
+ openai.com 10 ai_native Code & Development opt_out_default yes laptop-eng-07 +4
73
+ otter.ai 7 ai_native Audio, Voice & Music yes no laptop-sales-09 +2
74
+ midjourney.com 5 ai_native Image & Visual unstated no laptop-exec-01 +1
75
+ elevenlabs.io 4 ai_native Text & Language opt_out_default no laptop-eng-07 +1
76
+ fireflies.ai 3 ai_native Audio, Voice & Music no no desktop-support-03 +1
77
+ ```
78
+
79
+ In Python:
80
+
81
+ ```python
82
+ from shadowaitools import scan, to_csv
83
+
84
+ inventory = scan(
85
+ "zscaler-web.csv",
86
+ api_key="your_key",
87
+ sanctioned=["openai.com", "github.com"],
88
+ cache_file="lookups.json",
89
+ )
90
+
91
+ print(inventory["summary"])
92
+ # {'lines': 400, 'records': 400, 'unique_domains': 45, 'lookups': 45, 'ai_tools_found': 34,
93
+ # 'sanctioned': 2, 'unsanctioned': 32, 'users_involved': 10, 'training_default_yes': 16,
94
+ # 'training_no': 3, 'unstated': 15, 'quota_remaining': 9999940}
95
+
96
+ for tool in inventory["tools"]:
97
+ if not tool["sanctioned"] and tool["ai_type"] == "ai_native":
98
+ print(tool["domain"], tool["hits"], [u["name"] for u in tool["users"]], tool["trains_on_data"])
99
+
100
+ with open("inventory.csv", "w") as fh:
101
+ fh.write(to_csv(inventory))
102
+ ```
103
+
104
+ ## How a scan works
105
+
106
+ 1. **Parse.** The first non-empty line decides the format. A header row with a known column name means CSV (comma, tab, semicolon or pipe). `hostname=` or `dstname=` tokens mean key=value syslog. `query[A]` means dnsmasq. The Squid `access.log` layout and Windows DNS Server debug packets have their own detectors. Anything else is read line by line for the first hostname and the first private IP address.
107
+ 2. **Reduce.** Each hostname becomes a registrable domain (`chat.openai.com` to `openai.com`, `news.bbc.co.uk` to `bbc.co.uk`). Names in reserved zones (`.local`, `.internal`, `.lan`, `.corp`, `.home`, `.arpa`, `.test`, `.example`) are dropped before anything is sent.
108
+ 3. **Look up.** Each unique domain is sent once to `GET https://www.aitoolsblocklist.com/api/check?domain=<domain>` with the key in the `X-API-Key` header. With `cache_file`, domains seen on a previous run are answered from the cache.
109
+ 4. **Assemble.** Domains with `blocked: true` become tools. Hits, hosts and users are attached from the parse, the sanctioned flag from your list, and the category, AI type and training fields from the lookup.
110
+
111
+ A first run on a 400-line export with 45 distinct domains makes 45 lookups. A month of resolver logs with two million lines usually collapses to a few thousand registrable domains, because most traffic goes to a small set of hosts, and the second run through the same cache pays only for domains that are new that day. The `domains` command prints the exact count before any lookup is made.
112
+
113
+ The user or device column is optional. When the export carries `Identities`, `user`, `Source User`, `device_name`, `client_ip`, `src` or a similar column, every tool lists who reached it; when it does not, the inventory still has the tools and the hit counts.
114
+
115
+ ## Accepted exports
116
+
117
+ | Source | Format | Hostname column or field | User column or field |
118
+ |---|---|---|---|
119
+ | Cisco Umbrella activity export | csv | `Domain` | `Identities`, `Internal IP` |
120
+ | Cloudflare Gateway DNS log | csv | `QueryName` | `DeviceName`, `SourceIP`, `Email` |
121
+ | DNSFilter query log | csv | `domain` | `client`, `device` |
122
+ | NextDNS log export | csv | `domain` | `client_ip`, `device_name` |
123
+ | Palo Alto URL filtering log | csv | `URL` | `Source User`, `Source address` |
124
+ | Zscaler web log | csv | `url` | `user`, `cip` |
125
+ | Fortinet FortiGate web filter syslog | key-value | `hostname=` | `user=`, `srcip=` |
126
+ | SonicWall syslog | key-value | `dstname=` | `usr=`, `src=` |
127
+ | Pi-hole and dnsmasq | dnsmasq | `query[A] name` | `from address` |
128
+ | Squid | squid | `CONNECT host:443` | client IP, authenticated user |
129
+ | Windows DNS Server debug log | windows-dns | encoded question name | client address |
130
+ | Plain hostname or URL list | generic | the line | none |
131
+ | Any other text log | generic | first hostname on the line | first private IPv4 on the line |
132
+
133
+ Anything with a header row and a hostname column works, whatever produced it. The `formats` command prints this list from the installed version.
134
+
135
+ ## API
136
+
137
+ ### `scan(source, api_key=None, **options)`
138
+
139
+ `source` is a file path, a string of contents, `bytes` or an open file. Options:
140
+
141
+ | Option | Default | Meaning |
142
+ |---|---|---|
143
+ | `api_key` | env `SHADOWAITOOLS_API_KEY` or `ATB_API_KEY` | AI Tools Blocklist key |
144
+ | `concurrency` | 1 | parallel lookups |
145
+ | `pause` | 0.0 | seconds to wait after each lookup |
146
+ | `max_lines` | none | stop parsing after this many lines |
147
+ | `cache_file` | none | JSON file of previous lookups, reused and extended |
148
+ | `sanctioned` | `[]` | domains to flag as sanctioned |
149
+ | `on_progress` | none | callback `(done, total)` |
150
+ | `timeout`, `max_retries`, `base_url` | 30, 2, production | passed to the client |
151
+
152
+ Returns a dict with `generated`, `format`, `summary`, `by_category` and `tools`.
153
+
154
+ ### Tool fields
155
+
156
+ | Field | Values |
157
+ |---|---|
158
+ | `domain`, `hosts`, `hits`, `users` | from the export |
159
+ | `sanctioned` | `True` when the domain is in your list |
160
+ | `primary_category` | one of 18 functional categories |
161
+ | `categories` | `[{"category", "subcategory"}]`, a tool can sit in several |
162
+ | `ai_type` | `ai_native` or `ai_enabled` |
163
+ | `trains_on_data` | `yes`, `no`, `opt_out_default`, `unstated` |
164
+ | `opt_out_available`, `enterprise_no_training`, `api_no_training` | same value set |
165
+ | `terms_checked` | ISO date the vendor terms were last read |
166
+
167
+ ### Other functions
168
+
169
+ | Function | Purpose |
170
+ |---|---|
171
+ | `parse_log(source, max_lines=None)` | `{"format", "lines", "records": [{"host", "user"}]}` with no network call |
172
+ | `extract_domains(records)` | `[{"domain", "hosts", "hits", "users"}]` sorted by hits |
173
+ | `registrable_domain(host)` | reduce a hostname |
174
+ | `to_csv(inventory)` | one CSV row per tool |
175
+ | `to_table(inventory)` | fixed-width text table |
176
+ | `Client(api_key).lookup(domain)` | the raw lookup |
177
+
178
+ ### Exceptions
179
+
180
+ `AuthenticationError` (401), `QuotaError` (403, inactive account or monthly quota used up), `RateLimitError` (429 after two retries) and the base `ShadowAIToolsError` (anything else, including 503 after retries). Each carries `.status` and `.body`.
181
+
182
+ ## Command line
183
+
184
+ ```
185
+ shadowaitools scan <file> [--key KEY] [--json FILE] [--csv FILE] [--cache FILE]
186
+ [--sanctioned a.com,b.com] [--max-lines N] [--concurrency N]
187
+ [--pause SECONDS] [--quiet]
188
+ shadowaitools domains <file> unique registrable domains and hit counts, no lookups
189
+ shadowaitools formats the accepted export formats
190
+ ```
191
+
192
+ `domains` is the dry run: it prints the format that was detected and the number of lookups a scan would need.
193
+
194
+ ## Worked examples
195
+
196
+ ### Weekly report for a security team
197
+
198
+ ```python
199
+ # weekly_shadow_ai.py
200
+ import datetime
201
+ import json
202
+ from shadowaitools import scan, to_csv
203
+
204
+ week = datetime.date.today().isocalendar()[1]
205
+ inv = scan(
206
+ f"/exports/umbrella-week-{week}.csv",
207
+ cache_file="/var/lib/shadowaitools/lookups.json",
208
+ sanctioned=open("/etc/shadowaitools/approved.txt").read().split(),
209
+ pause=0.05,
210
+ )
211
+
212
+ with open(f"/reports/shadow-ai-week-{week}.csv", "w") as fh:
213
+ fh.write(to_csv(inv))
214
+
215
+ risky = [t for t in inv["tools"] if not t["sanctioned"] and t["trains_on_data"] in ("yes", "opt_out_default")]
216
+ print(f"week {week}: {inv['summary']['ai_tools_found']} tools, {len(risky)} unsanctioned tools that train on input")
217
+ for t in sorted(risky, key=lambda t: -t["hits"])[:10]:
218
+ print(f" {t['domain']:28} {t['hits']:5} hits {len(t['users']):3} users {t['primary_category']}")
219
+ ```
220
+
221
+ ### Comparing two weeks
222
+
223
+ ```python
224
+ from shadowaitools import scan
225
+
226
+ before = scan("proxy-week-36.log", cache_file="lookups.json")
227
+ after = scan("proxy-week-37.log", cache_file="lookups.json")
228
+
229
+ seen_before = {t["domain"] for t in before["tools"]}
230
+ new_tools = [t for t in after["tools"] if t["domain"] not in seen_before]
231
+ print("new AI tools this week:", [t["domain"] for t in new_tools])
232
+ ```
233
+
234
+ Because both scans share the cache, the second one only pays for domains that did not appear in the first.
235
+
236
+ ### Streaming a large export in chunks
237
+
238
+ Exports from a busy resolver run to millions of lines. `parse_log` accepts a string, so a file can be read in blocks and grouped before a single lookup is made.
239
+
240
+ ```python
241
+ from shadowaitools import Client, extract_domains, parse_log, registrable_domain
242
+
243
+ records = []
244
+ with open("dns-month.log", encoding="utf-8", errors="replace") as fh:
245
+ block = []
246
+ for line in fh:
247
+ block.append(line)
248
+ if len(block) == 200_000:
249
+ records.extend(parse_log("".join(block))["records"])
250
+ block = []
251
+ if block:
252
+ records.extend(parse_log("".join(block))["records"])
253
+
254
+ groups = extract_domains(records)
255
+ print(len(records), "records,", len(groups), "unique domains")
256
+
257
+ client = Client("your_key")
258
+ tools = []
259
+ for g in groups:
260
+ r = client.lookup(g["domain"])
261
+ if r.get("blocked"):
262
+ tools.append((g["domain"], g["hits"], r.get("primary_category"), r.get("trains_on_data")))
263
+ ```
264
+
265
+ ### Only the lookup
266
+
267
+ ```python
268
+ from shadowaitools import Client
269
+
270
+ c = Client("your_key")
271
+ print(c.lookup("chat.openai.com"))
272
+ # {'domain': 'openai.com', 'blocked': True, 'primary_category': 'Code & Development', 'ai_type': 'ai_native',
273
+ # 'categories': [...], 'trains_on_data': 'opt_out_default', 'opt_out_available': 'yes', ...}
274
+ print(c.lookup("example.com")["blocked"])
275
+ # False
276
+ ```
277
+
278
+ ## Why the logs are the right starting point
279
+
280
+ Every governance framework begins with an inventory. The [NIST AI Risk Management Framework](https://www.nist.gov/itl/ai-risk-management-framework) puts "Map" before "Measure" and "Manage": an organisation has to know which AI systems are in use before any control can be applied. The [ENISA](https://www.enisa.europa.eu/) work on AI cybersecurity makes the same point for European organisations, and the [CISA](https://www.cisa.gov/) guidance on secure AI deployment assumes that operators can enumerate the AI services their people reach. On the data-protection side, the [ICO's guidance on AI and data protection](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/artificial-intelligence/) treats the flow of personal data into third-party AI services as a processing activity that has to be documented.
281
+
282
+ The inventory you need already exists in the DNS filter, proxy or firewall. It is complete in a way a survey can never be, it costs nothing to export, and it names the hostnames rather than the products people remember. The missing step is classification, which is what the lookup adds: is this domain an AI tool, what kind, and what does the vendor do with the input. Alongside the tool inventory, the same organisation usually wants the opposite control for its own agents, which is where an allow list for AI agents comes in: the [AI agent allow list](https://www.aiagentallowlist.com) tells a browsing agent which pages on a site it may open and which it must not, so the two datasets cover both directions of AI traffic.
283
+
284
+ ## Hosted audit
285
+
286
+ The package produces the inventory. The hosted [shadow AI inventory](https://www.shadowaitools.com) at shadowaitools.com produces the report: upload the same export, get every tool with category and risk level, the per-user breakdown, dated training verdicts, the sanctioned split against your approved list, sector policy verdicts from the AI Policy Profiles, a CSV and a PDF evidence pack. The free preview names a fifth of the tools found and comes with a preview PDF; full reports are one-time purchases, and the subscription plans on aitoolsblocklist.com include one to ten audits a month.
287
+
288
+ ## Related packages
289
+
290
+ - [`aiblocklist`](https://pypi.org/project/aiblocklist/) and [`aitoolsblocklist`](https://pypi.org/project/aitoolsblocklist/): Python clients for the lookup and feed APIs of the [AI domain blocklist](https://www.aitoolsblocklist.com), 20,000+ AI tool domains in 18 categories, refreshed daily, with EDL, PAC, hosts and DNS feeds.
291
+ - [`aiagentallowlist`](https://pypi.org/project/aiagentallowlist/): per-URL allow, deny and flag verdicts for browsing agents across 40 million+ domains, up to 28 verified page types each.
292
+ - [`websiteclassificationapi`](https://pypi.org/project/websiteclassificationapi/): the [website categorization API](https://www.websitecategorizationapi.com), 700+ IAB content categories for any URL.
293
+ - [`cipawebfiltering`](https://pypi.org/project/cipawebfiltering/) and [`phishingdetectionapi`](https://pypi.org/project/phishingdetectionapi/): the [CIPA web filtering](https://www.cipawebfiltering.com) client for schools and the [phishing detection API](https://www.phishingdetectionapi.com) with 390,000+ DNS-verified active phishing domains.
294
+ - The [web filtering database](https://www.webfilteringdatabase.com), 120M+ domains in 59 categories, for the same firewalls and resolvers.
295
+ - Node.js: [`shadowaitools` on npm](https://www.npmjs.com/package/shadowaitools), same formats, same inventory, same cache file.
296
+ - Source: [github.com/explainableaixai/shadowaitools](https://github.com/explainableaixai/shadowaitools) and [gitlab.com/url-classifications/shadowaitools](https://gitlab.com/url-classifications/shadowaitools).
297
+
298
+ ## Frequently asked questions
299
+
300
+ **How do I detect shadow AI on my network?**
301
+ Export a week of DNS, proxy or firewall logs, run `shadowaitools scan export.csv`, and read the inventory. Every hostname is matched against 20,000+ known AI tool domains, so the result is the AI traffic that actually crossed your network. For a report with the per-user breakdown and a PDF, upload the same file at [shadowaitools.com](https://www.shadowaitools.com).
302
+
303
+ **Is anything installed on endpoints or inspected in transit?**
304
+ No. The tool reads a log export that your DNS filter, proxy or firewall already produces. There is no agent, no TLS inspection and no change to the network.
305
+
306
+ **Does the export leave my machine?**
307
+ No. It is parsed locally. Only unique registrable domains are looked up, one request each, and reserved internal zones are never sent.
308
+
309
+ **Which exports are supported?**
310
+ Cisco Umbrella, Zscaler, Palo Alto, Fortinet, Cloudflare Gateway, DNSFilter, NextDNS, Pi-hole, SonicWall, Squid, Windows DNS Server debug logs, any CSV with a header row, key=value syslog lines and plain hostname lists. See the table above.
311
+
312
+ **What is the difference between `ai_native` and `ai_enabled`?**
313
+ `ai_native` is a service whose product is the AI: a chatbot, a code assistant, an image generator. `ai_enabled` is an ordinary product that has added AI features, such as an office suite with a built-in assistant. Both appear in the inventory because both can receive pasted content; the flag lets you treat them differently.
314
+
315
+ **How current is the database behind the lookup?**
316
+ It is rebuilt daily, with about 300,000 new domains checked every day against a 120-million-domain corpus, so new tools are caught close to launch. The training verdicts come from a review of 13,000+ vendor terms and carry the date they were checked.
317
+
318
+ **Can I limit how many lookups a scan makes?**
319
+ Yes. `shadowaitools domains file` shows the count first; `--max-lines` caps parsing; `--cache` makes repeated runs free for domains already seen; `--pause` slows the run down.
320
+
321
+ **Who is behind shadowaitools?**
322
+ Alpha Quantum, which also builds the [AI tools blocklist](https://www.aitoolsblocklist.com), the [AI agent allow list](https://www.aiagentallowlist.com), the [website categorization API](https://www.websitecategorizationapi.com) and the [web filtering database](https://www.webfilteringdatabase.com).
323
+
324
+ ## Links
325
+
326
+ - Hosted shadow AI audit: [https://www.shadowaitools.com](https://www.shadowaitools.com)
327
+ - The lookup database: [https://www.aitoolsblocklist.com](https://www.aitoolsblocklist.com)
328
+ - AI agent allow list: [https://www.aiagentallowlist.com](https://www.aiagentallowlist.com)
329
+ - NIST AI Risk Management Framework: [https://www.nist.gov/itl/ai-risk-management-framework](https://www.nist.gov/itl/ai-risk-management-framework)
330
+ - ENISA: [https://www.enisa.europa.eu/](https://www.enisa.europa.eu/)
331
+ - CISA: [https://www.cisa.gov/](https://www.cisa.gov/)
332
+ - ICO, AI and data protection: [https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/artificial-intelligence/](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/artificial-intelligence/)
333
+
334
+ ## License
335
+
336
+ MIT
@@ -0,0 +1,12 @@
1
+ shadowaitools/__init__.py,sha256=8YK1F6a9Ke8WnrqX-SALXmh2eesP82D6MjNye9Y98z4,896
2
+ shadowaitools/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ shadowaitools/cli.py,sha256=Q9JJ04FwzzBRVyYb-9gbfySFg1RCqlZE8OJ6-VleQzY,4456
4
+ shadowaitools/client.py,sha256=jruD60mOg-wVOpxegsdcV6TBA2aTcvFbPS0dcBbn4EA,3343
5
+ shadowaitools/inventory.py,sha256=LmVQsmnLs9VLloa1d7yDjtulbLusqeaKpstjYSEqhKo,7661
6
+ shadowaitools/parser.py,sha256=LZnU_t2waCZaCIzRy0xVy9-jHX78xLGKszeY3BuY3OY,10502
7
+ shadowaitools-1.0.0.dist-info/LICENSE,sha256=-kjrwollysEMZkPQkJIq5zyefl9XyPw5egz8knSXiB4,1070
8
+ shadowaitools-1.0.0.dist-info/METADATA,sha256=gBmfr5Suco7RIHi8nRTgDtHv4fhcrKTJM3Pie_qtJJE,20484
9
+ shadowaitools-1.0.0.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
10
+ shadowaitools-1.0.0.dist-info/entry_points.txt,sha256=utczEx3srgMc1hVAilkKbDRRlly0QLmF21llmOYKrGU,57
11
+ shadowaitools-1.0.0.dist-info/top_level.txt,sha256=WSKXw8XMllFV1xzLGtBuGNpHnd9lFmJv_Do8uK7KNgI,14
12
+ shadowaitools-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.4)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shadowaitools = shadowaitools.cli:main
@@ -0,0 +1 @@
1
+ shadowaitools