File size: 26,921 Bytes
18cb2e2 99b813e 18cb2e2 683ec2d 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 683ec2d 99b813e 18cb2e2 683ec2d 18cb2e2 683ec2d 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 683ec2d 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 683ec2d 99b813e 683ec2d 99b813e 683ec2d 99b813e 683ec2d 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e 18cb2e2 99b813e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 | """RelayShield security tools for smolagents.
Nine agent-specific security checks from RelayShield (https://api.relayshield.net/developers),
a live threat-intelligence API. Mirrors the tools shipped as the "RelayShield Agentic Attack
Surface" MCP server on Hugging Face Spaces
(https://huggingface.co/spaces/relayshieldadmin/relayshield-agentic-attack-surface).
v2 (2026-07-19) β response to community feedback on the v1 release: added five new tools
(oauth_watchlist, supply_chain, session_risk, nhi_exposure, secret_scan β grouped as an
"agent authority / credential exposure" family alongside the original four), and switched
every tool from a human-formatted string to a typed structured result:
{
"outcome": "finding" | "no_known_finding" | "error",
"recommended_action": "allow" | "review" | "deny" | "defer",
"reason_codes": [...],
"evidence": [...],
"coverage": {"complete": bool, "scope": "what was actually checked"},
"freshness": {"observed_at": "...", "expires_at": null},
"error": {"kind": "...", "message": "...", "retryable": bool} | null
}
"no_known_finding" deliberately does not mean "safe" β it means nothing was found in the
sources and scope actually queried. A caller using these as a pre-action gate should check
"error" before trusting "outcome", since a failed check is not the same as a clean one.
Setup:
pip install smolagents requests
No signup required to try these tools: if RELAYSHIELD_API_KEY isn't set, they
fall back automatically to a shared, quota-capped demo key (20 calls/day,
shared across everyone using the default β plenty to try a few tools, not
enough for production use). Every result carries a "_demo_notice" field
while the fallback is active, pointing at real signup.
export RELAYSHIELD_API_KEY="your-key-here" # optional β unlocks your own quota
Usage:
from relayshield_smolagents_tool import RelayShieldSupplyChainTool
tool = RelayShieldSupplyChainTool()
result = tool.forward(vendor_domains=["vendor.example.com"])
Get a key at https://api.relayshield.net/developers?source=hf-smolagents ($499/mo for 10,000 calls,
or pay-per-call via x402 USDC with no key required for the equivalent /v1/payg/* routes).
"""
import os
import requests
from smolagents import Tool
API_BASE_URL: str = "https://api.relayshield.net"
# Shared, quota-capped (20 calls/day, all callers combined) fallback key β lets
# these tools work with zero signup. Deliberately a separate, low-privilege key
# from any server-side-only demo key elsewhere in RelayShield's stack: this one
# lives in a public pip package and public HF Space file, so it must be safe to
# have scraped and reused by anyone, hence the shared daily cap rather than an
# unlimited key. Get your own uncapped key at
# https://api.relayshield.net/developers?source=hf-smolagents
_DEMO_API_KEY = "rs_demo_729639c084f7a638"
_DEMO_NOTICE = (
"This call used RelayShield's free shared demo key (20 calls/day, shared across "
"everyone using the default β no signup required). Get your own key for reliable, "
"unshared access: https://api.relayshield.net/developers?source=hf-smolagents"
)
def _relayshield_headers(api_key: str) -> dict[str, str]:
return {"Content-Type": "application/json", "X-RS-API-KEY": api_key}
def _get_api_key() -> str:
return os.environ.get("RELAYSHIELD_API_KEY") or _DEMO_API_KEY
def _using_demo_key() -> bool:
return not bool(os.environ.get("RELAYSHIELD_API_KEY"))
def _post(path: str, payload: dict) -> tuple[dict | None, dict | None]:
"""POSTs to a RelayShield metered endpoint. Returns (data, error).
error is None on success, or a schema-shaped
{"kind": ..., "message": ..., "retryable": bool} dict on failure β distinguishing
auth/rate_limited/timeout/upstream/malformed_response/other so a caller using these
tools as a policy gate can tell "the check failed" apart from "the check found nothing."
"""
api_key = _get_api_key()
try:
resp = requests.post(
f"{API_BASE_URL}{path}",
json=payload,
headers=_relayshield_headers(api_key),
timeout=15,
)
except requests.Timeout:
return None, {
"kind": "timeout",
"message": f"RelayShield API call to {path} timed out after 15s.",
"retryable": True,
}
except requests.RequestException as exc:
return None, {
"kind": "upstream",
"message": f"RelayShield API call to {path} failed: {exc}",
"retryable": True,
}
if resp.status_code in (401, 403):
return None, {
"kind": "auth",
"message": f"RelayShield API call to {path} returned {resp.status_code} β check RELAYSHIELD_API_KEY.",
"retryable": False,
}
if resp.status_code == 429:
message = f"RelayShield API call to {path} was rate-limited."
if _using_demo_key():
message += (
" The shared demo key's daily quota (20 calls/day, shared across everyone "
"using the default) is exhausted for today. Get your own key: "
"https://api.relayshield.net/developers?source=hf-smolagents"
)
return None, {"kind": "rate_limited", "message": message, "retryable": True}
if resp.status_code >= 500:
return None, {
"kind": "upstream",
"message": f"RelayShield API call to {path} returned {resp.status_code}.",
"retryable": True,
}
if resp.status_code >= 400:
return None, {
"kind": "other",
"message": f"RelayShield API call to {path} returned {resp.status_code}: {resp.text[:200]}",
"retryable": False,
}
try:
response_json = resp.json()
except ValueError:
return None, {
"kind": "malformed_response",
"message": f"RelayShield API call to {path} returned non-JSON content.",
"retryable": False,
}
# A valid-but-non-dict JSON body (list/string) is treated as malformed rather than
# crashing on .get() β same class of bug CodeRabbit caught in the CrewAI PR's first
# review pass on this project's earlier tools.
if not isinstance(response_json, dict):
return None, {
"kind": "malformed_response",
"message": f"RelayShield API call to {path} returned an unexpected response shape.",
"retryable": False,
}
return response_json.get("data", {}), None
def _outcome_for_severity(highest_severity: str | None, found: bool = True) -> tuple[str, str]:
"""Maps a RelayShield severity string to (outcome, recommended_action).
CRITICAL escalates to a deny recommendation; HIGH/MEDIUM/LOW are surfaced as findings
for human/agent review rather than an automatic block, since these are enrichment
signals, not a certified detection.
"""
if not found or highest_severity in (None, "NONE", "CLEAN"):
return "no_known_finding", "allow"
if highest_severity == "CRITICAL":
return "finding", "deny"
return "finding", "review"
def _structured_result(
outcome: str,
recommended_action: str,
*,
reason_codes: list[str] | None = None,
evidence: list | None = None,
scope: str,
coverage_complete: bool = True,
observed_at: str | None = None,
) -> dict:
result = {
"outcome": outcome,
"recommended_action": recommended_action,
"reason_codes": reason_codes or [],
"evidence": evidence or [],
"coverage": {"complete": coverage_complete, "scope": scope},
"freshness": {"observed_at": observed_at, "expires_at": None},
"error": None,
}
if _using_demo_key():
result["_demo_notice"] = _DEMO_NOTICE
return result
def _error_result(error: dict) -> dict:
result = {
"outcome": "error",
"recommended_action": "defer",
"reason_codes": [],
"evidence": [],
"coverage": {"complete": False, "scope": ""},
"freshness": {"observed_at": None, "expires_at": None},
"error": error,
}
if _using_demo_key():
result["_demo_notice"] = _DEMO_NOTICE
return result
class RelayShieldMCPRiskTool(Tool):
"""Typosquat / reputation / registration-age risk check for MCP servers
and agent tool registries, backed by RelayShield's live threat-intel API."""
name = "relayshield_mcp_registry_risk"
description = (
"Checks an MCP server URL or package name for typosquat risk against known MCP "
"ecosystem domains, presence in RelayShield's criminal IOC corpus, and domain-"
"registration age. Use this before connecting an agent to an unfamiliar MCP server "
"or tool registry. Returns a structured result with outcome/recommended_action, "
"not just a verdict string."
)
inputs = {
"server_url": {
"type": "string",
"description": "Full URL of the MCP server to check, e.g. 'https://example.com/mcp'. Provide this or package_name.",
"nullable": True,
},
"package_name": {
"type": "string",
"description": "Package name of the MCP server if no server_url is available. Checks are more limited without a server_url.",
"nullable": True,
},
}
output_type = "object"
def forward(self, server_url: str | None = None, package_name: str | None = None) -> dict:
if not server_url and not package_name:
return _error_result({
"kind": "other",
"message": "Provide either server_url or package_name.",
"retryable": False,
})
payload: dict[str, str] = {}
if server_url:
payload["server_url"] = server_url
if package_name:
payload["package_name"] = package_name
data, error = _post("/v1/metered/mcp-registry-risk", payload)
if error:
return _error_result(error)
findings = data.get("findings", [])
outcome, action = _outcome_for_severity(data.get("verdict"), found=bool(findings))
return _structured_result(
outcome, action,
reason_codes=[f.get("type", "unknown") for f in findings],
evidence=findings,
scope=f"Checked {data.get('queried', server_url or package_name)!r} against known-MCP-domain typosquat "
"distance, RelayShield's criminal IOC corpus, and RDAP registration age.",
)
class RelayShieldPromptInjectionBreachTool(Tool):
"""Checks whether an email's credentials were exposed via a breach specifically
sourced from a prompt-injection attack against an AI agent, as opposed to
traditional phishing/malware-sourced breaches."""
name = "relayshield_prompt_injection_breach"
description = (
"Checks an email address for credential exposure sourced specifically from "
"prompt-injection attacks against AI agents (distinct from ordinary breach/phishing "
"sources). Use this to vet an agent identity or user account before granting it "
"elevated trust or access."
)
inputs = {
"email": {
"type": "string",
"description": "Email address to check for credential exposure sourced from prompt-injection attacks against AI agents.",
},
}
output_type = "object"
def forward(self, email: str) -> dict:
data, error = _post("/v1/metered/prompt-injection-breach", {"email": email})
if error:
return _error_result(error)
sessions = data.get("sessions", [])
severities = [s.get("severity") for s in sessions if s.get("severity")]
highest = max(severities, key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0), default=None)
outcome, action = _outcome_for_severity(highest, found=data.get("found", False))
return _structured_result(
outcome, action,
reason_codes=["prompt_injection_sourced_breach"] if data.get("found") else [],
evidence=sessions,
scope=f"Checked {email} against stolen-session records flagged as prompt-injection-sourced "
"(heuristic keyword classifier over dump-announcement text, not confirmed attribution).",
)
class RelayShieldTechStackCVETool(Tool):
"""Checks a declared technology stack (or a domain's stored stack) against
actively-exploited CVEs and high-EPSS-score vulnerabilities."""
name = "relayshield_tech_stack_cve"
description = (
"Checks a declared technology stack (e.g. nginx, WordPress, LangChain, CrewAI, n8n) "
"against actively-exploited CVEs (CISA KEV) and high-EPSS-score vulnerabilities. Covers "
"AI agent orchestration frameworks and their common companion infrastructure. Use this "
"before deploying or continuing to run a given technology stack in production."
)
inputs = {
"tech_stack": {
"type": "array",
"description": "List of declared technology product names, e.g. ['nginx', 'wordpress', 'cisco ios']. Provide this or domain.",
"nullable": True,
},
"domain": {
"type": "string",
"description": "Alternative: pull the declared tech_stack from a domain's stored profile instead of listing it inline.",
"nullable": True,
},
}
output_type = "object"
def forward(self, tech_stack: list[str] | None = None, domain: str | None = None) -> dict:
if not tech_stack and not domain:
return _error_result({
"kind": "other",
"message": "Provide either tech_stack or domain.",
"retryable": False,
})
payload: dict = {}
if tech_stack:
payload["tech_stack"] = tech_stack
if domain:
payload["domain"] = domain
data, error = _post("/v1/metered/tech-stack-cve", payload)
if error:
return _error_result(error)
# v1 shipped tool read data["matched_cves"], a key the API has never returned β
# the real keys are all_matches/critical_cves. Fixed here.
matched = data.get("all_matches", [])
critical = data.get("critical_cves", [])
highest = "CRITICAL" if critical else ("HIGH" if matched else None)
outcome, action = _outcome_for_severity(highest, found=bool(matched))
return _structured_result(
outcome, action,
reason_codes=[c.get("cve_id", "unknown") for c in critical] if critical else [],
evidence=matched,
scope=f"Checked {data.get('tech_stack_queried', tech_stack or domain)} against CISA KEV and "
"high-EPSS-score CVEs.",
)
class RelayShieldBulkIdentityRiskTool(Tool):
"""Scores up to 10 organizational domains, each with up to 5 associated agent/
employee emails, for combined breach/infostealer/session/CVE risk in one call."""
name = "relayshield_bulk_identity_risk"
description = (
"Scores up to 10 organizational domains, each with up to 5 associated agent or "
"employee emails, for combined breach/infostealer/session/CVE risk in one call. Built "
"for AI agent governance and identity-posture use cases β the entry point for scoring "
"many identities per organization in one pass rather than N sequential calls."
)
inputs = {
"targets": {
"type": "array",
"description": (
"Up to 10 objects, each shaped like "
"{'domain': 'acme.com', 'agents': ['ceo@acme.com']} β 'agents' is optional, up to 5 per domain."
),
},
}
output_type = "object"
def forward(self, targets: list[dict]) -> dict:
data, error = _post("/v1/metered/bulk-identity-risk", {"targets": targets})
if error:
return _error_result(error)
results = data.get("results", [])
critical = data.get("critical_count", 0)
high = data.get("high_count", 0)
highest = "CRITICAL" if critical else ("HIGH" if high else None)
outcome, action = _outcome_for_severity(highest, found=bool(critical or high))
return _structured_result(
outcome, action,
reason_codes=[r.get("domain", "unknown") for r in results
if r.get("domain_risk") in ("CRITICAL", "HIGH") or r.get("highest_agent_risk") in ("CRITICAL", "HIGH")],
evidence=results,
scope=f"Scored {data.get('queried', len(results))} domain(s) for combined breach/infostealer/session/CVE risk.",
)
class RelayShieldOAuthWatchlistTool(Tool):
"""Checks an email for OAuth-app exposure via breach history and active
stealer-log corpus matches β connected SaaS apps and delegated access risk."""
name = "relayshield_oauth_watchlist"
description = (
"Checks an email address for OAuth-connected-app exposure: apps matched against breach "
"history, plus stolen OAuth/session tokens found in criminal stealer logs. Use this to "
"assess risk inherited from an agent or user's connected SaaS applications and delegated "
"access before trusting that identity's current authority."
)
inputs = {
"email": {
"type": "string",
"description": "Email address to check for OAuth app exposure.",
},
}
output_type = "object"
def forward(self, email: str) -> dict:
data, error = _post("/v1/metered/oauth-watchlist", {"email": email})
if error:
return _error_result(error)
matched_apps = data.get("matched_apps", [])
stolen_tokens = data.get("stolen_tokens", [])
outcome, action = _outcome_for_severity(
data.get("highest_severity"), found=bool(matched_apps or stolen_tokens)
)
return _structured_result(
outcome, action,
reason_codes=(["oauth_app_breach_match"] if matched_apps else [])
+ (["stolen_oauth_token"] if stolen_tokens else []),
evidence=matched_apps + stolen_tokens,
scope=f"Checked {email} against HIBP breach history for OAuth-app matches and RelayShield's "
"stealer-log corpus for stolen session/OAuth tokens.",
observed_at=data.get("checked_at"),
)
class RelayShieldSupplyChainTool(Tool):
"""Checks up to 10 vendor domains for breach/infostealer exposure β third-party
and supply-chain risk inherited through vendor relationships."""
name = "relayshield_supply_chain"
description = (
"Checks up to 10 vendor domains (or vendor emails) for breach and infostealer exposure, "
"returning a per-vendor risk level and an aggregate dark-web exposure score. Use this to "
"vet third-party vendors, MCP server operators, or supply-chain dependencies before an "
"agent integrates with or grants access to them."
)
inputs = {
"vendor_domains": {
"type": "array",
"description": "Up to 10 vendor domains to check, e.g. ['vendor.example.com']. Provide this or vendor_emails.",
"nullable": True,
},
"vendor_emails": {
"type": "array",
"description": "Alternative: vendor email addresses β the domain portion is extracted automatically.",
"nullable": True,
},
}
output_type = "object"
def forward(self, vendor_domains: list[str] | None = None, vendor_emails: list[str] | None = None) -> dict:
if not vendor_domains and not vendor_emails:
return _error_result({
"kind": "other",
"message": "Provide either vendor_domains or vendor_emails.",
"retryable": False,
})
payload: dict = {}
if vendor_domains:
payload["vendor_domains"] = vendor_domains
if vendor_emails:
payload["vendor_emails"] = vendor_emails
data, error = _post("/v1/metered/supply-chain", payload)
if error:
return _error_result(error)
results = data.get("results", [])
highest = data.get("highest_risk")
outcome, action = _outcome_for_severity(highest if highest != "CLEAN" else None, found=highest not in (None, "CLEAN"))
return _structured_result(
outcome, action,
reason_codes=data.get("critical_vendors", []) + data.get("high_risk_vendors", []),
evidence=results,
scope=f"Checked {data.get('domains_checked', 0)} vendor domain(s) for breach and infostealer exposure.",
observed_at=data.get("checked_at"),
)
class RelayShieldSessionRiskTool(Tool):
"""Checks an email for active/reusable stolen session material found in
RelayShield's stealer-log corpus β session hijack and AiTM (adversary-in-the-middle)
exposure that can bypass normal authentication controls."""
name = "relayshield_session_risk"
description = (
"Checks an email address for active or reusable stolen session material (cookies, "
"tokens) found in criminal stealer logs β exposure that can bypass MFA/authentication "
"entirely, not just a password. Use this to assess whether an agent or user identity "
"currently has hijackable sessions in circulation."
)
inputs = {
"email": {
"type": "string",
"description": "Email address to check for active session/AiTM exposure.",
},
}
output_type = "object"
def forward(self, email: str) -> dict:
data, error = _post("/v1/metered/session-risk", {"email": email})
if error:
return _error_result(error)
sessions = data.get("sessions", [])
outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
return _structured_result(
outcome, action,
reason_codes=[s.get("service_category", "unknown") for s in sessions],
evidence=sessions,
scope=f"Checked {email} against RelayShield's stolen-session corpus for reusable session/cookie material.",
)
class RelayShieldNHIExposureTool(Tool):
"""Checks a domain (or vendor domains) for exposed non-human-identity credentials β
API keys, service-account tokens, PATs, and other machine credentials found in
criminal stealer logs."""
name = "relayshield_nhi_exposure"
description = (
"Checks a domain, or up to 10 vendor domains, for exposed non-human-identity (NHI) "
"credentials β API keys, service-account tokens, personal access tokens, and other "
"machine identities found in criminal stealer logs. Use this to assess exposure of the "
"machine credentials an agent or its supply chain actually runs on, not just human logins."
)
inputs = {
"domain": {
"type": "string",
"description": "Your own domain to check. Provide this or vendor_domains (or both).",
"nullable": True,
},
"vendor_domains": {
"type": "array",
"description": "Up to 10 vendor/supply-chain domains to check.",
"nullable": True,
},
}
output_type = "object"
def forward(self, domain: str | None = None, vendor_domains: list[str] | None = None) -> dict:
if not domain and not vendor_domains:
return _error_result({
"kind": "other",
"message": "Provide either domain or vendor_domains.",
"retryable": False,
})
payload: dict = {}
if domain:
payload["domain"] = domain
if vendor_domains:
payload["vendor_domains"] = vendor_domains
data, error = _post("/v1/metered/nhi-exposure", payload)
if error:
return _error_result(error)
findings = data.get("findings", [])
outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
return _structured_result(
outcome, action,
reason_codes=[f.get("type", "unknown") for f in findings],
evidence=findings,
scope=f"Checked {data.get('domains_checked', 0)} domain(s) against RelayShield's stealer-log "
"corpus for exposed non-human-identity credentials.",
observed_at=data.get("checked_at"),
)
class RelayShieldSecretScanTool(Tool):
"""Checks a domain (or vendor domains) for secrets/credentials exposed in
public GitHub/GitLab repositories."""
name = "relayshield_secret_scan"
description = (
"Checks a domain, or up to 5 vendor domains, for secrets and credentials exposed in "
"public GitHub/GitLab repositories. Use this to assess whether an agent's own domain, or "
"a vendor/supply-chain dependency's domain, has leaked credentials sitting in indexed "
"public source code."
)
inputs = {
"domain": {
"type": "string",
"description": "Your own domain to check. Provide this or vendor_domains (or both).",
"nullable": True,
},
"vendor_domains": {
"type": "array",
"description": "Up to 5 vendor domains to check.",
"nullable": True,
},
}
output_type = "object"
def forward(self, domain: str | None = None, vendor_domains: list[str] | None = None) -> dict:
if not domain and not vendor_domains:
return _error_result({
"kind": "other",
"message": "Provide either domain or vendor_domains.",
"retryable": False,
})
payload: dict = {}
if domain:
payload["domain"] = domain
if vendor_domains:
payload["vendor_domains"] = vendor_domains
data, error = _post("/v1/metered/secret-scan", payload)
if error:
return _error_result(error)
findings = data.get("findings", [])
outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
return _structured_result(
outcome, action,
reason_codes=[f.get("severity", "unknown") for f in findings],
evidence=findings,
scope=f"Checked {data.get('domains_checked', 0)} domain(s) against public GitHub/GitLab "
"repositories for exposed secrets.",
observed_at=data.get("checked_at"),
)
|