mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(scan): add initial SAST pipeline
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""OpenUltraSAST package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from .findings import StaticFinding
|
||||
from .rank import RankingScore, composite_priority
|
||||
|
||||
|
||||
class FalsePositiveReason(StrEnum):
|
||||
UNREACHABLE_PATH = "unreachable_path"
|
||||
MISSING_ATTACKER_CONTROL = "missing_attacker_control"
|
||||
SANITIZER_DISPROVED = "sanitizer_disproved"
|
||||
STATIC_RULE_MISMATCH = "static_rule_mismatch"
|
||||
INCORRECT_MODEL_ASSUMPTION = "incorrect_model_assumption"
|
||||
DUPLICATE = "duplicate"
|
||||
INSUFFICIENT_IMPACT = "insufficient_impact"
|
||||
UNSUPPORTED = "unsupported"
|
||||
CONTRADICTED = "contradicted"
|
||||
UNVERIFIED = "unverified"
|
||||
|
||||
|
||||
class FindingOutcome(StrEnum):
|
||||
VERIFIED = "verified"
|
||||
FALSE_POSITIVE = "false_positive"
|
||||
REJECTED = "rejected"
|
||||
DUPLICATE = "duplicate"
|
||||
UNSUPPORTED = "unsupported"
|
||||
CONTRADICTED = "contradicted"
|
||||
UNVERIFIED = "unverified"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FalsePositiveLearning:
|
||||
finding_id: str
|
||||
path: str
|
||||
vulnerability_class: str
|
||||
reason: FalsePositiveReason
|
||||
evidence: str
|
||||
scope: str
|
||||
demotion: float = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankingMetrics:
|
||||
tier: str
|
||||
verified_findings: int
|
||||
false_positives: int
|
||||
time_to_verification_seconds: float
|
||||
missed_fixture_vulnerabilities: int
|
||||
false_positive_reasons: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankingCalibration:
|
||||
path: str
|
||||
original_priority: float
|
||||
calibrated_priority: float
|
||||
applied_learning_ids: list[str]
|
||||
retrieval_filter_adjustments: dict[str, str]
|
||||
prompt_constraints: list[str]
|
||||
|
||||
|
||||
def record_false_positive_learning(
|
||||
finding: StaticFinding,
|
||||
*,
|
||||
reason: FalsePositiveReason,
|
||||
evidence: str,
|
||||
scope: str | None = None,
|
||||
) -> FalsePositiveLearning:
|
||||
if not evidence.strip():
|
||||
raise ValueError("false-positive learning requires evidence")
|
||||
vulnerability_class = _vulnerability_class(finding)
|
||||
return FalsePositiveLearning(
|
||||
finding_id=finding.finding_id,
|
||||
path=finding.path,
|
||||
vulnerability_class=vulnerability_class,
|
||||
reason=reason,
|
||||
evidence=evidence,
|
||||
scope=scope or _default_scope(finding.path, vulnerability_class),
|
||||
)
|
||||
|
||||
|
||||
def learning_from_finding_outcome(
|
||||
finding: StaticFinding,
|
||||
*,
|
||||
outcome: FindingOutcome,
|
||||
evidence: str,
|
||||
reason: FalsePositiveReason | None = None,
|
||||
scope: str | None = None,
|
||||
) -> FalsePositiveLearning | None:
|
||||
if outcome == FindingOutcome.VERIFIED:
|
||||
return None
|
||||
return record_false_positive_learning(
|
||||
finding,
|
||||
reason=reason or _reason_from_outcome(outcome),
|
||||
evidence=evidence,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
def compute_ranking_metrics(events: list[dict[str, object]], *, tier: str) -> RankingMetrics:
|
||||
verified = 0
|
||||
false_positives = 0
|
||||
reason_counts: dict[str, int] = {}
|
||||
verification_times: list[float] = []
|
||||
missed = 0
|
||||
for event in events:
|
||||
if event.get("tier") != tier:
|
||||
continue
|
||||
outcome = event.get("outcome")
|
||||
if outcome == FindingOutcome.VERIFIED:
|
||||
verified += 1
|
||||
if outcome in _FALSE_POSITIVE_OUTCOMES:
|
||||
false_positives += 1
|
||||
reason = str(event.get("reason", FalsePositiveReason.UNVERIFIED))
|
||||
reason_counts[reason] = reason_counts.get(reason, 0) + 1
|
||||
duration = event.get("time_to_verification_seconds")
|
||||
if isinstance(duration, int | float) and not isinstance(duration, bool):
|
||||
verification_times.append(float(duration))
|
||||
missed_value = event.get("missed_fixture_vulnerabilities", 0)
|
||||
if isinstance(missed_value, int) and not isinstance(missed_value, bool):
|
||||
missed += missed_value
|
||||
return RankingMetrics(
|
||||
tier=tier,
|
||||
verified_findings=verified,
|
||||
false_positives=false_positives,
|
||||
time_to_verification_seconds=round(sum(verification_times) / len(verification_times), 2) if verification_times else 0.0,
|
||||
missed_fixture_vulnerabilities=missed,
|
||||
false_positive_reasons=dict(sorted(reason_counts.items())),
|
||||
)
|
||||
|
||||
|
||||
def calibrate_ranking(ranking: RankingScore, learnings: list[FalsePositiveLearning]) -> RankingCalibration:
|
||||
applicable = [learning for learning in learnings if _learning_applies(ranking.path, learning)]
|
||||
calibrated = ranking.priority
|
||||
prompt_constraints: list[str] = []
|
||||
retrieval_adjustments: dict[str, str] = {}
|
||||
for learning in applicable:
|
||||
calibrated = max(1.0, calibrated - learning.demotion)
|
||||
prompt_constraints.append(f"Do not repeat unsupported {learning.vulnerability_class} claim for scope {learning.scope} without new evidence.")
|
||||
retrieval_adjustments[learning.vulnerability_class] = learning.scope
|
||||
return RankingCalibration(
|
||||
path=ranking.path,
|
||||
original_priority=ranking.priority,
|
||||
calibrated_priority=round(calibrated, 2),
|
||||
applied_learning_ids=[learning.finding_id for learning in applicable],
|
||||
retrieval_filter_adjustments=retrieval_adjustments,
|
||||
prompt_constraints=prompt_constraints,
|
||||
)
|
||||
|
||||
|
||||
def calibrate_rankings(
|
||||
rankings: list[RankingScore],
|
||||
learnings: list[FalsePositiveLearning],
|
||||
) -> tuple[list[RankingScore], list[RankingCalibration]]:
|
||||
calibrations = [calibrate_ranking(ranking, learnings) for ranking in rankings]
|
||||
calibrated = [apply_ranking_calibration(ranking, calibration) for ranking, calibration in zip(rankings, calibrations, strict=True)]
|
||||
return sorted(calibrated, key=lambda item: (-item.priority, item.path)), calibrations
|
||||
|
||||
|
||||
def apply_ranking_calibration(ranking: RankingScore, calibration: RankingCalibration) -> RankingScore:
|
||||
if ranking.path != calibration.path:
|
||||
raise ValueError("calibration path does not match ranking path")
|
||||
return RankingScore(
|
||||
path=ranking.path,
|
||||
surface=ranking.surface,
|
||||
influence=ranking.influence,
|
||||
reachability=ranking.reachability,
|
||||
priority=max(1.0, min(ranking.priority, calibration.calibrated_priority)),
|
||||
rationale=f"{ranking.rationale}; calibrated by false-positive learning" if calibration.applied_learning_ids else ranking.rationale,
|
||||
model_id=ranking.model_id,
|
||||
static_boosts=ranking.static_boosts,
|
||||
)
|
||||
|
||||
|
||||
def write_false_positive_learnings(learnings: list[FalsePositiveLearning], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"learnings": [asdict(learning) for learning in learnings]}, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _vulnerability_class(finding: StaticFinding) -> str:
|
||||
if finding.tags:
|
||||
return finding.tags[0]
|
||||
return finding.title.lower().replace(" ", "_")[:64]
|
||||
|
||||
|
||||
def _default_scope(path: str, vulnerability_class: str) -> str:
|
||||
parent = Path(path).parent.as_posix()
|
||||
return f"{parent if parent != '.' else path}:{vulnerability_class}"
|
||||
|
||||
|
||||
def _learning_applies(path: str, learning: FalsePositiveLearning) -> bool:
|
||||
if learning.path == path:
|
||||
return True
|
||||
if ":" not in learning.scope:
|
||||
return False
|
||||
scope_path, _ = learning.scope.rsplit(":", 1)
|
||||
return scope_path not in ("", ".") and path.startswith(f"{scope_path}/")
|
||||
|
||||
|
||||
def _reason_from_outcome(outcome: FindingOutcome) -> FalsePositiveReason:
|
||||
if outcome == FindingOutcome.DUPLICATE:
|
||||
return FalsePositiveReason.DUPLICATE
|
||||
if outcome == FindingOutcome.UNSUPPORTED:
|
||||
return FalsePositiveReason.UNSUPPORTED
|
||||
if outcome == FindingOutcome.CONTRADICTED:
|
||||
return FalsePositiveReason.CONTRADICTED
|
||||
if outcome == FindingOutcome.UNVERIFIED:
|
||||
return FalsePositiveReason.UNVERIFIED
|
||||
if outcome in (FindingOutcome.FALSE_POSITIVE, FindingOutcome.REJECTED):
|
||||
return FalsePositiveReason.INCORRECT_MODEL_ASSUMPTION
|
||||
raise ValueError(f"unsupported finding outcome for false-positive learning: {outcome}")
|
||||
|
||||
|
||||
_FALSE_POSITIVE_OUTCOMES = {
|
||||
FindingOutcome.FALSE_POSITIVE,
|
||||
FindingOutcome.REJECTED,
|
||||
FindingOutcome.DUPLICATE,
|
||||
FindingOutcome.UNSUPPORTED,
|
||||
FindingOutcome.CONTRADICTED,
|
||||
FindingOutcome.UNVERIFIED,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .config import load_config
|
||||
from .findings import quick_scan_findings, write_findings
|
||||
from .index import build_code_chunks
|
||||
from .preprocess import preprocess_repository
|
||||
from .rank import rank_targets, write_rankings
|
||||
from .reports import write_markdown_report
|
||||
from .run import create_scan_run
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="ousast")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
scan = subparsers.add_parser("scan", help="scan a local repository")
|
||||
scan.add_argument("path", type=Path)
|
||||
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
|
||||
scan.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
||||
|
||||
index = subparsers.add_parser("index", help="chunk a local repository for embedding index construction")
|
||||
index.add_argument("path", type=Path)
|
||||
index.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
||||
index.add_argument("--chunk-lines", type=int, default=80)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "scan":
|
||||
return _scan(args.path, args.config, args.mode)
|
||||
if args.command == "index":
|
||||
return _index(args.path, args.config, args.chunk_lines)
|
||||
return 2
|
||||
|
||||
|
||||
def _scan(path: Path, config_path: Path, mode: str) -> int:
|
||||
if mode != "quick":
|
||||
raise SystemExit(f"mode {mode!r} is specified but only quick preprocess artifacts are implemented")
|
||||
if not path.exists() or not path.is_dir():
|
||||
raise SystemExit(f"scan path is not a directory: {path}")
|
||||
|
||||
config = load_config(config_path if config_path.exists() else None)
|
||||
run = create_scan_run(path, config)
|
||||
_, targets = preprocess_repository(run.target, run.root / "preprocess" / "file_targets.json")
|
||||
rankings = rank_targets(targets)
|
||||
write_rankings(rankings, run.root / "rank" / "ranking.json")
|
||||
findings = quick_scan_findings(run.target, targets, rankings)
|
||||
write_findings(findings, run.root / "findings.json")
|
||||
write_markdown_report(findings, run.root / "report.md")
|
||||
print(f"scan_id={run.scan_id}")
|
||||
print(f"run_dir={run.root}")
|
||||
print(f"file_targets={len(targets)}")
|
||||
print(f"ranked_targets={len(rankings)}")
|
||||
print(f"findings={len(findings)}")
|
||||
return 0
|
||||
|
||||
|
||||
def _index(path: Path, config_path: Path, chunk_lines: int) -> int:
|
||||
if not path.exists() or not path.is_dir():
|
||||
raise SystemExit(f"index path is not a directory: {path}")
|
||||
config = load_config(config_path if config_path.exists() else None)
|
||||
run = create_scan_run(path, config)
|
||||
snapshot, targets = preprocess_repository(run.target, run.root / "preprocess" / "file_targets.json")
|
||||
chunks = build_code_chunks(run.target, targets, max_lines=chunk_lines)
|
||||
payload = {
|
||||
"store": config.embeddings.store or "json-local",
|
||||
"embedding_model": config.embeddings.model,
|
||||
"repo_root": snapshot.root,
|
||||
"repo_commit": snapshot.commit,
|
||||
"chunk_count": len(chunks),
|
||||
"chunks": [chunk.__dict__ for chunk in chunks],
|
||||
}
|
||||
path_out = run.root / "index" / "chunks.json"
|
||||
path_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
path_out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
print(f"scan_id={run.scan_id}")
|
||||
print(f"run_dir={run.root}")
|
||||
print(f"chunks={len(chunks)}")
|
||||
print(f"index_artifact={path_out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_VECTOR_STORE = "json-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelConfig:
|
||||
ranker: str | None = None
|
||||
hunter: str | None = None
|
||||
verifier: str | None = None
|
||||
patcher: str | None = None
|
||||
|
||||
|
||||
@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 ResolvedConfig:
|
||||
models: ModelConfig = ModelConfig()
|
||||
embeddings: EmbeddingConfig = EmbeddingConfig()
|
||||
sandbox: SandboxConfig = SandboxConfig()
|
||||
dynamic: DynamicConfig = DynamicConfig()
|
||||
evidence: EvidenceConfig = EvidenceConfig()
|
||||
runs_dir: str = ".openultrasast/runs"
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> ResolvedConfig:
|
||||
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", {})),
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
def _load_embeddings(value: object) -> EmbeddingConfig:
|
||||
data = _section(value)
|
||||
return EmbeddingConfig(model=_string(data.get("model")), store=_string(data.get("store")) or DEFAULT_VECTOR_STORE)
|
||||
|
||||
|
||||
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)),
|
||||
memory_mb=int(data.get("memory_mb", 2048)),
|
||||
timeout_seconds=int(data.get("timeout_seconds", 300)),
|
||||
pids_limit=int(data.get("pids_limit", 512)),
|
||||
)
|
||||
|
||||
|
||||
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")),
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .preprocess import FileTarget
|
||||
from .rank import RankingScore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticFinding:
|
||||
finding_id: str
|
||||
path: str
|
||||
title: str
|
||||
severity: str
|
||||
confidence: str
|
||||
evidence_level: str
|
||||
rationale: str
|
||||
line: int | None
|
||||
tags: list[str]
|
||||
ranking_priority: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatternRule:
|
||||
rule_id: str
|
||||
title: str
|
||||
severity: str
|
||||
tags: tuple[str, ...]
|
||||
pattern: re.Pattern[str]
|
||||
|
||||
|
||||
PATTERN_RULES = (
|
||||
PatternRule(
|
||||
rule_id="c-unsafe-copy",
|
||||
title="Unsafe C string or input function needs review",
|
||||
severity="high",
|
||||
tags=("memory_unsafe",),
|
||||
pattern=re.compile(r"\b(gets|strcpy|strcat|sprintf)\s*\("),
|
||||
),
|
||||
PatternRule(
|
||||
rule_id="python-unsafe-eval",
|
||||
title="Dynamic Python execution needs review",
|
||||
severity="high",
|
||||
tags=("syscall_entry",),
|
||||
pattern=re.compile(r"\b(eval|exec)\s*\("),
|
||||
),
|
||||
PatternRule(
|
||||
rule_id="python-unsafe-deserialization",
|
||||
title="Unsafe Python deserialization needs review",
|
||||
severity="high",
|
||||
tags=("deserialization",),
|
||||
pattern=re.compile(r"\b(pickle\.loads|yaml\.load)\s*\("),
|
||||
),
|
||||
PatternRule(
|
||||
rule_id="python-shell-true",
|
||||
title="Subprocess shell execution needs review",
|
||||
severity="medium",
|
||||
tags=("syscall_entry",),
|
||||
pattern=re.compile(r"subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def quick_scan_findings(root: Path, targets: list[FileTarget], rankings: list[RankingScore]) -> list[StaticFinding]:
|
||||
ranking_by_path = {ranking.path: ranking for ranking in rankings}
|
||||
findings: list[StaticFinding] = []
|
||||
for target in targets:
|
||||
text = _read_text(root / target.path)
|
||||
for rule in PATTERN_RULES:
|
||||
match = rule.pattern.search(text)
|
||||
if match is None:
|
||||
continue
|
||||
ranking = ranking_by_path.get(target.path)
|
||||
findings.append(_finding_from_match(target, rule, text, match.start(), ranking))
|
||||
return sorted(findings, key=lambda item: (_severity_sort(item.severity), -item.ranking_priority, item.path))
|
||||
|
||||
|
||||
def build_quick_hunter_prompt(target: FileTarget, source_excerpt: str) -> str:
|
||||
return (
|
||||
"Review this file for security-relevant issues. Return structured findings only when evidence exists.\n"
|
||||
f"Path: {target.path}\n"
|
||||
f"Language: {target.language}\n"
|
||||
f"Tags: {', '.join(target.tags) or 'none'}\n"
|
||||
"Source excerpt:\n"
|
||||
f"{source_excerpt[:4000]}"
|
||||
)
|
||||
|
||||
|
||||
def write_findings(findings: list[StaticFinding], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"findings": [asdict(finding) for finding in findings]}
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _finding_from_match(
|
||||
target: FileTarget,
|
||||
rule: PatternRule,
|
||||
text: str,
|
||||
offset: int,
|
||||
ranking: RankingScore | None,
|
||||
) -> StaticFinding:
|
||||
line = text.count("\n", 0, offset) + 1
|
||||
return StaticFinding(
|
||||
finding_id=f"{rule.rule_id}:{target.path}:{line}",
|
||||
path=target.path,
|
||||
title=rule.title,
|
||||
severity=rule.severity,
|
||||
confidence="medium",
|
||||
evidence_level="static_corroboration",
|
||||
rationale=f"Static pattern {rule.rule_id} matched line {line}; manual verification still required.",
|
||||
line=line,
|
||||
tags=sorted(set(target.tags) | set(rule.tags)),
|
||||
ranking_priority=ranking.priority if ranking is not None else 1.0,
|
||||
)
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _severity_sort(severity: str) -> int:
|
||||
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
return order.get(severity, 5)
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .preprocess import FileTarget, RepoSnapshot
|
||||
|
||||
|
||||
VALID_NAMESPACES = {"repo_code", "docs", "static_findings", "mechanisms", "skills", "traces"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodeChunk:
|
||||
chunk_id: str
|
||||
namespace: str
|
||||
path: str
|
||||
language: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
text: str
|
||||
metadata: dict[str, str | int | bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorRecord:
|
||||
chunk: CodeChunk
|
||||
embedding: list[float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorIndex:
|
||||
store: str
|
||||
embedding_model: str
|
||||
repo_root: str
|
||||
repo_commit: str | None
|
||||
records: list[VectorRecord]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetrievalHit:
|
||||
score: float
|
||||
chunk: CodeChunk
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetrievalPackage:
|
||||
role: str
|
||||
hits: list[RetrievalHit]
|
||||
text: str
|
||||
truncated: bool
|
||||
|
||||
|
||||
class EmbeddingClient(Protocol):
|
||||
def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def chunk_file(root: Path, target: FileTarget, *, max_lines: int = 80) -> list[CodeChunk]:
|
||||
if max_lines < 1:
|
||||
raise ValueError("max_lines must be at least 1")
|
||||
text = (root / target.path).read_text(errors="ignore")
|
||||
lines = text.splitlines()
|
||||
chunks: list[CodeChunk] = []
|
||||
for start in range(0, len(lines), max_lines):
|
||||
chunk_lines = lines[start : start + max_lines]
|
||||
if not any(line.strip() for line in chunk_lines):
|
||||
continue
|
||||
start_line = start + 1
|
||||
end_line = start + len(chunk_lines)
|
||||
chunk_text = "\n".join(chunk_lines)
|
||||
chunks.append(
|
||||
CodeChunk(
|
||||
chunk_id=_chunk_id(target.path, start_line, end_line, chunk_text),
|
||||
namespace="repo_code",
|
||||
path=target.path,
|
||||
language=target.language,
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
text=chunk_text,
|
||||
metadata={
|
||||
"path": target.path,
|
||||
"language": target.language,
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"has_fuzz_entry_point": target.has_fuzz_entry_point,
|
||||
"tags": ",".join(target.tags),
|
||||
},
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
def build_code_chunks(root: Path, targets: list[FileTarget], *, max_lines: int = 80) -> list[CodeChunk]:
|
||||
chunks: list[CodeChunk] = []
|
||||
for target in targets:
|
||||
chunks.extend(chunk_file(root, target, max_lines=max_lines))
|
||||
return chunks
|
||||
|
||||
|
||||
def chunk_text_namespace(
|
||||
*,
|
||||
namespace: str,
|
||||
path: str,
|
||||
text: str,
|
||||
metadata: dict[str, str | int | bool] | None = None,
|
||||
max_lines: int = 80,
|
||||
) -> list[CodeChunk]:
|
||||
if namespace not in VALID_NAMESPACES:
|
||||
raise ValueError(f"unsupported namespace: {namespace}")
|
||||
if max_lines < 1:
|
||||
raise ValueError("max_lines must be at least 1")
|
||||
lines = text.splitlines()
|
||||
chunks: list[CodeChunk] = []
|
||||
for start in range(0, len(lines), max_lines):
|
||||
chunk_lines = lines[start : start + max_lines]
|
||||
if not any(line.strip() for line in chunk_lines):
|
||||
continue
|
||||
start_line = start + 1
|
||||
end_line = start + len(chunk_lines)
|
||||
chunk_text = "\n".join(chunk_lines)
|
||||
chunk_metadata = {"path": path, "namespace": namespace, "start_line": start_line, "end_line": end_line}
|
||||
if metadata:
|
||||
chunk_metadata.update(metadata)
|
||||
chunks.append(
|
||||
CodeChunk(
|
||||
chunk_id=_chunk_id(f"{namespace}:{path}", start_line, end_line, chunk_text),
|
||||
namespace=namespace,
|
||||
path=path,
|
||||
language=str(chunk_metadata.get("language", "text")),
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
text=chunk_text,
|
||||
metadata=chunk_metadata,
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
def index_reuse_key(snapshot: RepoSnapshot, embedding_model: str) -> str:
|
||||
source = {
|
||||
"root": snapshot.root,
|
||||
"commit": snapshot.commit,
|
||||
"file_count": snapshot.file_count,
|
||||
"languages": snapshot.languages,
|
||||
"embedding_model": embedding_model,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def build_vector_index(
|
||||
*,
|
||||
snapshot: RepoSnapshot,
|
||||
chunks: list[CodeChunk],
|
||||
embedding_model: str,
|
||||
client: EmbeddingClient,
|
||||
store: str = "json-local",
|
||||
batch_size: int = 32,
|
||||
) -> VectorIndex:
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size must be at least 1")
|
||||
records: list[VectorRecord] = []
|
||||
for start in range(0, len(chunks), batch_size):
|
||||
batch = chunks[start : start + batch_size]
|
||||
vectors = client.embed(model=embedding_model, inputs=[chunk.text for chunk in batch])
|
||||
if len(vectors) != len(batch):
|
||||
raise ValueError("embedding response count did not match chunk count")
|
||||
records.extend(VectorRecord(chunk=chunk, embedding=vector) for chunk, vector in zip(batch, vectors, strict=True))
|
||||
return VectorIndex(
|
||||
store=store,
|
||||
embedding_model=embedding_model,
|
||||
repo_root=snapshot.root,
|
||||
repo_commit=snapshot.commit,
|
||||
records=records,
|
||||
)
|
||||
|
||||
|
||||
def write_vector_index(index: VectorIndex, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(_index_to_payload(index), indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def read_vector_index(path: Path) -> VectorIndex:
|
||||
payload = json.loads(path.read_text())
|
||||
return VectorIndex(
|
||||
store=str(payload["store"]),
|
||||
embedding_model=str(payload["embedding_model"]),
|
||||
repo_root=str(payload["repo_root"]),
|
||||
repo_commit=payload.get("repo_commit"),
|
||||
records=[
|
||||
VectorRecord(
|
||||
chunk=CodeChunk(**item["chunk"]),
|
||||
embedding=[float(value) for value in item["embedding"]],
|
||||
)
|
||||
for item in payload.get("records", [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def query_vector_index(
|
||||
index: VectorIndex,
|
||||
query_embedding: list[float],
|
||||
*,
|
||||
metadata_filter: dict[str, str | int | bool] | None = None,
|
||||
limit: int = 5,
|
||||
) -> list[tuple[float, CodeChunk]]:
|
||||
matches: list[tuple[float, CodeChunk]] = []
|
||||
for record in index.records:
|
||||
if metadata_filter and not _metadata_matches(record.chunk.metadata, metadata_filter):
|
||||
continue
|
||||
matches.append((_cosine_similarity(query_embedding, record.embedding), record.chunk))
|
||||
return sorted(matches, key=lambda item: item[0], reverse=True)[:limit]
|
||||
|
||||
|
||||
def build_retrieval_package(
|
||||
*,
|
||||
role: str,
|
||||
index: VectorIndex,
|
||||
query_embedding: list[float],
|
||||
metadata_filter: dict[str, str | int | bool] | None = None,
|
||||
limit: int = 5,
|
||||
max_chars: int = 4000,
|
||||
) -> RetrievalPackage:
|
||||
hits: list[RetrievalHit] = []
|
||||
parts: list[str] = []
|
||||
total = 0
|
||||
truncated = False
|
||||
for score, chunk in query_vector_index(index, query_embedding, metadata_filter=metadata_filter, limit=limit):
|
||||
block = f"[{chunk.namespace}] {chunk.path}:{chunk.start_line}-{chunk.end_line}\n{chunk.text}"
|
||||
if total + len(block) > max_chars:
|
||||
truncated = True
|
||||
break
|
||||
hits.append(RetrievalHit(score=score, chunk=chunk))
|
||||
parts.append(block)
|
||||
total += len(block)
|
||||
return RetrievalPackage(role=role, hits=hits, text="\n\n".join(parts), truncated=truncated)
|
||||
|
||||
|
||||
def _index_to_payload(index: VectorIndex) -> dict[str, object]:
|
||||
return {
|
||||
"store": index.store,
|
||||
"embedding_model": index.embedding_model,
|
||||
"repo_root": index.repo_root,
|
||||
"repo_commit": index.repo_commit,
|
||||
"records": [{"chunk": asdict(record.chunk), "embedding": record.embedding} for record in index.records],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_matches(metadata: dict[str, str | int | bool], expected: dict[str, str | int | bool]) -> bool:
|
||||
return all(metadata.get(key) == value for key, value in expected.items())
|
||||
|
||||
|
||||
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
||||
if len(left) != len(right):
|
||||
return 0.0
|
||||
numerator = sum(a * b for a, b in zip(left, right, strict=True))
|
||||
left_norm = math.sqrt(sum(a * a for a in left))
|
||||
right_norm = math.sqrt(sum(b * b for b in right))
|
||||
if left_norm == 0 or right_norm == 0:
|
||||
return 0.0
|
||||
return numerator / (left_norm * right_norm)
|
||||
|
||||
|
||||
def _chunk_id(path: str, start_line: int, end_line: int, text: str) -> str:
|
||||
digest = hashlib.sha256(f"{path}:{start_line}:{end_line}:{text}".encode()).hexdigest()[:16]
|
||||
return f"{path}:{start_line}-{end_line}:{digest}"
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
IGNORED_DIRS = {".git", ".hg", ".svn", ".openultrasast", "__pycache__", "node_modules", ".venv", "venv"}
|
||||
|
||||
LANGUAGE_BY_EXTENSION = {
|
||||
".c": "c",
|
||||
".cc": "cpp",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".cs": "csharp",
|
||||
".swift": "swift",
|
||||
}
|
||||
|
||||
MEMORY_UNSAFE_LANGUAGES = {"c", "cpp"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoSnapshot:
|
||||
root: str
|
||||
commit: str | None
|
||||
file_count: int
|
||||
languages: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileTarget:
|
||||
path: str
|
||||
absolute_path: str
|
||||
language: str
|
||||
loc: int
|
||||
tags: list[str]
|
||||
has_fuzz_entry_point: bool
|
||||
|
||||
|
||||
def preprocess_repository(root: Path, output_path: Path | None = None) -> tuple[RepoSnapshot, list[FileTarget]]:
|
||||
resolved = root.resolve()
|
||||
files = enumerate_source_files(resolved)
|
||||
targets = [build_file_target(resolved, path) for path in files]
|
||||
languages: dict[str, int] = {}
|
||||
for target in targets:
|
||||
languages[target.language] = languages.get(target.language, 0) + 1
|
||||
|
||||
snapshot = RepoSnapshot(
|
||||
root=str(resolved),
|
||||
commit=_git_commit(resolved),
|
||||
file_count=len(targets),
|
||||
languages=dict(sorted(languages.items())),
|
||||
)
|
||||
|
||||
if output_path is not None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"snapshot": asdict(snapshot), "file_targets": [asdict(target) for target in targets]}
|
||||
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
return snapshot, targets
|
||||
|
||||
|
||||
def enumerate_source_files(root: Path) -> list[Path]:
|
||||
patterns = _load_ignore_patterns(root)
|
||||
paths: list[Path] = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file() or _has_ignored_dir(root, path) or _is_ignored(root, path, patterns):
|
||||
continue
|
||||
if detect_language(path) != "unknown":
|
||||
paths.append(path)
|
||||
return sorted(paths, key=lambda item: item.relative_to(root).as_posix())
|
||||
|
||||
|
||||
def build_file_target(root: Path, path: Path) -> FileTarget:
|
||||
language = detect_language(path)
|
||||
text = _read_text(path)
|
||||
tags = detect_tags(path.relative_to(root).as_posix(), language, text)
|
||||
return FileTarget(
|
||||
path=path.relative_to(root).as_posix(),
|
||||
absolute_path=str(path),
|
||||
language=language,
|
||||
loc=count_loc(text),
|
||||
tags=tags,
|
||||
has_fuzz_entry_point="LLVMFuzzerTestOneInput" in text,
|
||||
)
|
||||
|
||||
|
||||
def detect_language(path: Path) -> str:
|
||||
language = LANGUAGE_BY_EXTENSION.get(path.suffix.lower())
|
||||
if language is not None:
|
||||
return language
|
||||
first_line = _read_first_line(path)
|
||||
if first_line.startswith("#!") and "python" in first_line:
|
||||
return "python"
|
||||
if first_line.startswith("#!") and "node" in first_line:
|
||||
return "javascript"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def count_loc(text: str) -> int:
|
||||
return sum(1 for line in text.splitlines() if line.strip())
|
||||
|
||||
|
||||
def detect_tags(relative_path: str, language: str, text: str) -> list[str]:
|
||||
haystack = f"{relative_path}\n{text}".lower()
|
||||
tags: set[str] = set()
|
||||
if language in MEMORY_UNSAFE_LANGUAGES:
|
||||
tags.add("memory_unsafe")
|
||||
if any(token in haystack for token in ("parse", "parser", "decode", "lexer")):
|
||||
tags.add("parser")
|
||||
if any(token in haystack for token in ("crypto", "cipher", "hash", "hmac", "rsa", "ecdsa", "encrypt", "decrypt")):
|
||||
tags.add("crypto")
|
||||
if any(token in haystack for token in ("auth", "login", "permission", "jwt", "session")):
|
||||
tags.add("auth_boundary")
|
||||
if any(token in haystack for token in ("deserialize", "pickle", "yaml.load", "json.parse", "unmarshal")):
|
||||
tags.add("deserialization")
|
||||
if any(token in haystack for token in ("exec(", "system(", "popen", "subprocess", "fork(", "spawn")):
|
||||
tags.add("syscall_entry")
|
||||
if any(token in haystack for token in ("socket", "listen(", "accept(", "http", "route", "endpoint")):
|
||||
tags.add("network_entry")
|
||||
if any(token in haystack for token in ("open(", "readfile", "writefile", "filepath", "pathlib", "fs.")):
|
||||
tags.add("filesystem_entry")
|
||||
if "llvmfuzzertestoneinput" in haystack:
|
||||
tags.add("fuzzable")
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def _load_ignore_patterns(root: Path) -> list[str]:
|
||||
ignore_file = root / ".gitignore"
|
||||
if not ignore_file.exists():
|
||||
return []
|
||||
patterns: list[str] = []
|
||||
for line in ignore_file.read_text(errors="ignore").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and not stripped.startswith("!"):
|
||||
patterns.append(stripped.rstrip("/"))
|
||||
return patterns
|
||||
|
||||
|
||||
def _has_ignored_dir(root: Path, path: Path) -> bool:
|
||||
relative_parts = path.relative_to(root).parts
|
||||
return any(part in IGNORED_DIRS for part in relative_parts[:-1])
|
||||
|
||||
|
||||
def _is_ignored(root: Path, path: Path, patterns: list[str]) -> bool:
|
||||
relative = path.relative_to(root).as_posix()
|
||||
return any(fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(errors="ignore")
|
||||
|
||||
|
||||
def _read_first_line(path: Path) -> str:
|
||||
try:
|
||||
with path.open("r", errors="ignore") as handle:
|
||||
return handle.readline().strip().lower()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _git_commit(root: Path) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return result.stdout.strip() or None
|
||||
@@ -0,0 +1 @@
|
||||
"""Provider integrations."""
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class OpenRouterError(RuntimeError):
|
||||
"""Raised when an OpenRouter request or response is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenRouterChatClient:
|
||||
api_key: str
|
||||
base_url: str = "https://openrouter.ai/api/v1"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenRouterChatClient":
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise OpenRouterError("OPENROUTER_API_KEY is required for OpenRouter chat calls")
|
||||
return cls(api_key=api_key, base_url=os.environ.get("OPENROUTER_BASE_URL", cls.base_url))
|
||||
|
||||
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
|
||||
payload = json.dumps({"model": model, "messages": messages, "temperature": 0}).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url.rstrip('/')}/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||
response_payload = json.loads(response.read().decode())
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise OpenRouterError(f"OpenRouter chat request failed: {exc}") from exc
|
||||
|
||||
content = _extract_message_content(response_payload)
|
||||
return parse_json_content(content)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenRouterEmbeddingClient:
|
||||
api_key: str
|
||||
base_url: str = "https://openrouter.ai/api/v1"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenRouterEmbeddingClient":
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise OpenRouterError("OPENROUTER_API_KEY is required for OpenRouter embedding calls")
|
||||
return cls(api_key=api_key, base_url=os.environ.get("OPENROUTER_BASE_URL", cls.base_url))
|
||||
|
||||
def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]:
|
||||
payload = json.dumps({"model": model, "input": inputs}).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url.rstrip('/')}/embeddings",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||
response_payload = json.loads(response.read().decode())
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise OpenRouterError(f"OpenRouter embedding request failed: {exc}") from exc
|
||||
return parse_embedding_response(response_payload)
|
||||
|
||||
|
||||
def parse_embedding_response(payload: object) -> list[list[float]]:
|
||||
if not isinstance(payload, dict):
|
||||
raise OpenRouterError("embedding response must be a JSON object")
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list):
|
||||
raise OpenRouterError("embedding response must include data list")
|
||||
vectors: list[list[float]] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
raise OpenRouterError("embedding data item must be an object")
|
||||
embedding = item.get("embedding")
|
||||
if not isinstance(embedding, list) or not embedding:
|
||||
raise OpenRouterError("embedding data item must include embedding list")
|
||||
vector: list[float] = []
|
||||
for value in embedding:
|
||||
if not isinstance(value, int | float) or isinstance(value, bool):
|
||||
raise OpenRouterError("embedding values must be numeric")
|
||||
vector.append(float(value))
|
||||
vectors.append(vector)
|
||||
return vectors
|
||||
|
||||
|
||||
def parse_json_content(content: str) -> object:
|
||||
stripped = content.strip()
|
||||
fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL)
|
||||
if fenced is not None:
|
||||
stripped = fenced.group(1).strip()
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OpenRouterError(f"model response was not valid JSON: {exc}") from exc
|
||||
|
||||
|
||||
def _extract_message_content(payload: object) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
raise OpenRouterError("OpenRouter response must be a JSON object")
|
||||
choices = payload.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
raise OpenRouterError("OpenRouter response did not include choices")
|
||||
first = choices[0]
|
||||
if not isinstance(first, dict):
|
||||
raise OpenRouterError("OpenRouter choice must be an object")
|
||||
message = first.get("message")
|
||||
if not isinstance(message, dict):
|
||||
raise OpenRouterError("OpenRouter choice did not include a message")
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise OpenRouterError("OpenRouter message content was empty")
|
||||
return content
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Protocol
|
||||
from pathlib import Path
|
||||
|
||||
from .preprocess import FileTarget
|
||||
|
||||
|
||||
class RankingError(ValueError):
|
||||
"""Raised when ranker output does not match the expected schema."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankingScore:
|
||||
path: str
|
||||
surface: int
|
||||
influence: int
|
||||
reachability: int
|
||||
priority: float
|
||||
rationale: str
|
||||
model_id: str | None
|
||||
static_boosts: list[str]
|
||||
|
||||
|
||||
class RankerClient(Protocol):
|
||||
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def rank_targets(targets: list[FileTarget], model_rankings: object | None = None, model_id: str | None = None) -> list[RankingScore]:
|
||||
model_by_path = parse_ranker_response(model_rankings, model_id) if model_rankings is not None else {}
|
||||
rankings: list[RankingScore] = []
|
||||
for target in targets:
|
||||
heuristic = heuristic_rank(target)
|
||||
override = model_by_path.get(target.path)
|
||||
rankings.append(_merge_rankings(heuristic, override) if override is not None else heuristic)
|
||||
return sorted(rankings, key=lambda item: (-item.priority, item.path))
|
||||
|
||||
|
||||
def rank_targets_with_model(
|
||||
targets: list[FileTarget],
|
||||
*,
|
||||
client: RankerClient,
|
||||
model: str,
|
||||
chunk_size: int = 150,
|
||||
timeout_seconds: int = 60,
|
||||
) -> list[RankingScore]:
|
||||
if chunk_size < 1:
|
||||
raise RankingError("chunk_size must be at least 1")
|
||||
model_rankings: list[dict[str, object]] = []
|
||||
for start in range(0, len(targets), chunk_size):
|
||||
chunk = targets[start : start + chunk_size]
|
||||
response = client.complete_json(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": _ranker_system_prompt()},
|
||||
{"role": "user", "content": json.dumps([_target_prompt_payload(target) for target in chunk], sort_keys=True)},
|
||||
],
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
for ranking in parse_ranker_response(response, model).values():
|
||||
model_rankings.append(asdict(ranking))
|
||||
return rank_targets(targets, {"rankings": model_rankings}, model_id=model)
|
||||
|
||||
|
||||
def write_rankings(rankings: list[RankingScore], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"rankings": [asdict(ranking) for ranking in rankings]}
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def heuristic_rank(target: FileTarget) -> RankingScore:
|
||||
tags = set(target.tags)
|
||||
surface = 1
|
||||
influence = 1
|
||||
reachability = 1
|
||||
boosts: list[str] = []
|
||||
|
||||
if "memory_unsafe" in tags:
|
||||
surface = max(surface, 3)
|
||||
boosts.append("memory_unsafe")
|
||||
if "parser" in tags or "deserialization" in tags:
|
||||
surface = max(surface, 4)
|
||||
reachability = max(reachability, 3)
|
||||
boosts.append("input_parser")
|
||||
if "network_entry" in tags:
|
||||
reachability = max(reachability, 4)
|
||||
boosts.append("network_entry")
|
||||
if "auth_boundary" in tags:
|
||||
surface = max(surface, 3)
|
||||
reachability = max(reachability, 3)
|
||||
boosts.append("auth_boundary")
|
||||
if "crypto" in tags:
|
||||
influence = max(influence, 4)
|
||||
boosts.append("crypto")
|
||||
if "syscall_entry" in tags or "filesystem_entry" in tags:
|
||||
surface = max(surface, 3)
|
||||
boosts.append("privileged_boundary")
|
||||
if target.has_fuzz_entry_point or "fuzzable" in tags:
|
||||
surface = max(surface, 5)
|
||||
reachability = max(reachability, 4)
|
||||
boosts.append("fuzzable")
|
||||
if target.loc >= 500:
|
||||
influence = max(influence, 3)
|
||||
boosts.append("large_file")
|
||||
|
||||
return RankingScore(
|
||||
path=target.path,
|
||||
surface=surface,
|
||||
influence=influence,
|
||||
reachability=reachability,
|
||||
priority=composite_priority(surface, influence, reachability),
|
||||
rationale=_heuristic_rationale(target, boosts),
|
||||
model_id=None,
|
||||
static_boosts=sorted(set(boosts)),
|
||||
)
|
||||
|
||||
|
||||
def composite_priority(surface: int, influence: int, reachability: int) -> float:
|
||||
return round(_clamp_score(surface) * 0.5 + _clamp_score(influence) * 0.2 + _clamp_score(reachability) * 0.3, 2)
|
||||
|
||||
|
||||
def parse_ranker_response(payload: object, model_id: str | None) -> dict[str, RankingScore]:
|
||||
items = _ranking_items(payload)
|
||||
rankings: dict[str, RankingScore] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
raise RankingError("each ranking item must be an object")
|
||||
path = item.get("path")
|
||||
rationale = item.get("rationale", "model ranking")
|
||||
if not isinstance(path, str) or not path:
|
||||
raise RankingError("ranking item requires non-empty path")
|
||||
if not isinstance(rationale, str) or not rationale:
|
||||
raise RankingError(f"ranking item for {path} requires rationale")
|
||||
surface = _required_score(item, "surface", path)
|
||||
influence = _required_score(item, "influence", path)
|
||||
reachability = _required_score(item, "reachability", path)
|
||||
static_boosts = item.get("static_boosts", [])
|
||||
if not isinstance(static_boosts, list):
|
||||
raise RankingError(f"ranking item for {path} has invalid static_boosts")
|
||||
rankings[path] = RankingScore(
|
||||
path=path,
|
||||
surface=surface,
|
||||
influence=influence,
|
||||
reachability=reachability,
|
||||
priority=composite_priority(surface, influence, reachability),
|
||||
rationale=rationale,
|
||||
model_id=model_id,
|
||||
static_boosts=sorted(str(boost) for boost in static_boosts),
|
||||
)
|
||||
return rankings
|
||||
|
||||
|
||||
def _ranking_items(payload: object) -> list[object]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
rankings = payload.get("rankings")
|
||||
if isinstance(rankings, list):
|
||||
return rankings
|
||||
raise RankingError("ranker response must be a list or object with rankings list")
|
||||
|
||||
|
||||
def _required_score(item: dict[str, object], field: str, path: str) -> int:
|
||||
value = item.get(field)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1 or value > 5:
|
||||
raise RankingError(f"ranking item for {path} requires integer {field} from 1 to 5")
|
||||
return value
|
||||
|
||||
|
||||
def _merge_rankings(heuristic: RankingScore, model: RankingScore) -> RankingScore:
|
||||
return RankingScore(
|
||||
path=heuristic.path,
|
||||
surface=max(heuristic.surface, model.surface),
|
||||
influence=max(heuristic.influence, model.influence),
|
||||
reachability=max(heuristic.reachability, model.reachability),
|
||||
priority=composite_priority(
|
||||
max(heuristic.surface, model.surface),
|
||||
max(heuristic.influence, model.influence),
|
||||
max(heuristic.reachability, model.reachability),
|
||||
),
|
||||
rationale=model.rationale,
|
||||
model_id=model.model_id,
|
||||
static_boosts=sorted(set(heuristic.static_boosts) | set(model.static_boosts)),
|
||||
)
|
||||
|
||||
|
||||
def _heuristic_rationale(target: FileTarget, boosts: list[str]) -> str:
|
||||
if boosts:
|
||||
return f"heuristic rank from tags: {', '.join(sorted(set(boosts)))}"
|
||||
return f"baseline rank for {target.language} file"
|
||||
|
||||
|
||||
def _clamp_score(value: int) -> int:
|
||||
return min(5, max(1, value))
|
||||
|
||||
|
||||
def _ranker_system_prompt() -> str:
|
||||
return (
|
||||
"Score each file for security review priority. Return JSON only with a rankings array. "
|
||||
"Each item must include path, surface, influence, reachability, rationale, and optional static_boosts. "
|
||||
"Scores must be integers from 1 to 5."
|
||||
)
|
||||
|
||||
|
||||
def _target_prompt_payload(target: FileTarget) -> dict[str, object]:
|
||||
return {
|
||||
"path": target.path,
|
||||
"language": target.language,
|
||||
"loc": target.loc,
|
||||
"tags": target.tags,
|
||||
"has_fuzz_entry_point": target.has_fuzz_entry_point,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .findings import StaticFinding
|
||||
|
||||
|
||||
def write_markdown_report(findings: list[StaticFinding], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = ["# OpenUltraSAST Report", "", f"Findings: {len(findings)}", ""]
|
||||
if not findings:
|
||||
lines.append("No quick-mode findings were emitted.")
|
||||
for finding in findings:
|
||||
lines.extend(
|
||||
[
|
||||
f"## {finding.title}",
|
||||
"",
|
||||
f"- ID: `{finding.finding_id}`",
|
||||
f"- Path: `{finding.path}`",
|
||||
f"- Line: `{finding.line}`",
|
||||
f"- Severity: `{finding.severity}`",
|
||||
f"- Confidence: `{finding.confidence}`",
|
||||
f"- Evidence level: `{finding.evidence_level}`",
|
||||
f"- Ranking priority: `{finding.ranking_priority}`",
|
||||
f"- Tags: `{', '.join(finding.tags)}`",
|
||||
"",
|
||||
finding.rationale,
|
||||
"",
|
||||
]
|
||||
)
|
||||
path.write_text("\n".join(lines).rstrip() + "\n")
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .config import ResolvedConfig, write_resolved_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanRun:
|
||||
scan_id: str
|
||||
root: Path
|
||||
target: Path
|
||||
|
||||
|
||||
def create_scan_run(target: Path, config: ResolvedConfig) -> ScanRun:
|
||||
resolved_target = target.resolve()
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
scan_id = f"{timestamp}-{uuid4().hex[:8]}"
|
||||
root = resolved_target / config.runs_dir / scan_id
|
||||
root.mkdir(parents=True, exist_ok=False)
|
||||
(root / "preprocess").mkdir()
|
||||
write_resolved_config(config, root / "resolved_config.json")
|
||||
return ScanRun(scan_id=scan_id, root=root, target=resolved_target)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .index import VectorIndex, index_reuse_key, query_vector_index, read_vector_index, write_vector_index
|
||||
from .preprocess import RepoSnapshot
|
||||
|
||||
|
||||
SELECTED_LOCAL_VECTOR_STORE = "json-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorStoreBakeoffResult:
|
||||
store: str
|
||||
metadata_filtering: bool
|
||||
local_persistence: bool
|
||||
incremental_keying: bool
|
||||
export_import: bool
|
||||
retrieval_quality: bool
|
||||
selected_default: bool
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return all(
|
||||
(
|
||||
self.metadata_filtering,
|
||||
self.local_persistence,
|
||||
self.incremental_keying,
|
||||
self.export_import,
|
||||
self.retrieval_quality,
|
||||
self.selected_default,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def bakeoff_json_local(index: VectorIndex, output_path: Path, *, query_embedding: list[float]) -> VectorStoreBakeoffResult:
|
||||
write_vector_index(index, output_path)
|
||||
loaded = read_vector_index(output_path)
|
||||
metadata_matches = query_vector_index(loaded, query_embedding, metadata_filter={"language": "python"}, limit=1)
|
||||
unfiltered_matches = query_vector_index(loaded, query_embedding, limit=1)
|
||||
snapshot = RepoSnapshot(
|
||||
root=loaded.repo_root,
|
||||
commit=loaded.repo_commit,
|
||||
file_count=len({record.chunk.path for record in loaded.records}),
|
||||
languages=_language_counts(loaded),
|
||||
)
|
||||
result = VectorStoreBakeoffResult(
|
||||
store=SELECTED_LOCAL_VECTOR_STORE,
|
||||
metadata_filtering=bool(metadata_matches) and metadata_matches[0][1].metadata.get("language") == "python",
|
||||
local_persistence=output_path.exists() and output_path.stat().st_size > 0,
|
||||
incremental_keying=index_reuse_key(snapshot, loaded.embedding_model) == index_reuse_key(snapshot, loaded.embedding_model),
|
||||
export_import=len(loaded.records) == len(index.records) and loaded.embedding_model == index.embedding_model,
|
||||
retrieval_quality=bool(unfiltered_matches) and unfiltered_matches[0][0] > 0,
|
||||
selected_default=SELECTED_LOCAL_VECTOR_STORE == "json-local",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def bakeoff_payload(result: VectorStoreBakeoffResult) -> dict[str, object]:
|
||||
payload = asdict(result)
|
||||
payload["passed"] = result.passed
|
||||
return payload
|
||||
|
||||
|
||||
def _language_counts(index: VectorIndex) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for record in index.records:
|
||||
counts[record.chunk.language] = counts.get(record.chunk.language, 0) + 1
|
||||
return counts
|
||||
Reference in New Issue
Block a user