Files
Open_Ultra_SAST/src/openultrasast/config.py
T

411 lines
14 KiB
Python
Raw Normal View History

2026-06-17 21:13:51 +02:00
from __future__ import annotations
import json
import os
import tomllib
from dataclasses import asdict, dataclass
from pathlib import Path
DEFAULT_VECTOR_STORE = "json-local"
def load_dotenv(path: Path | None = None, *, force: bool = False) -> None:
"""Load KEY=VALUE lines from .env into os.environ. Stdlib only; never overrides."""
if not force and (os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("OPENULTRASAST_SKIP_DOTENV")):
return
env_path = path if path is not None else Path(".env")
if not env_path.is_file():
return
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("'").strip('"')
if key and key not in os.environ:
os.environ[key] = value
2026-06-17 21:13:51 +02:00
@dataclass(frozen=True)
class ModelConfig:
ranker: str | None = None
hunter: str | None = None
verifier: str | None = None
patcher: str | None = None
judge: str | None = None # second, independent judgement before anything is published as proven
chat_base_url: str | None = None # chat endpoint; independent of the embedding endpoint
chat_api_key_env: str | None = None # environment variable holding that endpoint's key
2026-06-17 21:13:51 +02:00
@dataclass(frozen=True)
class EmbeddingConfig:
model: str | None = None
store: str = DEFAULT_VECTOR_STORE
@dataclass(frozen=True)
class SandboxConfig:
network: bool = False
workspace_readonly: bool = True
memory_mb: int = 2048
timeout_seconds: int = 300
pids_limit: int = 512
@dataclass(frozen=True)
class DynamicConfig:
enabled: bool = False
network_scope: tuple[str, ...] = ()
@dataclass(frozen=True)
class EvidenceConfig:
minimum_report_verified: str = "static_corroboration"
minimum_exploit: str = "crash_reproduced"
minimum_patch: str = "root_cause_explained"
@dataclass(frozen=True)
class StaticAnalysisConfig:
sarif_paths: tuple[str, ...] = ()
@dataclass(frozen=True)
class ScoreConfig:
k: float = 60.0
min_score: int = 80
block_severity_reachable: int = 5
blocking: bool = False
@dataclass(frozen=True)
class RulesetConfig:
# Emission floors that replace "every regex match becomes a finding". The
# defaults (0.0) emit everything, keeping output byte-identical by default.
min_emit_priority: float = 0.0
min_emit_precision: float = 0.0
@dataclass(frozen=True)
class HarnessxConfig:
# Optional agentic plane (the openultrasast[harnessx] extra). `provider`
# selects the HarnessX LLM provider used by both the hunter pool and the
# llm-judge verifier; the budget caps bound per-task cost and token spend.
# Inert unless the extra is installed and a model is configured in [models].
provider: str = "anthropic"
max_cost_usd: float = 2.0
token_threshold: int = 120_000
@dataclass(frozen=True)
class HardeningConfig:
# Release-readiness controls. redact_secrets masks credentials in traces/reports;
# max_findings caps the reported finding count for bounded CI runs (0 = unlimited).
redact_secrets: bool = True
max_findings: int = 0
@dataclass(frozen=True)
class FusionConfig:
# Two-panel fusion adjudication for triggered findings (standard mode). Runs
# deterministically by default; setting panel_model routes the panels through
# the configured HarnessX provider. high_assurance forces fusion on every finding.
enabled: bool = True
panel_model: str | None = None
decider_model: str | None = None
high_assurance: bool = False
@dataclass(frozen=True)
class ComplexityConfig:
# Stage-2 map size and hunter attention. Small defaults keep PR/nightly map work cheap.
top_k: int = 20
max_hunter_hotspots: int = 8
@dataclass(frozen=True)
class RegressConfig:
# Stage-3 candidate cap and optional per-language sandbox image pins. Isolation limits stay on SandboxConfig.
max_candidates: int = 5
images: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
class VariantsConfig:
# corpus-seeded-mechanisms: structural variant search in MAP (standard/deep only).
enabled: bool = True
max_mechanisms: int = 500
@dataclass(frozen=True)
class LearningConfig:
# learning-harness: the classified detector set and its rounds.
enabled: bool = False # off until round zero has written the family configurations this reads
families_path: str | None = None # taxonomy file; the shipped ruleset when unset
configs_dir: str = ".openultrasast/learning/configs"
k_runs: int = 5 # runs per pair; a single run misses most real changes
round_cost_cap_usd: float = 10.0
minibatch: int = 8 # train pairs in a round's first stage
detector_model: str | None = None
judge_model: str | None = None
cutoff_date: str | None = None # rows fixed after this date are reported as a separate slice
# Whether the detector model reasons before answering. Off by default because that is what round zero was
# measured under, not because it is known to be better: the provider silently ignores `temperature` while
# thinking, and thinking was disabled to make `temperature: 0` mean something. It did not — the measured
# disagreement between runs comes from the agentic path, not the decoder — so which mode wins is a question
# for a second baseline rather than for a default (Req 8.3).
thinking: bool = False
@dataclass(frozen=True)
class ObligationsConfig:
# authorization-obligations: the obligation checker in MAP (standard/deep only).
enabled: bool = True
min_siblings: int = 3
policy_path: str = ".openultrasast/obligations.toml"
2026-06-17 21:13:51 +02:00
@dataclass(frozen=True)
class ResolvedConfig:
models: ModelConfig = ModelConfig()
embeddings: EmbeddingConfig = EmbeddingConfig()
sandbox: SandboxConfig = SandboxConfig()
dynamic: DynamicConfig = DynamicConfig()
evidence: EvidenceConfig = EvidenceConfig()
static_analysis: StaticAnalysisConfig = StaticAnalysisConfig()
score: ScoreConfig = ScoreConfig()
ruleset: RulesetConfig = RulesetConfig()
harnessx: HarnessxConfig = HarnessxConfig()
fusion: FusionConfig = FusionConfig()
hardening: HardeningConfig = HardeningConfig()
complexity: ComplexityConfig = ComplexityConfig()
regress: RegressConfig = RegressConfig()
variants: VariantsConfig = VariantsConfig()
obligations: ObligationsConfig = ObligationsConfig()
learning: LearningConfig = LearningConfig()
2026-06-17 21:13:51 +02:00
runs_dir: str = ".openultrasast/runs"
def load_config(config_path: Path | None = None) -> ResolvedConfig:
load_dotenv()
2026-06-17 21:13:51 +02:00
data: dict[str, object] = {}
if config_path is not None and config_path.exists():
with config_path.open("rb") as handle:
data = tomllib.load(handle)
return ResolvedConfig(
models=_load_models(data.get("models", {})),
embeddings=_load_embeddings(data.get("embeddings", {})),
sandbox=_load_sandbox(data.get("sandbox", {})),
dynamic=_load_dynamic(data.get("dynamic", {})),
evidence=_load_evidence(data.get("evidence", {})),
static_analysis=_load_static_analysis(data.get("static_analysis", {})),
score=_load_score(data.get("score", {})),
ruleset=_load_ruleset_config(data.get("ruleset", {})),
harnessx=_load_harnessx(data.get("harnessx", {})),
fusion=_load_fusion(data.get("fusion", {})),
hardening=_load_hardening(data.get("hardening", {})),
complexity=_load_complexity(data.get("complexity", {})),
regress=_load_regress(data.get("regress", {})),
variants=_load_variants(data.get("variants", {})),
obligations=_load_obligations(data.get("obligations", {})),
learning=_load_learning(data.get("learning", {})),
2026-06-17 21:13:51 +02:00
runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"),
)
def write_resolved_config(config: ResolvedConfig, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(config), indent=2, sort_keys=True) + "\n")
def _section(value: object) -> dict[str, object]:
return value if isinstance(value, dict) else {}
def _string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _load_models(value: object) -> ModelConfig:
data = _section(value)
return ModelConfig(
ranker=_string(data.get("ranker")),
hunter=_string(data.get("hunter")),
verifier=_string(data.get("verifier")),
patcher=_string(data.get("patcher")),
judge=_string(data.get("judge")),
chat_base_url=_string(data.get("chat_base_url")),
chat_api_key_env=_string(data.get("chat_api_key_env")),
2026-06-17 21:13:51 +02:00
)
def _load_embeddings(value: object) -> EmbeddingConfig:
data = _section(value)
return EmbeddingConfig(
model=_string(data.get("model")) or _string(os.environ.get("OPENROUTER_EMBEDDING_MODEL")),
store=_string(data.get("store")) or DEFAULT_VECTOR_STORE,
)
2026-06-17 21:13:51 +02:00
def _load_sandbox(value: object) -> SandboxConfig:
data = _section(value)
return SandboxConfig(
network=bool(data.get("network", False)),
workspace_readonly=bool(data.get("workspace_readonly", True)),
2026-06-18 10:06:21 +02:00
memory_mb=_int_value(data.get("memory_mb"), 2048),
timeout_seconds=_int_value(data.get("timeout_seconds"), 300),
pids_limit=_int_value(data.get("pids_limit"), 512),
2026-06-17 21:13:51 +02:00
)
def _load_dynamic(value: object) -> DynamicConfig:
data = _section(value)
scope = data.get("network_scope", [])
if not isinstance(scope, list):
scope = []
return DynamicConfig(enabled=bool(data.get("enabled", False)), network_scope=tuple(str(item) for item in scope))
def _load_evidence(value: object) -> EvidenceConfig:
data = _section(value)
return EvidenceConfig(
minimum_report_verified=str(data.get("minimum_report_verified", "static_corroboration")),
minimum_exploit=str(data.get("minimum_exploit", "crash_reproduced")),
minimum_patch=str(data.get("minimum_patch", "root_cause_explained")),
)
2026-06-18 10:06:21 +02:00
def _int_value(value: object, default: int) -> int:
if isinstance(value, bool):
return default
if isinstance(value, int | str | bytes | bytearray):
return int(value)
return default
def _load_static_analysis(value: object) -> StaticAnalysisConfig:
data = _section(value)
paths = data.get("sarif_paths", [])
if not isinstance(paths, list):
paths = []
return StaticAnalysisConfig(sarif_paths=tuple(str(item) for item in paths))
def _load_score(value: object) -> ScoreConfig:
data = _section(value)
return ScoreConfig(
k=_float_value(data.get("k"), 60.0),
min_score=_int_value(data.get("min_score"), 80),
block_severity_reachable=_int_value(data.get("block_severity_reachable"), 5),
blocking=bool(data.get("blocking", False)),
)
def _load_ruleset_config(value: object) -> RulesetConfig:
data = _section(value)
return RulesetConfig(
min_emit_priority=_float_value(data.get("min_emit_priority"), 0.0),
min_emit_precision=_float_value(data.get("min_emit_precision"), 0.0),
)
def _load_harnessx(value: object) -> HarnessxConfig:
data = _section(value)
return HarnessxConfig(
provider=_string(data.get("provider")) or "anthropic",
max_cost_usd=_float_value(data.get("max_cost_usd"), 2.0),
token_threshold=_int_value(data.get("token_threshold"), 120_000),
)
def _load_hardening(value: object) -> HardeningConfig:
data = _section(value)
return HardeningConfig(
redact_secrets=bool(data.get("redact_secrets", True)),
max_findings=_int_value(data.get("max_findings"), 0),
)
def _load_fusion(value: object) -> FusionConfig:
data = _section(value)
return FusionConfig(
enabled=bool(data.get("enabled", True)),
panel_model=_string(data.get("panel_model")),
decider_model=_string(data.get("decider_model")),
high_assurance=bool(data.get("high_assurance", False)),
)
def _load_complexity(value: object) -> ComplexityConfig:
data = _section(value)
return ComplexityConfig(
top_k=_int_value(data.get("top_k"), 20),
max_hunter_hotspots=_int_value(data.get("max_hunter_hotspots"), 8),
)
def _load_regress(value: object) -> RegressConfig:
data = _section(value)
return RegressConfig(
max_candidates=_int_value(data.get("max_candidates"), 5),
images=_string_map(data.get("images")),
)
def _string_map(value: object) -> tuple[tuple[str, str], ...]:
data = _section(value)
images: list[tuple[str, str]] = []
for key, item in data.items():
pinned = _string(item)
if pinned is not None:
images.append((str(key), pinned))
return tuple(sorted(images))
def _float_value(value: object, default: float) -> float:
if isinstance(value, bool):
return default
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return default
return default
def _load_variants(value: object) -> VariantsConfig:
data = _section(value)
return VariantsConfig(
enabled=bool(data.get("enabled", True)),
max_mechanisms=_int_value(data.get("max_mechanisms"), 500),
)
def _load_learning(value: object) -> LearningConfig:
data = _section(value)
return LearningConfig(
enabled=bool(data.get("enabled", LearningConfig.enabled)),
families_path=_string(data.get("families_path")),
configs_dir=_string(data.get("configs_dir")) or LearningConfig.configs_dir,
k_runs=_int_value(data.get("k_runs"), LearningConfig.k_runs),
round_cost_cap_usd=_float_value(data.get("round_cost_cap_usd"), LearningConfig.round_cost_cap_usd),
minibatch=_int_value(data.get("minibatch"), LearningConfig.minibatch),
detector_model=_string(data.get("detector_model")),
judge_model=_string(data.get("judge_model")),
cutoff_date=_string(data.get("cutoff_date")),
thinking=bool(data.get("thinking", False)),
)
def _load_obligations(value: object) -> ObligationsConfig:
data = _section(value)
return ObligationsConfig(
enabled=bool(data.get("enabled", True)),
min_siblings=_int_value(data.get("min_siblings"), 3),
policy_path=str(data.get("policy_path", ".openultrasast/obligations.toml")),
)