authgent-server 0.2.2__tar.gz → 0.2.4__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.
Files changed (124) hide show
  1. {authgent_server-0.2.2 → authgent_server-0.2.4}/.env.production +5 -0
  2. {authgent_server-0.2.2 → authgent_server-0.2.4}/PKG-INFO +1 -1
  3. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/__init__.py +1 -1
  4. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/__init__.py +2 -0
  5. authgent_server-0.2.4/authgent_server/endpoints/scan.py +117 -0
  6. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/wellknown.py +2 -0
  7. {authgent_server-0.2.2 → authgent_server-0.2.4}/pyproject.toml +1 -1
  8. authgent_server-0.2.4/tests/test_scan_endpoint.py +101 -0
  9. {authgent_server-0.2.2 → authgent_server-0.2.4}/.env.example +0 -0
  10. {authgent_server-0.2.2 → authgent_server-0.2.4}/.gitignore +0 -0
  11. {authgent_server-0.2.2 → authgent_server-0.2.4}/Dockerfile +0 -0
  12. {authgent_server-0.2.2 → authgent_server-0.2.4}/README.md +0 -0
  13. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/app.py +0 -0
  14. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/cli.py +0 -0
  15. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/config.py +0 -0
  16. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/crypto.py +0 -0
  17. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/db.py +0 -0
  18. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/dependencies.py +0 -0
  19. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/agents.py +0 -0
  20. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/audit.py +0 -0
  21. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/authorize.py +0 -0
  22. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/device.py +0 -0
  23. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/health.py +0 -0
  24. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/introspect.py +0 -0
  25. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/register.py +0 -0
  26. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/revoke.py +0 -0
  27. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/stepup.py +0 -0
  28. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/token.py +0 -0
  29. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/token_check.py +0 -0
  30. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/endpoints/token_inspect.py +0 -0
  31. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/errors.py +0 -0
  32. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/logging.py +0 -0
  33. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/__init__.py +0 -0
  34. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/cors.py +0 -0
  35. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/error_handler.py +0 -0
  36. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/rate_limit.py +0 -0
  37. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/request_id.py +0 -0
  38. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/middleware/sanitize.py +0 -0
  39. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/__init__.py +0 -0
  40. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/agent.py +0 -0
  41. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/audit_log.py +0 -0
  42. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/authorization_code.py +0 -0
  43. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/base.py +0 -0
  44. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/consent.py +0 -0
  45. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/delegation_receipt.py +0 -0
  46. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/device_code.py +0 -0
  47. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/oauth_client.py +0 -0
  48. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/refresh_token.py +0 -0
  49. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/signing_key.py +0 -0
  50. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/stepup_request.py +0 -0
  51. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/token_blocklist.py +0 -0
  52. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/models/user.py +0 -0
  53. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/__init__.py +0 -0
  54. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/attestation.py +0 -0
  55. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/events.py +0 -0
  56. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/hitl.py +0 -0
  57. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/keys.py +0 -0
  58. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/policy.py +0 -0
  59. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/providers/protocols.py +0 -0
  60. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/scanner.py +0 -0
  61. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/schemas/__init__.py +0 -0
  62. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/schemas/agent.py +0 -0
  63. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/schemas/client.py +0 -0
  64. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/schemas/common.py +0 -0
  65. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/schemas/token.py +0 -0
  66. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/__init__.py +0 -0
  67. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/agent_service.py +0 -0
  68. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/audit_service.py +0 -0
  69. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/chaining_verifier.py +0 -0
  70. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/claims_transcription.py +0 -0
  71. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/client_service.py +0 -0
  72. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/consent_service.py +0 -0
  73. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/delegation_service.py +0 -0
  74. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/dpop_service.py +0 -0
  75. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/external_oidc.py +0 -0
  76. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/jwks_service.py +0 -0
  77. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/stepup_service.py +0 -0
  78. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/services/token_service.py +0 -0
  79. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/templates/consent.html +0 -0
  80. {authgent_server-0.2.2 → authgent_server-0.2.4}/authgent_server/utils.py +0 -0
  81. {authgent_server-0.2.2 → authgent_server-0.2.4}/docker-compose.yml +0 -0
  82. {authgent_server-0.2.2 → authgent_server-0.2.4}/migrations/alembic.ini +0 -0
  83. {authgent_server-0.2.2 → authgent_server-0.2.4}/migrations/env.py +0 -0
  84. {authgent_server-0.2.2 → authgent_server-0.2.4}/migrations/versions/.gitkeep +0 -0
  85. {authgent_server-0.2.2 → authgent_server-0.2.4}/migrations/versions/001_initial_schema.py +0 -0
  86. {authgent_server-0.2.2 → authgent_server-0.2.4}/migrations/versions/002_dcr_software_fields.py +0 -0
  87. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/__init__.py +0 -0
  88. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/adversarial_live_test.py +0 -0
  89. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/agent_to_agent_simulation.py +0 -0
  90. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/conftest.py +0 -0
  91. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/live_discovery_simulation.py +0 -0
  92. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/simulation_test.py +0 -0
  93. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_agent_discovery.py +0 -0
  94. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_agents.py +0 -0
  95. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_audit_endpoint.py +0 -0
  96. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_authorize.py +0 -0
  97. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_cli_phase0.py +0 -0
  98. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_crypto.py +0 -0
  99. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_delegation.py +0 -0
  100. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_device.py +0 -0
  101. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_dpop.py +0 -0
  102. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_dpop_integration.py +0 -0
  103. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_error_handler.py +0 -0
  104. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_external_oidc.py +0 -0
  105. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_health.py +0 -0
  106. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_hitl_provider.py +0 -0
  107. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_identity_chaining.py +0 -0
  108. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_integration_workflows.py +0 -0
  109. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_introspect.py +0 -0
  110. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_log_redaction.py +0 -0
  111. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_real_world_agents.py +0 -0
  112. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_register.py +0 -0
  113. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_registration_policy.py +0 -0
  114. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_revoke.py +0 -0
  115. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_scanner.py +0 -0
  116. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_security.py +0 -0
  117. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_stepup.py +0 -0
  118. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_token.py +0 -0
  119. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_token_advanced.py +0 -0
  120. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_token_check.py +0 -0
  121. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_token_expiry.py +0 -0
  122. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_token_inspect.py +0 -0
  123. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_transaction_tokens.py +0 -0
  124. {authgent_server-0.2.2 → authgent_server-0.2.4}/tests/test_wellknown.py +0 -0
