agent-ready-audit 0.1.0__tar.gz
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.
- agent_ready_audit-0.1.0/.gitignore +12 -0
- agent_ready_audit-0.1.0/PKG-INFO +16 -0
- agent_ready_audit-0.1.0/README.md +3 -0
- agent_ready_audit-0.1.0/pyproject.toml +17 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/__init__.py +14 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/__main__.py +3 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/_version.py +1 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/analyzer.py +589 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/cli.py +69 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/config.py +32 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/data/__init__.py +1 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/data/checks.yaml +51 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/data/report.schema.json +1 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/discovery.py +31 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/errors.py +26 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/http.py +226 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/models.py +156 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/reports.py +97 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/robots.py +153 -0
- agent_ready_audit-0.1.0/src/agent_ready_audit/rules.py +58 -0
- agent_ready_audit-0.1.0/tests/conftest.py +110 -0
- agent_ready_audit-0.1.0/tests/test_analyzer_reports.py +95 -0
- agent_ready_audit-0.1.0/tests/test_cli.py +74 -0
- agent_ready_audit-0.1.0/tests/test_discovery.py +56 -0
- agent_ready_audit-0.1.0/tests/test_http.py +124 -0
- agent_ready_audit-0.1.0/tests/test_models_config.py +55 -0
- agent_ready_audit-0.1.0/tests/test_robots.py +30 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-ready-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic audits of website readiness for agents.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: beautifulsoup4<5,>=4.13
|
|
7
|
+
Requires-Dist: defusedxml<1,>=0.7
|
|
8
|
+
Requires-Dist: httpx<1,>=0.28
|
|
9
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
10
|
+
Requires-Dist: pyyaml<7,>=6
|
|
11
|
+
Requires-Dist: typer<1,>=0.16
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Agent-Ready Audit
|
|
15
|
+
|
|
16
|
+
`agent-ready-audit https://example.com` runs a bounded, robots-aware audit of one public website root.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires=["hatchling"]
|
|
3
|
+
build-backend="hatchling.build"
|
|
4
|
+
[project]
|
|
5
|
+
name="agent-ready-audit"
|
|
6
|
+
version="0.1.0"
|
|
7
|
+
description="Deterministic audits of website readiness for agents."
|
|
8
|
+
readme="README.md"
|
|
9
|
+
requires-python=">=3.12"
|
|
10
|
+
dependencies=["beautifulsoup4>=4.13,<5","defusedxml>=0.7,<1","httpx>=0.28,<1","pydantic>=2.12,<3","pyyaml>=6,<7","typer>=0.16,<1"]
|
|
11
|
+
[project.scripts]
|
|
12
|
+
agent-ready-audit="agent_ready_audit.cli:app"
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages=["src/agent_ready_audit"]
|
|
15
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
16
|
+
"src/agent_ready_audit/data/checks.yaml"="agent_ready_audit/data/checks.yaml"
|
|
17
|
+
"src/agent_ready_audit/data/report.schema.json"="agent_ready_audit/data/report.schema.json"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from agent_ready_audit._version import __version__
|
|
2
|
+
from agent_ready_audit.analyzer import audit
|
|
3
|
+
from agent_ready_audit.models import AuditConfig, AuditFinding, AuditReport, AuditRequest, CategoryScore, PageFetch
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AuditConfig",
|
|
7
|
+
"AuditFinding",
|
|
8
|
+
"AuditReport",
|
|
9
|
+
"AuditRequest",
|
|
10
|
+
"CategoryScore",
|
|
11
|
+
"PageFetch",
|
|
12
|
+
"audit",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# ruff: noqa: E501, E701, E702
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import heapq
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from urllib.parse import urljoin, urlsplit
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from bs4 import BeautifulSoup
|
|
15
|
+
from defusedxml import ElementTree
|
|
16
|
+
|
|
17
|
+
from agent_ready_audit._version import __version__
|
|
18
|
+
from agent_ready_audit.errors import (
|
|
19
|
+
BudgetExceededError,
|
|
20
|
+
FetchError,
|
|
21
|
+
FetchTimeoutError,
|
|
22
|
+
RobotsDisallowedError,
|
|
23
|
+
TargetSecurityError,
|
|
24
|
+
)
|
|
25
|
+
from agent_ready_audit.http import FetchBudget, FetchedResource, SafeHttpClient, origin, safe_url
|
|
26
|
+
from agent_ready_audit.models import (
|
|
27
|
+
AuditFinding,
|
|
28
|
+
AuditLimits,
|
|
29
|
+
AuditReport,
|
|
30
|
+
AuditRequest,
|
|
31
|
+
CategoryScore,
|
|
32
|
+
Evidence,
|
|
33
|
+
FetchSummary,
|
|
34
|
+
FindingStatus,
|
|
35
|
+
PageFetch,
|
|
36
|
+
ScoreSummary,
|
|
37
|
+
Severity,
|
|
38
|
+
TargetSummary,
|
|
39
|
+
)
|
|
40
|
+
from agent_ready_audit.robots import AI_AGENTS, Policy, RobotsManager
|
|
41
|
+
|
|
42
|
+
PROBES = (
|
|
43
|
+
"/sitemap.xml",
|
|
44
|
+
"/llms.txt",
|
|
45
|
+
"/openapi.json",
|
|
46
|
+
"/swagger.json",
|
|
47
|
+
"/.well-known/security.txt",
|
|
48
|
+
"/metrics",
|
|
49
|
+
"/health",
|
|
50
|
+
"/healthz",
|
|
51
|
+
"/readyz",
|
|
52
|
+
"/debug",
|
|
53
|
+
)
|
|
54
|
+
EXPOSURE = {"/metrics", "/health", "/healthz", "/readyz", "/debug"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Facts:
|
|
59
|
+
url: str
|
|
60
|
+
links: list[str] = field(default_factory=list)
|
|
61
|
+
text: str = ""
|
|
62
|
+
canonical: str | None = None
|
|
63
|
+
canonical_count: int = 0
|
|
64
|
+
jsonld: int = 0
|
|
65
|
+
bad_jsonld: int = 0
|
|
66
|
+
types: set[str] = field(default_factory=set)
|
|
67
|
+
social: int = 0
|
|
68
|
+
code: int = 0
|
|
69
|
+
sitemap: list[str] = field(default_factory=list)
|
|
70
|
+
sitemap_bad: bool = False
|
|
71
|
+
openapi: str | None = None
|
|
72
|
+
paths: int = 0
|
|
73
|
+
auth: bool = False
|
|
74
|
+
mcp: set[str] = field(default_factory=set)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def json_ld_types(self) -> set[str]:
|
|
78
|
+
return self.types
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def code_blocks(self) -> int:
|
|
82
|
+
return self.code
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def sitemap_urls(self) -> list[str]:
|
|
86
|
+
return self.sitemap
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def openapi_valid(self) -> bool:
|
|
90
|
+
return bool(self.openapi and self.openapi.startswith(("3.0.", "3.1.")) and self.paths)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def openapi_paths(self) -> int:
|
|
94
|
+
return self.paths
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def openapi_auth_documented(self) -> bool:
|
|
98
|
+
return self.auth
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def mcp_signals(self) -> set[str]:
|
|
102
|
+
values: set[str] = set()
|
|
103
|
+
if "transport" in self.mcp:
|
|
104
|
+
values.add("protocolReference")
|
|
105
|
+
if "endpoint" in self.mcp:
|
|
106
|
+
values.add("endpointOrLink")
|
|
107
|
+
if "config" in self.mcp:
|
|
108
|
+
values.add("clientConfig")
|
|
109
|
+
return values
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _links(values: list[str], base: str, limit: int) -> list[str]:
|
|
113
|
+
out = []
|
|
114
|
+
for raw in values:
|
|
115
|
+
try:
|
|
116
|
+
p = urlsplit(urljoin(base, raw))
|
|
117
|
+
if p.scheme not in {"http", "https"} or not p.hostname or p.username or p.password:
|
|
118
|
+
continue
|
|
119
|
+
value = safe_url(p.geturl())
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
if value not in out:
|
|
123
|
+
out.append(value)
|
|
124
|
+
if len(out) >= limit:
|
|
125
|
+
break
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse(fetched: FetchedResource, request: AuditRequest) -> Facts:
|
|
130
|
+
page = fetched.public
|
|
131
|
+
url = page.final_url or page.requested_url
|
|
132
|
+
path = urlsplit(url).path.lower()
|
|
133
|
+
kind = page.content_type or ""
|
|
134
|
+
facts = Facts(url)
|
|
135
|
+
if page.status_code != 200:
|
|
136
|
+
return facts
|
|
137
|
+
if kind in {"text/html", "application/xhtml+xml"}:
|
|
138
|
+
soup = BeautifulSoup(fetched.body, "html.parser")
|
|
139
|
+
facts.text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).lower()[:200000]
|
|
140
|
+
facts.code = len(soup.find_all(["code", "pre"]))
|
|
141
|
+
facts.links = _links(
|
|
142
|
+
[str(x.get("href")) for x in soup.find_all(["a", "link"], href=True)],
|
|
143
|
+
url,
|
|
144
|
+
request.config.max_links_per_document,
|
|
145
|
+
)
|
|
146
|
+
canonical = []
|
|
147
|
+
for node in soup.find_all("link", href=True):
|
|
148
|
+
if "canonical" in {str(x).lower() for x in node.get("rel") or []}:
|
|
149
|
+
canonical += _links([str(node["href"])], url, 1)
|
|
150
|
+
facts.canonical_count = len(canonical)
|
|
151
|
+
facts.canonical = canonical[0] if canonical else None
|
|
152
|
+
for node in soup.find_all("meta"):
|
|
153
|
+
name = str(node.get("property") or node.get("name") or "").lower()
|
|
154
|
+
facts.social += name.startswith("og:") or name.startswith("twitter:")
|
|
155
|
+
for node in soup.find_all("script"):
|
|
156
|
+
if str(node.get("type") or "").lower() != "application/ld+json":
|
|
157
|
+
continue
|
|
158
|
+
facts.jsonld += 1
|
|
159
|
+
try:
|
|
160
|
+
value = json.loads(node.string or node.get_text())
|
|
161
|
+
items = value if isinstance(value, list) else [value]
|
|
162
|
+
for item in items[:1000]:
|
|
163
|
+
if isinstance(item, dict) and isinstance(item.get("@type"), str):
|
|
164
|
+
facts.types.add(item["@type"])
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
facts.bad_jsonld += 1
|
|
167
|
+
combined = facts.text + " " + " ".join(facts.links)
|
|
168
|
+
if "model context protocol" in combined or "streamable http" in combined:
|
|
169
|
+
facts.mcp.add("transport")
|
|
170
|
+
if re.search(r"(?:^|[/_-])mcp(?:$|[/_.-])", combined):
|
|
171
|
+
facts.mcp.add("endpoint")
|
|
172
|
+
if "mcpservers" in combined or "client config" in combined:
|
|
173
|
+
facts.mcp.add("config")
|
|
174
|
+
elif "sitemap" in path or "xml" in kind:
|
|
175
|
+
try:
|
|
176
|
+
root = ElementTree.fromstring(fetched.body)
|
|
177
|
+
locations = [x.text.strip() for x in root.iter() if x.tag.rsplit("}", 1)[-1].lower() == "loc" and x.text][
|
|
178
|
+
: request.config.max_sitemap_urls
|
|
179
|
+
]
|
|
180
|
+
facts.sitemap = _links(locations, url, request.config.max_sitemap_urls)
|
|
181
|
+
facts.sitemap_bad = root.tag.rsplit("}", 1)[-1].lower() not in {"urlset", "sitemapindex"}
|
|
182
|
+
except Exception:
|
|
183
|
+
facts.sitemap_bad = True
|
|
184
|
+
elif path.endswith(("openapi.json", "swagger.json", "openapi.yaml", "openapi.yml")):
|
|
185
|
+
try:
|
|
186
|
+
text = fetched.body.decode()
|
|
187
|
+
value = json.loads(text) if text.lstrip().startswith("{") else yaml.safe_load(text)
|
|
188
|
+
if isinstance(value, dict):
|
|
189
|
+
version = value.get("openapi")
|
|
190
|
+
paths = value.get("paths")
|
|
191
|
+
facts.openapi = version if isinstance(version, str) else None
|
|
192
|
+
facts.paths = len(paths) if isinstance(paths, dict) else 0
|
|
193
|
+
components = value.get("components")
|
|
194
|
+
facts.auth = bool(value.get("security")) or (
|
|
195
|
+
isinstance(components, dict) and bool(components.get("securitySchemes"))
|
|
196
|
+
)
|
|
197
|
+
except (ValueError, UnicodeError, yaml.YAMLError):
|
|
198
|
+
pass
|
|
199
|
+
elif kind.startswith("text/"):
|
|
200
|
+
facts.text = fetched.body.decode("utf-8", errors="replace").lower()[:200000]
|
|
201
|
+
return facts
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _finding(
|
|
205
|
+
identifier: str,
|
|
206
|
+
category: str,
|
|
207
|
+
status: FindingStatus,
|
|
208
|
+
description: str,
|
|
209
|
+
*,
|
|
210
|
+
evidence: list[Evidence] | None = None,
|
|
211
|
+
penalty: float = 0,
|
|
212
|
+
) -> AuditFinding:
|
|
213
|
+
titles = {
|
|
214
|
+
"HTTP_REACHABLE": "Website root is reachable",
|
|
215
|
+
"HTTP_HTTPS": "Final site root uses HTTPS",
|
|
216
|
+
"ROBOTS_VALID": "Robots policy is available and parseable",
|
|
217
|
+
"ROBOTS_AI_ACCESS": "AI crawler access is documented",
|
|
218
|
+
"CANONICAL": "Canonical identity agrees with the final URL",
|
|
219
|
+
"SITEMAP": "A usable sitemap is discoverable",
|
|
220
|
+
"MACHINE": "Machine-facing surfaces are linked",
|
|
221
|
+
"OPENAPI": "A usable OpenAPI document is discoverable",
|
|
222
|
+
"MCP": "An actionable agent integration is discoverable",
|
|
223
|
+
"METADATA": "Structured metadata identifies the site",
|
|
224
|
+
"DOCS": "Documentation is discoverable and useful",
|
|
225
|
+
"PRICING": "Pricing or custom pricing is discoverable",
|
|
226
|
+
"EXPOSURE_METRICS": "Public operational surfaces avoid sensitive telemetry",
|
|
227
|
+
}
|
|
228
|
+
severity = {
|
|
229
|
+
FindingStatus.FAIL: Severity.HIGH,
|
|
230
|
+
FindingStatus.WARN: Severity.MEDIUM,
|
|
231
|
+
FindingStatus.NOT_TESTED: Severity.LOW,
|
|
232
|
+
}.get(status, Severity.INFO)
|
|
233
|
+
return AuditFinding(
|
|
234
|
+
id=identifier,
|
|
235
|
+
category=category,
|
|
236
|
+
status=status,
|
|
237
|
+
severity=severity,
|
|
238
|
+
title=titles[identifier],
|
|
239
|
+
description=description,
|
|
240
|
+
evidence=evidence or [],
|
|
241
|
+
remediation="Review the affected public surface and make it deliberately agent-readable."
|
|
242
|
+
if status in {FindingStatus.FAIL, FindingStatus.WARN}
|
|
243
|
+
else None,
|
|
244
|
+
security_penalty=penalty,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def checks(
|
|
249
|
+
root: PageFetch | None,
|
|
250
|
+
facts: dict[str, Facts],
|
|
251
|
+
policies: dict[str, Policy],
|
|
252
|
+
resources: dict[str, PageFetch],
|
|
253
|
+
final: str | None,
|
|
254
|
+
) -> list[AuditFinding]:
|
|
255
|
+
rootfacts = facts.get(root.final_url or "") if root else None
|
|
256
|
+
reachable = bool(root and root.status_code and 200 <= root.status_code < 300)
|
|
257
|
+
policy = policies.get(origin(final)) if final else None
|
|
258
|
+
result = [
|
|
259
|
+
_finding(
|
|
260
|
+
"HTTP_REACHABLE",
|
|
261
|
+
"http",
|
|
262
|
+
FindingStatus.PASS if reachable else FindingStatus.FAIL,
|
|
263
|
+
"The root returned 2xx." if reachable else "The root did not return 2xx.",
|
|
264
|
+
),
|
|
265
|
+
_finding(
|
|
266
|
+
"HTTP_HTTPS",
|
|
267
|
+
"http",
|
|
268
|
+
FindingStatus.PASS if final and urlsplit(final).scheme == "https" else FindingStatus.FAIL,
|
|
269
|
+
"The final root uses HTTPS."
|
|
270
|
+
if final and urlsplit(final).scheme == "https"
|
|
271
|
+
else "The final root is not HTTPS.",
|
|
272
|
+
),
|
|
273
|
+
]
|
|
274
|
+
robot_status = (
|
|
275
|
+
FindingStatus.PASS
|
|
276
|
+
if policy and policy.status != "unavailable" and policy.malformed == 0
|
|
277
|
+
else FindingStatus.NOT_TESTED
|
|
278
|
+
)
|
|
279
|
+
result.append(
|
|
280
|
+
_finding(
|
|
281
|
+
"ROBOTS_VALID",
|
|
282
|
+
"crawlability",
|
|
283
|
+
robot_status,
|
|
284
|
+
"robots.txt was interpreted."
|
|
285
|
+
if robot_status == FindingStatus.PASS
|
|
286
|
+
else "robots.txt was unavailable or malformed.",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
allowed = sum(policy.can_fetch(x, final) for x in AI_AGENTS) if policy and final else 0
|
|
290
|
+
result.append(
|
|
291
|
+
_finding(
|
|
292
|
+
"ROBOTS_AI_ACCESS",
|
|
293
|
+
"crawlability",
|
|
294
|
+
FindingStatus.PASS
|
|
295
|
+
if allowed == len(AI_AGENTS)
|
|
296
|
+
else FindingStatus.WARN
|
|
297
|
+
if allowed
|
|
298
|
+
else FindingStatus.NOT_TESTED,
|
|
299
|
+
f"{allowed} of {len(AI_AGENTS)} tracked AI identities may fetch the root.",
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
canonical = bool(
|
|
303
|
+
rootfacts
|
|
304
|
+
and rootfacts.canonical
|
|
305
|
+
and root
|
|
306
|
+
and rootfacts.canonical.rstrip("/") == str(root.final_url).rstrip("/")
|
|
307
|
+
and rootfacts.canonical_count == 1
|
|
308
|
+
)
|
|
309
|
+
result.append(
|
|
310
|
+
_finding(
|
|
311
|
+
"CANONICAL",
|
|
312
|
+
"canonicalIdentity",
|
|
313
|
+
FindingStatus.PASS if canonical else FindingStatus.FAIL,
|
|
314
|
+
"Canonical identity is consistent."
|
|
315
|
+
if canonical
|
|
316
|
+
else "Canonical identity is missing, duplicated, or conflicting.",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
maps = [x for x in facts.values() if x.sitemap and not x.sitemap_bad]
|
|
320
|
+
result.append(
|
|
321
|
+
_finding(
|
|
322
|
+
"SITEMAP",
|
|
323
|
+
"machineDiscovery",
|
|
324
|
+
FindingStatus.PASS if maps else FindingStatus.WARN,
|
|
325
|
+
"A usable sitemap was found." if maps else "No usable sitemap was found.",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
links = rootfacts.links if rootfacts else []
|
|
329
|
+
machine = [x for x in links if any(t in x.lower() for t in ("docs", "api", "developer", "mcp", "agent", "pricing"))]
|
|
330
|
+
result.append(
|
|
331
|
+
_finding(
|
|
332
|
+
"MACHINE",
|
|
333
|
+
"machineDiscovery",
|
|
334
|
+
FindingStatus.PASS if machine else FindingStatus.WARN,
|
|
335
|
+
f"{len(machine)} machine-facing root links were found.",
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
specs = [x for x in facts.values() if x.openapi and x.openapi.startswith(("3.0.", "3.1.")) and x.paths]
|
|
339
|
+
result.append(
|
|
340
|
+
_finding(
|
|
341
|
+
"OPENAPI",
|
|
342
|
+
"apiDiscovery",
|
|
343
|
+
FindingStatus.PASS if specs else FindingStatus.FAIL,
|
|
344
|
+
"A usable OpenAPI document was found." if specs else "No usable OpenAPI document was found.",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
mcp = set().union(*(x.mcp for x in facts.values())) if facts else set()
|
|
348
|
+
result.append(
|
|
349
|
+
_finding(
|
|
350
|
+
"MCP",
|
|
351
|
+
"agentDiscovery",
|
|
352
|
+
FindingStatus.PASS
|
|
353
|
+
if {"transport", "endpoint"} <= mcp
|
|
354
|
+
else FindingStatus.WARN
|
|
355
|
+
if mcp
|
|
356
|
+
else FindingStatus.FAIL,
|
|
357
|
+
"MCP discovery is actionable."
|
|
358
|
+
if {"transport", "endpoint"} <= mcp
|
|
359
|
+
else "MCP discovery is absent or incomplete.",
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
relevant = {"Organization", "Product", "SoftwareApplication", "WebSite", "Service"}
|
|
363
|
+
meta = bool(rootfacts and rootfacts.jsonld and not rootfacts.bad_jsonld and rootfacts.types & relevant)
|
|
364
|
+
result.append(
|
|
365
|
+
_finding(
|
|
366
|
+
"METADATA",
|
|
367
|
+
"structuredMetadata",
|
|
368
|
+
FindingStatus.PASS if meta else FindingStatus.WARN,
|
|
369
|
+
"Relevant valid JSON-LD was found." if meta else "Relevant valid JSON-LD was not found.",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
docs = [x for x in facts.values() if any(t in x.url.lower() for t in ("docs", "documentation", "developer"))]
|
|
373
|
+
useful = bool(
|
|
374
|
+
docs
|
|
375
|
+
and any(x.code and any(t in x.text for t in ("authentication", "error", "rate limit", "status")) for x in docs)
|
|
376
|
+
)
|
|
377
|
+
result.append(
|
|
378
|
+
_finding(
|
|
379
|
+
"DOCS",
|
|
380
|
+
"docsUsability",
|
|
381
|
+
FindingStatus.PASS if useful else FindingStatus.WARN,
|
|
382
|
+
"Useful documentation was found." if useful else "Documentation or operational examples were incomplete.",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
pricing = [x for x in facts.values() if "pricing" in x.url.lower()]
|
|
386
|
+
clear = bool(
|
|
387
|
+
pricing
|
|
388
|
+
and any(any(t in x.text for t in ("$", "custom pricing", "contact sales", "free", "plan")) for x in pricing)
|
|
389
|
+
)
|
|
390
|
+
result.append(
|
|
391
|
+
_finding(
|
|
392
|
+
"PRICING",
|
|
393
|
+
"pricingDiscovery",
|
|
394
|
+
FindingStatus.PASS if clear else FindingStatus.WARN,
|
|
395
|
+
"Pricing is discoverable." if clear else "Pricing was absent or unclear.",
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
exposed = [
|
|
399
|
+
x
|
|
400
|
+
for x in facts.values()
|
|
401
|
+
if urlsplit(x.url).path in EXPOSURE
|
|
402
|
+
and any(t in x.text for t in ("# help", "# type", "traceback", "hostname", "database", "build sha", "backend"))
|
|
403
|
+
]
|
|
404
|
+
penalty = min(20, 10 if any(urlsplit(x.url).path in {"/metrics", "/debug"} for x in exposed) else 5 * len(exposed))
|
|
405
|
+
result.append(
|
|
406
|
+
_finding(
|
|
407
|
+
"EXPOSURE_METRICS",
|
|
408
|
+
"securityExposure",
|
|
409
|
+
FindingStatus.FAIL if exposed else FindingStatus.PASS,
|
|
410
|
+
"Sensitive operational metadata was public."
|
|
411
|
+
if exposed
|
|
412
|
+
else "No sensitive operational metadata was observed.",
|
|
413
|
+
evidence=[Evidence(kind="publicOperationalSurface", url=x.url) for x in exposed],
|
|
414
|
+
penalty=penalty,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def score(findings: list[AuditFinding], weights: dict[str, float]) -> tuple[list[AuditFinding], ScoreSummary]:
|
|
421
|
+
categories = []
|
|
422
|
+
earned_total = 0.0
|
|
423
|
+
evaluated = 0
|
|
424
|
+
total = 0
|
|
425
|
+
rendered = []
|
|
426
|
+
factors = {FindingStatus.PASS: 1, FindingStatus.WARN: 0.5, FindingStatus.FAIL: 0, FindingStatus.NOT_TESTED: 0}
|
|
427
|
+
for category, weight in weights.items():
|
|
428
|
+
items = [x for x in findings if x.category == category and x.status != FindingStatus.INFO]
|
|
429
|
+
each = weight / len(items) if items else 0
|
|
430
|
+
earned = sum(each * factors[x.status] for x in items)
|
|
431
|
+
earned_total += earned
|
|
432
|
+
total += len(items)
|
|
433
|
+
evaluated += sum(x.status != FindingStatus.NOT_TESTED for x in items)
|
|
434
|
+
categories.append(
|
|
435
|
+
CategoryScore(
|
|
436
|
+
category=category,
|
|
437
|
+
weight=weight,
|
|
438
|
+
score=round(earned / weight * 100, 1),
|
|
439
|
+
earned_points=round(earned, 2),
|
|
440
|
+
possible_points=weight,
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
for item in items:
|
|
444
|
+
rendered.append(item.model_copy(update={"score_delta": round(each * (factors[item.status] - 1), 4)}))
|
|
445
|
+
rendered.extend(x for x in findings if x.category not in weights)
|
|
446
|
+
penalty = min(20, sum(x.security_penalty for x in findings))
|
|
447
|
+
return rendered, ScoreSummary(
|
|
448
|
+
overall=max(0, min(100, round(earned_total - penalty))),
|
|
449
|
+
completeness=round(evaluated / total, 4) if total else 1,
|
|
450
|
+
security_penalty=penalty,
|
|
451
|
+
categories=categories,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
async def audit(request: AuditRequest, *, client: SafeHttpClient | None = None) -> AuditReport:
|
|
456
|
+
raw = yaml.safe_load(files("agent_ready_audit.data").joinpath("checks.yaml").read_text())
|
|
457
|
+
weights = {str(k): float(v) for k, v in raw["categories"].items()}
|
|
458
|
+
version = str(raw["version"])
|
|
459
|
+
active = client or SafeHttpClient(request.config, version=__version__)
|
|
460
|
+
budget = FetchBudget(request.config.max_pages, request.config.max_total_bytes)
|
|
461
|
+
resources: dict[str, PageFetch] = {}
|
|
462
|
+
documents: dict[str, Facts] = {}
|
|
463
|
+
limitations: list[str] = []
|
|
464
|
+
disallowed = 0
|
|
465
|
+
timedout = 0
|
|
466
|
+
|
|
467
|
+
def record(page: PageFetch) -> None:
|
|
468
|
+
key = safe_url(page.requested_url)
|
|
469
|
+
old = resources.get(key)
|
|
470
|
+
if old is None or page.method == "GET" or old.status_code is None:
|
|
471
|
+
resources[key] = page
|
|
472
|
+
|
|
473
|
+
robots = RobotsManager(active, budget, record)
|
|
474
|
+
root = None
|
|
475
|
+
final = None
|
|
476
|
+
status = "failed"
|
|
477
|
+
try:
|
|
478
|
+
async with asyncio.timeout(request.config.overall_timeout_seconds):
|
|
479
|
+
try:
|
|
480
|
+
rootraw = await active.fetch(request.url, budget=budget, authorize=robots.authorize)
|
|
481
|
+
root = rootraw.public
|
|
482
|
+
record(root)
|
|
483
|
+
rootfacts = parse(rootraw, request)
|
|
484
|
+
documents[rootfacts.url] = rootfacts
|
|
485
|
+
final = root.final_url or request.url
|
|
486
|
+
except RobotsDisallowedError as exc:
|
|
487
|
+
disallowed += 1
|
|
488
|
+
record(PageFetch(requested_url=request.url, error_code=exc.code))
|
|
489
|
+
limitations.append("robots.txt disallowed the requested root")
|
|
490
|
+
status = "restricted"
|
|
491
|
+
raise StopAsyncIteration from None
|
|
492
|
+
except (FetchError, FetchTimeoutError) as exc:
|
|
493
|
+
timedout += isinstance(exc, FetchTimeoutError)
|
|
494
|
+
record(PageFetch(requested_url=request.url, error_code=exc.code))
|
|
495
|
+
limitations.append("the requested root was unreachable")
|
|
496
|
+
status = "unreachable"
|
|
497
|
+
raise StopAsyncIteration from None
|
|
498
|
+
site = origin(final)
|
|
499
|
+
queue: list[tuple[int, str]] = []
|
|
500
|
+
queued: set[str] = set()
|
|
501
|
+
|
|
502
|
+
def enqueue(url: str) -> None:
|
|
503
|
+
value = safe_url(url)
|
|
504
|
+
if value not in queued and value not in resources and origin(value) == site:
|
|
505
|
+
queued.add(value)
|
|
506
|
+
heapq.heappush(queue, (0 if any(x in value for x in ("openapi", "swagger", "mcp")) else 1, value))
|
|
507
|
+
|
|
508
|
+
for p in robots.policies.values():
|
|
509
|
+
for item in p.sitemaps:
|
|
510
|
+
enqueue(item)
|
|
511
|
+
if request.config.standard_probes:
|
|
512
|
+
for path in PROBES:
|
|
513
|
+
enqueue(site + path)
|
|
514
|
+
for item in rootfacts.links:
|
|
515
|
+
enqueue(item)
|
|
516
|
+
while queue:
|
|
517
|
+
_, url = heapq.heappop(queue)
|
|
518
|
+
|
|
519
|
+
async def authorize(value: str) -> None:
|
|
520
|
+
if origin(value) != site:
|
|
521
|
+
raise TargetSecurityError("cross-origin redirect is not allowed")
|
|
522
|
+
await robots.authorize(value)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
if urlsplit(url).path in EXPOSURE:
|
|
526
|
+
head = await active.fetch(url, budget=budget, method="HEAD", authorize=authorize)
|
|
527
|
+
record(head.public)
|
|
528
|
+
if not head.public.status_code or not 200 <= head.public.status_code < 300:
|
|
529
|
+
continue
|
|
530
|
+
fetched = await active.fetch(
|
|
531
|
+
url,
|
|
532
|
+
budget=budget,
|
|
533
|
+
max_bytes=256 * 1024 if urlsplit(url).path in EXPOSURE else None,
|
|
534
|
+
authorize=authorize,
|
|
535
|
+
)
|
|
536
|
+
record(fetched.public)
|
|
537
|
+
facts = parse(fetched, request)
|
|
538
|
+
documents[facts.url] = facts
|
|
539
|
+
for item in facts.links + facts.sitemap:
|
|
540
|
+
enqueue(item)
|
|
541
|
+
except RobotsDisallowedError as exc:
|
|
542
|
+
disallowed += 1
|
|
543
|
+
record(PageFetch(requested_url=url, error_code=exc.code))
|
|
544
|
+
limitations.append("robots.txt prevented planned checks")
|
|
545
|
+
except BudgetExceededError:
|
|
546
|
+
limitations.append("resource or byte budget was exhausted")
|
|
547
|
+
break
|
|
548
|
+
except (FetchError, TargetSecurityError) as exc:
|
|
549
|
+
record(PageFetch(requested_url=url, error_code=exc.code))
|
|
550
|
+
limitations.append("a discovered resource could not be fetched safely")
|
|
551
|
+
status = "success" if root.status_code and 200 <= root.status_code < 300 else "unreachable"
|
|
552
|
+
if limitations and status == "success":
|
|
553
|
+
status = "partial"
|
|
554
|
+
except StopAsyncIteration:
|
|
555
|
+
pass
|
|
556
|
+
except TimeoutError:
|
|
557
|
+
timedout += 1
|
|
558
|
+
limitations.append("the overall audit deadline was reached")
|
|
559
|
+
status = "partial" if resources else "unreachable"
|
|
560
|
+
findings, summary = score(checks(root, documents, robots.policies, resources, final), weights)
|
|
561
|
+
pages = sorted(resources.values(), key=lambda x: (x.requested_url, x.method))
|
|
562
|
+
return AuditReport(
|
|
563
|
+
generated_at=datetime.now(UTC),
|
|
564
|
+
analyzer_version=__version__,
|
|
565
|
+
rules_version=version,
|
|
566
|
+
status=status,
|
|
567
|
+
target=TargetSummary(requested_root=request.url, final_root=final),
|
|
568
|
+
limits=AuditLimits(
|
|
569
|
+
max_pages=request.config.max_pages,
|
|
570
|
+
request_timeout_seconds=request.config.request_timeout_seconds,
|
|
571
|
+
overall_timeout_seconds=request.config.overall_timeout_seconds,
|
|
572
|
+
max_response_bytes=request.config.max_response_bytes,
|
|
573
|
+
max_total_bytes=request.config.max_total_bytes,
|
|
574
|
+
standard_probes=request.config.standard_probes,
|
|
575
|
+
),
|
|
576
|
+
fetch_summary=FetchSummary(
|
|
577
|
+
resources_attempted=len(budget.urls),
|
|
578
|
+
resources_fetched=sum(x.status_code is not None for x in pages),
|
|
579
|
+
http_requests=active.request_count,
|
|
580
|
+
http_retries=active.retry_count,
|
|
581
|
+
bytes_processed=budget.bytes_processed,
|
|
582
|
+
robots_disallowed=disallowed,
|
|
583
|
+
timed_out=timedout,
|
|
584
|
+
),
|
|
585
|
+
score=summary,
|
|
586
|
+
resources=pages,
|
|
587
|
+
findings=findings[: request.config.max_findings],
|
|
588
|
+
limitations=list(dict.fromkeys(limitations)),
|
|
589
|
+
)
|