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.
- shadowaitools/__init__.py +22 -0
- shadowaitools/__main__.py +5 -0
- shadowaitools/cli.py +107 -0
- shadowaitools/client.py +98 -0
- shadowaitools/inventory.py +184 -0
- shadowaitools/parser.py +266 -0
- shadowaitools-1.0.0.dist-info/LICENSE +21 -0
- shadowaitools-1.0.0.dist-info/METADATA +336 -0
- shadowaitools-1.0.0.dist-info/RECORD +12 -0
- shadowaitools-1.0.0.dist-info/WHEEL +5 -0
- shadowaitools-1.0.0.dist-info/entry_points.txt +2 -0
- shadowaitools-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
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())
|
shadowaitools/client.py
ADDED
|
@@ -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)
|
shadowaitools/parser.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
shadowaitools
|