@@ -14,6 +14,11 @@ AUTHGENT_DATABASE_URL=sqlite+aiosqlite:////tmp/demo/authgent.db
14
14
  # Strict CORS — only the deployed frontend + local dev origins.
15
15
  AUTHGENT_CORS_ORIGINS=["https://authgent.github.io", "http://localhost:3000", "http://localhost:3001"]
16
16
 
17
+ # Public origin used for issuer claim, RFC 9207 iss=, and SEP-2351 path-suffix
18
+ # metadata. Without this, the AS advertises localhost:8000 in production and
19
+ # every issuer-comparison check at the MCP client fails.
20
+ AUTHGENT_SERVER_URL=https://authgent-demo.dhruvagnihotri.com
21
+
17
22
  AUTHGENT_REGISTRATION_POLICY=open
18
23
  AUTHGENT_CONSENT_MODE=auto_approve
19
24
  AUTHGENT_HOST=0.0.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authgent-server
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Open-source reference implementation of the IETF agent-identity stack — OAuth 2.1 + identity-chaining + transaction-tokens + DPoP + MCP
5
5
  Project-URL: Homepage, https://github.com/authgent/authgent
6
6
  Project-URL: Documentation, https://github.com/authgent/authgent/tree/main/server
@@ -1,3 +1,3 @@
1
1
  """authgent-server — The open-source identity provider for AI agents."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
@@ -10,6 +10,7 @@ from authgent_server.endpoints.health import router as health_router
10
10
  from authgent_server.endpoints.introspect import router as introspect_router
11
11
  from authgent_server.endpoints.register import router as register_router
12
12
  from authgent_server.endpoints.revoke import router as revoke_router
13
+ from authgent_server.endpoints.scan import router as scan_router
13
14
  from authgent_server.endpoints.stepup import router as stepup_router
14
15
  from authgent_server.endpoints.token import router as token_router
15
16
  from authgent_server.endpoints.token_check import router as token_check_router
@@ -30,3 +31,4 @@ api_router.include_router(audit_router)
30
31
  api_router.include_router(token_inspect_router)
31
32
  api_router.include_router(wellknown_router)
32
33
  api_router.include_router(health_router)
34
+ api_router.include_router(scan_router)
@@ -0,0 +1,117 @@
1
+ """GET /api/scan — public MCP-OAuth conformance scanner endpoint.
2
+
3
+ Wraps :func:`authgent_server.scanner.scan` so the static GitHub Pages
4
+ ``/scan`` page can call it without bundling the scanner. Stateless;
5
+ no DB writes; safe to expose publicly. Per-IP rate limiting is provided
6
+ by the existing :class:`RateLimitMiddleware`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict
12
+ from typing import Literal
13
+ from urllib.parse import urlparse
14
+
15
+ from fastapi import APIRouter, HTTPException, Query
16
+ from pydantic import BaseModel
17
+
18
+ from authgent_server.scanner import (
19
+ Finding,
20
+ has_blocking,
21
+ scan,
22
+ )
23
+
24
+ router = APIRouter(tags=["scan"])
25
+
26
+
27
+ class ScanResponse(BaseModel):
28
+ target: str
29
+ grade: Literal["A", "B", "C", "D", "F"]
30
+ score: int # 0-100
31
+ blocking: bool
32
+ finding_count: int
33
+ findings: list[dict]
34
+
35
+
36
+ def _grade(findings: list[Finding]) -> tuple[str, int]:
37
+ """Compute a letter grade and 0-100 score from findings."""
38
+ weight = {"info": 1, "warning": 5, "critical": 25, "error": 15}
39
+ penalty = sum(weight.get(f.severity, 5) for f in findings)
40
+ score = max(0, 100 - penalty)
41
+ if score >= 95:
42
+ return "A", score
43
+ if score >= 85:
44
+ return "B", score
45
+ if score >= 70:
46
+ return "C", score
47
+ if score >= 50:
48
+ return "D", score
49
+ return "F", score
50
+
51
+
52
+ def _is_safe_url(url: str) -> bool:
53
+ """Reject URLs that target the Oracle host's own internal network.
54
+
55
+ The scanner makes outbound HTTP. If a visitor pastes
56
+ ``http://localhost`` / ``127.0.0.1`` / a private IP, the scanner would
57
+ probe authgent's own internal services. Block those.
58
+ """
59
+ parsed = urlparse(url)
60
+ if parsed.scheme not in ("http", "https"):
61
+ return False
62
+ host = (parsed.hostname or "").lower()
63
+ if not host:
64
+ return False
65
+ blocked_hosts = {"localhost", "0.0.0.0", "::1"}
66
+ if host in blocked_hosts:
67
+ return False
68
+ if host.startswith("127.") or host.startswith("169.254."):
69
+ return False
70
+ if host.startswith("10.") or host.startswith("192.168."):
71
+ return False
72
+ if host.startswith("172.") and len(host.split(".")) > 1:
73
+ try:
74
+ second = int(host.split(".")[1])
75
+ if 16 <= second <= 31:
76
+ return False
77
+ except ValueError:
78
+ pass
79
+ if host.endswith(".internal") or host.endswith(".local"):
80
+ return False
81
+ return True
82
+
83
+
84
+ @router.get("/api/scan", response_model=ScanResponse)
85
+ async def scan_endpoint(
86
+ url: str = Query(
87
+ ...,
88
+ description="MCP server base URL to audit, e.g. https://mcp.example.com",
89
+ max_length=2048,
90
+ ),
91
+ ) -> ScanResponse:
92
+ """Run the MCP-OAuth conformance scanner against ``url`` and return JSON."""
93
+ if not _is_safe_url(url):
94
+ raise HTTPException(
95
+ status_code=400,
96
+ detail=(
97
+ "url must be an http(s) URL pointing at a public host. "
98
+ "Loopback, link-local, and RFC 1918 addresses are blocked."
99
+ ),
100
+ )
101
+ try:
102
+ findings = await scan(url)
103
+ except Exception as exc: # noqa: BLE001 — surface a friendly error
104
+ raise HTTPException(
105
+ status_code=502,
106
+ detail=f"Scanner failed: {exc}",
107
+ ) from exc
108
+
109
+ grade, score = _grade(findings)
110
+ return ScanResponse(
111
+ target=url,
112
+ grade=grade, # type: ignore[arg-type]
113
+ score=score,
114
+ blocking=has_blocking(findings),
115
+ finding_count=len(findings),
116
+ findings=[asdict(f) for f in findings],
117
+ )
@@ -73,6 +73,8 @@ async def oauth_server_metadata(
73
73
  "scopes_supported": scopes,
74
74
  "resource_indicators_supported": True,
75
75
  "dpop_signing_alg_values_supported": ["ES256"],
76
+ # RFC 9207 / MCP SEP-2468 — we emit iss= on every /authorize redirect.
77
+ "authorization_response_iss_parameter_supported": True,
76
78
  "service_documentation": "https://authgent.dev/docs",
77
79
  }
