feat(scan): add initial SAST pipeline

This commit is contained in:
Marius Ciepluch
2026-06-17 21:13:51 +02:00
parent 43a41b5e78
commit e7df721d16
26 changed files with 2164 additions and 41 deletions
+5
View File
@@ -0,0 +1,5 @@
"""OpenUltraSAST package."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+225
View File
@@ -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,
}
+86
View File
@@ -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())
+128
View File
@@ -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")),
)
+129
View File
@@ -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)
+269
View File
@@ -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}"
+189
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Provider integrations."""
+127
View File
@@ -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
+215
View File
@@ -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,
}
+31
View File
@@ -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")
+26
View File
@@ -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)
+70
View File
@@ -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