mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
engine: worker Deployment from the image (S3 queue with leases, dispatcher --executor queue, engine-image workflow, scoped RustFS policy); merge main
Implemented with Codex. Cluster deploy pending the k3s kubeconfig and the scoped account. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
name: Engine image
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'benchmarks/learn/engine_trace*.py'
|
||||||
|
- 'benchmarks/learn/engine_worker_service.py'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.github/workflows/engine-image.yml'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
concurrency:
|
||||||
|
group: engine-image-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- id: names
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
run: |
|
||||||
|
echo "image=ghcr.io/${OWNER,,}/openultrasast" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=sha-${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.names.outputs.image }}:${{ steps.names.outputs.sha }}
|
||||||
|
${{ steps.names.outputs.image }}:main
|
||||||
|
- env:
|
||||||
|
IMAGE: ${{ steps.names.outputs.image }}
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
run: echo "$IMAGE@$DIGEST" >> "$GITHUB_STEP_SUMMARY"
|
||||||
+1
-1
@@ -52,7 +52,7 @@ COPY benchmarks ./benchmarks
|
|||||||
# The core install is zero-dependency by design; `semantic` adds the tree-sitter grammars the candidate
|
# The core install is zero-dependency by design; `semantic` adds the tree-sitter grammars the candidate
|
||||||
# enumerator uses. The LLM endpoint stays optional and is configured by environment, never baked in.
|
# enumerator uses. The LLM endpoint stays optional and is configured by environment, never baked in.
|
||||||
RUN python3 -m venv /venv \
|
RUN python3 -m venv /venv \
|
||||||
&& /venv/bin/pip install --no-cache-dir -e ".[semantic]"
|
&& /venv/bin/pip install --no-cache-dir -e ".[semantic,s3]"
|
||||||
ENV PATH="/venv/bin:${PATH}"
|
ENV PATH="/venv/bin:${PATH}"
|
||||||
|
|
||||||
# Analysis never needs root.
|
# Analysis never needs root.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from engine_trace_k8s import IMAGE, Kubernetes
|
from engine_trace_k8s import IMAGE, Kubernetes
|
||||||
|
from engine_trace_queue import Dispatcher
|
||||||
from engine_trace_worker import EXCLUDED_DIRS, safe_path, unit_record, write_json
|
from engine_trace_worker import EXCLUDED_DIRS, safe_path, unit_record, write_json
|
||||||
|
|
||||||
from openultrasast.learn.examples import read_jsonl
|
from openultrasast.learn.examples import read_jsonl
|
||||||
@@ -44,7 +45,9 @@ SNAPSHOT_FILES = (
|
|||||||
"benchmarks/learn/engine_trace.py",
|
"benchmarks/learn/engine_trace.py",
|
||||||
"benchmarks/learn/engine_trace_worker.py",
|
"benchmarks/learn/engine_trace_worker.py",
|
||||||
"benchmarks/learn/engine_trace_k8s.py",
|
"benchmarks/learn/engine_trace_k8s.py",
|
||||||
"src/openultrasast/model/trace.py",
|
"benchmarks/learn/engine_trace_queue.py",
|
||||||
|
"benchmarks/learn/engine_worker_service.py",
|
||||||
|
"benchmarks/learn/engine_trace_parse.py",
|
||||||
"src/openultrasast/model/taint.py",
|
"src/openultrasast/model/taint.py",
|
||||||
"src/openultrasast/cpg/queries/taint.sc",
|
"src/openultrasast/cpg/queries/taint.sc",
|
||||||
)
|
)
|
||||||
@@ -628,11 +631,14 @@ def run_pin(pin, source, provenance, inputs, out, args):
|
|||||||
write_json(output / "pin.json", {**pin, "questions": prepare_questions(pin, checkout, args.question_deadline)})
|
write_json(output / "pin.json", {**pin, "questions": prepare_questions(pin, checkout, args.question_deadline)})
|
||||||
name = "ousast-trace-" + pin_name(pin)[:16]
|
name = "ousast-trace-" + pin_name(pin)[:16]
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
if executor == "k8s":
|
if executor in {"k8s", "k8s-jobs"}:
|
||||||
done, node = args.kubernetes.execute(source, checkout, output, json.loads((output / "pin.json").read_text()))
|
done, node = args.kubernetes.execute(source, checkout, output, json.loads((output / "pin.json").read_text()))
|
||||||
status, reason = "failed", done.stderr
|
status, reason = "failed", done.stderr
|
||||||
if done.returncode:
|
if done.returncode:
|
||||||
(output / "container.log").write_text(reason)
|
(output / "container.log").write_text(reason)
|
||||||
|
elif executor == "queue":
|
||||||
|
done, node = args.dispatcher.execute(checkout, output, json.loads((output / "pin.json").read_text()), pin_name(pin)[:-5])
|
||||||
|
status, reason = "timeout", f"queue wait deadline {args.queue_timeout}s"
|
||||||
else:
|
else:
|
||||||
command = docker_command(source, checkout, output, name, args.image, args.deadline, args.question_deadline)
|
command = docker_command(source, checkout, output, name, args.image, args.deadline, args.question_deadline)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
@@ -666,12 +672,20 @@ def run_pin(pin, source, provenance, inputs, out, args):
|
|||||||
status=status if unit["supported"] else "unsupported", reason=reason if unit["supported"] else unit["reason"]
|
status=status if unit["supported"] else "unsupported", reason=reason if unit["supported"] else unit["reason"]
|
||||||
)
|
)
|
||||||
record["units"].append(row)
|
record["units"].append(row)
|
||||||
|
if executor != "queue":
|
||||||
record["container_exit"] = done.returncode if done else None
|
record["container_exit"] = done.returncode if done else None
|
||||||
|
else:
|
||||||
|
record.setdefault("container_exit", None)
|
||||||
if done is not None and done.returncode != 0:
|
if done is not None and done.returncode != 0:
|
||||||
for row in record["units"]:
|
for row in record["units"]:
|
||||||
if row["supported"]:
|
if row["supported"]:
|
||||||
row.update(status="failed", reason=reason)
|
row.update(status="failed", reason=reason)
|
||||||
record.update(done=True, seconds=time.monotonic() - started, analyzer=provenance, image=args.image)
|
record.update(
|
||||||
|
done=True,
|
||||||
|
seconds=time.monotonic() - started,
|
||||||
|
analyzer=provenance,
|
||||||
|
image=record.get("image", args.image) if executor == "queue" else args.image,
|
||||||
|
)
|
||||||
if (output / "container.log").exists():
|
if (output / "container.log").exists():
|
||||||
shutil.copyfile(output / "container.log", out / pin_name(pin).replace(".json", ".log"))
|
shutil.copyfile(output / "container.log", out / pin_name(pin).replace(".json", ".log"))
|
||||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
@@ -681,8 +695,10 @@ def run_pin(pin, source, provenance, inputs, out, args):
|
|||||||
"analyzer": provenance,
|
"analyzer": provenance,
|
||||||
"units": [unit_record(u, "failed" if u["supported"] else "unsupported", str(exc)) for u in pin["units"]],
|
"units": [unit_record(u, "failed" if u["supported"] else "unsupported", str(exc)) for u in pin["units"]],
|
||||||
}
|
}
|
||||||
if executor == "k8s":
|
if executor in {"k8s", "k8s-jobs"}:
|
||||||
record.update(executor="k8s", node=node, image=args.image)
|
record.update(executor="k8s", node=node, image=args.image)
|
||||||
|
if executor == "queue":
|
||||||
|
record.update(executor="queue", worker=record.get("worker", node), pod=record.get("pod"))
|
||||||
save_pin(out / pin_name(pin), record)
|
save_pin(out / pin_name(pin), record)
|
||||||
print(
|
print(
|
||||||
json.dumps({"repo": pin["repo"], "pin": pin["pin"], "statuses": dict(Counter(u["status"] for u in record["units"]))}),
|
json.dumps({"repo": pin["repo"], "pin": pin["pin"], "statuses": dict(Counter(u["status"] for u in record["units"]))}),
|
||||||
@@ -810,7 +826,9 @@ def main():
|
|||||||
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/openultrasast")
|
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/openultrasast")
|
||||||
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
||||||
parser.add_argument("--image", help="default: openultrasast:dev for Docker; public 2.0.1 image for k8s/mixed")
|
parser.add_argument("--image", help="default: openultrasast:dev for Docker; public 2.0.1 image for k8s/mixed")
|
||||||
parser.add_argument("--executor", choices=("docker", "k8s", "mixed"), default="docker")
|
parser.add_argument("--executor", choices=("docker", "k8s", "k8s-jobs", "mixed", "queue"), default="docker")
|
||||||
|
parser.add_argument("--queue-status", action="store_true")
|
||||||
|
parser.add_argument("--queue-timeout", type=int, default=3600, help="total queue wait including execution, seconds")
|
||||||
parser.add_argument("--parallel", type=int, default=1, help="total pins in flight; mixed reserves one lane for Docker")
|
parser.add_argument("--parallel", type=int, default=1, help="total pins in flight; mixed reserves one lane for Docker")
|
||||||
parser.add_argument("--kube-context")
|
parser.add_argument("--kube-context")
|
||||||
parser.add_argument("--kube-namespace", default="ousast-engine")
|
parser.add_argument("--kube-namespace", default="ousast-engine")
|
||||||
@@ -829,11 +847,20 @@ def main():
|
|||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--summary", action="store_true")
|
parser.add_argument("--summary", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.queue_status:
|
||||||
|
print(json.dumps(Dispatcher(args).status(), indent=2))
|
||||||
|
return 0
|
||||||
|
if args.queue_timeout <= 0:
|
||||||
|
parser.error("--queue-timeout must be positive")
|
||||||
|
if args.executor == "queue" and (not args.image or "@sha256:" not in args.image):
|
||||||
|
parser.error("queue requires --image with the deployed image digest")
|
||||||
|
if args.executor == "queue" and args.deadline > 900:
|
||||||
|
parser.error("queue deadline must be <= Deployment termination grace (900s)")
|
||||||
if min(args.deadline, args.question_deadline) <= 0 or args.limit < 0:
|
if min(args.deadline, args.question_deadline) <= 0 or args.limit < 0:
|
||||||
parser.error("deadlines must be positive; limit must be nonnegative")
|
parser.error("deadlines must be positive; limit must be nonnegative")
|
||||||
if args.parallel < 1 or (args.executor == "mixed" and args.parallel < 2):
|
if args.parallel < 1 or (args.executor == "mixed" and args.parallel < 2):
|
||||||
parser.error("--parallel must be positive; mixed needs at least 2 total lanes")
|
parser.error("--parallel must be positive; mixed needs at least 2 total lanes")
|
||||||
if (args.executor != "docker" or args.k8s_check) and not args.kube_context:
|
if (args.executor in {"k8s", "k8s-jobs", "mixed"} or args.k8s_check) and not args.kube_context:
|
||||||
parser.error("--kube-context is required for Kubernetes")
|
parser.error("--kube-context is required for Kubernetes")
|
||||||
args.image = args.image or (IMAGE if args.executor != "docker" or args.k8s_check else "openultrasast:dev")
|
args.image = args.image or (IMAGE if args.executor != "docker" or args.k8s_check else "openultrasast:dev")
|
||||||
if args.k8s_check:
|
if args.k8s_check:
|
||||||
@@ -905,12 +932,14 @@ def main():
|
|||||||
args.docker_image = image.stdout.strip()
|
args.docker_image = image.stdout.strip()
|
||||||
if not args.docker_image:
|
if not args.docker_image:
|
||||||
raise ValueError("Docker returned no local image identity")
|
raise ValueError("Docker returned no local image identity")
|
||||||
if args.executor != "docker" and any(u["supported"] for pin in plan for u in pin["units"]):
|
if args.executor in {"k8s", "k8s-jobs", "mixed"} and any(u["supported"] for pin in plan for u in pin["units"]):
|
||||||
args.kubernetes = Kubernetes(args)
|
args.kubernetes = Kubernetes(args)
|
||||||
args.kubernetes.namespace()
|
args.kubernetes.namespace()
|
||||||
|
if args.executor == "queue":
|
||||||
|
args.dispatcher = Dispatcher(args)
|
||||||
with tempfile.TemporaryDirectory(prefix="trace-source-", dir=args.out) as scratch:
|
with tempfile.TemporaryDirectory(prefix="trace-source-", dir=args.out) as scratch:
|
||||||
source = Path(scratch) / "frozen"
|
source = Path(scratch) / "frozen"
|
||||||
provenance = freeze_source(source)
|
provenance = {"image": args.image, "executor": "queue"} if args.executor == "queue" else freeze_source(source)
|
||||||
failures = run_plan(plan, source, provenance, inputs, args)
|
failures = run_plan(plan, source, provenance, inputs, args)
|
||||||
return 2 if failures else 0
|
return 2 if failures else 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Host queue transport: source and task metadata only, never analyzer code."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from engine_worker_service import PENDING, client_from_env, pack, put, read, result_key, status
|
||||||
|
|
||||||
|
|
||||||
|
class Dispatcher:
|
||||||
|
def __init__(self, args, client=None, *, pause=time.sleep, clock=time.monotonic):
|
||||||
|
self.client = client if client is not None else client_from_env()
|
||||||
|
self.run = uuid.uuid4().hex
|
||||||
|
self.args, self.pause, self.clock = args, pause, clock
|
||||||
|
|
||||||
|
def execute(self, checkout, output, pin, pin_id):
|
||||||
|
task_id = f"{self.run}-{pin_id}"
|
||||||
|
task = dict(
|
||||||
|
run=self.run,
|
||||||
|
id=pin_id,
|
||||||
|
task_id=task_id,
|
||||||
|
pin=pin,
|
||||||
|
input=f"engine-queue/inputs/{self.run}/{pin_id}/source.tar",
|
||||||
|
image=self.args.image,
|
||||||
|
deadline=self.args.deadline,
|
||||||
|
question_deadline=self.args.question_deadline,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.client.put(task["input"], pack(checkout), {})
|
||||||
|
put(self.client, PENDING + task_id, task)
|
||||||
|
end = self.clock() + self.args.queue_timeout
|
||||||
|
while self.clock() < end:
|
||||||
|
result = read(self.client, result_key(task))
|
||||||
|
if result is not None:
|
||||||
|
(output / "result.json").write_text(json.dumps(result))
|
||||||
|
self.client.delete(task["input"])
|
||||||
|
return subprocess.CompletedProcess([], 0, "", ""), result.get("worker")
|
||||||
|
self.pause(2)
|
||||||
|
# Removing pending cancels work that has not been claimed; retain source for a live lease.
|
||||||
|
self.client.delete(PENDING + task_id)
|
||||||
|
return None, None
|
||||||
|
except Exception as exc:
|
||||||
|
# SDK errors can contain endpoint URLs or credentials. Do not persist their text.
|
||||||
|
raise OSError("queue transport failed") from exc
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return status(self.client)
|
||||||
@@ -19,8 +19,9 @@ import time
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine_trace_parse import parse_trace
|
||||||
|
|
||||||
from openultrasast.cpg.backend import DATAFLOW_OVERLAY, JoernBackend, _render
|
from openultrasast.cpg.backend import DATAFLOW_OVERLAY, JoernBackend, _render
|
||||||
from openultrasast.model.trace import parse_trace
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(path, value):
|
def write_json(path, value):
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Image-resident queue workers. Requires strongly consistent S3 GET/PUT/LIST.
|
||||||
|
|
||||||
|
Per-task Lamport bakery tickets use only ordinary object operations. A choosing
|
||||||
|
marker precedes ticket selection; contenders wait for choosing peers and lower
|
||||||
|
(ticket, worker) pairs. Late arrivals cannot displace a running owner. Leases
|
||||||
|
assume bounded clock skew (less than MARGIN / 2) and a worker killed by its deadline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import ctypes
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine_trace_worker import unit_record, worker
|
||||||
|
|
||||||
|
from openultrasast.plane.memory import S3Client, s3_settings
|
||||||
|
|
||||||
|
PENDING = "engine-queue/pending/"
|
||||||
|
CLAIMED = "engine-queue/claimed/"
|
||||||
|
RESULTS = "engine-results/"
|
||||||
|
MARGIN = 60
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def client_from_env():
|
||||||
|
settings = s3_settings(os.environ)
|
||||||
|
bucket = os.environ.get("S3_BUCKET")
|
||||||
|
if not bucket:
|
||||||
|
raise ValueError("S3_BUCKET is required")
|
||||||
|
return S3Client(bucket=bucket, **settings)
|
||||||
|
|
||||||
|
|
||||||
|
def read(client, key):
|
||||||
|
value = client.get(key)
|
||||||
|
return json.loads(value[0]) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def put(client, key, value):
|
||||||
|
client.put(key, json.dumps(value).encode(), {})
|
||||||
|
|
||||||
|
|
||||||
|
def result_key(task):
|
||||||
|
return f"{RESULTS}{task['run']}/{task['id']}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def pack(root):
|
||||||
|
stream = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=stream, mode="w") as archive:
|
||||||
|
archive.add(root, arcname=".")
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(data, root):
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||||
|
# Source snapshots need only regular files and directories, never links/devices.
|
||||||
|
for member in archive.getmembers():
|
||||||
|
target = (root / member.name).resolve()
|
||||||
|
if not target.is_relative_to(root.resolve()) or not (member.isfile() or member.isdir()):
|
||||||
|
raise ValueError("unsafe source archive")
|
||||||
|
archive.extractall(root, filter="data")
|
||||||
|
|
||||||
|
|
||||||
|
class Queue:
|
||||||
|
def __init__(self, client, identity=None, *, now=time.time, pause=time.sleep, stop=None):
|
||||||
|
self.client = client
|
||||||
|
self.identity = identity or f"{socket.gethostname()}-{uuid.uuid4().hex}"
|
||||||
|
self.now, self.pause = now, pause
|
||||||
|
self.stop = stop or threading.Event()
|
||||||
|
|
||||||
|
def markers(self, task_id=None):
|
||||||
|
result = []
|
||||||
|
for key in self.client.keys(CLAIMED):
|
||||||
|
if task_id is None or key.rsplit("/", 1)[-1] == task_id:
|
||||||
|
marker = read(self.client, key)
|
||||||
|
if marker:
|
||||||
|
result.append((key, marker))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def tasks(self):
|
||||||
|
tasks = {}
|
||||||
|
for key in self.client.keys(PENDING):
|
||||||
|
task = read(self.client, key)
|
||||||
|
if task:
|
||||||
|
tasks[task["task_id"]] = task
|
||||||
|
# A dead worker may have removed pending already. Its task remains in the lease.
|
||||||
|
for _, marker in self.markers():
|
||||||
|
if marker["expires"] < self.now():
|
||||||
|
task = marker["task"]
|
||||||
|
tasks.setdefault(task["task_id"], task)
|
||||||
|
return list(tasks.values())
|
||||||
|
|
||||||
|
def claim(self, task):
|
||||||
|
if read(self.client, result_key(task)) is not None or self.stop.is_set():
|
||||||
|
return None
|
||||||
|
key = f"{CLAIMED}{self.identity}/{task['task_id']}"
|
||||||
|
marker = dict(task=task, ticket=0, expires=self.now() + task["deadline"] + MARGIN)
|
||||||
|
put(self.client, key, marker)
|
||||||
|
try:
|
||||||
|
live = [m for _, m in self.markers(task["task_id"]) if m["expires"] > self.now()]
|
||||||
|
marker["ticket"] = 1 + max((m["ticket"] for m in live), default=0)
|
||||||
|
put(self.client, key, marker)
|
||||||
|
while not self.stop.is_set():
|
||||||
|
# Do not start once the execution budget no longer fits within our lease.
|
||||||
|
if self.now() >= marker["expires"] - task["deadline"] - MARGIN / 2:
|
||||||
|
break
|
||||||
|
peers = [(k, m) for k, m in self.markers(task["task_id"]) if k != key and m["expires"] > self.now()]
|
||||||
|
if any(m["ticket"] == 0 for _, m in peers):
|
||||||
|
self.pause(0.1)
|
||||||
|
continue
|
||||||
|
if any((m["ticket"], k) < (marker["ticket"], key) for k, m in peers):
|
||||||
|
break # loser backs off and can try another pending task
|
||||||
|
if read(self.client, key) != marker or read(self.client, result_key(task)) is not None:
|
||||||
|
break
|
||||||
|
self.client.delete(PENDING + task["task_id"])
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
self.client.delete(key)
|
||||||
|
raise
|
||||||
|
self.client.delete(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def process_one(self, execute=None):
|
||||||
|
for task in self.tasks():
|
||||||
|
key = self.claim(task)
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lease = read(self.client, key)
|
||||||
|
if lease is None:
|
||||||
|
continue
|
||||||
|
budget = min(task["deadline"], lease["expires"] - self.now() - MARGIN / 2)
|
||||||
|
if budget <= 0:
|
||||||
|
continue # Leave the expired lease's task available to recovery.
|
||||||
|
result = (execute or execute_task)(self.client, {**task, "runtime_budget": budget})
|
||||||
|
result.update(executor="queue", worker=self.identity, pod=socket.gethostname(), image=os.environ.get("ENGINE_IMAGE", ""))
|
||||||
|
put(self.client, result_key(task), result)
|
||||||
|
# Publish the result before removing either the input or the claim.
|
||||||
|
self.client.delete(task["input"])
|
||||||
|
for old_key, old in self.markers(task["task_id"]):
|
||||||
|
if old_key == key or old["expires"] < self.now():
|
||||||
|
self.client.delete(old_key)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Keep the lease and task for recovery, without logging credential-bearing errors.
|
||||||
|
LOG.error("task failed; lease retained for recovery")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _child(pin, root, out, deadline, question_deadline):
|
||||||
|
os.setsid()
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||||
|
worker(pin, root, out, deadline, question_deadline)
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_orphans():
|
||||||
|
# Linux PR_SET_CHILD_SUBREAPER: escaped Joern sessions reparent here, not
|
||||||
|
# outside the service. There is exactly one task process tree per worker.
|
||||||
|
if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0:
|
||||||
|
raise OSError("cannot enable task descendant cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
def kill_children():
|
||||||
|
"""Freeze parents before walking children, including new-session JVMs."""
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def freeze(parent):
|
||||||
|
for entry in Path("/proc").iterdir():
|
||||||
|
if not entry.name.isdigit() or int(entry.name) in seen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# comm can contain spaces or parentheses. Fields after it begin at state.
|
||||||
|
fields = (entry / "stat").read_text().rsplit(")", 1)[1].split()
|
||||||
|
if int(fields[1]) != parent:
|
||||||
|
continue
|
||||||
|
pid = int(entry.name)
|
||||||
|
os.kill(pid, signal.SIGSTOP)
|
||||||
|
seen.add(pid)
|
||||||
|
freeze(pid)
|
||||||
|
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A child that exited during discovery can reparent descendants to this
|
||||||
|
# subreaper. Repeat until every remaining descendant is frozen.
|
||||||
|
while True:
|
||||||
|
count = len(seen)
|
||||||
|
freeze(os.getpid())
|
||||||
|
if len(seen) == count:
|
||||||
|
break
|
||||||
|
for pid in seen:
|
||||||
|
with contextlib.suppress(ProcessLookupError):
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_task(client, task):
|
||||||
|
adopt_orphans()
|
||||||
|
pin = task["pin"]
|
||||||
|
started = time.monotonic()
|
||||||
|
budget = task.get("runtime_budget", task["deadline"])
|
||||||
|
status, reason, code = "failed", "worker produced no result", None
|
||||||
|
with tempfile.TemporaryDirectory(prefix="engine-task-") as scratch:
|
||||||
|
root, out = Path(scratch) / "case", Path(scratch) / "result.json"
|
||||||
|
root.mkdir()
|
||||||
|
try:
|
||||||
|
if task["deadline"] > 900 or task["deadline"] <= 0:
|
||||||
|
raise ValueError("deadline exceeds deployment grace")
|
||||||
|
if task.get("image") != os.environ.get("ENGINE_IMAGE"):
|
||||||
|
raise ValueError("task image differs from deployed image")
|
||||||
|
source = client.get(task["input"])
|
||||||
|
if source is None:
|
||||||
|
raise ValueError("missing source")
|
||||||
|
unpack(source[0], root)
|
||||||
|
# The shipped worker also checks frontend input and graph output for every language.
|
||||||
|
files = [p for p in root.rglob("*") if p.is_file()]
|
||||||
|
if not files:
|
||||||
|
raise ValueError("empty source")
|
||||||
|
LOG.info("input proof: first file bytes=%d", len(files[0].read_bytes()))
|
||||||
|
if time.monotonic() - started >= budget:
|
||||||
|
status, reason = "timeout", "task input deadline"
|
||||||
|
raise TimeoutError(reason)
|
||||||
|
process = multiprocessing.get_context("fork").Process(target=_child, args=(pin, root, out, budget, task["question_deadline"]))
|
||||||
|
process.start()
|
||||||
|
try:
|
||||||
|
process.join(max(0, budget - (time.monotonic() - started)))
|
||||||
|
if process.is_alive():
|
||||||
|
status, reason = "timeout", "task deadline"
|
||||||
|
else:
|
||||||
|
reason = f"worker exit {process.exitcode}"
|
||||||
|
code = process.exitcode
|
||||||
|
finally:
|
||||||
|
# Joern creates separate sessions. Kill the whole descendant tree,
|
||||||
|
# not merely the Python wrapper's process group.
|
||||||
|
kill_children()
|
||||||
|
process.join()
|
||||||
|
# Reap adopted JVMs and their launchers after the direct child.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
os.waitpid(-1, 0)
|
||||||
|
except ChildProcessError:
|
||||||
|
break
|
||||||
|
record = json.loads(out.read_text()) if out.exists() else {**pin, "units": []}
|
||||||
|
except Exception:
|
||||||
|
record = {**pin, "units": []}
|
||||||
|
reason = "worker input or execution failed"
|
||||||
|
if not record.get("done") or code != 0:
|
||||||
|
previous = {u["unit"]: u for u in record["units"]}
|
||||||
|
record["units"] = [
|
||||||
|
previous[u["unit"]]
|
||||||
|
if code is None and previous.get(u["unit"], {}).get("status") in {"path", "asked-nothing"}
|
||||||
|
else unit_record(u, status if u["supported"] else "unsupported", reason if u["supported"] else u["reason"])
|
||||||
|
for u in pin["units"]
|
||||||
|
]
|
||||||
|
record.update(done=True, container_exit=code, seconds=time.monotonic() - started)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def status(client):
|
||||||
|
return {
|
||||||
|
"pending": len(client.keys(PENDING)),
|
||||||
|
"claimed": len(client.keys(CLAIMED)),
|
||||||
|
"done": len([k for k in client.keys(RESULTS) if k.endswith(".json")]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
stop = threading.Event()
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||||
|
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||||
|
client = client_from_env()
|
||||||
|
queue = Queue(client, stop=stop)
|
||||||
|
|
||||||
|
class Health(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
self.send_response(200 if self.path == "/healthz" and not stop.is_set() else 503)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", args.port), Health)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
if not queue.process_one():
|
||||||
|
stop.wait(2)
|
||||||
|
except Exception:
|
||||||
|
LOG.error("queue unavailable; retrying")
|
||||||
|
stop.wait(5)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Offline audit of completed Joern results; never launches an analyzer or reads corpora.
|
||||||
|
|
||||||
|
Missing results stay in coverage, not signal denominators. Observed non-path rows
|
||||||
|
score zero for path features. Sink kinds are one-hot indicators, never ordinal
|
||||||
|
category IDs. Both signed and best-direction metrics expose reversed shortcuts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from slice_leak_audit import THRESHOLD, complete_pairs, metrics
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
UNITS = ROOT / "plane/experiments/exp-005-graph-slice.units.jsonl"
|
||||||
|
RESULTS = Path.home() / "ousast-results/plane/engine-trace"
|
||||||
|
OUTPUT = ROOT / "benchmarks/measurements/2026-10-04-joern-slice-leak-audit/record.json"
|
||||||
|
STATUSES = ("path", "asked-nothing", "failed", "timeout", "unsupported", "missing")
|
||||||
|
ASKED = {"path", "asked-nothing"}
|
||||||
|
SIGNALS = ("has_path", "shortest_path_steps", "sanitized", "guard_present", "asked")
|
||||||
|
ASYMMETRY = ("vulnerable_only", "fixed_only", "both", "neither", "not_both_asked")
|
||||||
|
|
||||||
|
|
||||||
|
def materialization_version():
|
||||||
|
# Read the producer's literal without importing its execution dependencies.
|
||||||
|
tree = ast.parse(Path(__file__).with_name("engine_trace.py").read_bytes())
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "MATERIALIZATION_VERSION" for t in node.targets):
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
raise ValueError("Producer has no materialization version")
|
||||||
|
|
||||||
|
|
||||||
|
def features(row):
|
||||||
|
path = row["status"] == "path"
|
||||||
|
traces = row.get("traces", []) if path else []
|
||||||
|
candidates = [(i, t) for i, t in enumerate(traces) if t.get("steps")]
|
||||||
|
index, trace = min(candidates, key=lambda it: len(it[1]["steps"])) if candidates else (None, {})
|
||||||
|
witnesses = row.get("witness_rows", [])
|
||||||
|
witness = witnesses[index] if index is not None and index < len(witnesses) else {}
|
||||||
|
guard = trace.get("guard", {})
|
||||||
|
sink = witness.get("sinkKind")
|
||||||
|
if not sink:
|
||||||
|
# Retain only the call target: arguments, literals and code are not kinds.
|
||||||
|
match = re.search(r"([\w:$>.\\-]+)\s*\(", str(witness.get("sink", "")))
|
||||||
|
sink = match[1] if match else "unknown"
|
||||||
|
return {
|
||||||
|
"has_path": int(path),
|
||||||
|
"shortest_path_steps": len(trace["steps"]) if trace else None if path else 0,
|
||||||
|
"sanitized": int(bool(trace.get("sanitized", witness.get("sanitized", False)))) if trace else None if path else 0,
|
||||||
|
"guard_present": int(
|
||||||
|
bool(guard.get("text") or guard.get("bounded") or witness.get("bound") or witness.get("bounded"))
|
||||||
|
or any("guard" in s.get("roles", []) for s in trace.get("steps", []))
|
||||||
|
)
|
||||||
|
if trace
|
||||||
|
else None
|
||||||
|
if path
|
||||||
|
else 0,
|
||||||
|
"sink_kind": str(sink) if path else "no-path",
|
||||||
|
"asked": int(row["status"] in ASKED),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scored(rows, signal):
|
||||||
|
eligible = [r for r in rows if r[signal] is not None]
|
||||||
|
result = metrics(eligible, signal)
|
||||||
|
result["excluded_rows"] = len(rows) - len(eligible)
|
||||||
|
result["flagged"] = (result["pairs"] >= 10 and (result["best_direction_within_pair_accuracy"] or 0) >= THRESHOLD) or (
|
||||||
|
result["rows"] >= 10 and (result["best_direction_pooled_auc"] or 0) >= THRESHOLD
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def signal_metrics(rows, kinds):
|
||||||
|
return {
|
||||||
|
**{s: scored(rows, s) for s in SIGNALS},
|
||||||
|
"sink_kind": {kind: scored([{**r, "is_kind": int(r["sink_kind"] == kind)} for r in rows], "is_kind") for kind in kinds},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scope_report(rows, kinds):
|
||||||
|
pairs = complete_pairs(rows)
|
||||||
|
asymmetry = Counter()
|
||||||
|
honest = []
|
||||||
|
for v, f in pairs:
|
||||||
|
if not (v["asked"] and f["asked"]):
|
||||||
|
asymmetry["not_both_asked"] += 1
|
||||||
|
else:
|
||||||
|
honest.extend((v, f))
|
||||||
|
asymmetry[
|
||||||
|
"both"
|
||||||
|
if v["has_path"] and f["has_path"]
|
||||||
|
else "vulnerable_only"
|
||||||
|
if v["has_path"]
|
||||||
|
else "fixed_only"
|
||||||
|
if f["has_path"]
|
||||||
|
else "neither"
|
||||||
|
] += 1
|
||||||
|
observed = [r for r in rows if r["status"] != "missing"]
|
||||||
|
return {
|
||||||
|
"rows": len(rows),
|
||||||
|
"coverage": {s: sum(r["status"] == s for r in rows) for s in STATUSES},
|
||||||
|
"complete_pairs": len(pairs),
|
||||||
|
"pair_asymmetry": {s: asymmetry[s] for s in ASYMMETRY},
|
||||||
|
"both_asked_pairs": len(honest) // 2,
|
||||||
|
"signals": signal_metrics(observed, kinds),
|
||||||
|
"both_asked_signals": signal_metrics(honest, kinds),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def audit(units, results, *, expected_rows=501, expected_pairs=195):
|
||||||
|
if len(units) != expected_rows or len({u["unit"] for u in units}) != expected_rows:
|
||||||
|
raise ValueError(f"Expected {expected_rows} unique units")
|
||||||
|
if len(complete_pairs(units)) != expected_pairs:
|
||||||
|
raise ValueError(f"Expected {expected_pairs} complete pairs")
|
||||||
|
if not results.is_dir():
|
||||||
|
raise ValueError(f"Results directory is unreadable: {results}")
|
||||||
|
version = materialization_version()
|
||||||
|
unit_index = {u["unit"]: u for u in units}
|
||||||
|
matched = {}
|
||||||
|
files = []
|
||||||
|
counts = Counter()
|
||||||
|
for path in sorted(results.glob("*.json")):
|
||||||
|
raw = path.read_bytes()
|
||||||
|
record = json.loads(raw) # Corruption fails loudly; producer uses atomic replace.
|
||||||
|
if not isinstance(record, dict) or "repo" not in record or "pin" not in record:
|
||||||
|
continue
|
||||||
|
stale = record.get("materialization_version") != version
|
||||||
|
unfinished = record.get("done") is not True
|
||||||
|
counts["stale"] += stale
|
||||||
|
counts["unfinished"] += unfinished
|
||||||
|
counts["skipped"] += stale or unfinished
|
||||||
|
files.append(
|
||||||
|
{"path": str(path), "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "stale": stale, "unfinished": unfinished}
|
||||||
|
)
|
||||||
|
if stale or unfinished:
|
||||||
|
continue
|
||||||
|
counts["accepted_files"] += 1
|
||||||
|
counts["result_rows_read"] += len(record["units"])
|
||||||
|
for row in record["units"]:
|
||||||
|
if row["unit"] not in unit_index:
|
||||||
|
continue
|
||||||
|
unit = unit_index[row["unit"]]
|
||||||
|
if any(row.get(k) != unit.get(k) for k in ("family", "label", "pair")):
|
||||||
|
raise ValueError(f"Result metadata mismatch: {row['unit']}")
|
||||||
|
if row["status"] not in STATUSES[:-1] or not row.get("source"):
|
||||||
|
raise ValueError(f"Invalid result status/source: {row['unit']}")
|
||||||
|
if row["unit"] in matched:
|
||||||
|
raise ValueError(f"Duplicate current result: {row['unit']}")
|
||||||
|
matched[row["unit"]] = {
|
||||||
|
**unit,
|
||||||
|
"source": row["source"],
|
||||||
|
"status": row["status"],
|
||||||
|
"repo": record["repo"],
|
||||||
|
"pin": record["pin"],
|
||||||
|
"pin_role": row.get("pin_role"),
|
||||||
|
**features(row),
|
||||||
|
}
|
||||||
|
rows = [
|
||||||
|
matched.get(u["unit"], {**u, "source": u.get("source", "unknown"), "status": "missing", **features({"status": "missing"})})
|
||||||
|
for u in units
|
||||||
|
]
|
||||||
|
kinds = sorted({r["sink_kind"] for r in rows if r["status"] != "missing"})
|
||||||
|
scopes = {"overall": scope_report(rows, kinds)}
|
||||||
|
for field in ("family", "source"):
|
||||||
|
scopes[f"by_{field}"] = {
|
||||||
|
value: scope_report([r for r in rows if r[field] == value], kinds) for value in sorted({r[field] for r in rows})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"cost_usd": 0,
|
||||||
|
"materialization_version": version,
|
||||||
|
"threshold": THRESHOLD,
|
||||||
|
"minimum_samples": 10,
|
||||||
|
"unit_rows_read": len(units),
|
||||||
|
"matched_rows": len(matched),
|
||||||
|
"inputs": {
|
||||||
|
**{k: counts[k] for k in ("accepted_files", "result_rows_read", "stale", "unfinished", "skipped")},
|
||||||
|
"files_read": len(files),
|
||||||
|
"bytes_read": sum(f["bytes"] for f in files),
|
||||||
|
"files": files,
|
||||||
|
},
|
||||||
|
"semantics": {
|
||||||
|
"signals": "Shortest nonempty trace (first on ties); its sanitizer, guard and corresponding witness sink call target.",
|
||||||
|
"missing": "Coverage only; absent source metadata is unknown. Skipped result units are never consumed.",
|
||||||
|
"non_path": "Observed non-path rows score zero; path rows lacking trace evidence have null trace features.",
|
||||||
|
"asked": "path or asked-nothing=1; failed, timeout, unsupported=0; missing excluded from metrics.",
|
||||||
|
"asymmetry": "Disjoint categories; not_both_asked takes precedence over path asymmetry.",
|
||||||
|
"flags": "Best of both directions, >=0.70 with >=10 pairs for pair accuracy or >=10 rows for AUC.",
|
||||||
|
"sink_kind": "One binary indicator per observed call target, including no-path/unknown; no ordinal encoding.",
|
||||||
|
"limits": "Exploratory associations, not proof of leakage; running input snapshot, no multiple-testing correction.",
|
||||||
|
},
|
||||||
|
**scopes,
|
||||||
|
"units": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--results", type=Path, default=RESULTS)
|
||||||
|
parser.add_argument("--units", type=Path, default=UNITS)
|
||||||
|
parser.add_argument("--output", type=Path, default=OUTPUT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
raw = args.units.read_bytes()
|
||||||
|
units = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
||||||
|
report = audit(units, args.results)
|
||||||
|
report["units_input"] = {"path": str(args.units), "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||||
|
overall, inputs = report["overall"], report["inputs"]
|
||||||
|
lines = [
|
||||||
|
f"Joern leak audit: $0; materialization_version={report['materialization_version']}",
|
||||||
|
f"Units read: {len(units)}; bytes={len(raw)}; file={args.units}",
|
||||||
|
f"Result files read: {inputs['files_read']}; bytes={inputs['bytes_read']}; accepted={inputs['accepted_files']}",
|
||||||
|
f"Skipped: {inputs['skipped']} (stale={inputs['stale']}, unfinished={inputs['unfinished']}; counts overlap)",
|
||||||
|
f"Result rows read: {inputs['result_rows_read']}; matched={report['matched_rows']}",
|
||||||
|
f"Coverage: {overall['coverage']}",
|
||||||
|
f"Pairs: {overall['complete_pairs']}; both asked={overall['both_asked_pairs']}",
|
||||||
|
f"Asymmetry: {overall['pair_asymmetry']}",
|
||||||
|
]
|
||||||
|
for signal in (*SIGNALS, "sink_kind"):
|
||||||
|
|
||||||
|
def brief(value, signal=signal):
|
||||||
|
if signal == "sink_kind":
|
||||||
|
return {k: brief_metric(m) for k, m in value.items()}
|
||||||
|
return brief_metric(value)
|
||||||
|
|
||||||
|
lines.append(f"{signal}: all={brief(overall['signals'][signal])}; both asked={brief(overall['both_asked_signals'][signal])}")
|
||||||
|
lines.append(f"Family/source metrics and per-file read evidence: {args.output}")
|
||||||
|
print("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def brief_metric(metric):
|
||||||
|
return {k: metric[k] for k in ("rows", "pairs", "within_pair_accuracy", "pooled_auc", "flagged")}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
{
|
||||||
|
"created": "2026-10-04",
|
||||||
|
"note": "COUNTS ONLY. Batch 3 of real (vulnerable function, fixed function) pairs whose function survives the fix, mined at zero model cost from reviewed GitHub advisories (pip, npm, maven, composer) and added as pointer-pair corpus benchmarks/pairs/advisory-fixes-3 (source `advisory-fixes-3` in learn/sources.toml); labelled, given static feature records, built into the memory store and embedded. No chat-model call, no Docker, no Joern. Candidate, path, function and repository names stay in the catalog; the pairs the reserved-population guard flagged are withheld (found by bisection with the test output discarded; names never printed).",
|
||||||
|
"method": {
|
||||||
|
"enumeration": "gh api /advisories?type=reviewed&ecosystem=<eco>&cwes=<family CWEs>&published=>2023-01-01 per ecosystem (pip, npm, maven, composer) x six compiled families; Go, Ruby and C# not enumerated; newest first",
|
||||||
|
"advisories_enumerated": 8531,
|
||||||
|
"advisories_published_after_2026_06_01": 1855,
|
||||||
|
"automated_screen": "as batch 2 (single family by CWE; linked fix commit, at most 3, one repository; repository not named anywhere under benchmarks/ incl. batches 1-2; excluded v3 file names never opened; no WordPress plugin; one pair per repository; SPDX licence permissive; one-parent commit; the function holding most changed code lines inside its own matcher span, declared once on both sides, body changed, >= 25% of the file's changed code lines) plus three widenings: (a) an advisory linking only a merged pull request uses its single commit or its squash/rebase merge commit; (b) commits of up to 6 non-test source files / 240 changed lines (batch 2: 4 / 120); (c) a second pass over advisories whose CWEs span two families, the family then chosen from the function's diff on review",
|
||||||
|
"file_reads": "commit metadata and the two versions of each changed source file through the contents API into scratch (no clone, no scanner, engine, plane Run or model on these repositories); scratch deleted after",
|
||||||
|
"manual_review": "every automated candidate's in-function diff read (agent, no model call); kept only when the named function itself holds the fix; a weak inline check replaced by a stronger helper call counts as the fix, a function that only forwards a flag or calls a fix made elsewhere does not",
|
||||||
|
"reserved_population_guard": "pytest -q tests/ -k 'reserved or v3' with output discarded, bisected over catalog subsets (18 runs; baseline with an empty batch green); final catalog green",
|
||||||
|
"rename_check": "canonical full_name of every kept repository from the API: no two kept pairs share a repository, none renamed into a name the benchmarks use",
|
||||||
|
"mechanism": "family default as batch 2 (injection/output_encoding/deserialization source_reaches_sink, path path_join_user_input, access_control missing_auth_guard, untrusted_destination validation_strength for CWE-601 else source_reaches_sink); provenance = agent"
|
||||||
|
},
|
||||||
|
"screened": {
|
||||||
|
"advisories_examined": 8805,
|
||||||
|
"outcomes": {
|
||||||
|
"no fix commit linked": 3480,
|
||||||
|
"repository already in benchmarks/ (populations v1/v2 or a pair corpus)": 1993,
|
||||||
|
"licence not permissive or unrecorded": 1249,
|
||||||
|
"repository already kept (one pair per repository)": 657,
|
||||||
|
"kept": 398,
|
||||||
|
"CWEs span several families": 274,
|
||||||
|
"no function declared on both sides with a changed body": 191,
|
||||||
|
"large commit (refactor or release, not the fix)": 173,
|
||||||
|
"fix spread over several commits or repositories": 167,
|
||||||
|
"merge or root commit": 115,
|
||||||
|
"no source file changed": 42,
|
||||||
|
"language outside the feature vocabulary (go/ruby/c#/...)": 32,
|
||||||
|
"manual review: the chosen function does not hold the fix (comment-only, misattributed, partial or family mismatch)": 19,
|
||||||
|
"repository unavailable": 9,
|
||||||
|
"WordPress plugin (population v3 safety)": 4,
|
||||||
|
"fix commit unavailable": 2
|
||||||
|
},
|
||||||
|
"outcomes_note": "single-family pass (screen 2, every advisory examined, no quota met) plus the two-family pass; `kept` = automated candidates; `manual review: the chosen function does not hold the fix (comment-only, misattributed, partial or family mismatch)` = the screen re-meeting a function the 2026-10-03 review had dropped",
|
||||||
|
"candidates_manually_reviewed": 412,
|
||||||
|
"kept_after_review": 255,
|
||||||
|
"review_drops": {
|
||||||
|
"total": 157,
|
||||||
|
"change in the function unrelated to the advisory (refactor, rename, formatting, another advisory)": 33,
|
||||||
|
"function misattributed (matcher span overrun or an undetected enclosing declaration)": 33,
|
||||||
|
"partial: the function only calls or configures the real fix": 29,
|
||||||
|
"function diff not small (rewrite or refactor around the fix)": 28,
|
||||||
|
"unsure": 17,
|
||||||
|
"family mismatch": 11,
|
||||||
|
"same fix as a kept pair in a fork or sibling repository (would leak across repository folds)": 3,
|
||||||
|
"WordPress plugin (population v3 safety; missed by the automated screen)": 2,
|
||||||
|
"reserved-population guard (flagged by the 2026-10-03 guard run; name not printed)": 1
|
||||||
|
},
|
||||||
|
"one_pair_per_repository_drops": 3,
|
||||||
|
"reserved_population_drops": 2,
|
||||||
|
"kept": 251
|
||||||
|
},
|
||||||
|
"kept": {
|
||||||
|
"total": 251,
|
||||||
|
"by_family": {
|
||||||
|
"access_control": 20,
|
||||||
|
"deserialization": 9,
|
||||||
|
"injection": 56,
|
||||||
|
"output_encoding": 69,
|
||||||
|
"path": 71,
|
||||||
|
"untrusted_destination": 26
|
||||||
|
},
|
||||||
|
"post_cutoff_by_family": {
|
||||||
|
"injection": 5,
|
||||||
|
"output_encoding": 5,
|
||||||
|
"path": 2
|
||||||
|
},
|
||||||
|
"post_cutoff_total": 12,
|
||||||
|
"by_language": {
|
||||||
|
"java": 37,
|
||||||
|
"javascript": 37,
|
||||||
|
"php": 46,
|
||||||
|
"python": 83,
|
||||||
|
"typescript": 48
|
||||||
|
},
|
||||||
|
"post_cutoff_by_language": {
|
||||||
|
"python": 6,
|
||||||
|
"typescript": 6
|
||||||
|
},
|
||||||
|
"by_family_language": {
|
||||||
|
"access_control:java": 5,
|
||||||
|
"access_control:javascript": 3,
|
||||||
|
"access_control:php": 7,
|
||||||
|
"access_control:python": 2,
|
||||||
|
"access_control:typescript": 3,
|
||||||
|
"deserialization:java": 2,
|
||||||
|
"deserialization:php": 1,
|
||||||
|
"deserialization:python": 6,
|
||||||
|
"injection:java": 5,
|
||||||
|
"injection:javascript": 7,
|
||||||
|
"injection:php": 9,
|
||||||
|
"injection:python": 24,
|
||||||
|
"injection:typescript": 11,
|
||||||
|
"output_encoding:java": 7,
|
||||||
|
"output_encoding:javascript": 20,
|
||||||
|
"output_encoding:php": 12,
|
||||||
|
"output_encoding:python": 14,
|
||||||
|
"output_encoding:typescript": 16,
|
||||||
|
"path:java": 10,
|
||||||
|
"path:javascript": 4,
|
||||||
|
"path:php": 10,
|
||||||
|
"path:python": 34,
|
||||||
|
"path:typescript": 13,
|
||||||
|
"untrusted_destination:java": 8,
|
||||||
|
"untrusted_destination:javascript": 3,
|
||||||
|
"untrusted_destination:php": 7,
|
||||||
|
"untrusted_destination:python": 3,
|
||||||
|
"untrusted_destination:typescript": 5
|
||||||
|
},
|
||||||
|
"by_ecosystem": {
|
||||||
|
"composer": 46,
|
||||||
|
"maven": 37,
|
||||||
|
"npm": 81,
|
||||||
|
"pip": 87
|
||||||
|
},
|
||||||
|
"licences": {
|
||||||
|
"Apache-2.0": 80,
|
||||||
|
"BSD-2-Clause": 3,
|
||||||
|
"BSD-3-Clause": 18,
|
||||||
|
"ISC": 1,
|
||||||
|
"MIT": 147,
|
||||||
|
"MPL-2.0": 1,
|
||||||
|
"Unlicense": 1
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"holdout": 124,
|
||||||
|
"train": 127
|
||||||
|
},
|
||||||
|
"published_by_year": {
|
||||||
|
"2023": 50,
|
||||||
|
"2024": 52,
|
||||||
|
"2025": 66,
|
||||||
|
"2026": 83
|
||||||
|
},
|
||||||
|
"published_range": [
|
||||||
|
"2023-01-04",
|
||||||
|
"2026-10-01"
|
||||||
|
],
|
||||||
|
"via_pull_request": 13,
|
||||||
|
"commit_over_120_source_lines": 10,
|
||||||
|
"two_family_advisory_family_chosen_on_review": 3,
|
||||||
|
"verification": {
|
||||||
|
"verified": 251,
|
||||||
|
"excerpt_bytes_read": 1297516
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"asked": "injection and untrusted_destination >= 60 each; path, access_control, output_encoding, deserialization >= 30 where advisories allow; >= 250 total",
|
||||||
|
"met": {
|
||||||
|
"access_control": false,
|
||||||
|
"deserialization": false,
|
||||||
|
"injection": false,
|
||||||
|
"output_encoding": true,
|
||||||
|
"path": true,
|
||||||
|
"untrusted_destination": false
|
||||||
|
},
|
||||||
|
"limit": "the reviewed-advisory pool, not the review: every advisory since 2023-01-01 in the four ecosystems was examined; the post-cutoff pool was largely spent by batch 2 (its repositories are now excluded), and untrusted_destination/access_control/deserialization advisories mostly lack a linked single fix commit or sit in repositories the benchmarks already use"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"command": "ousast learn labels --root . --out <scratch> --snapshot <scratch>",
|
||||||
|
"rows": [
|
||||||
|
6981,
|
||||||
|
7985
|
||||||
|
],
|
||||||
|
"pairs_rows": [
|
||||||
|
1936,
|
||||||
|
2940
|
||||||
|
],
|
||||||
|
"new_pair_rows": 1004
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"command": "benchmarks/learn/build_features.py --labels <the 1004 new rows> --units <merged units.json of `ousast plane harvest --catalog benchmarks/pairs/advisory-fixes-3/catalog.toml` (manifests written, no Run started)> --catalog benchmarks/pairs/advisory-fixes-3/catalog.toml --kinds pairs",
|
||||||
|
"records": {
|
||||||
|
"plane:access_control": 40,
|
||||||
|
"plane:deserialization": 18,
|
||||||
|
"plane:injection": 112,
|
||||||
|
"plane:output_encoding": 138,
|
||||||
|
"plane:path": 142,
|
||||||
|
"plane:untrusted_destination": 52,
|
||||||
|
"static:access_control": 40,
|
||||||
|
"static:deserialization": 18,
|
||||||
|
"static:injection": 112,
|
||||||
|
"static:output_encoding": 138,
|
||||||
|
"static:path": 142,
|
||||||
|
"static:untrusted_destination": 52
|
||||||
|
},
|
||||||
|
"instrument_states_plane_all_families": {
|
||||||
|
"agree:missing": 164,
|
||||||
|
"agree:none": 338,
|
||||||
|
"delta:not_applicable": 502,
|
||||||
|
"engine:missing": 406,
|
||||||
|
"engine:none": 96,
|
||||||
|
"entry_points:not_applicable": 502,
|
||||||
|
"facts:not_applicable": 502,
|
||||||
|
"language:ran": 502,
|
||||||
|
"model_sinks:missing": 502,
|
||||||
|
"quick:ran": 502,
|
||||||
|
"roles:ran": 502,
|
||||||
|
"source:ran": 502,
|
||||||
|
"verify:missing": 164,
|
||||||
|
"verify:none": 338
|
||||||
|
},
|
||||||
|
"note": "quick rules and deterministic roles ran on each side; engine, verify, agree and model sinks never ran for these pairs (missing/none: null features, never zeros)"
|
||||||
|
},
|
||||||
|
"memory_build": {
|
||||||
|
"command": "ousast learn memory build --labels <labels> --units <merged units.json> --profile static --root . (exit 2: adjudications 9 of 43 anonymous functions, unchanged since 2026-10-01; rows kept)",
|
||||||
|
"examples": [
|
||||||
|
1133,
|
||||||
|
1635
|
||||||
|
],
|
||||||
|
"pairs_examples": [
|
||||||
|
968,
|
||||||
|
1470
|
||||||
|
],
|
||||||
|
"pairs_counts_after": {
|
||||||
|
"absent_fixed_side": 0,
|
||||||
|
"examples": 1470,
|
||||||
|
"labels": 2940,
|
||||||
|
"no_excerpt": 0,
|
||||||
|
"no_record": 1470,
|
||||||
|
"recorded": 1470,
|
||||||
|
"unread": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"complete_pairs": {
|
||||||
|
"how": "store example rows (profile static) keyed as learn.experiments.freeze_units pairs them: (repo, candidate, family) holding both labels; whole memory, every family",
|
||||||
|
"before": 459,
|
||||||
|
"after": 710,
|
||||||
|
"by_family": {
|
||||||
|
"access_control": [
|
||||||
|
37,
|
||||||
|
57
|
||||||
|
],
|
||||||
|
"config_secrets": [
|
||||||
|
12,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
"deserialization": [
|
||||||
|
32,
|
||||||
|
41
|
||||||
|
],
|
||||||
|
"injection": [
|
||||||
|
68,
|
||||||
|
124
|
||||||
|
],
|
||||||
|
"memory": [
|
||||||
|
149,
|
||||||
|
149
|
||||||
|
],
|
||||||
|
"output_encoding": [
|
||||||
|
77,
|
||||||
|
146
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
38,
|
||||||
|
109
|
||||||
|
],
|
||||||
|
"unknown": [
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"untrusted_destination": [
|
||||||
|
44,
|
||||||
|
70
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exp_004_005_units": "195 complete pairs inside exp-004/005's frozen 501 units (six families, evaluation folds); those units are frozen and were not regenerated here"
|
||||||
|
},
|
||||||
|
"positives_only_groups": {
|
||||||
|
"before": 99,
|
||||||
|
"after": 99,
|
||||||
|
"groups_both_by_family": {
|
||||||
|
"access_control": [
|
||||||
|
37,
|
||||||
|
57
|
||||||
|
],
|
||||||
|
"config_secrets": [
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"deserialization": [
|
||||||
|
32,
|
||||||
|
41
|
||||||
|
],
|
||||||
|
"injection": [
|
||||||
|
53,
|
||||||
|
109
|
||||||
|
],
|
||||||
|
"memory": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"output_encoding": [
|
||||||
|
38,
|
||||||
|
107
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
38,
|
||||||
|
109
|
||||||
|
],
|
||||||
|
"unknown": [
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"untrusted_destination": [
|
||||||
|
42,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"embeddings": {
|
||||||
|
"command": "ousast learn memory embed --budget-usd 0.20 (dry run first: 144,806 estimated tokens, $0.0029)",
|
||||||
|
"requested": 1479,
|
||||||
|
"cached": 1004,
|
||||||
|
"embedded": 475,
|
||||||
|
"calls": 15,
|
||||||
|
"tokens": 150339,
|
||||||
|
"usd": 0.003007
|
||||||
|
},
|
||||||
|
"spend": {
|
||||||
|
"chat_model_calls": 0,
|
||||||
|
"embedding_usd": 0.003007,
|
||||||
|
"github_api": "metadata and raw contents only"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -17,6 +17,21 @@ This page uses two words strictly:
|
|||||||
been made.** Section B is guidance, not a record. (kind runs a Kubernetes cluster inside Docker
|
been made.** Section B is guidance, not a record. (kind runs a Kubernetes cluster inside Docker
|
||||||
containers on one machine.)
|
containers on one machine.)
|
||||||
|
|
||||||
|
The kind profile runs on a 7.7 GB server VM. Observability is off by default
|
||||||
|
(`kind_observability = false` in `ops/k8s/profiles/kind.toml`). This saves the measured
|
||||||
|
930 MiB used by `otel-system`, about 40% of the cluster's 2.3 GB. The smoke Run still
|
||||||
|
completes with those deployments stopped. To enable the stack, run
|
||||||
|
`OUSAST_KIND_OBSERVABILITY=1 ops/ax/up.sh` or set `kind_observability = true` in the profile.
|
||||||
|
|
||||||
|
`up.sh` stages copied Substrate manifests in a temporary checkout view and installs through
|
||||||
|
`hack/install-ate-kind.sh`. The local `ops/ax/substrate-kind-lean/` overlay removes the collector,
|
||||||
|
Prometheus, Jaeger and their supporting resources before installation. This preserves the
|
||||||
|
installer's secret, CRD and image setup. The upstream checkout is unchanged. The shared
|
||||||
|
`ate-otel-config` disables trace, metric and log export and the OTEL SDK; it removes the old
|
||||||
|
collector endpoint and metric timing keys. On an existing cluster, `up.sh` scales the three
|
||||||
|
observability deployments to zero, replaces the ConfigMap data and rolls its consumers when
|
||||||
|
the configuration changes. Opting in restores the upstream ConfigMap and observability manifests.
|
||||||
|
|
||||||
## What runs where today (implemented)
|
## What runs where today (implemented)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -357,7 +372,7 @@ This section answers the maintainer's questions from the code and the upstream c
|
|||||||
|
|
||||||
The decisions of 2026-10-02 set the target: **two profiles of one code base**.
|
The decisions of 2026-10-02 set the target: **two profiles of one code base**.
|
||||||
|
|
||||||
- `kind` is this laptop. It stays a first-class local execution mode. Every change is proven here first.
|
- `kind` is this server VM. It stays a first-class local execution mode. Every change is proven here first.
|
||||||
- `k3s` is production. (k3s is a small Kubernetes distribution.)
|
- `k3s` is production. (k3s is a small Kubernetes distribution.)
|
||||||
|
|
||||||
Everything marked **planned** is not built. The rest is what runs on this host today.
|
Everything marked **planned** is not built. The rest is what runs on this host today.
|
||||||
@@ -368,7 +383,7 @@ flowchart LR
|
|||||||
hook["ousast pre-push: local, 30 s deadline"]
|
hook["ousast pre-push: local, 30 s deadline"]
|
||||||
cli["ousast plane run, remote mode (planned)"]
|
cli["ousast plane run, remote mode (planned)"]
|
||||||
end
|
end
|
||||||
subgraph k3s ["k3s cluster (the kind profile is the same picture on one laptop)"]
|
subgraph k3s ["k3s cluster (the kind profile is the same picture on one server VM)"]
|
||||||
subgraph axns ["Namespace ax-system"]
|
subgraph axns ["Namespace ax-system"]
|
||||||
axs["ax-server Deployment + Redis"]
|
axs["ax-server Deployment + Redis"]
|
||||||
end
|
end
|
||||||
@@ -403,7 +418,7 @@ This picture has no inbound path:
|
|||||||
- the tasks push their artifacts to the store through the egress gateway;
|
- the tasks push their artifacts to the store through the egress gateway;
|
||||||
- the CLI reads the store.
|
- the CLI reads the store.
|
||||||
|
|
||||||
Today's EndpointSlice back to a laptop (`ops/ax/receiver-service.yaml.tmpl`) belongs to the kind
|
Today's EndpointSlice back to the server VM (`ops/ax/receiver-service.yaml.tmpl`) belongs to the kind
|
||||||
profile only. It does not appear in production.
|
profile only. It does not appear in production.
|
||||||
|
|
||||||
### ax, Kubernetes and Agent Substrate in three sentences
|
### ax, Kubernetes and Agent Substrate in three sentences
|
||||||
|
|||||||
+1
-1
@@ -159,7 +159,7 @@ flowchart LR
|
|||||||
ax is a Kubernetes application over Agent Substrate. Every Task runs the one runner image as an actor. Each actor
|
ax is a Kubernetes application over Agent Substrate. Every Task runs the one runner image as an actor. Each actor
|
||||||
occupies a whole worker. This code base targets two profiles:
|
occupies a whole worker. This code base targets two profiles:
|
||||||
|
|
||||||
- `kind` on a laptop (today).
|
- `kind` on a server VM (today).
|
||||||
- `k3s` in production (planned). There the same CLI submits Runs remotely, and the tasks deliver to the S3 store.
|
- `k3s` in production (planned). There the same CLI submits Runs remotely, and the tasks deliver to the S3 store.
|
||||||
|
|
||||||
[Production topology: ax on a Kubernetes cluster](deployment.md#production-topology-ax-on-a-kubernetes-cluster)
|
[Production topology: ax on a Kubernetes cluster](deployment.md#production-topology-ax-on-a-kubernetes-cluster)
|
||||||
|
|||||||
@@ -214,3 +214,42 @@ bucket `OUSAST_MEMORY_TEST_BUCKET`, else `S3_BUCKET`. The tests:
|
|||||||
- never configure the bucket.
|
- never configure the bucket.
|
||||||
|
|
||||||
More on the host setup: [ax on this host](ops/ax/README.md).
|
More on the host setup: [ax on this host](ops/ax/README.md).
|
||||||
|
|
||||||
|
## Scoped account for engine workers
|
||||||
|
|
||||||
|
Create a **new account** for the k3s engine Deployment. Attach only this policy.
|
||||||
|
Do not give it the existing memory-store account's policies.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "ListEnginePrefixes",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "s3:ListBucket",
|
||||||
|
"Resource": "arn:aws:s3:::sast-memory",
|
||||||
|
"Condition": {
|
||||||
|
"StringLike": {
|
||||||
|
"s3:prefix": ["engine-queue/", "engine-queue/*", "engine-results/", "engine-results/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "EngineObjectsOnly",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:s3:::sast-memory/engine-queue/*",
|
||||||
|
"arn:aws:s3:::sast-memory/engine-results/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cluster can read and write only queued engine inputs, claims, and results.
|
||||||
|
It cannot list or read the rest of the memory store. It cannot change bucket
|
||||||
|
settings. The host queue dispatcher can use the same scoped account. Store its
|
||||||
|
keys in the `ousast-engine-s3` Secret; no key values belong in manifests.
|
||||||
|
Deployment and coordinator commands are in `ops/k8s/engine/README.md`.
|
||||||
|
|||||||
+16
-1
@@ -17,6 +17,21 @@ in `~/.cache/ousast/ax-src/`. The cluster's context, registry and pool sizes com
|
|||||||
(`ops/k8s/profiles/kind-images.json`), which the templates are re-pinned from and which is committed with them.
|
(`ops/k8s/profiles/kind-images.json`), which the templates are re-pinned from and which is committed with them.
|
||||||
`up.sh` and `smoke-run.sh` run the checkout's code with `OUSAST_PYTHON` (default `.venv/bin/python`).
|
`up.sh` and `smoke-run.sh` run the checkout's code with `OUSAST_PYTHON` (default `.venv/bin/python`).
|
||||||
|
|
||||||
|
The kind profile runs on a 7.7 GB server VM. Observability is off by default
|
||||||
|
(`kind_observability = false` in `ops/k8s/profiles/kind.toml`). This saves the measured
|
||||||
|
930 MiB used by `otel-system`, about 40% of the cluster's 2.3 GB. The smoke Run still
|
||||||
|
completes with those deployments stopped. To enable the stack, run
|
||||||
|
`OUSAST_KIND_OBSERVABILITY=1 ops/ax/up.sh` or set `kind_observability = true` in the profile.
|
||||||
|
|
||||||
|
`up.sh` stages copied Substrate manifests in a temporary checkout view and installs through
|
||||||
|
`hack/install-ate-kind.sh`. The local `ops/ax/substrate-kind-lean/` overlay removes the collector,
|
||||||
|
Prometheus, Jaeger and their supporting resources before installation. This preserves the
|
||||||
|
installer's secret, CRD and image setup. The upstream checkout is unchanged. The shared
|
||||||
|
`ate-otel-config` disables trace, metric and log export and the OTEL SDK; it removes the old
|
||||||
|
collector endpoint and metric timing keys. On an existing cluster, `up.sh` scales the three
|
||||||
|
observability deployments to zero, replaces the ConfigMap data and rolls its consumers when
|
||||||
|
the configuration changes. Opting in restores the upstream ConfigMap and observability manifests.
|
||||||
|
|
||||||
## How a Run executes
|
## How a Run executes
|
||||||
|
|
||||||
A Run (`plane/runs/*.yaml`, kind `openultrasast.io/v1alpha1 Run`) is a DAG of steps; each step names an ax Task
|
A Run (`plane/runs/*.yaml`, kind `openultrasast.io/v1alpha1 Run`) is a DAG of steps; each step names an ax Task
|
||||||
@@ -191,7 +206,7 @@ configure the bucket.
|
|||||||
No deployment outside this host's kind cluster has been made. The step-by-step guidance, with what is implemented
|
No deployment outside this host's kind cluster has been made. The step-by-step guidance, with what is implemented
|
||||||
and what is planned, is `docs/deployment.md` (the site page "Deployment"). The Model and Workspace manifests under
|
and what is planned, is `docs/deployment.md` (the site page "Deployment"). The Model and Workspace manifests under
|
||||||
`plane/` move as they are and the Task templates need their image reference re-pinned to your registry; these parts
|
`plane/` move as they are and the Task templates need their image reference re-pinned to your registry; these parts
|
||||||
assume this laptop and must change first (checked 2026-10-02):
|
assume this server VM and must change first (checked 2026-10-02):
|
||||||
|
|
||||||
| Assumption | Where | Needed in a real cluster |
|
| Assumption | Where | Needed in a real cluster |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage an installer checkout with lean kind manifests; never modify the source checkout.
|
||||||
|
|
||||||
|
Usage: python ops/ax/prepare-substrate.py SOURCE EMPTY_DESTINATION
|
||||||
|
The destination lives only for an install/render. Source/build inputs are symlinked;
|
||||||
|
manifests are copied because the installer uses fixed paths (including the OTEL ConfigMap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(source: Path, destination: Path) -> None:
|
||||||
|
source = source.resolve()
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
if any(destination.iterdir()):
|
||||||
|
raise ValueError(f"staging directory must be empty: {destination}")
|
||||||
|
for entry in source.iterdir():
|
||||||
|
if entry.name != "manifests":
|
||||||
|
(destination / entry.name).symlink_to(entry, target_is_directory=entry.is_dir())
|
||||||
|
shutil.copytree(source / "manifests", destination / "manifests")
|
||||||
|
install = destination / "manifests/ate-install"
|
||||||
|
shutil.copytree(install / "kind", install / "kind-upstream")
|
||||||
|
overlay = Path(__file__).resolve().parent / "substrate-kind-lean"
|
||||||
|
shutil.copytree(overlay, install / "kind", dirs_exist_ok=True)
|
||||||
|
# The installer applies this file directly before it renders the bundle.
|
||||||
|
# Keep the strategic-merge directive only in the overlay's patch file.
|
||||||
|
config = yaml.safe_load((overlay / "ate-otel-config.yaml").read_text())
|
||||||
|
config["data"].pop("$patch")
|
||||||
|
(install / "kind/disabled-otel-config.yaml").write_text(yaml.safe_dump(config))
|
||||||
|
(install / "kind/ate-otel-config.yaml").rename(install / "kind/otel-config-patch.yaml")
|
||||||
|
(install / "kind/disabled-otel-config.yaml").rename(install / "kind/ate-otel-config.yaml")
|
||||||
|
kustomization = install / "kind/kustomization.yaml"
|
||||||
|
kustomization.write_text(kustomization.read_text().replace("path: ate-otel-config.yaml", "path: otel-config-patch.yaml"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
prepare(Path(sys.argv[1]), Path(sys.argv[2]))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ate-otel-config
|
||||||
|
namespace: ate-system
|
||||||
|
data:
|
||||||
|
$patch: replace
|
||||||
|
OTEL_TRACES_EXPORTER: none
|
||||||
|
OTEL_METRICS_EXPORTER: none
|
||||||
|
OTEL_LOGS_EXPORTER: none
|
||||||
|
OTEL_SDK_DISABLED: "true"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
# prepare-substrate.py stages the upstream kind overlay beside this directory.
|
||||||
|
resources:
|
||||||
|
- ../kind-upstream
|
||||||
|
patches:
|
||||||
|
- target:
|
||||||
|
namespace: otel-system
|
||||||
|
patch: |-
|
||||||
|
$patch: delete
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: unused
|
||||||
|
- target:
|
||||||
|
kind: Namespace
|
||||||
|
name: otel-system
|
||||||
|
patch: |-
|
||||||
|
$patch: delete
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: otel-system
|
||||||
|
- target:
|
||||||
|
kind: ClusterRole
|
||||||
|
name: ate-prometheus
|
||||||
|
patch: |-
|
||||||
|
$patch: delete
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: ate-prometheus
|
||||||
|
- target:
|
||||||
|
kind: RoleBinding
|
||||||
|
name: ate-prometheus
|
||||||
|
namespace: ate-system
|
||||||
|
patch: |-
|
||||||
|
$patch: delete
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: ate-prometheus
|
||||||
|
namespace: ate-system
|
||||||
|
- path: ate-otel-config.yaml
|
||||||
+61
-1
@@ -15,6 +15,7 @@ profile() { PYTHONPATH="$REPO_ROOT/src" "$PY" -m openultrasast.plane.profile --p
|
|||||||
CTX="$(profile kube_context)" # kind names its context kind-<cluster>
|
CTX="$(profile kube_context)" # kind names its context kind-<cluster>
|
||||||
export KIND_CLUSTER_NAME="${CTX#kind-}"
|
export KIND_CLUSTER_NAME="${CTX#kind-}"
|
||||||
export KO_DOCKER_REPO="${KO_DOCKER_REPO:-$(profile registry)}"
|
export KO_DOCKER_REPO="${KO_DOCKER_REPO:-$(profile registry)}"
|
||||||
|
OBSERVABILITY="$(profile kind_observability)"
|
||||||
IMAGES_FILE="$(profile images)" # the digest pins the reconciler and the templates read
|
IMAGES_FILE="$(profile images)" # the digest pins the reconciler and the templates read
|
||||||
SRC="${OUSAST_AX_SRC:-$HOME/.cache/ousast/ax-src}"
|
SRC="${OUSAST_AX_SRC:-$HOME/.cache/ousast/ax-src}"
|
||||||
|
|
||||||
@@ -37,9 +38,68 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
step "agent substrate (ate-system)"
|
step "agent substrate (ate-system)"
|
||||||
|
# The upstream installer has fixed manifest paths and also creates secrets/CRDs.
|
||||||
|
# Give it a temporary checkout view so it never installs the observability resources.
|
||||||
|
install_substrate() (
|
||||||
|
if [ "$OBSERVABILITY" = true ]; then
|
||||||
|
cd "$SRC/substrate"
|
||||||
|
else
|
||||||
|
# Symlinked build inputs must not make the staged view a different binary version.
|
||||||
|
export VERSION="${VERSION:-$(git -C "$SRC/substrate" describe --tags --always --dirty)}"
|
||||||
|
staged="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$staged"' EXIT
|
||||||
|
"$PY" "$REPO_ROOT/ops/ax/prepare-substrate.py" "$SRC/substrate" "$staged"
|
||||||
|
cd "$staged"
|
||||||
|
fi
|
||||||
|
KUBECTL_CONTEXT="$CTX" hack/install-ate-kind.sh --deploy-ate-system
|
||||||
|
)
|
||||||
if ready ate-system; then echo "substrate ready; kept"; else
|
if ready ate-system; then echo "substrate ready; kept"; else
|
||||||
(cd "$SRC/substrate" && hack/install-ate-kind.sh --deploy-ate-system)
|
install_substrate
|
||||||
fi
|
fi
|
||||||
|
# Reconcile the choice even when the existing control plane is already ready.
|
||||||
|
if [ "$OBSERVABILITY" = true ]; then
|
||||||
|
for manifest in otel-collector prometheus; do
|
||||||
|
kubectl --context "$CTX" apply -f "$SRC/substrate/manifests/ate-install/kind/$manifest.yaml"
|
||||||
|
done
|
||||||
|
otel_config="$SRC/substrate/manifests/ate-install/kind/ate-otel-config.yaml"
|
||||||
|
else
|
||||||
|
otel_config="$REPO_ROOT/ops/ax/substrate-kind-lean/ate-otel-config.yaml"
|
||||||
|
for deployment in opentelemetry-collector prometheus jaeger; do
|
||||||
|
existing="$(kubectl --context "$CTX" -n otel-system get deployment "$deployment" --ignore-not-found -o name)"
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
kubectl --context "$CTX" -n otel-system scale "$existing" --replicas=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "kind observability off: existing otel-system deployments scaled to 0; opt in with OUSAST_KIND_OBSERVABILITY=1 ops/ax/up.sh"
|
||||||
|
fi
|
||||||
|
# Replace data to remove stale exporter/endpoint keys in either direction. ConfigMap
|
||||||
|
# envFrom is read at pod start; a content annotation triggers only changed rollouts.
|
||||||
|
otel_data="$("$PY" - "$otel_config" <<'PYCONFIG'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
config = yaml.safe_load(open(sys.argv[1]))
|
||||||
|
config["data"].pop("$patch", None)
|
||||||
|
print(json.dumps(config["data"], sort_keys=True))
|
||||||
|
PYCONFIG
|
||||||
|
)"
|
||||||
|
kubectl --context "$CTX" -n ate-system patch configmap ate-otel-config --type=json \
|
||||||
|
-p "[{\"op\":\"replace\",\"path\":\"/data\",\"value\":$otel_data}]"
|
||||||
|
otel_hash="$(printf '%s' "$otel_data" | sha256sum | cut -d ' ' -f1)"
|
||||||
|
otel_workloads="$(kubectl --context "$CTX" -n ate-system get deployments,daemonsets -o json | "$PY" -c '
|
||||||
|
import json, sys
|
||||||
|
for item in json.load(sys.stdin)["items"]:
|
||||||
|
spec = item["spec"]["template"]["spec"]
|
||||||
|
containers = spec.get("containers", []) + spec.get("initContainers", [])
|
||||||
|
if any(env.get("configMapRef", {}).get("name") == "ate-otel-config"
|
||||||
|
for container in containers for env in container.get("envFrom", [])):
|
||||||
|
print(item["kind"].lower() + "/" + item["metadata"]["name"])
|
||||||
|
')"
|
||||||
|
for workload in $otel_workloads; do
|
||||||
|
kubectl --context "$CTX" -n ate-system patch "$workload" --type=merge \
|
||||||
|
-p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"ousast.io/otel-config\":\"$otel_hash\"}}}}}"
|
||||||
|
kubectl --context "$CTX" -n ate-system rollout status "$workload" --timeout=300s
|
||||||
|
done
|
||||||
kubectl --context "$CTX" -n ate-system get pods
|
kubectl --context "$CTX" -n ate-system get pods
|
||||||
|
|
||||||
step "egress gateway (atenet-egress, agentgateway variant: one prebuilt image, nothing built)"
|
step "egress gateway (atenet-egress, agentgateway variant: one prebuilt image, nothing built)"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Engine workers on k3s
|
||||||
|
|
||||||
|
Build the new **Engine image** workflow on main (or dispatch it), then copy its
|
||||||
|
`ghcr.io/<owner>/openultrasast@sha256:...` job-summary value. The old September
|
||||||
|
image does not include boto3 or this service. The release workflow still builds
|
||||||
|
version tags. The Dockerfile copies the worker and parser under `/app/benchmarks/learn`
|
||||||
|
and installs `.[semantic,s3]`; no analyzer tarball is sent by the dispatcher.
|
||||||
|
|
||||||
|
Use two schedulable nodes with at least 3 GiB available each. Required hostname
|
||||||
|
anti-affinity places one worker on each node. Updates use Recreate so a third
|
||||||
|
worker does not wait forever for a third node. Local scratch uses the container's
|
||||||
|
filesystem, with no host mount or Kubernetes API access.
|
||||||
|
|
||||||
|
Create a **new scoped account** using the policy in `docs/rustfs.md`. Export its
|
||||||
|
`S3_ENDPOINT`, `S3_BUCKET=sast-memory`, `AWS_ACCESS_KEY_ID`, and
|
||||||
|
`AWS_SECRET_ACCESS_KEY` in the coordinator shell. Do not use the memory-store
|
||||||
|
account. The following commands are for the coordinator; they were not run by the
|
||||||
|
implementation agent.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ENGINE_IMAGE='ghcr.io/<owner>/openultrasast@sha256:<digest-from-workflow>'
|
||||||
|
# From the repository root, edit the digest parameter without changing the template:
|
||||||
|
export ENGINE_OVERLAY="$(mktemp -d /tmp/ousast-engine.XXXXXX)"
|
||||||
|
cp ops/k8s/engine/{namespace,deployment,kustomization}.yaml "$ENGINE_OVERLAY/"
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
image, digest = os.environ['ENGINE_IMAGE'].split('@', 1)
|
||||||
|
p = Path(os.environ['ENGINE_OVERLAY']) / 'kustomization.yaml'
|
||||||
|
p.write_text(p.read_text().replace('ghcr.io/OWNER/openultrasast', image)
|
||||||
|
.replace('sha256:REPLACE_WITH_ENGINE_IMAGE_DIGEST', digest))
|
||||||
|
PY
|
||||||
|
kubectl apply -f ops/k8s/engine/namespace.yaml
|
||||||
|
kubectl -n ousast-engine create secret generic ousast-engine-s3 \
|
||||||
|
--from-literal=S3_ENDPOINT="$S3_ENDPOINT" \
|
||||||
|
--from-literal=S3_BUCKET="$S3_BUCKET" \
|
||||||
|
--from-literal=AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \
|
||||||
|
--from-literal=AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
|
||||||
|
kubectl apply -k "$ENGINE_OVERLAY"
|
||||||
|
kubectl -n ousast-engine rollout status deployment/ousast-engine-worker
|
||||||
|
kubectl -n ousast-engine get pods -o wide
|
||||||
|
|
||||||
|
export PATH=/home/mc/Source/OpenUltraSAST/.venv/bin:$PATH
|
||||||
|
export PYTHONPATH="$PWD/src"
|
||||||
|
python benchmarks/learn/engine_trace.py --queue-status
|
||||||
|
python benchmarks/learn/engine_trace.py --executor queue \
|
||||||
|
--image "$ENGINE_IMAGE" --parallel 2 --deadline 900 --queue-timeout 3600 \
|
||||||
|
--units plane/experiments/exp-005-graph-slice.units.jsonl \
|
||||||
|
--out "$HOME/ousast-results/plane/engine-trace-queue"
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatcher uses the existing local memory examples by default. Add
|
||||||
|
`--examples /path/to/examples.jsonl` for a frozen example snapshot. Materialization
|
||||||
|
uses locally cached source pins, just as the Docker executor does. `--executor
|
||||||
|
k8s-jobs` preserves the old Job lane (`k8s` is its legacy alias); `queue` requires
|
||||||
|
no kubectl access. Resume skips completed local units and retries failed or timed
|
||||||
|
out units. `progress.json`, `STOP`, filtering, and `--summary` retain their existing
|
||||||
|
meaning. Each invocation uses a new run ID, so an old remote result cannot satisfy
|
||||||
|
a new retry. The dispatcher records worker and pod identity in each pin JSON.
|
||||||
|
|
||||||
|
Tasks contain the pin, units, prepared questions, source object key, and deadlines.
|
||||||
|
Results live at `engine-results/<run>/<pin-id>.json`; inputs are removed after
|
||||||
|
publication. The Docker lane retains JSON and a log, not a result tar, so the queue
|
||||||
|
also returns JSON. Queue timeout records timeout locally and removes pending;
|
||||||
|
an already claimed task finishes remotely and cleans its input. Orphaned inputs
|
||||||
|
from a failed submission can be removed under `engine-queue/inputs/` after checking
|
||||||
|
that no pending task or claim references them.
|
||||||
|
|
||||||
|
Claims use per-worker choosing markers followed by ordered tickets. A worker waits
|
||||||
|
for choosing peers and every lower ticket before removing pending and starting.
|
||||||
|
This requires strongly consistent GET/PUT/LIST from RustFS, including paginated
|
||||||
|
LIST, and synchronized node clocks with skew below 30 seconds. Half of the 60-second lease margin is reserved for clock
|
||||||
|
skew; the remaining execution budget is checked again after claiming.
|
||||||
|
It does not require conditional writes, object tags, S3 Select, or bucket-admin
|
||||||
|
permissions. A dead worker's task is recovered from its expired claim. Execution
|
||||||
|
runs in a child process. Linux subreaping and a frozen descendant walk kill its
|
||||||
|
entire tree, including Joern's separate sessions, at the task deadline before the
|
||||||
|
lease expires. As with any unfenced lease, a node suspended beyond its lease must be
|
||||||
|
terminated before it rejoins; the protocol cannot guarantee exclusivity against
|
||||||
|
arbitrary process suspension or an inconsistent object store.
|
||||||
|
|
||||||
|
SIGTERM stops new claims and lets the current task finish. The 900-second
|
||||||
|
termination grace matches the maximum task deadline. Keep these values aligned
|
||||||
|
if changing the deployment. `/healthz` serves both probes and becomes unready on
|
||||||
|
shutdown. Errors omit SDK exception text to avoid exposing credentials.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ousast-engine-worker
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ousast-engine-worker
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ousast-engine-worker
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
terminationGracePeriodSeconds: 900
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: ousast-engine-worker
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
containers:
|
||||||
|
- name: worker
|
||||||
|
image: engine-image
|
||||||
|
command: [python, /app/benchmarks/learn/engine_worker_service.py]
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: ousast-engine-s3
|
||||||
|
env:
|
||||||
|
- name: ENGINE_IMAGE
|
||||||
|
value: engine-image
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 3Gi
|
||||||
|
cpu: '1'
|
||||||
|
limits:
|
||||||
|
memory: 3Gi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
ports:
|
||||||
|
- name: health
|
||||||
|
containerPort: 8080
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: health
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 10
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: ousast-engine
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
# Deliberately non-pullable until the coordinator supplies the workflow's digest.
|
||||||
|
images:
|
||||||
|
- name: engine-image
|
||||||
|
newName: ghcr.io/OWNER/openultrasast
|
||||||
|
digest: sha256:REPLACE_WITH_ENGINE_IMAGE_DIGEST
|
||||||
|
replacements:
|
||||||
|
- source:
|
||||||
|
kind: Deployment
|
||||||
|
name: ousast-engine-worker
|
||||||
|
fieldPath: spec.template.spec.containers.0.image
|
||||||
|
targets:
|
||||||
|
- select:
|
||||||
|
kind: Deployment
|
||||||
|
name: ousast-engine-worker
|
||||||
|
fieldPaths:
|
||||||
|
- spec.template.spec.containers.0.env.0.value
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: ousast-engine
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Reference only; excluded from kustomization. Create using the README command.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ousast-engine-s3
|
||||||
|
namespace: ousast-engine
|
||||||
|
type: Opaque
|
||||||
|
stringData: {}
|
||||||
|
# Required keys: S3_ENDPOINT, S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"runner": "localhost:5001/ousast-runner@sha256:d2a55293999cc34581ef1f02a1ffb0c2dd8fc0cbe001ac952df81600e868f019"
|
"runner": "localhost:5001/ousast-runner@sha256:784bc6afc869c679c4966d04d3f9653a8d379726b899809590cc83327f4038e3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# The kind profile: the plane on this laptop's single-node kind cluster, which ops/ax/up.sh creates and which is
|
# The kind profile: the plane on this server VM's single-node kind cluster, which ops/ax/up.sh creates and which is
|
||||||
# the development default (plane-on-kubernetes design section 1). Values only; a credential never goes here
|
# the development default (plane-on-kubernetes design section 1). Values only; a credential never goes here
|
||||||
# (keys stay in the environment or .env). Every field is overridable by OUSAST_<FIELD> in the environment, a pool
|
# (keys stay in the environment or .env). Every field is overridable by OUSAST_<FIELD> in the environment, a pool
|
||||||
# by OUSAST_POOL_<NAME>_<FIELD>. This file may name the kind cluster; the code must not (tests/test_plane_literals.py).
|
# by OUSAST_POOL_<NAME>_<FIELD>. This file may name the kind cluster; the code must not (tests/test_plane_literals.py).
|
||||||
|
|
||||||
|
kind_observability = false # opt in with OUSAST_KIND_OBSERVABILITY=1
|
||||||
exec = "local" # the reconciler runs on this host against the cluster below
|
exec = "local" # the reconciler runs on this host against the cluster below
|
||||||
kube_context = "kind-ousast" # kind's context for KIND_CLUSTER_NAME=ousast
|
kube_context = "kind-ousast" # kind's context for KIND_CLUSTER_NAME=ousast
|
||||||
registry = "localhost:5001" # the kind-local registry up.sh creates (Substrate rewrites it for the node)
|
registry = "localhost:5001" # the kind-local registry up.sh creates (Substrate rewrites it for the node)
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ recorded = "2026-10-03"
|
|||||||
status = "development"
|
status = "development"
|
||||||
exclude_tiers = ["title"]
|
exclude_tiers = ["title"]
|
||||||
|
|
||||||
|
# Batch 3: more advisory fixes whose function survives the fix, weighted toward injection and untrusted_destination.
|
||||||
|
# Same slice, cache layout and checks; screening record
|
||||||
|
# benchmarks/measurements/2026-10-04-decision-engine-harvest-3/record.json.
|
||||||
|
[[source]]
|
||||||
|
id = "advisory-fixes-3"
|
||||||
|
kind = "pairs"
|
||||||
|
file = "benchmarks/pairs/advisory-fixes-3/catalog.toml"
|
||||||
|
evaluation = ["benchmarks/pairs/advisory-fixes-3/verification.json"]
|
||||||
|
recorded = "2026-10-04"
|
||||||
|
status = "development"
|
||||||
|
exclude_tiers = ["title"]
|
||||||
|
|
||||||
[[source]]
|
[[source]]
|
||||||
id = "adjudications"
|
id = "adjudications"
|
||||||
kind = "adjudications"
|
kind = "adjudications"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""The plane's configuration surface: one ``PlaneProfile`` per cluster (plane-on-kubernetes design section 1).
|
"""The plane's configuration surface: one ``PlaneProfile`` per cluster (plane-on-kubernetes design section 1).
|
||||||
|
|
||||||
A profile is a TOML file under ``ops/k8s/profiles/`` (``kind.toml`` for the laptop's kind cluster, ``k3s.toml`` for
|
A profile is a TOML file under ``ops/k8s/profiles/`` (``kind.toml`` for the server VM's kind cluster, ``k3s.toml`` for
|
||||||
the production cluster) holding every value that differs between clusters: the execution mode, the kube context,
|
the production cluster) holding every value that differs between clusters: the execution mode, the kube context,
|
||||||
the registry and the digest-pinned images file, the router and ax-server addresses (empty: tunnel through the
|
the registry and the digest-pinned images file, the router and ax-server addresses (empty: tunnel through the
|
||||||
Kubernetes API), the memory store, the atespace, ax's snapshot bucket, the worker pools and the image pull secret.
|
Kubernetes API), the memory store, the atespace, ax's snapshot bucket, the worker pools and the image pull secret.
|
||||||
@@ -59,6 +59,7 @@ class PlaneProfile:
|
|||||||
name: str
|
name: str
|
||||||
path: Path
|
path: Path
|
||||||
exec: str = "local"
|
exec: str = "local"
|
||||||
|
kind_observability: bool = False
|
||||||
kube_context: str = ""
|
kube_context: str = ""
|
||||||
registry: str = ""
|
registry: str = ""
|
||||||
images: Path = Path("images.json")
|
images: Path = Path("images.json")
|
||||||
@@ -135,6 +136,10 @@ def _reject_secret(where: str, key: str, value: object, declared: bool = False)
|
|||||||
|
|
||||||
|
|
||||||
def _coerce(where: str, key: str, value: object, kind: str) -> Any:
|
def _coerce(where: str, key: str, value: object, kind: str) -> Any:
|
||||||
|
if kind == "bool":
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ProfileError(f"{where}: {key} must be a boolean, got {value!r}")
|
||||||
|
return value
|
||||||
if kind == "int":
|
if kind == "int":
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
raise ProfileError(f"{where}: {key} must be an integer, got {value!r}")
|
raise ProfileError(f"{where}: {key} must be an integer, got {value!r}")
|
||||||
@@ -172,6 +177,10 @@ def _env_overrides(environ: Mapping[str, str], where: str, values: dict[str, Any
|
|||||||
raw = environ.get(_ENV_PREFIX + name.upper())
|
raw = environ.get(_ENV_PREFIX + name.upper())
|
||||||
if raw is not None and raw != "":
|
if raw is not None and raw != "":
|
||||||
value: Any = raw
|
value: Any = raw
|
||||||
|
if kind == "bool":
|
||||||
|
if raw.lower() not in ("true", "false", "1", "0"):
|
||||||
|
raise ProfileError(f"{where}: {_ENV_PREFIX}{name.upper()} must be true/false or 1/0, got {raw!r}")
|
||||||
|
value = raw.lower() in ("true", "1")
|
||||||
if kind == "int":
|
if kind == "int":
|
||||||
try:
|
try:
|
||||||
value = int(raw)
|
value = int(raw)
|
||||||
@@ -241,7 +250,8 @@ def _lookup(profile: PlaneProfile, dotted: str) -> str:
|
|||||||
return str(getattr(profile.pools[pool_name], key))
|
return str(getattr(profile.pools[pool_name], key))
|
||||||
if head not in _SCALARS or rest:
|
if head not in _SCALARS or rest:
|
||||||
raise ProfileError(f"{profile.path}: no such field {dotted!r}")
|
raise ProfileError(f"{profile.path}: no such field {dotted!r}")
|
||||||
return str(getattr(profile, head))
|
value = getattr(profile, head)
|
||||||
|
return str(value).lower() if isinstance(value, bool) else str(value)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ from openultrasast.pairs import load_pair_catalog
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
PAIRS = ROOT / "benchmarks" / "pairs"
|
PAIRS = ROOT / "benchmarks" / "pairs"
|
||||||
# batch 1 (2026-10-01) and batch 2 (2026-10-03, functions that survive the fix): one source each, same rules
|
# batch 1 (2026-10-01), batches 2 (2026-10-03) and 3 (2026-10-04, functions that survive the fix): one source each,
|
||||||
BATCHES = {"advisory-fixes": PAIRS / "advisory-fixes", "advisory-fixes-2": PAIRS / "advisory-fixes-2"}
|
# same rules
|
||||||
|
BATCHES = {name: PAIRS / name for name in ("advisory-fixes", "advisory-fixes-2", "advisory-fixes-3")}
|
||||||
SHA = re.compile(r"[0-9a-f]{40}")
|
SHA = re.compile(r"[0-9a-f]{40}")
|
||||||
PERMISSIVE_OR_COPYLEFT = re.compile(
|
PERMISSIVE_OR_COPYLEFT = re.compile(
|
||||||
r"^(MIT|BSD-[23]-Clause|Apache-2\.0|ISC|Zlib|0BSD|Unlicense|MPL-[12]\.[01]"
|
r"^(MIT|BSD-[23]-Clause|Apache-2\.0|ISC|Zlib|0BSD|Unlicense|MPL-[12]\.[01]"
|
||||||
@@ -106,10 +107,18 @@ def test_no_pair_repeats_a_repository_the_benchmarks_already_name(source_id: str
|
|||||||
PERMISSIVE = {"MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "MPL-2.0", "Unlicense"}
|
PERMISSIVE = {"MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "MPL-2.0", "Unlicense"}
|
||||||
|
|
||||||
|
|
||||||
def test_batch_two_is_permissive_and_dated_against_the_cutoff() -> None:
|
@pytest.mark.parametrize(("source_id", "batch"), [("advisory-fixes-2", "2026-10-03"), ("advisory-fixes-3", "2026-10-04")])
|
||||||
"""Batch 2 publishes permissive licences only and records each advisory's publication against the 2026-06-01
|
def test_later_batches_are_permissive_and_dated_against_the_cutoff(source_id: str, batch: str) -> None:
|
||||||
|
"""Batches 2 and 3 publish permissive licences only and record each advisory's publication against the 2026-06-01
|
||||||
cutoff, so memorisation can be measured on the post-cutoff pairs."""
|
cutoff, so memorisation can be measured on the post-cutoff pairs."""
|
||||||
for item in _raw(BATCHES["advisory-fixes-2"]):
|
for item in _raw(BATCHES[source_id]):
|
||||||
assert item["license"] in PERMISSIVE, item["name"]
|
assert item["license"] in PERMISSIVE, item["name"]
|
||||||
assert item["batch"] == "2026-10-03", item["name"]
|
assert item["batch"] == batch, item["name"]
|
||||||
assert item["post_cutoff"] is (item["published_at"][:10] >= "2026-06-01"), item["name"]
|
assert item["post_cutoff"] is (item["published_at"][:10] >= "2026-06-01"), item["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_repository_repeats_across_batches() -> None:
|
||||||
|
seen: Counter[str] = Counter()
|
||||||
|
for folder in BATCHES.values():
|
||||||
|
seen.update({repo_name(item["repo"]) for item in _raw(folder)})
|
||||||
|
assert [repo for repo, n in seen.items() if n > 1] == []
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Offline overlay contract and installer staging; no Kubernetes executable needed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OVERLAY = ROOT / "ops/ax/substrate-kind-lean"
|
||||||
|
DISABLED = {
|
||||||
|
"OTEL_TRACES_EXPORTER": "none",
|
||||||
|
"OTEL_METRICS_EXPORTER": "none",
|
||||||
|
"OTEL_LOGS_EXPORTER": "none",
|
||||||
|
"OTEL_SDK_DISABLED": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ax_lean_overlay_removes_observability_and_replaces_export_config() -> None:
|
||||||
|
overlay = yaml.safe_load((OVERLAY / "kustomization.yaml").read_text())
|
||||||
|
assert overlay["resources"] == ["../kind-upstream"]
|
||||||
|
deletes = [p for p in overlay["patches"] if "target" in p]
|
||||||
|
assert [p["target"] for p in deletes] == [
|
||||||
|
{"namespace": "otel-system"},
|
||||||
|
{"kind": "Namespace", "name": "otel-system"},
|
||||||
|
{"kind": "ClusterRole", "name": "ate-prometheus"},
|
||||||
|
{"kind": "RoleBinding", "name": "ate-prometheus", "namespace": "ate-system"},
|
||||||
|
]
|
||||||
|
assert all(yaml.safe_load(p["patch"])["$patch"] == "delete" for p in deletes)
|
||||||
|
config = yaml.safe_load((OVERLAY / overlay["patches"][-1]["path"]).read_text())
|
||||||
|
assert config["metadata"] == {"name": "ate-otel-config", "namespace": "ate-system"}
|
||||||
|
# Whole-data replacement drops the upstream endpoint, interval and timeout,
|
||||||
|
# including any future keys that would otherwise survive a merge.
|
||||||
|
assert config["data"] == {"$patch": "replace", **DISABLED}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ax_staging_preserves_upstream_and_installer_prerequisites(tmp_path: Path) -> None:
|
||||||
|
prepare = runpy.run_path(str(ROOT / "ops/ax/prepare-substrate.py"))["prepare"]
|
||||||
|
source = tmp_path / "source"
|
||||||
|
kind = source / "manifests/ate-install/kind"
|
||||||
|
kind.mkdir(parents=True)
|
||||||
|
original = "resources: [otel-collector.yaml, prometheus.yaml, ate-otel-config.yaml]\n"
|
||||||
|
(kind / "kustomization.yaml").write_text(original)
|
||||||
|
(kind / "ate-otel-config.yaml").write_text("upstream config")
|
||||||
|
(kind / "postgres").mkdir()
|
||||||
|
(kind / "postgres/kustomization.yaml").write_text("resources: [../../postgres]\n")
|
||||||
|
(source / "go.mod").write_text("module test\n")
|
||||||
|
(source / "hack").mkdir()
|
||||||
|
(source / "hack/install-ate-kind.sh").write_text("upstream installer")
|
||||||
|
stage = tmp_path / "stage"
|
||||||
|
prepare(source, stage)
|
||||||
|
install = stage / "manifests/ate-install"
|
||||||
|
assert (install / "kind-upstream/kustomization.yaml").read_text() == original
|
||||||
|
assert (install / "kind/postgres/kustomization.yaml").read_text() == "resources: [../../postgres]\n"
|
||||||
|
assert (kind / "kustomization.yaml").read_text() == original
|
||||||
|
assert (kind / "ate-otel-config.yaml").read_text() == "upstream config"
|
||||||
|
assert (stage / "hack/install-ate-kind.sh").read_text() == "upstream installer"
|
||||||
|
assert (stage / "go.mod").is_symlink()
|
||||||
|
config = yaml.safe_load((install / "kind/ate-otel-config.yaml").read_text())
|
||||||
|
assert config["data"] == DISABLED # direct installer apply must not contain $patch
|
||||||
|
overlay = yaml.safe_load((install / "kind/kustomization.yaml").read_text())
|
||||||
|
patch = yaml.safe_load((install / "kind" / overlay["patches"][-1]["path"]).read_text())
|
||||||
|
assert patch["data"] == {"$patch": "replace", **DISABLED}
|
||||||
|
with pytest.raises(ValueError, match="must be empty"):
|
||||||
|
prepare(source, stage)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("enabled", [False, True])
|
||||||
|
@pytest.mark.parametrize("existing", [False, True])
|
||||||
|
def test_ax_reconciles_observability_on_ready_clusters(tmp_path: Path, enabled: bool, existing: bool) -> None:
|
||||||
|
"""Execute the reconciliation shell with a function double, never real kubectl."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
script = (ROOT / "ops/ax/up.sh").read_text()
|
||||||
|
section = script.split("# Reconcile the choice", 1)[1].split('kubectl --context "$CTX" -n ate-system get pods', 1)[0]
|
||||||
|
section = "# Reconcile the choice" + section
|
||||||
|
source = tmp_path / "substrate/manifests/ate-install/kind"
|
||||||
|
source.mkdir(parents=True)
|
||||||
|
upstream_data = {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317", "OTEL_LOGS_EXPORTER": "otlp"}
|
||||||
|
(source / "ate-otel-config.yaml").write_text(yaml.safe_dump({"data": upstream_data}))
|
||||||
|
log = tmp_path / "calls"
|
||||||
|
consumer = {
|
||||||
|
"kind": "Deployment",
|
||||||
|
"metadata": {"name": "ate-controller"},
|
||||||
|
"spec": {"template": {"spec": {"containers": [{"envFrom": [{"configMapRef": {"name": "ate-otel-config"}}]}]}}},
|
||||||
|
}
|
||||||
|
nonconsumer = {"kind": "Deployment", "metadata": {"name": "unrelated"}, "spec": {"template": {"spec": {"containers": []}}}}
|
||||||
|
mock = """
|
||||||
|
set -euo pipefail
|
||||||
|
kubectl() {
|
||||||
|
printf '%s\\t' "$@" >> "$CALL_LOG"
|
||||||
|
printf '\\n' >> "$CALL_LOG"
|
||||||
|
case " $* " in
|
||||||
|
*" get deployment "*)
|
||||||
|
if [ "$EXISTING" = 1 ]; then printf 'deployment.apps/%s\\n' "$7"; fi ;;
|
||||||
|
*" get deployments,daemonsets "*) printf '%s' "$WORKLOADS" ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"OBSERVABILITY": str(enabled).lower(),
|
||||||
|
"EXISTING": str(int(existing)),
|
||||||
|
"PY": sys.executable,
|
||||||
|
"CTX": "test-context",
|
||||||
|
"SRC": str(tmp_path),
|
||||||
|
"REPO_ROOT": str(ROOT),
|
||||||
|
"CALL_LOG": str(log),
|
||||||
|
"WORKLOADS": json.dumps({"items": [consumer, nonconsumer]}),
|
||||||
|
}
|
||||||
|
result = subprocess.run(["bash", "-c", mock + section], env=env, text=True, capture_output=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
calls = [line.rstrip("\t").split("\t") for line in log.read_text().splitlines()]
|
||||||
|
scales = [call for call in calls if "scale" in call]
|
||||||
|
assert len(scales) == (3 if existing and not enabled else 0)
|
||||||
|
assert all(call[-1] == "--replicas=0" for call in scales)
|
||||||
|
applies = [call for call in calls if "apply" in call]
|
||||||
|
assert len(applies) == (2 if enabled else 0)
|
||||||
|
config_patch = next(call[-1] for call in calls if "configmap" in call)
|
||||||
|
assert json.loads(config_patch) == [{"op": "replace", "path": "/data", "value": upstream_data if enabled else DISABLED}]
|
||||||
|
assert not any("deployment/unrelated" in call for call in calls)
|
||||||
|
assert any("deployment/ate-controller" in call for call in calls)
|
||||||
|
if not enabled:
|
||||||
|
assert "OUSAST_KIND_OBSERVABILITY=1" in result.stdout
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Offline queue protocol, image imports and deployment contract controls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
with pytest.MonkeyPatch.context() as patch:
|
||||||
|
patch.syspath_prepend(str(ROOT / "benchmarks/learn"))
|
||||||
|
service = importlib.import_module("engine_worker_service")
|
||||||
|
transport = importlib.import_module("engine_trace_queue")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeS3:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.choosing = None
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
with self.lock:
|
||||||
|
return (self.objects[key], None) if key in self.objects else None
|
||||||
|
|
||||||
|
def put(self, key, data, labels):
|
||||||
|
with self.lock:
|
||||||
|
self.objects[key] = data
|
||||||
|
if self.choosing and key.startswith(service.CLAIMED) and json.loads(data)["ticket"] == 0:
|
||||||
|
self.choosing.wait(timeout=5)
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
with self.lock:
|
||||||
|
self.objects.pop(key, None)
|
||||||
|
|
||||||
|
def keys(self, prefix):
|
||||||
|
with self.lock:
|
||||||
|
return sorted(k for k in self.objects if k.startswith(prefix))
|
||||||
|
|
||||||
|
|
||||||
|
def task():
|
||||||
|
return dict(
|
||||||
|
run="run",
|
||||||
|
id="pin",
|
||||||
|
task_id="run-pin",
|
||||||
|
input="engine-queue/inputs/run/pin/source.tar",
|
||||||
|
deadline=10,
|
||||||
|
question_deadline=2,
|
||||||
|
pin={"repo": "owner/repo", "pin": "commit", "units": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_racing_claimants_run_exactly_once():
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
service.put(store, service.PENDING + item["task_id"], item)
|
||||||
|
store.choosing = threading.Barrier(2)
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def execute(client, entry):
|
||||||
|
called.append(entry["task_id"])
|
||||||
|
time.sleep(0.05)
|
||||||
|
return {**entry["pin"], "done": True, "instrument": {"bytes": 11}}
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(2) as pool:
|
||||||
|
futures = [pool.submit(service.Queue(store, identity).process_one, execute) for identity in ["worker-b", "worker-a"]]
|
||||||
|
results = [f.result(timeout=10) for f in futures]
|
||||||
|
assert sum(results) == 1
|
||||||
|
assert called == ["run-pin"]
|
||||||
|
result = service.read(store, service.result_key(item))
|
||||||
|
assert result["worker"] in {"worker-a", "worker-b"}
|
||||||
|
assert not store.keys(service.CLAIMED)
|
||||||
|
assert not store.keys(service.PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_expired_lease_recovered_without_pending():
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
service.put(store, "engine-queue/claimed/dead/run-pin", dict(task=item, ticket=1, expires=99))
|
||||||
|
queue = service.Queue(store, "new", now=lambda: 100)
|
||||||
|
assert queue.process_one(lambda *_: {**item["pin"], "done": True})
|
||||||
|
assert service.read(store, service.result_key(item))["worker"] == "new"
|
||||||
|
assert not store.keys(service.CLAIMED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_late_arrival_cannot_displace_running_owner():
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
first = service.Queue(store, "z-first")
|
||||||
|
key = first.claim(item)
|
||||||
|
assert key
|
||||||
|
stop = threading.Event()
|
||||||
|
later = service.Queue(store, "a-later", stop=stop)
|
||||||
|
with ThreadPoolExecutor(1) as pool:
|
||||||
|
future = pool.submit(later.claim, item)
|
||||||
|
assert future.result(timeout=3) is None
|
||||||
|
assert service.read(store, key) is not None
|
||||||
|
service.put(store, service.result_key(item), {"done": True})
|
||||||
|
store.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_queue_dispatch_roundtrip_and_timeout(tmp_path):
|
||||||
|
store = FakeS3()
|
||||||
|
checkout, output = tmp_path / "case", tmp_path / "out"
|
||||||
|
checkout.mkdir()
|
||||||
|
output.mkdir()
|
||||||
|
(checkout / "app.py").write_text("print(1)\n")
|
||||||
|
args = SimpleNamespace(deadline=10, question_deadline=2, queue_timeout=2, image="image@sha256:abc")
|
||||||
|
unit = {"unit": "u", "supported": True, "reason": "", "family": "injection"}
|
||||||
|
pin = {"repo": "owner/repo", "pin": "sha", "units": [unit], "questions": []}
|
||||||
|
|
||||||
|
def execute(client, item):
|
||||||
|
unpacked = tmp_path / "unpacked"
|
||||||
|
unpacked.mkdir()
|
||||||
|
service.unpack(client.get(item["input"])[0], unpacked)
|
||||||
|
assert (unpacked / "app.py").read_bytes() == b"print(1)\n"
|
||||||
|
return {
|
||||||
|
**item["pin"],
|
||||||
|
"done": True,
|
||||||
|
"container_exit": 0,
|
||||||
|
"units": [service.unit_record(unit, "asked-nothing")],
|
||||||
|
"instrument": {"bytes": 9, "heap_mb": 2560},
|
||||||
|
}
|
||||||
|
|
||||||
|
queue = service.Queue(store, "pod-1")
|
||||||
|
dispatcher = transport.Dispatcher(args, store, pause=lambda _: queue.process_one(execute))
|
||||||
|
completed, worker = dispatcher.execute(checkout, output, pin, "pin")
|
||||||
|
assert completed.returncode == 0
|
||||||
|
assert worker == "pod-1"
|
||||||
|
result = json.loads((output / "result.json").read_text())
|
||||||
|
assert result["executor"] == "queue"
|
||||||
|
assert result["units"][0]["status"] == "asked-nothing"
|
||||||
|
assert result["instrument"]["heap_mb"] == 2560
|
||||||
|
assert dispatcher.status() == {"pending": 0, "claimed": 0, "done": 1}
|
||||||
|
assert not store.keys("engine-queue/inputs/")
|
||||||
|
ticks = iter([0, 3])
|
||||||
|
dispatcher = transport.Dispatcher(args, store, clock=lambda: next(ticks))
|
||||||
|
assert dispatcher.execute(checkout, output, pin, "other") == (None, None)
|
||||||
|
assert not store.keys(service.PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_imports_without_yaml():
|
||||||
|
code = "import sys; sys.modules['yaml'] = None; import engine_worker_service; import engine_trace_worker"
|
||||||
|
done = subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, "PYTHONPATH": f"{ROOT / 'src'}:{ROOT / 'benchmarks/learn'}"},
|
||||||
|
)
|
||||||
|
assert done.returncode == 0, done.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_k8s_render_contract():
|
||||||
|
base = ROOT / "ops/k8s/engine"
|
||||||
|
config = yaml.safe_load((base / "kustomization.yaml").read_text())
|
||||||
|
# Render this base's resource inclusion, namespace, image and replacement transforms offline.
|
||||||
|
resources = [yaml.safe_load((base / name).read_text()) for name in config["resources"]]
|
||||||
|
deployment = next(r for r in resources if r["kind"] == "Deployment")
|
||||||
|
deployment["metadata"]["namespace"] = config["namespace"]
|
||||||
|
image = config["images"][0]
|
||||||
|
pod = deployment["spec"]["template"]["spec"]
|
||||||
|
container = pod["containers"][0]
|
||||||
|
assert container["image"] == image["name"]
|
||||||
|
container["image"] = image["newName"] + "@" + image["digest"]
|
||||||
|
replacement = config["replacements"][0]
|
||||||
|
assert replacement["source"]["fieldPath"] == "spec.template.spec.containers.0.image"
|
||||||
|
assert replacement["targets"][0]["fieldPaths"] == ["spec.template.spec.containers.0.env.0.value"]
|
||||||
|
container["env"][0]["value"] = container["image"]
|
||||||
|
assert "@sha256:" in container["image"]
|
||||||
|
assert deployment["spec"]["replicas"] == 2
|
||||||
|
assert container["resources"] == {"requests": {"memory": "3Gi", "cpu": "1"}, "limits": {"memory": "3Gi"}}
|
||||||
|
affinity = pod["affinity"]["podAntiAffinity"]["requiredDuringSchedulingIgnoredDuringExecution"][0]
|
||||||
|
assert affinity["topologyKey"] == "kubernetes.io/hostname"
|
||||||
|
assert affinity["labelSelector"]["matchLabels"] == deployment["spec"]["template"]["metadata"]["labels"]
|
||||||
|
assert pod["terminationGracePeriodSeconds"] == 900
|
||||||
|
assert container["envFrom"] == [{"secretRef": {"name": "ousast-engine-s3"}}]
|
||||||
|
for probe in ["readinessProbe", "livenessProbe"]:
|
||||||
|
assert container[probe]["httpGet"] == {"path": "/healthz", "port": "health"}
|
||||||
|
assert not pod["automountServiceAccountToken"]
|
||||||
|
assert pod["securityContext"]["runAsNonRoot"]
|
||||||
|
assert "hostPath" not in json.dumps(resources)
|
||||||
|
assert "privileged" not in json.dumps(resources)
|
||||||
|
assert all(r["kind"] != "Secret" for r in resources)
|
||||||
|
secret = yaml.safe_load((base / "secret.example.yaml").read_text())
|
||||||
|
assert secret["stringData"] == {}
|
||||||
|
assert "AWS_SECRET_ACCESS_KEY" not in json.dumps(resources)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_image_and_rustfs_policy():
|
||||||
|
dockerfile = (ROOT / "Dockerfile").read_text()
|
||||||
|
assert "COPY benchmarks ./benchmarks" in dockerfile
|
||||||
|
assert ".[semantic,s3]" in dockerfile
|
||||||
|
for name in ["engine_trace_worker.py", "engine_trace_parse.py", "engine_worker_service.py"]:
|
||||||
|
assert (ROOT / "benchmarks/learn" / name).stat().st_size > 0
|
||||||
|
doc = (ROOT / "docs/rustfs.md").read_text().split("## Scoped account for engine workers")[1]
|
||||||
|
policy = json.loads(doc.split("```json\n")[1].split("```")[0])
|
||||||
|
listing, objects = policy["Statement"]
|
||||||
|
assert listing["Action"] == "s3:ListBucket"
|
||||||
|
assert set(listing["Condition"]["StringLike"]["s3:prefix"]) == {
|
||||||
|
"engine-queue/",
|
||||||
|
"engine-queue/*",
|
||||||
|
"engine-results/",
|
||||||
|
"engine-results/*",
|
||||||
|
}
|
||||||
|
assert set(objects["Action"]) == {"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}
|
||||||
|
assert set(objects["Resource"]) == {"arn:aws:s3:::sast-memory/engine-queue/*", "arn:aws:s3:::sast-memory/engine-results/*"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("hang", [False, True])
|
||||||
|
def test_engine_worker_process_deadline_and_shipped_schema(tmp_path, monkeypatch, hang):
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
item["image"] = "image@sha256:abc"
|
||||||
|
item["deadline"] = 0.1 if hang else 5
|
||||||
|
item["pin"]["units"] = [{"unit": "u", "supported": True, "reason": ""}]
|
||||||
|
(tmp_path / "app.py").write_text("print(1)\n")
|
||||||
|
store.put(item["input"], service.pack(tmp_path), {})
|
||||||
|
monkeypatch.setenv("ENGINE_IMAGE", item["image"])
|
||||||
|
|
||||||
|
def fake_worker(pin, root, out, deadline, question_deadline):
|
||||||
|
assert (root / "app.py").read_bytes() == b"print(1)\n"
|
||||||
|
if hang:
|
||||||
|
time.sleep(10)
|
||||||
|
out.write_text(json.dumps({**pin, "done": True, "units": [service.unit_record(pin["units"][0], "asked-nothing")]}))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "worker", fake_worker)
|
||||||
|
result = service.execute_task(store, item)
|
||||||
|
assert result["done"]
|
||||||
|
assert result["units"][0]["status"] == ("timeout" if hang else "asked-nothing")
|
||||||
|
assert result["container_exit"] == (None if hang else 0)
|
||||||
|
assert result["seconds"] < 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_stopping_does_not_claim():
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
service.put(store, service.PENDING + item["task_id"], item)
|
||||||
|
stop = threading.Event()
|
||||||
|
stop.set()
|
||||||
|
assert not service.Queue(store, stop=stop).process_one(lambda *_: pytest.fail("claimed after SIGTERM"))
|
||||||
|
assert store.keys(service.PENDING)
|
||||||
|
assert not store.keys(service.CLAIMED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_rejects_archive_links_and_escape(tmp_path):
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
for name, kind in [("../escape", tarfile.REGTYPE), ("link", tarfile.SYMTYPE)]:
|
||||||
|
stream = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=stream, mode="w") as archive:
|
||||||
|
member = tarfile.TarInfo(name)
|
||||||
|
member.type = kind
|
||||||
|
member.linkname = "/etc/passwd"
|
||||||
|
archive.addfile(member)
|
||||||
|
with pytest.raises(ValueError, match="unsafe source archive"):
|
||||||
|
service.unpack(stream.getvalue(), tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_worker_timeout_kills_separate_session_descendant(tmp_path, monkeypatch):
|
||||||
|
store = FakeS3()
|
||||||
|
item = task()
|
||||||
|
item.update(image="image@sha256:abc", deadline=0.5)
|
||||||
|
item["pin"]["units"] = [{"unit": "u", "supported": True, "reason": ""}]
|
||||||
|
(tmp_path / "app.py").write_text("print(1)\n")
|
||||||
|
store.put(item["input"], service.pack(tmp_path), {})
|
||||||
|
monkeypatch.setenv("ENGINE_IMAGE", item["image"])
|
||||||
|
pid_file = tmp_path / "escaped-pid"
|
||||||
|
|
||||||
|
def fake_worker(*_):
|
||||||
|
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], start_new_session=True)
|
||||||
|
pid_file.write_text(str(child.pid))
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "worker", fake_worker)
|
||||||
|
result = service.execute_task(store, item)
|
||||||
|
assert result["units"][0]["status"] == "timeout"
|
||||||
|
assert pid_file.exists(), "instrument: fake worker never launched its descendant"
|
||||||
|
pid = int(pid_file.read_text())
|
||||||
|
assert not Path(f"/proc/{pid}").exists(), "escaped session survived the task deadline"
|
||||||
@@ -14,7 +14,6 @@ import pytest
|
|||||||
from openultrasast.cpg.backend import BEGIN, END, CpgResult, JoernBackend
|
from openultrasast.cpg.backend import BEGIN, END, CpgResult, JoernBackend
|
||||||
from openultrasast.model.specs import taint_specs
|
from openultrasast.model.specs import taint_specs
|
||||||
from openultrasast.model.taint import request_params
|
from openultrasast.model.taint import request_params
|
||||||
from openultrasast.model.trace import parse_trace
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).resolve().parents[1] / "benchmarks/learn/engine_trace.py"
|
SCRIPT = Path(__file__).resolve().parents[1] / "benchmarks/learn/engine_trace.py"
|
||||||
_spec = importlib.util.spec_from_file_location("engine_trace", SCRIPT)
|
_spec = importlib.util.spec_from_file_location("engine_trace", SCRIPT)
|
||||||
@@ -22,6 +21,8 @@ assert _spec and _spec.loader
|
|||||||
trace = importlib.util.module_from_spec(_spec)
|
trace = importlib.util.module_from_spec(_spec)
|
||||||
with pytest.MonkeyPatch.context() as patch:
|
with pytest.MonkeyPatch.context() as patch:
|
||||||
patch.syspath_prepend(str(SCRIPT.parent))
|
patch.syspath_prepend(str(SCRIPT.parent))
|
||||||
|
from engine_trace_parse import parse_trace
|
||||||
|
|
||||||
_spec.loader.exec_module(trace)
|
_spec.loader.exec_module(trace)
|
||||||
worker = sys.modules["engine_trace_worker"]
|
worker = sys.modules["engine_trace_worker"]
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ def test_docker_command_is_serial_offline_frozen():
|
|||||||
assert cmd[-1] == "60"
|
assert cmd[-1] == "60"
|
||||||
assert "/frozen/benchmarks/learn/engine_trace_worker.py" in cmd
|
assert "/frozen/benchmarks/learn/engine_trace_worker.py" in cmd
|
||||||
assert "benchmarks/learn/engine_trace_worker.py" in trace.SNAPSHOT_FILES
|
assert "benchmarks/learn/engine_trace_worker.py" in trace.SNAPSHOT_FILES
|
||||||
|
assert "benchmarks/learn/engine_trace_parse.py" in trace.SNAPSHOT_FILES
|
||||||
|
|
||||||
|
|
||||||
def test_summary_separates_unanswered_pairs(tmp_path):
|
def test_summary_separates_unanswered_pairs(tmp_path):
|
||||||
@@ -461,6 +463,8 @@ sys.modules['yaml'] = None
|
|||||||
sys.modules['boto3'] = None
|
sys.modules['boto3'] = None
|
||||||
script = pathlib.Path(sys.argv[1])
|
script = pathlib.Path(sys.argv[1])
|
||||||
size = len(script.read_bytes())
|
size = len(script.read_bytes())
|
||||||
|
# Direct script execution in the image puts the sibling modules on sys.path.
|
||||||
|
sys.path.insert(0, str(script.parent))
|
||||||
runpy.run_path(str(script), run_name='trace_import_check')
|
runpy.run_path(str(script), run_name='trace_import_check')
|
||||||
print(json.dumps({'bytes': size, 'modules': sorted(
|
print(json.dumps({'bytes': size, 'modules': sorted(
|
||||||
name for name in sys.modules if name == 'openultrasast' or name.startswith('openultrasast.')
|
name for name in sys.modules if name == 'openultrasast' or name.startswith('openultrasast.')
|
||||||
@@ -486,7 +490,6 @@ def test_trace_container_transitive_import_boundary():
|
|||||||
"openultrasast.cpg.session",
|
"openultrasast.cpg.session",
|
||||||
"openultrasast.model",
|
"openultrasast.model",
|
||||||
"openultrasast.model.contracts",
|
"openultrasast.model.contracts",
|
||||||
"openultrasast.model.trace",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1397,3 +1400,55 @@ def test_k8s_check_roundtrip_and_retention(tmp_path, monkeypatch, capsys, keep):
|
|||||||
assert {"name": "CHECK", "value": "1"} in job["spec"]["template"]["spec"]["containers"][0]["env"]
|
assert {"name": "CHECK", "value": "1"} in job["spec"]["template"]["spec"]["containers"][0]["env"]
|
||||||
assert bool(data) is keep
|
assert bool(data) is keep
|
||||||
assert any("delete" in c for c in commands) is not keep
|
assert any("delete" in c for c in commands) is not keep
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("outcome", ["success", "timeout", "failure"])
|
||||||
|
def test_engine_queue_pin_persistence_resume_and_progress(tmp_path, monkeypatch, outcome):
|
||||||
|
from engine_trace_queue import Dispatcher
|
||||||
|
from engine_worker_service import Queue
|
||||||
|
from test_engine_queue import FakeS3
|
||||||
|
|
||||||
|
store = FakeS3()
|
||||||
|
args = k8s_args(tmp_path, executor="queue", queue_timeout=2, image="image@sha256:abc")
|
||||||
|
plan = trace.make_plan([unit()], tmp_path)
|
||||||
|
|
||||||
|
class Inputs:
|
||||||
|
def materialize(self, pin, target):
|
||||||
|
target.mkdir()
|
||||||
|
(target / "app.py").write_text("def run(): pass")
|
||||||
|
return {"files": 1, "bytes": 15}
|
||||||
|
|
||||||
|
def execute(client, task):
|
||||||
|
return {
|
||||||
|
**task["pin"],
|
||||||
|
"done": True,
|
||||||
|
"container_exit": 0,
|
||||||
|
"units": [worker.unit_record(u, "asked-nothing") for u in task["pin"]["units"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
queue = Queue(store, "pod-worker")
|
||||||
|
if outcome == "timeout":
|
||||||
|
ticks = iter([0, 3])
|
||||||
|
args.dispatcher = Dispatcher(args, store, clock=lambda: next(ticks))
|
||||||
|
else:
|
||||||
|
args.dispatcher = Dispatcher(args, store, pause=lambda _: queue.process_one(execute))
|
||||||
|
if outcome == "failure":
|
||||||
|
|
||||||
|
def fail(*_):
|
||||||
|
raise RuntimeError("credential-must-not-appear")
|
||||||
|
|
||||||
|
monkeypatch.setattr(store, "put", fail)
|
||||||
|
monkeypatch.setattr(trace, "prepare_questions", lambda *_: {"a": {}})
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda *_a, **_k: pytest.fail("queue must not launch Docker/kubectl"))
|
||||||
|
assert trace.run_plan(plan, None, {"image": args.image}, Inputs(), args) is (outcome != "success")
|
||||||
|
result = json.loads((tmp_path / trace.pin_name(plan[0])).read_text())
|
||||||
|
assert result["executor"] == "queue"
|
||||||
|
assert result["units"][0]["status"] == {"success": "asked-nothing", "timeout": "timeout", "failure": "failed"}[outcome]
|
||||||
|
assert "credential-must-not-appear" not in json.dumps(result)
|
||||||
|
if outcome == "success":
|
||||||
|
assert result["worker"] == "pod-worker"
|
||||||
|
assert trace.make_plan([unit()], tmp_path) == []
|
||||||
|
else:
|
||||||
|
assert trace.make_plan([unit()], tmp_path, rerun_status=("failed", "timeout"))
|
||||||
|
progress = json.loads((tmp_path / "progress.json").read_text())
|
||||||
|
assert progress["pins_done"] == 1 and progress["pins_in_flight"] == 0
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Synthetic, offline result snapshots; no analyzer, model or corpus access."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def audit_module(monkeypatch):
|
||||||
|
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "benchmarks/learn"))
|
||||||
|
return importlib.import_module("joern_leak_audit")
|
||||||
|
|
||||||
|
|
||||||
|
def units(count=12):
|
||||||
|
return [
|
||||||
|
{"unit": f"{pair}-{label}", "pair": str(pair), "label": label, "family": "injection", "group": "g", "fold": "f"}
|
||||||
|
for pair in range(count)
|
||||||
|
for label in (1, 0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def write_result(root, module, rows, name="pin", *, done=True, version=None):
|
||||||
|
path = root / f"{name}.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"repo": "repo",
|
||||||
|
"pin": name,
|
||||||
|
"done": done,
|
||||||
|
"materialization_version": module.materialization_version() if version is None else version,
|
||||||
|
"units": rows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def result(unit, status):
|
||||||
|
return {
|
||||||
|
**unit,
|
||||||
|
"source": "authored",
|
||||||
|
"status": status,
|
||||||
|
"pin_role": "vulnerable" if unit["label"] else "fixed",
|
||||||
|
"traces": [{"steps": [{"roles": ["source"]}, {"roles": ["sink"]}], "sanitized": False, "guard": {"text": "", "bounded": False}}]
|
||||||
|
if status == "path"
|
||||||
|
else [],
|
||||||
|
"witness_rows": [{"sink": "execute(x)"}] if status == "path" else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run(module, root, population):
|
||||||
|
return module.audit(population, root, expected_rows=len(population), expected_pairs=len(population) // 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_stale_unfinished_and_proves_read(audit_module, tmp_path):
|
||||||
|
population = units(2)
|
||||||
|
paths = [
|
||||||
|
write_result(tmp_path, audit_module, [result(population[0], "path")], "valid"),
|
||||||
|
write_result(tmp_path, audit_module, [result(population[1], "path")], "stale", version=-1),
|
||||||
|
write_result(tmp_path, audit_module, [result(population[2], "path")], "unfinished", done=False),
|
||||||
|
write_result(tmp_path, audit_module, [result(population[3], "path")], "both", version=-1, done=False),
|
||||||
|
]
|
||||||
|
report = run(audit_module, tmp_path, population)
|
||||||
|
evidence = report["inputs"]
|
||||||
|
assert (evidence["stale"], evidence["unfinished"], evidence["skipped"]) == (2, 2, 3)
|
||||||
|
assert evidence["files_read"] == 4 and evidence["accepted_files"] == evidence["result_rows_read"] == 1
|
||||||
|
assert evidence["bytes_read"] == sum(p.stat().st_size for p in paths)
|
||||||
|
assert report["matched_rows"] == 1
|
||||||
|
assert report["overall"]["coverage"]["missing"] == 3
|
||||||
|
assert report["by_source"]["unknown"]["rows"] == 3
|
||||||
|
assert report["overall"]["signals"]["has_path"]["rows"] == 1
|
||||||
|
assert report["overall"]["both_asked_signals"]["has_path"]["pooled_auc"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_asymmetry_and_honest_denominator(audit_module, tmp_path):
|
||||||
|
population = units(6)
|
||||||
|
statuses = [
|
||||||
|
"path",
|
||||||
|
"asked-nothing",
|
||||||
|
"asked-nothing",
|
||||||
|
"path",
|
||||||
|
"path",
|
||||||
|
"path",
|
||||||
|
"asked-nothing",
|
||||||
|
"asked-nothing",
|
||||||
|
"path",
|
||||||
|
"failed",
|
||||||
|
"unsupported",
|
||||||
|
"timeout",
|
||||||
|
]
|
||||||
|
write_result(tmp_path, audit_module, [result(u, s) for u, s in zip(population, statuses, strict=True)])
|
||||||
|
report = run(audit_module, tmp_path, population)
|
||||||
|
overall = report["overall"]
|
||||||
|
assert overall["pair_asymmetry"] == {"vulnerable_only": 1, "fixed_only": 1, "both": 1, "neither": 1, "not_both_asked": 2}
|
||||||
|
assert overall["both_asked_pairs"] == 4
|
||||||
|
assert overall["both_asked_signals"]["asked"]["within_pair_accuracy"] == 0.5
|
||||||
|
assert overall["coverage"] == {"path": 5, "asked-nothing": 4, "failed": 1, "timeout": 1, "unsupported": 1, "missing": 0}
|
||||||
|
assert report["by_family"]["injection"] == overall == report["by_source"]["authored"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("balanced", [False, True])
|
||||||
|
def test_constructed_leak_and_balanced_set(audit_module, tmp_path, balanced):
|
||||||
|
population = units()
|
||||||
|
rows = [result(u, "path" if u["label"] == (int(u["pair"]) % 2 if balanced else 1) else "asked-nothing") for u in population]
|
||||||
|
write_result(tmp_path, audit_module, rows)
|
||||||
|
overall = run(audit_module, tmp_path, population)["overall"]
|
||||||
|
for denominator in ("signals", "both_asked_signals"):
|
||||||
|
signals = overall[denominator]
|
||||||
|
for name in ("has_path", "shortest_path_steps"):
|
||||||
|
assert signals[name]["flagged"] is not balanced
|
||||||
|
assert signals[name]["within_pair_accuracy"] == signals[name]["pooled_auc"] == (0.5 if balanced else 1)
|
||||||
|
assert signals["sink_kind"]["execute"]["flagged"] is not balanced
|
||||||
|
assert not signals["asked"]["flagged"]
|
||||||
|
assert not signals["sanitized"]["flagged"]
|
||||||
|
assert not signals["guard_present"]["flagged"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimum_samples_reverse_and_source_proxy(audit_module, tmp_path):
|
||||||
|
population = units(2)
|
||||||
|
write_result(tmp_path, audit_module, [result(u, "path" if not u["label"] else "asked-nothing") for u in population])
|
||||||
|
assert not run(audit_module, tmp_path, population)["overall"]["signals"]["has_path"]["flagged"]
|
||||||
|
population = units(12)
|
||||||
|
rows = []
|
||||||
|
for u in population:
|
||||||
|
row = result(u, "path" if not u["label"] else "unsupported")
|
||||||
|
row["source"] = "positive" if u["label"] else "negative"
|
||||||
|
rows.append(row)
|
||||||
|
write_result(tmp_path, audit_module, rows)
|
||||||
|
report = run(audit_module, tmp_path, population)
|
||||||
|
assert report["overall"]["signals"]["asked"]["flagged"]
|
||||||
|
assert report["overall"]["signals"]["asked"]["pooled_auc"] == 0
|
||||||
|
assert report["overall"]["both_asked_pairs"] == 0
|
||||||
|
assert report["by_source"]["positive"]["signals"]["asked"]["pooled_auc"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortest_trace_evidence_and_missing_trace(audit_module):
|
||||||
|
row = result(units(1)[0], "path")
|
||||||
|
row["traces"].insert(0, {"steps": [{}] * 5, "sanitized": False})
|
||||||
|
row["witness_rows"].insert(0, {"sink": "longer(x)"})
|
||||||
|
row["traces"][1].update(sanitized=True, guard={"bounded": True, "text": "off-path bound"})
|
||||||
|
features = audit_module.features(row)
|
||||||
|
assert (features["shortest_path_steps"], features["sanitized"], features["guard_present"], features["sink_kind"]) == (
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"execute",
|
||||||
|
)
|
||||||
|
row["traces"] = []
|
||||||
|
assert audit_module.features(row)["shortest_path_steps"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_inputs_fail_loudly(audit_module, tmp_path):
|
||||||
|
population = units(1)
|
||||||
|
write_result(tmp_path, audit_module, [result(population[0], "path")] * 2)
|
||||||
|
with pytest.raises(ValueError, match="Duplicate"):
|
||||||
|
run(audit_module, tmp_path, population)
|
||||||
|
write_result(tmp_path, audit_module, [{**result(population[0], "path"), "label": 0}])
|
||||||
|
with pytest.raises(ValueError, match="metadata mismatch"):
|
||||||
|
run(audit_module, tmp_path, population)
|
||||||
@@ -156,3 +156,27 @@ def test_profile_is_frozen_and_typed() -> None:
|
|||||||
assert isinstance(loaded, PlaneProfile)
|
assert isinstance(loaded, PlaneProfile)
|
||||||
with pytest.raises(AttributeError):
|
with pytest.raises(AttributeError):
|
||||||
loaded.exec = "remote" # type: ignore[misc]
|
loaded.exec = "remote" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_observability_defaults_off() -> None:
|
||||||
|
assert load_profile("kind", environ={}).kind_observability is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("raw", "expected"), [("1", True), ("true", True), ("0", False), ("FALSE", False)])
|
||||||
|
def test_kind_observability_environment_override(tmp_path: Path, raw: str, expected: bool) -> None:
|
||||||
|
path = write_profile(tmp_path, MINIMAL + f"kind_observability = {str(not expected).lower()}\n")
|
||||||
|
assert load_profile(str(path), environ={"OUSAST_KIND_OBSERVABILITY": raw}).kind_observability is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_observability_validation_and_print(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
path = write_profile(tmp_path, MINIMAL + 'kind_observability = "false"\n')
|
||||||
|
with pytest.raises(ProfileError, match="kind_observability must be a boolean"):
|
||||||
|
load_profile(str(path), environ={})
|
||||||
|
path = write_profile(tmp_path)
|
||||||
|
with pytest.raises(ProfileError, match="OUSAST_KIND_OBSERVABILITY must be"):
|
||||||
|
load_profile(str(path), environ={"OUSAST_KIND_OBSERVABILITY": "maybe"})
|
||||||
|
monkeypatch.setenv("OUSAST_KIND_OBSERVABILITY", "1")
|
||||||
|
assert profile_module.main(["--profile", str(path), "--print", "kind_observability"]) == 0
|
||||||
|
assert capsys.readouterr().out == "true\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user