78
80
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "authgent-server"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Open-source reference implementation of the IETF agent-identity stack — OAuth 2.1 + identity-chaining + transaction-tokens + DPoP + MCP"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,101 @@
1
+ """Tests for the public ``/api/scan`` endpoint.
2
+
3
+ The endpoint must (a) refuse loopback / RFC-1918 / link-local targets so
4
+ the scanner can't probe the host's own internal network, and (b) return
5
+ a structured grade + findings for safe URLs.
6
+
7
+ The actual scan is patched out — the routing + safety guards are what
8
+ matters here. ``test_scanner.py`` covers the scanner internals.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from authgent_server.endpoints.scan import _grade, _is_safe_url
16
+ from authgent_server.scanner import Finding
17
+
18
+ # --- safety guard ----------------------------------------------------------
19
+
20
+
21
+ @pytest.mark.parametrize(
22
+ "url",
23
+ [
24
+ "http://localhost/",
25
+ "http://127.0.0.1:8000",
26
+ "https://10.0.0.5",
27
+ "http://192.168.1.1",
28
+ "http://172.16.0.1",
29
+ "http://172.31.255.255",
30
+ "http://169.254.169.254", # AWS metadata
31
+ "http://thing.internal",
32
+ "http://thing.local",
33
+ "ftp://example.com",
34
+ "javascript:alert(1)",
35
+ "",
36
+ ],
37
+ )
38
+ def test_unsafe_urls_rejected(url: str):
39
+ assert _is_safe_url(url) is False
40
+
41
+
42
+ @pytest.mark.parametrize(
43
+ "url",
44
+ [
45
+ "https://mcp.example.com",
46
+ "https://mcp.example.com/sub",
47
+ "http://198.51.100.1", # TEST-NET-2 (public-routed for examples)
48
+ "https://api.descope.com",
49
+ ],
50
+ )
51
+ def test_safe_urls_accepted(url: str):
52
+ assert _is_safe_url(url) is True
53
+
54
+
55
+ # --- grading ---------------------------------------------------------------
56
+
57
+
58
+ def _f(severity):
59
+ return Finding(
60
+ check_id="X",
61
+ severity=severity,
62
+ title="t",
63
+ detail="d",
64
+ spec_link="https://example",
65
+ remediation="r",
66
+ )
67
+
68
+
69
+ def test_grade_clean_is_a():
70
+ grade, score = _grade([])
71
+ assert grade == "A"
72
+ assert score == 100
73
+
74
+
75
+ def test_grade_one_critical_drops_to_c_or_lower():
76
+ grade, _ = _grade([_f("critical")])
77
+ assert grade in ("C", "D", "F", "B") # depends on weights; never A
78
+
79
+
80
+ def test_grade_three_criticals_is_failing():
81
+ grade, _ = _grade([_f("critical")] * 3)
82
+ assert grade in ("D", "F")
83
+
84
+
85
+ # --- HTTP --------------------------------------------------------------
86
+
87
+
88
+ def test_scan_endpoint_rejects_loopback(test_client):
89
+ resp = test_client.get("/api/scan", params={"url": "http://localhost"})
90
+ assert resp.status_code == 400
91
+ assert "blocked" in resp.json()["detail"].lower()
92
+
93
+
94
+ def test_scan_endpoint_rejects_private_ip(test_client):
95
+ resp = test_client.get("/api/scan", params={"url": "http://10.0.0.1"})
96
+ assert resp.status_code == 400
97
+
98
+
99
+ def test_scan_endpoint_rejects_non_http(test_client):
100
+ resp = test_client.get("/api/scan", params={"url": "ftp://example.com"})
101
+ assert resp.status_code == 400