asm-mcp 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.
asm_mcp/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # Copyright 2026 F6 JSC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """MCP server for the F6 ASM (Attack Surface Management) client API."""
5
+
6
+ __version__ = "1.0.0"
7
+
8
+ __all__ = ["__version__"]
asm_mcp/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # Copyright 2026 F6 JSC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Entry point for `python -m asm_mcp`."""
5
+
6
+ from asm_mcp.server import cli
7
+
8
+ if __name__ == "__main__":
9
+ cli()
asm_mcp/arguments.py ADDED
@@ -0,0 +1,243 @@
1
+ # Copyright 2026 F6 JSC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Tool arguments as typed models: one model per tool both checks what the
5
+ caller sent and produces the tool's inputSchema, so the two cannot drift.
6
+
7
+ The checks themselves are the ones in params.py; the models only say which
8
+ check applies to which argument. Their messages name the argument and the
9
+ value, because the model reads them and has to fix the call.
10
+ """
11
+
12
+ from collections.abc import Callable
13
+ from typing import Annotated, Any, Literal
14
+
15
+ from pydantic import BaseModel, Field, PlainValidator, ValidationError, WithJsonSchema
16
+ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
17
+
18
+ from asm_mcp.catalog import (
19
+ ASSET_STATUSES,
20
+ ASSET_TYPES,
21
+ ISSUE_STATUSES,
22
+ OPEN_ISSUE_STATUSES,
23
+ PERIMETER_STATUSES,
24
+ SEVERITIES,
25
+ TEST_NAME_EXAMPLES,
26
+ )
27
+ from asm_mcp.params import (
28
+ MAX_LIST_ITEMS,
29
+ as_int,
30
+ check_fields,
31
+ check_id,
32
+ check_issue_id,
33
+ check_ports,
34
+ check_seq_update,
35
+ choice_list,
36
+ text_list,
37
+ )
38
+ from asm_mcp.settings import MAX_PAGE_SIZE
39
+
40
+
41
+ def checked(kind: Any, check: Callable[[Any], Any], schema: JsonSchemaValue) -> Any:
42
+ """An argument of type `kind` validated by one of the params.py checks and
43
+ shown to the client as `schema`. The check alone decides what passes."""
44
+ return Annotated[kind, PlainValidator(check), WithJsonSchema(schema)]
45
+
46
+
47
+ def _int(name: str, minimum: int, maximum: int) -> Any:
48
+ return checked(int | None, lambda v: as_int(name, v, minimum=minimum, maximum=maximum), NUMBER)
49
+
50
+
51
+ def _choices(name: str, allowed: dict[str, Any]) -> Any:
52
+ schema = {"type": "array", "items": {"type": "string", "enum": list(allowed)}}
53
+ return checked(list[str], choice_list(name, tuple(allowed)), schema)
54
+
55
+
56
+ def _texts(name: str) -> Any:
57
+ return checked(list[str], text_list(name), STRINGS)
58
+
59
+
60
+ def _issue_ids(value: Any) -> list[str]:
61
+ if value is None:
62
+ return []
63
+ if isinstance(value, str):
64
+ value = [value]
65
+ if not isinstance(value, list) or len(value) > MAX_LIST_ITEMS:
66
+ raise ValueError(f"issue_hash must be a list of at most {MAX_LIST_ITEMS} issue ids")
67
+ return list(dict.fromkeys(check_issue_id(v) for v in value))
68
+
69
+
70
+ STRING = {"type": "string"}
71
+ NUMBER = {"type": "number"}
72
+ STRINGS = {"type": "array", "items": STRING}
73
+
74
+ CompanyId = checked(str, lambda v: check_id(v, "company_id"), STRING)
75
+ SeqUpdate = checked(str | None, check_seq_update, STRING)
76
+ Fields = checked(list[str], check_fields, STRINGS)
77
+
78
+ COMPANY_DESCRIPTION = "Company id from asm_list_companies (field id), e.g. 3f2b6c1e-0000-4a5b-8c9d-0123456789ab."
79
+ SEQ_DESCRIPTION = (
80
+ "Resume cursor: the seqUpdate of a previous answer of this tool with the same filters. "
81
+ "Omit to start from the oldest record. Saved after a full walk, it returns only what "
82
+ "changed since."
83
+ )
84
+ PAGE_DESCRIPTION = (
85
+ f"Records per API request, at most {MAX_PAGE_SIZE}. A page that times out is retried at "
86
+ "half the size automatically."
87
+ )
88
+ COMPACT_DESCRIPTION = (
89
+ "Default true: drop heavy and repeated fields (whois/DNS of domains, discovery graph, "
90
+ "descriptions, company fields). false returns records as the API sent them."
91
+ )
92
+ SEVERITY_DESCRIPTION = (
93
+ "crit / high / medium / low. In records they read as alertSeverity Crit / Error / Warning / "
94
+ "Low and alertBody.status 'Critical severity' / 'High severity' / ..."
95
+ )
96
+
97
+
98
+ class Arguments(BaseModel):
99
+ """Base of every tool's arguments. Unknown keys are ignored."""
100
+
101
+
102
+ class ListCompaniesArguments(Arguments):
103
+ pass
104
+
105
+
106
+ class GetAssetsArguments(Arguments):
107
+ company_id: CompanyId = Field(description=COMPANY_DESCRIPTION)
108
+ seq_update: SeqUpdate = Field(None, description=SEQ_DESCRIPTION)
109
+ max_items: _int("max_items", 1, 5000) = Field(
110
+ None, description="Integer, 1-5000, default 500: stop after this many assets."
111
+ )
112
+ page_size: _int("page_size", 1, MAX_PAGE_SIZE) = Field(None, description=PAGE_DESCRIPTION)
113
+ type: _choices("type", ASSET_TYPES) = Field(
114
+ default_factory=list,
115
+ description="Asset types; empty = all. Infrastructure = domain, ip, ip_range, ssl.",
116
+ )
117
+ status: _choices("status", ASSET_STATUSES) = Field(
118
+ default_factory=list,
119
+ description=(
120
+ "Empty = all, including excluded assets ('false'). The company perimeter is "
121
+ "['confirmed', 'new']."
122
+ ),
123
+ )
124
+ asset: _texts("asset") = Field(default_factory=list, description="Exact asset values (domain, IP).")
125
+ software: _texts("software") = Field(default_factory=list, description="Software names, e.g. nginx.")
126
+ version: _texts("version") = Field(default_factory=list, description="Software versions.")
127
+ compact: bool = Field(True, description=COMPACT_DESCRIPTION)
128
+ fields: Fields = Field(
129
+ default_factory=list,
130
+ description="Return only these fields, e.g. ['asset', 'type', 'status', 'hasIssues', 'ports'].",
131
+ )
132
+
133
+
134
+ class GetIssuesArguments(Arguments):
135
+ company_id: CompanyId = Field(description=COMPANY_DESCRIPTION)
136
+ seq_update: SeqUpdate = Field(None, description=SEQ_DESCRIPTION)
137
+ max_items: _int("max_items", 1, 5000) = Field(
138
+ None, description="Integer, 1-5000, default 200: stop after this many issues."
139
+ )
140
+ page_size: _int("page_size", 1, MAX_PAGE_SIZE) = Field(None, description=PAGE_DESCRIPTION)
141
+ severity: _choices("severity", SEVERITIES) = Field(default_factory=list, description=SEVERITY_DESCRIPTION)
142
+ status: _choices("status", ISSUE_STATUSES) = Field(
143
+ default_factory=list,
144
+ description=f"Empty = every status. Open issues = {list(OPEN_ISSUE_STATUSES)}.",
145
+ )
146
+ category: _texts("category") = Field(
147
+ default_factory=list, description="e.g. Vulnerabilities, Network Security, DNS & Domains."
148
+ )
149
+ test_name: _texts("test_name") = Field(
150
+ default_factory=list,
151
+ description="Exact test names, e.g. " + ", ".join(repr(t) for t in TEST_NAME_EXAMPLES[:3]) + ".",
152
+ )
153
+ asset: _texts("asset") = Field(default_factory=list, description="Issues of these assets (domain, IP).")
154
+ port: checked(list[int], check_ports, {"type": "array", "items": NUMBER}) = Field(
155
+ default_factory=list, description="Ports, e.g. [22, 3389]."
156
+ )
157
+ issue_hash: checked(list[str], _issue_ids, STRINGS) = Field(
158
+ default_factory=list, description="Specific issues by id (alertHash)."
159
+ )
160
+ cve: _texts("cve") = Field(default_factory=list, description="CVE ids, e.g. CVE-2024-6387.")
161
+ sort_by_first_seen: bool | None = Field(None, description="Order by first detection instead of last update.")
162
+ compact: bool = Field(True, description=COMPACT_DESCRIPTION)
163
+ fields: Fields = Field(
164
+ default_factory=list,
165
+ description=(
166
+ "Return only these fields, dotted paths allowed, e.g. ['alertHash', 'alertSeverity', "
167
+ "'alertStatus', 'testName', 'alertBody.asset', 'alertBody.reason']."
168
+ ),
169
+ )
170
+
171
+
172
+ class IssueEvidenceArguments(Arguments):
173
+ issue_id: checked(str, check_issue_id, STRING) = Field(description="Issue id (alertHash, 32 hex digits).")
174
+
175
+
176
+ class AssetsSummaryArguments(Arguments):
177
+ company_id: CompanyId = Field(description=COMPANY_DESCRIPTION)
178
+ type: _choices("type", ASSET_TYPES) = Field(default_factory=list, description="Empty = all types.")
179
+ status: checked(list[str] | None, lambda v: None if v is None else choice_list("status", tuple(ASSET_STATUSES))(v),
180
+ {"type": "array", "items": {"type": "string", "enum": list(ASSET_STATUSES)}}) = Field(
181
+ None,
182
+ description=f"Default: the perimeter {list(PERIMETER_STATUSES)}. Pass all three to include excluded.",
183
+ )
184
+
185
+
186
+ class IssuesSummaryArguments(Arguments):
187
+ company_id: CompanyId = Field(description=COMPANY_DESCRIPTION)
188
+ status: checked(list[str] | None, lambda v: None if v is None else choice_list("status", tuple(ISSUE_STATUSES))(v),
189
+ {"type": "array", "items": {"type": "string", "enum": list(ISSUE_STATUSES)}}) = Field(
190
+ None, description=f"Default: open issues {list(OPEN_ISSUE_STATUSES)}. Pass every status for history."
191
+ )
192
+ severity: _choices("severity", SEVERITIES) = Field(default_factory=list, description=SEVERITY_DESCRIPTION)
193
+ asset: _texts("asset") = Field(default_factory=list, description="Only issues of these assets.")
194
+
195
+
196
+ class DescribeArguments(Arguments):
197
+ section: Literal["assets", "issues", "requests", "limits"] | None = Field(
198
+ None, description="Optional: only this part of the reference."
199
+ )
200
+
201
+
202
+ class RequestArguments(Arguments):
203
+ method: Literal["GET", "POST"] = Field(
204
+ "GET", description="GET for most routes; POST for dashboard/scores and issues/leaks."
205
+ )
206
+ path: str = Field(description="Path under /api/v2 without a leading slash, e.g. \"reports/<company_id>/last\".")
207
+ params: dict[str, Any] = Field(
208
+ default_factory=dict,
209
+ description="Query parameters (GET) or JSON body (POST), only the keys the route accepts.",
210
+ )
211
+
212
+
213
+ class _ToolSchema(GenerateJsonSchema):
214
+ """inputSchema without anyOf-with-null, defaults or titles."""
215
+
216
+ def nullable_schema(self, schema: Any) -> JsonSchemaValue:
217
+ return self.generate_inner(schema["schema"])
218
+
219
+ def default_schema(self, schema: Any) -> JsonSchemaValue:
220
+ return self.generate_inner(schema["schema"])
221
+
222
+ def field_title_should_be_set(self, schema: Any) -> bool:
223
+ return False
224
+
225
+
226
+ def input_schema(model: type[Arguments]) -> dict[str, Any]:
227
+ schema = model.model_json_schema(schema_generator=_ToolSchema)
228
+ schema.pop("title", None)
229
+ return schema
230
+
231
+
232
+ def describe_errors(error: ValidationError) -> str:
233
+ """One line per bad argument, worded for the model that has to fix the call."""
234
+ messages = []
235
+ for item in error.errors(include_url=False):
236
+ name = ".".join(str(part) for part in item["loc"])
237
+ if item["type"] == "missing":
238
+ messages.append(f"{name} is required")
239
+ elif item["type"] == "value_error":
240
+ messages.append(str(item["ctx"]["error"]))
241
+ else:
242
+ messages.append(f"{name}: {item['msg']}")
243
+ return "; ".join(messages)
asm_mcp/catalog.py ADDED
@@ -0,0 +1,166 @@
1
+ # Copyright 2026 F6 JSC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Reference data about the ASM client API, taken from its swagger (Client API
5
+ 3.0.0) and the server code, and the read-only routes asm_request may reach.
6
+
7
+ Everything here is answered locally by asm_describe, with no API call."""
8
+
9
+ from dataclasses import dataclass
10
+
11
+ # --------------------------------------------------------------------------- values
12
+
13
+ ASSET_TYPES: dict[str, str] = {
14
+ "domain": "domain or subdomain found for the company",
15
+ "ip": "single IP address with ports, ASN, netname, location",
16
+ "ip_range": "IP range (netblock) with ASN and netname",
17
+ "ssl": "TLS certificate: common name, issuer, validity",
18
+ "software": "software/version detected on a host and port (the source of most CVE issues)",
19
+ "login_form": "login form found on a company site",
20
+ "typosq": "typosquatting domain imitating the company (a third party's asset, not the company's)",
21
+ }
22
+ INFRA_ASSET_TYPES = ("domain", "ip", "ip_range", "ssl")
23
+
24
+ # 'false' is a string here: the swagger lists it as a JSON boolean by mistake,
25
+ # the server (ASSET_STATUS) compares strings.
26
+ ASSET_STATUSES: dict[str, str] = {
27
+ "confirmed": "in the perimeter, confirmed by the client or by rules",
28
+ "new": "found and attributed to the company, not yet reviewed",
29
+ "false": "excluded from the perimeter (false positive or not the company's)",
30
+ }
31
+ PERIMETER_STATUSES = ("confirmed", "new")
32
+
33
+ ISSUE_STATUSES: dict[str, str] = {
34
+ "detected": "open, found by the last scan",
35
+ "under review": "open, being handled",
36
+ "solved": "no longer observed",
37
+ "manually solved": "closed by a user",
38
+ "ignored": "accepted risk",
39
+ "false positive": "marked as not a real problem",
40
+ }
41
+ OPEN_ISSUE_STATUSES = ("detected", "under review")
42
+
43
+ # filter value -> alertSeverity in a record -> alertBody.status in a record
44
+ SEVERITIES: dict[str, tuple[str, str]] = {
45
+ "crit": ("Crit", "Critical severity"),
46
+ "high": ("Error", "High severity"),
47
+ "medium": ("Warning", "Medium severity"),
48
+ "low": ("Low", "Low severity"),
49
+ }
50
+
51
+ ISSUE_CATEGORIES = (
52
+ "Vulnerabilities", "Network Security", "DNS & Domains", "SSL/TLS Security", "Email Security",
53
+ )
54
+ TEST_NAME_EXAMPLES = (
55
+ "CVE vulnurability", # sic: the server spells it this way, filters must too
56
+ "Insecure Network Headers",
57
+ "Remote management open port: 22",
58
+ "Certificate expires soon",
59
+ "Invalid certificate",
60
+ "Domain Expired",
61
+ "Open login form",
62
+ "DNS DNSSEC",
63
+ "SPF Record Published",
64
+ "DMARC Policy Enabled",
65
+ )
66
+
67
+ ASSET_FIELDS: dict[str, tuple[str, ...]] = {
68
+ "common": ("id", "asset", "type", "status", "hasIssues", "score", "discoverBy", "detectionDate",
69
+ "lastSeen", "lastScan", "tags", "seqUpdate"),
70
+ "domain": ("registrar", "regDate", "expDate", "owner", "email", "redirecting", "ssl_cert", "domainInfo"),
71
+ "ip": ("asn", "netname", "location", "ports", "range_number"),
72
+ "ip_range": ("ip_range_asn", "ip_range_netname", "ip_range_location", "ip_range_ports"),
73
+ "ssl": ("common", "issuer", "valid_from", "valid_till", "hash"),
74
+ "software": ("software", "version", "software_type", "software_port", "vulnerabilities"),
75
+ "login_form": ("login_form_domain", "login_form_software", "login_form_report_id"),
76
+ "typosq": ("typosq_root_domain", "typosq_owner", "typosq_redirect", "typosq_domain_status",
77
+ "typosq_dotmagnet_id"),
78
+ }
79
+ ISSUE_FIELDS = (
80
+ "alertHash", "alertSeverity", "alertStatus", "testName", "alertFirstSeen", "alertLastSeen",
81
+ "alertLifetime", "commandToCheck", "alertMitreInfo", "alertAdditionInfo", "seqUpdate",
82
+ "alertBody.asset", "alertBody.valuetype", "alertBody.category", "alertBody.status",
83
+ "alertBody.reason", "alertBody.description", "alertBody.cve", "alertBody.software_info",
84
+ "alertBody.port", "alertBody.protocol", "alertBody.url", "alertBody.score", "alertBody.path",
85
+ )
86
+
87
+ QUIRKS = (
88
+ "The status filter of /issues/updated lets through records with other statuses; tools "
89
+ "re-check status and severity locally and report how many they dropped (droppedByFilter).",
90
+ "With a status filter and a small count the API answers an empty page with seqUpdate null; "
91
+ "tools never ask for fewer than 100 records when a filter is set and trim locally.",
92
+ "Large /assets/updated pages of domains time out (whois/DNS in every record), and large "
93
+ "/issues/updated pages are sometimes refused with 400 \"Failed to get paged issues\"; such a "
94
+ "page is retried at half the size, down to 50.",
95
+ "The resume cursor is the seqUpdate of the last record returned, so continuing with it never "
96
+ "skips records; records are ordered oldest-first and an updated record is renumbered.",
97
+ "Without a status filter /assets/updated returns excluded assets (status 'false') too; the "
98
+ "company perimeter is status confirmed + new.",
99
+ "testName of CVE findings is spelled 'CVE vulnurability'.",
100
+ "POST issues/leaks fails without an explicit status list; asm_request sends every valid status "
101
+ "when status is left out.",
102
+ "GET reports/{company_id}/last can exceed the 1 MB result limit for a large company; "
103
+ "GET reports/{company_id} lists report ids, reports/{report_id}/report returns one report.",
104
+ )
105
+
106
+ RATE_LIMIT = (
107
+ "one request per second per token across all endpoints (0.1 s for paid clients), 429 otherwise; "
108
+ "accounts may also have a request quota (403 \"Your API request limit has been reached\")"
109
+ )
110
+
111
+
112
+ # --------------------------------------------------------------------------- asm_request routes
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Route:
117
+ """A read-only endpoint asm_request may call.
118
+
119
+ template: path under /api/v2, placeholders in braces; params: the only
120
+ keys accepted, sent as the query string (GET) or the JSON body (POST).
121
+ """
122
+
123
+ method: str
124
+ template: str
125
+ description: str
126
+ params: tuple[str, ...] = ()
127
+ required: tuple[str, ...] = ()
128
+ # Values sent when the caller leaves a param out, for params the server
129
+ # needs although the swagger marks them optional.
130
+ defaults: tuple[tuple[str, object], ...] = ()
131
+
132
+ @property
133
+ def segments(self) -> tuple[str, ...]:
134
+ return tuple(self.template.split("/"))
135
+
136
+
137
+ ROUTES: tuple[Route, ...] = (
138
+ Route("GET", "companies", "companies available to the token (also asm_list_companies)"),
139
+ Route("GET", "companies/logs", "change log of companies", ("companyId", "dateFrom", "dateTo")),
140
+ Route("GET", "companies/{company_id}", "company card: client, license type, scan country, branches"),
141
+ Route("GET", "assets/{company_id}",
142
+ "perimeter settings: confirmed and excluded domains and IPs set by hand"),
143
+ Route("GET", "reports/{company_id}", "list of the company's reports"),
144
+ Route("GET", "reports/{company_id}/last", "the latest report of the company"),
145
+ Route("GET", "reports/{report_id}/report", "one full report by its id"),
146
+ Route("GET", "issues/evidence/{issue_id}", "evidence of one issue (also asm_get_issue_evidence)"),
147
+ Route("GET", "users/logs", "actions of this account's users", ("dateFrom", "dateTo")),
148
+ Route("GET", "clients", "clients visible to the token"),
149
+ Route("GET", "clients/{client_id}", "one client"),
150
+ Route("GET", "partners", "partners visible to the token"),
151
+ Route("POST", "dashboard/scores", "security score history and counters of a company",
152
+ ("companyId", "dateFrom", "dateTo"), ("companyId",)),
153
+ Route("POST", "issues/leaks", "leaked credentials of the company's domains; companyId is a list",
154
+ ("companyId", "dateFrom", "dateTo", "status"), ("companyId", "dateFrom", "dateTo"),
155
+ # Without status the server fails its own validation ("Status[1] must be one of ...").
156
+ (("status", ["detected", "under review", "solved", "ignored", "false positive"]),)),
157
+ )
158
+
159
+ # Literal segments of every route in the API, including the ones that change data:
160
+ # a placeholder may never take one of them, so a template cannot be bent into
161
+ # another endpoint ("companies/{company_id}" must not reach "companies/create").
162
+ LITERAL_SEGMENTS = frozenset({
163
+ "companies", "assets", "reports", "issues", "users", "clients", "partners", "dashboard",
164
+ "logs", "last", "report", "evidence", "scores", "leaks", "updated", "create", "update",
165
+ "add", "remove", "status", "change", "comments", "domain", "ip", "get_token", "web", "hi",
166
+ })
asm_mcp/client.py ADDED
@@ -0,0 +1,241 @@
1
+ # Copyright 2026 F6 JSC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The HTTP client: requests to the ASM API with a Bearer token, throttling,
5
+ retries and the checks that keep an unexpected body out of the model's context."""
6
+
7
+ import asyncio
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from asm_mcp.settings import (
13
+ API_ROOT,
14
+ MAX_ERROR_CHARS,
15
+ MAX_RESULT_BYTES,
16
+ RETRY_STATUSES,
17
+ USER_AGENT,
18
+ Settings,
19
+ )
20
+
21
+
22
+ class BudgetExceeded(Exception):
23
+ """The caller's deadline would pass before the next request or retry could finish.
24
+ Pages already received stay valid; the caller returns them with a resume cursor."""
25
+
26
+
27
+ class APIError(RuntimeError):
28
+ """A non-2xx answer from ASM, with the status kept for callers that branch on it."""
29
+
30
+ def __init__(self, status: int, message: str) -> None:
31
+ super().__init__(message)
32
+ self.status = status
33
+
34
+
35
+ class RequestTimeout(RuntimeError):
36
+ """One request outlived ASM_TIMEOUT. Paging reacts by halving the page:
37
+ heavy /assets/updated pages of domains are the usual cause."""
38
+
39
+
40
+ # Closes the hints of the statuses where the request never got a real answer,
41
+ # so that the model does not report "nothing found".
42
+ NOT_FETCHED = " — nothing was read, so report this data as unchecked, not as empty"
43
+
44
+ # Built from the server code (middlewares.try_handle_client_api, RateLimitDecorator,
45
+ # checker_api_limit, the request validators), so a failing call says what to fix
46
+ # instead of echoing a code.
47
+ STATUS_HINTS: dict[int, str] = {
48
+ 400: (
49
+ "bad request — the body below names what the server rejected; fix that argument "
50
+ "(asm_describe lists the accepted values and params)"
51
+ ),
52
+ 401: "ASM_API_TOKEN is missing, wrong or revoked — issue a new API token in ASM",
53
+ 403: (
54
+ "either this token has no access to the company (check company_id against "
55
+ "asm_list_companies), a partner account called a client-only endpoint, or the API "
56
+ "request quota of the account is used up (\"Your API request limit has been reached\")"
57
+ ),
58
+ 404: "no such record or endpoint on this ASM installation",
59
+ 422: "ASM failed to build the answer (\"An error has occurred\"), retry later" + NOT_FETCHED,
60
+ 429: (
61
+ "rate limited — one request per second per token (0.1 s for paid clients); lower the "
62
+ "pace with ASM_MIN_INTERVAL"
63
+ ),
64
+ 500: "server-side error on the ASM side, retry in a few minutes" + NOT_FETCHED,
65
+ 502: "ASM is unavailable behind its proxy, retry in a few minutes" + NOT_FETCHED,
66
+ 503: "ASM is temporarily unavailable, retry in a few minutes" + NOT_FETCHED,
67
+ 504: "ASM did not answer in time — lower page_size so it has less to process" + NOT_FETCHED,
68
+ }
69
+
70
+
71
+ class Throttle:
72
+ """Keeps requests at most one per min_interval seconds.
73
+
74
+ The ASM limit is per token across all endpoints, so there is one interval
75
+ for the whole process. The lock is held across the wait to serialise callers;
76
+ a deadline that the wait would overrun raises BudgetExceeded instead.
77
+ """
78
+
79
+ def __init__(self, min_interval: float) -> None:
80
+ self._min_interval = min_interval
81
+ self._lock = asyncio.Lock()
82
+ self._last_start: float | None = None
83
+
84
+ async def acquire(self, deadline: float | None = None) -> None:
85
+ if self._min_interval <= 0:
86
+ return
87
+ async with self._lock:
88
+ loop = asyncio.get_running_loop()
89
+ wait = 0.0
90
+ if self._last_start is not None:
91
+ wait = max(0.0, self._min_interval - (loop.time() - self._last_start))
92
+ if deadline is not None and loop.time() + wait >= deadline:
93
+ raise BudgetExceeded()
94
+ if wait:
95
+ await asyncio.sleep(wait)
96
+ self._last_start = loop.time()
97
+
98
+
99
+ class ASMClient:
100
+ """The ASM API as this process sees it: one connection pool and one throttle
101
+ for the server's whole lifetime, so calls reuse open connections and share
102
+ the rate limit.
103
+
104
+ trust_env=False keeps HTTP_PROXY and SSL_CERT_FILE out of the picture: the
105
+ proxy is ASM_PROXY, the CA is ASM_CA_BUNDLE, both stated. follow_redirects
106
+ stays off on purpose: a redirect would carry the token to wherever it points.
107
+ A transport, when given, replaces the network entirely (tests), so the
108
+ proxy and TLS settings do not apply to it.
109
+ """
110
+
111
+ def __init__(self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None) -> None:
112
+ self._settings = settings
113
+ self._throttle = Throttle(settings.min_interval)
114
+ network: dict[str, Any] = (
115
+ {"verify": settings.verify, "proxy": settings.proxy}
116
+ if transport is None
117
+ else {"transport": transport}
118
+ )
119
+ self._http = httpx.AsyncClient(
120
+ timeout=settings.timeout, follow_redirects=False, trust_env=False, **network
121
+ )
122
+
123
+ async def aclose(self) -> None:
124
+ await self._http.aclose()
125
+
126
+ async def request(
127
+ self,
128
+ method: str,
129
+ path: str,
130
+ *,
131
+ params: dict[str, Any] | None = None,
132
+ body: dict[str, Any] | None = None,
133
+ deadline: float | None = None,
134
+ max_bytes: int = MAX_RESULT_BYTES,
135
+ ) -> Any:
136
+ """One API call. `path` is relative to /api/v2 and built by the caller
137
+ from constants and checked segments only (params.py)."""
138
+ settings = self._settings
139
+ if not settings.base_url or not settings.api_token:
140
+ raise RuntimeError(
141
+ "ASM_BASE_URL and ASM_API_TOKEN environment variables must be set "
142
+ "(the address of your ASM installation and an API token)"
143
+ )
144
+ url = f"{settings.base_url}{API_ROOT}/{path.lstrip('/')}"
145
+ headers = {
146
+ "Accept": "application/json",
147
+ "User-Agent": USER_AGENT,
148
+ "Authorization": f"Bearer {settings.api_token}",
149
+ }
150
+ query = {k: v for k, v in (params or {}).items() if v is not None}
151
+ loop = asyncio.get_running_loop()
152
+ timeout = settings.timeout
153
+
154
+ try:
155
+ for attempt in range(settings.retries + 1):
156
+ await self._throttle.acquire(deadline)
157
+ timeout = settings.timeout
158
+ if deadline is not None:
159
+ remaining = deadline - loop.time()
160
+ if remaining <= 0.1:
161
+ raise BudgetExceeded()
162
+ # A request cut short by the budget is a budget stop, not a slow page.
163
+ timeout = min(timeout, remaining)
164
+ response = await self._http.request(
165
+ method, url, params=query, json=body, headers=headers, timeout=timeout
166
+ )
167
+ if response.status_code not in RETRY_STATUSES or attempt == settings.retries:
168
+ break
169
+ retry_after = (response.headers.get("Retry-After") or "").strip()
170
+ delay = (
171
+ float(retry_after)
172
+ if retry_after.replace(".", "", 1).isdigit()
173
+ else max(settings.min_interval, 0.5) * (2**attempt)
174
+ )
175
+ if deadline is not None and loop.time() + delay >= deadline:
176
+ raise BudgetExceeded()
177
+ await asyncio.sleep(delay)
178
+ except httpx.TimeoutException as error:
179
+ # Cut short by the caller's budget, with no time left: a budget stop, not a slow page.
180
+ if timeout < settings.timeout and deadline is not None and deadline - loop.time() <= 0.5:
181
+ raise BudgetExceeded() from error
182
+ raise RequestTimeout(
183
+ f"ASM did not answer {method} {path} within {settings.timeout:.0f} s "
184
+ "(ASM_TIMEOUT) — lower page_size or narrow the filters" + NOT_FETCHED
185
+ ) from error
186
+ except httpx.HTTPError as error:
187
+ # Transport failures can carry an empty message, so name the class and
188
+ # the URL, and say how the proxy comes into play. Values of the ambient
189
+ # variables are never echoed: they can carry a password.
190
+ detail = str(error) or type(error).__name__
191
+ if settings.proxy:
192
+ hint = f" Requests go through ASM_PROXY ({settings.proxy_display})."
193
+ elif settings.ambient_proxy:
194
+ hint = " HTTP(S)_PROXY is ignored; set ASM_PROXY to route through a proxy."
195
+ else:
196
+ hint = ""
197
+ raise RuntimeError(f"Network error contacting {url}: {detail}.{hint}") from error
198
+
199
+ return _parse(response, path, max_bytes)
200
+
201
+
202
+ def _parse(response: httpx.Response, path: str, max_bytes: int) -> Any:
203
+ """The JSON body of a successful answer, or an error that says what went wrong."""
204
+ if response.is_redirect:
205
+ location = response.headers.get("Location", "")
206
+ raise RuntimeError(
207
+ f"API error {response.status_code} (redirect to {location or 'unknown'}): not followed, "
208
+ "because it would carry the token elsewhere — check ASM_BASE_URL (scheme, host, a "
209
+ "trailing path) and whether a login proxy sits in front of the API"
210
+ )
211
+
212
+ if not response.is_success:
213
+ parts = [f"API error {response.status_code} {response.reason_phrase}".rstrip()]
214
+ if hint := STATUS_HINTS.get(response.status_code, ""):
215
+ parts.append(hint)
216
+ # Only echo a JSON error body, and only a bounded slice of it: an HTML
217
+ # error page can carry tracebacks, settings and upstream names.
218
+ if _is_json(response) and (text := response.text.strip()):
219
+ parts.append(text[:MAX_ERROR_CHARS])
220
+ raise APIError(response.status_code, " — ".join(parts))
221
+
222
+ if len(response.content) > max_bytes:
223
+ raise RuntimeError(
224
+ f"response is {len(response.content)} bytes, over the {max_bytes} byte limit "
225
+ "— narrow the request"
226
+ )
227
+ if not response.content:
228
+ return {"status": response.status_code, "body": None}
229
+ # Decided by the body, not the header. An HTML page or a binary file does
230
+ # not parse, so neither reaches the model.
231
+ try:
232
+ return response.json()
233
+ except ValueError as error:
234
+ content_type = response.headers.get("content-type", "unknown")
235
+ raise RuntimeError(
236
+ f"expected JSON from {path}, got an unparsable body (content-type '{content_type}')"
237
+ ) from error
238
+
239
+
240
+ def _is_json(response: httpx.Response) -> bool:
241
+ return response.headers.get("content-type", "").split(";")[0].strip() == "application/json"