bbscanner / app /module_inputs.py
Wuhp
Add private bounded race replay and NoSQL HTTP operator checks
561be49
Raw History Blame Contribute Delete
5.39 kB
"""In-memory per-job artifact and endpoint inputs; never execute supplied content."""
import json
import re
from typing import Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator
try:
from .replay_inputs import RaceReplay, NoSQLReplay
except ImportError:
from replay_inputs import RaceReplay, NoSQLReplay
def clean_sbom(text):
if not text.strip():
return ""
try:
source = json.loads(text)
except ValueError as exc:
raise ValueError("SBOM must be CycloneDX JSON") from exc
if not isinstance(source, dict) or source.get("bomFormat") != "CycloneDX":
raise ValueError("Only CycloneDX JSON SBOMs are supported")
items = source.get("components", [])
if not isinstance(items, list) or not 1 <= len(items) <= 300:
raise ValueError("SBOM requires 1-300 application components")
cleaned = []
seen = set()
for item in items:
if not isinstance(item, dict):
raise ValueError("Invalid SBOM component")
purl, name, version = item.get("purl"), item.get("name"), item.get("version")
if not all(isinstance(v, str) and 0 < len(v) <= 500 for v in (purl, name, version)):
raise ValueError("Each SBOM component requires bounded name, version and purl")
if not purl.startswith("pkg:") or any(c in purl for c in "\r\n\x00"):
raise ValueError("Invalid component purl")
if purl in seen:
continue
seen.add(purl)
cleaned.append({"type": "library", "name": name, "version": version,
"purl": purl, "bom-ref": purl})
return json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
"components": cleaned}, separators=(",", ":"))
class ModuleInputs(BaseModel):
race: RaceReplay | None = None
nosql: NoSQLReplay | None = None
source_text: str = Field(default="", max_length=200000)
sast_language: Literal["", "python", "javascript", "typescript"] = ""
trufflehog_verify: bool = False
sbom_text: str = Field(default="", max_length=1000000)
api_target: bool = False
graphql_path: str = Field(default="", max_length=250)
cors_path: str = Field(default="", max_length=250)
websocket_path: str = Field(default="", max_length=250)
idor_path: str = Field(default="", max_length=250)
idor_parameter: str = Field(default="id", max_length=50)
idor_baseline: str = Field(default="", max_length=100)
idor_values: list[str] = Field(default_factory=list, max_length=3)
llm_path: str = Field(default="", max_length=250)
prompt_field: str = Field(default="prompt", max_length=50)
response_field: str = Field(default="response", max_length=50)
@field_validator("sbom_text")
@classmethod
def validate_sbom(cls, text):
return clean_sbom(text)
@field_validator("llm_path", "idor_path", "graphql_path", "cors_path", "websocket_path")
@classmethod
def validate_llm_path(cls, path):
if not path:
return path
parts = urlsplit(path)
if (not path.startswith("/") or path.startswith("//") or parts.scheme or parts.netloc
or parts.query or parts.fragment or ".." in path or
any(c in path for c in "\r\n\x00\\")):
raise ValueError("Endpoint must be a same-origin path without query/fragment")
return path
@field_validator("prompt_field", "response_field", "idor_parameter")
@classmethod
def validate_field(cls, value):
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,49}", value):
raise ValueError("Fields must be simple property or parameter names")
return value
@field_validator("idor_baseline")
@classmethod
def validate_idor_baseline(cls, value):
if value and not re.fullmatch(r"[A-Za-z0-9_.:@-]{1,100}", value):
raise ValueError("IDOR values must be simple numeric IDs, UUIDs or reference strings")
return value
@field_validator("idor_values")
@classmethod
def validate_idor_values(cls, values):
if len(values) != len(set(values)):
raise ValueError("IDOR values must be unique")
if any(not re.fullmatch(r"[A-Za-z0-9_.:@-]{1,100}", value) for value in values):
raise ValueError("Supply up to three simple IDOR reference values")
return values
def public_summary(self):
return {"race_configured": self.race is not None, "nosql_configured": self.nosql is not None,
"race_state_verifier": bool(self.race and self.race.verification), "graphql_configured": bool(self.graphql_path), "cors_configured": bool(self.cors_path),
"websocket_configured": bool(self.websocket_path), "sast_language": self.sast_language,
"trufflehog_verify": self.trufflehog_verify,
"source_bytes": len(self.source_text.encode("utf-8")),
"sbom_components": len(json.loads(self.sbom_text)["components"]) if self.sbom_text else 0,
"api_target": self.api_target, "llm_path": self.llm_path,
"idor_configured": bool(self.idor_path and self.idor_baseline and self.idor_values),
"idor_value_count": len(self.idor_values),
"prompt_field": self.prompt_field, "response_field": self.response_field}