import csv
import io
import json
import os
import re
import socket
import unittest
from contextlib import redirect_stderr, redirect_stdout
from datetime import datetime
from helpers import EXAMPLE_CSV, EXAMPLE_JSONL, ROOT, TempDirTest
import shadow_ai_report
from shadow_ai_report import FULL_EDITION_URL, FULL_REGISTER_TEXT, cli
from shadow_ai_report.parse import TOOLS_FILE
from shadow_ai_report.report import Analysis, build_summary, render_html
def fmt(n):
return "{:,}".format(n)
def tool_list():
with open(TOOLS_FILE, encoding="utf-8-sig", newline="") as f:
return {r["domain"].lower(): r for r in csv.DictReader(f)}
def naive_host(v):
v = v.strip().lower().rstrip(".")
return v[4:] if v.startswith("www.") else v
def naive_match(host, tools):
for d in tools:
if host == d or host.endswith("." + d):
return d
return None
def expected_from_rows(rows, tools):
"""Independent, deliberately simple re-count used to check the report numbers."""
total, ai, users_all, ai_users, found, domains, per_tool, blocked = 0, 0, set(), set(), set(), set(), {}, 0
for host, user, is_blocked in rows:
total += 1
domains.add(host)
if user:
users_all.add(user)
d = naive_match(host, tools)
if d:
ai += 1
found.add(d)
per_tool[d] = per_tool.get(d, 0) + 1
blocked += bool(is_blocked)
if user:
ai_users.add(user)
high = sum(1 for d in found if tools[d]["risk_level"] == "high" or tools[d]["data_sovereignty"] == "high")
flagged = sum(1 for d in found if tools[d]["risk_level"] in ("high", "medium")
or tools[d]["data_sovereignty"] in ("high", "medium"))
return {"requests": total, "ai_requests": ai, "ai_tools": len(found), "users_seen": len(users_all),
"ai_users": len(ai_users), "distinct_domains": len(domains), "per_tool": per_tool,
"high": high, "flagged": flagged, "ai_blocked": blocked}
def example_csv_expected():
tools = tool_list()
rows, lines = [], 0
with open(EXAMPLE_CSV, encoding="utf-8", newline="") as f:
for r in csv.DictReader(f):
lines += 1
host = naive_host(r.get("query_name") or "")
if not re.match(r"^[a-z0-9.-]+\.[a-z]+$", host):
continue
rows.append((host, r["user"] or r["src_ip"], r["action"] == "blocked"))
exp = expected_from_rows(rows, tools)
exp["lines"] = lines
return exp
def example_jsonl_expected():
tools = tool_list()
rows, lines = [], 0
with open(EXAMPLE_JSONL, encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
lines += 1
try:
o = json.loads(line)
except ValueError:
continue
host = naive_host(o.get("QueryName") or "")
if not host:
continue
email = o.get("Email") or ""
user = email if email and not email.startswith("non_identity@") else o.get("SrcIP")
rows.append((host, user, str(o.get("ResolverDecision", "")).startswith("blocked")))
exp = expected_from_rows(rows, tools)
exp["lines"] = lines
return exp
def run_cli(*args):
out, err = io.StringIO(), io.StringIO()
with redirect_stdout(out), redirect_stderr(err):
code = cli.main(list(args))
return code, out.getvalue(), err.getvalue()
class TestExampleReports(TempDirTest):
def tile(self, html, label):
m = re.search(r'
%s
([^<]*)
' % re.escape(label), html)
self.assertIsNotNone(m, label)
return m.group(1)
def fact(self, html, label):
m = re.search(r'%s
([^<]*)
' % re.escape(label), html)
self.assertIsNotNone(m, label)
return m.group(1)
def check_html(self, html, exp):
self.assertEqual(self.tile(html, "AI tools detected"), fmt(exp["ai_tools"]))
self.assertEqual(self.tile(html, "Users on AI tools"), fmt(exp["ai_users"]))
self.assertEqual(self.tile(html, "AI-tool requests"), fmt(exp["ai_requests"]))
self.assertEqual(self.tile(html, "Share of requests"),
"{:.1f}%".format(100.0 * exp["ai_requests"] / exp["requests"]))
self.assertEqual(self.tile(html, "High-risk tools"), fmt(exp["high"]))
self.assertIn("of %s users or devices in the log" % fmt(exp["users_seen"]), html)
self.assertIn("%s blocked by your filter" % fmt(exp["ai_blocked"]), html)
self.assertEqual(self.fact(html, "Log lines read"), fmt(exp["lines"]))
self.assertEqual(self.fact(html, "Requests analysed"), fmt(exp["requests"]))
self.assertEqual(self.fact(html, "Lines skipped"), fmt(exp["lines"] - exp["requests"]))
self.assertEqual(self.fact(html, "Distinct domains seen"), fmt(exp["distinct_domains"]))
self.assertIn("%d of the AI tools found carry a high or medium rating" % exp["flagged"], html)
# every detected tool appears in the table with its request count
tools = tool_list()
for d, n in exp["per_tool"].items():
pat = (r'%s
.*?data-label="Requests">([\d,]+)' % re.escape(d))
m = re.search(pat, html, re.S)
self.assertIsNotNone(m, d)
self.assertEqual(m.group(1), fmt(n), d)
self.assertIn(tools[d]["name"].replace("&", "&"), html)
def test_example_csv(self):
exp = example_csv_expected()
# sanity-check the example itself: ~3,000 rows, 7 days, ~25 tools, some flagged
self.assertEqual(exp["requests"], 3000)
self.assertEqual(exp["lines"], 3003)
self.assertEqual(exp["ai_tools"], 25)
self.assertGreaterEqual(exp["high"], 2)
for popular in ("chatgpt.com", "perplexity.ai", "character.ai", "midjourney.com", "openai.com"):
self.assertIn(popular, exp["per_tool"])
out = self.path("r.html")
code, stdout, err = run_cli(EXAMPLE_CSV, "-o", out, "--org", "Example Industries Inc.")
self.assertEqual(code, 0, err)
with open(out, encoding="utf-8") as f:
html = f.read()
self.check_html(html, exp)
self.assertIn("Example Industries Inc.", html)
self.assertIn("21–27 Sep 2026", html)
self.assertIn("(7 days)", html)
self.assertRegex(stdout, r"AI tools detected\s+%d\n" % exp["ai_tools"])
self.assertRegex(stdout, r"Lines skipped\s+3\n")
self.assertIn("Report written: %s" % os.path.abspath(out), stdout)
def test_example_jsonl(self):
exp = example_jsonl_expected()
self.assertEqual(exp["requests"], 1000)
out = self.path("r.html")
code, _, err = run_cli(EXAMPLE_JSONL, "-o", out)
self.assertEqual(code, 0, err)
with open(out, encoding="utf-8") as f:
html = f.read()
self.check_html(html, exp)
self.assertIn("Your organisation", html)
self.assertIn("cloudflare-dns", html)
def test_both_files_together(self):
e1, e2 = example_csv_expected(), example_jsonl_expected()
out, js = self.path("r.html"), self.path("r.json")
code, _, err = run_cli(EXAMPLE_CSV, EXAMPLE_JSONL, "-o", out, "--json", js)
self.assertEqual(code, 0, err)
with open(js, encoding="utf-8") as f:
s = json.load(f)
self.assertEqual(s["totals"]["requests"], e1["requests"] + e2["requests"])
self.assertEqual(s["totals"]["ai_requests"], e1["ai_requests"] + e2["ai_requests"])
self.assertEqual(s["totals"]["lines_read"], e1["lines"] + e2["lines"])
self.assertEqual(len(s["inputs"]), 2)
class TestOutputs(TempDirTest):
def test_json_and_csv(self):
exp = example_csv_expected()
out, js, cs = self.path("r.html"), self.path("r.json"), self.path("tools.csv")
code, stdout, err = run_cli(EXAMPLE_CSV, "-o", out, "--json", js, "--csv", cs)
self.assertEqual(code, 0, err)
with open(js, encoding="utf-8") as f:
s = json.load(f)
t = s["totals"]
self.assertEqual(t["requests"], exp["requests"])
self.assertEqual(t["lines_read"], exp["lines"])
self.assertEqual(t["lines_skipped"], 3)
self.assertEqual(t["distinct_domains"], exp["distinct_domains"])
self.assertEqual(t["ai_requests"], exp["ai_requests"])
self.assertEqual(t["ai_tools"], exp["ai_tools"])
self.assertEqual(t["ai_users"], exp["ai_users"])
self.assertEqual(t["users_seen"], exp["users_seen"])
self.assertEqual(t["ai_blocked"], exp["ai_blocked"])
self.assertEqual(t["high_risk_or_exposure_tools"], exp["high"])
self.assertEqual(t["flagged_tools"], exp["flagged"])
self.assertTrue(s["period"]["start"].startswith("2026-09-21T"))
self.assertTrue(s["period"]["end"].startswith("2026-09-27T"))
self.assertEqual(s["period"]["days"], 7)
self.assertEqual({r["domain"]: r["requests"] for r in s["tools"]}, exp["per_tool"])
self.assertEqual(s["full_edition"]["url"], FULL_EDITION_URL)
self.assertEqual(s["full_edition"]["register"], FULL_REGISTER_TEXT)
self.assertEqual(s["tool_list"]["tools"], 300)
self.assertIn("JSON written", stdout)
with open(cs, encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
self.assertEqual(len(rows), exp["ai_tools"])
self.assertEqual(list(rows[0].keys())[:4], ["tool", "domain", "category", "subcategory"])
reqs = [int(r["requests"]) for r in rows]
self.assertEqual(reqs, sorted(reqs, reverse=True))
self.assertEqual({r["domain"]: int(r["requests"]) for r in rows}, exp["per_tool"])
def test_no_user_or_time_columns(self):
log = self.write("list.txt", "dify.ai\napi.dify.ai\nexample.org\n")
js, out = self.path("r.json"), self.path("r.html")
code, _, err = run_cli(log, "-o", out, "--json", js)
self.assertEqual(code, 0, err)
with open(js, encoding="utf-8") as f:
s = json.load(f)
self.assertIsNone(s["totals"]["users_seen"])
self.assertIsNone(s["totals"]["ai_blocked"])
self.assertEqual(s["period"]["start"], None)
self.assertEqual(s["users"], [])
with open(out, encoding="utf-8") as f:
html = f.read()
self.assertIn("no user column in the log", html)
self.assertIn("Not stated in the log", html)
self.assertNotIn('id="users"', html)
class TestAnonymise(TempDirTest):
def test_pseudonyms(self):
out, js = self.path("r.html"), self.path("r.json")
code, _, err = run_cli(EXAMPLE_CSV, "-o", out, "--json", js, "--anonymise")
self.assertEqual(code, 0, err)
with open(out, encoding="utf-8") as f:
html = f.read()
with open(js, encoding="utf-8") as f:
text = f.read()
for blob in (html, text):
self.assertNotIn("@example.com", blob)
self.assertNotIn("192.0.2.", blob)
s = json.loads(text)
names = [u["user"] for u in s["users"]]
self.assertEqual(names, ["User %d" % (i + 1) for i in range(len(names))])
counts = [u["ai_requests"] for u in s["users"]]
self.assertEqual(counts, sorted(counts, reverse=True))
self.assertIn("User 1", html)
# stable: a second run gives the same mapping
js2 = self.path("r2.json")
run_cli(EXAMPLE_CSV, "-o", self.path("r2.html"), "--json", js2, "--anonymise")
with open(js2, encoding="utf-8") as f:
self.assertEqual(json.load(f)["users"], s["users"])
def test_same_user_same_pseudonym(self):
a = Analysis()
for dom, user in (("dify.ai", "zed@x.org"), ("dust.tt", "amy@x.org"), ("api.dify.ai", "zed@x.org"),
("dify.ai", "bob@x.org")):
a.add(dom, user)
s = build_summary(a, anonymise=True)
self.assertEqual(s["users"][0], {"user": "User 1", "ai_requests": 2, "tools": 1,
"top_tools": ["Dify"], "flagged_tools": 0})
self.assertEqual(sorted(u["user"] for u in s["users"]), ["User 1", "User 2", "User 3"])
class TestHtmlSafety(TempDirTest):
def test_self_contained(self):
out = self.path("r.html")
code, _, err = run_cli(EXAMPLE_CSV, "-o", out)
self.assertEqual(code, 0, err)
with open(out, encoding="utf-8") as f:
html = f.read()
low = html.lower()
for bad in ("",dify.ai\n')
out = self.path("r.html")
code, _, err = run_cli(log, "-o", out, "--org", "Acme & Co")
self.assertEqual(code, 0, err)
with open(out, encoding="utf-8") as f:
html = f.read()
self.assertNotIn("